feat(build): add Wails frontend prebuild hook

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 18:58:25 +00:00
parent 7f1da7766a
commit 1265dc158b
2 changed files with 249 additions and 0 deletions

View file

@ -66,6 +66,10 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
}
// Wails v2 strategy: Use 'wails build'
if err := b.PreBuild(ctx, cfg); err != nil {
return nil, err
}
// Ensure output directory exists
if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil {
return nil, coreerr.E("WailsBuilder.Build", "failed to create output directory", err)
@ -86,6 +90,30 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
return artifacts, nil
}
// PreBuild runs the frontend build step before Wails compiles the desktop app.
//
// err := b.PreBuild(ctx, cfg) // runs `deno task build` or `npm run build`
func (b *WailsBuilder) PreBuild(ctx context.Context, cfg *build.Config) error {
if cfg == nil {
return coreerr.E("WailsBuilder.PreBuild", "config is nil", nil)
}
frontendDir, command, args, err := b.resolveFrontendBuild(cfg.FS, cfg.ProjectDir)
if err != nil {
return err
}
if command == "" {
return nil
}
output, err := ax.CombinedOutput(ctx, frontendDir, nil, command, args...)
if err != nil {
return coreerr.E("WailsBuilder.PreBuild", command+" build failed: "+output, err)
}
return nil
}
// isWailsV3 checks if the project uses Wails v3 by inspecting go.mod.
func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool {
goModPath := ax.Join(dir, "go.mod")
@ -96,6 +124,53 @@ func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool {
return core.Contains(content, "github.com/wailsapp/wails/v3")
}
// resolveFrontendBuild selects the frontend directory and build command.
//
// dir, command, args, err := b.resolveFrontendBuild(io.Local, ".")
func (b *WailsBuilder) resolveFrontendBuild(fs io.Medium, projectDir string) (string, string, []string, error) {
frontendDir := b.resolveFrontendDir(fs, projectDir)
if frontendDir == "" {
return "", "", nil, nil
}
if b.hasDenoConfig(fs, frontendDir) {
command, err := b.resolveDenoCli()
if err != nil {
return "", "", nil, err
}
return frontendDir, command, []string{"task", "build"}, nil
}
if fs.IsFile(ax.Join(frontendDir, "package.json")) {
command, err := b.resolveNpmCli()
if err != nil {
return "", "", nil, err
}
return frontendDir, command, []string{"run", "build"}, nil
}
return "", "", nil, nil
}
// resolveFrontendDir returns the directory that contains the frontend build manifest.
func (b *WailsBuilder) resolveFrontendDir(fs io.Medium, projectDir string) string {
frontendDir := ax.Join(projectDir, "frontend")
if fs.IsDir(frontendDir) && (b.hasDenoConfig(fs, frontendDir) || fs.IsFile(ax.Join(frontendDir, "package.json"))) {
return frontendDir
}
if b.hasDenoConfig(fs, projectDir) || fs.IsFile(ax.Join(projectDir, "package.json")) {
return projectDir
}
return ""
}
// hasDenoConfig reports whether the frontend directory contains a Deno manifest.
func (b *WailsBuilder) hasDenoConfig(fs io.Medium, dir string) bool {
return fs.IsFile(ax.Join(dir, "deno.json")) || fs.IsFile(ax.Join(dir, "deno.jsonc"))
}
// buildV2Target compiles for a single target platform using wails (v2).
func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, target build.Target) (build.Artifact, error) {
wailsCommand, err := b.resolveWailsCli()
@ -258,6 +333,40 @@ func (b *WailsBuilder) resolveWailsCli(paths ...string) (string, error) {
return command, nil
}
// resolveDenoCli returns the executable path for the deno CLI.
func (b *WailsBuilder) resolveDenoCli(paths ...string) (string, error) {
if len(paths) == 0 {
paths = []string{
"/usr/local/bin/deno",
"/opt/homebrew/bin/deno",
}
}
command, err := ax.ResolveCommand("deno", paths...)
if err != nil {
return "", coreerr.E("WailsBuilder.resolveDenoCli", "deno CLI not found. Install it from https://deno.com/runtime", err)
}
return command, nil
}
// resolveNpmCli returns the executable path for the npm CLI.
func (b *WailsBuilder) resolveNpmCli(paths ...string) (string, error) {
if len(paths) == 0 {
paths = []string{
"/usr/local/bin/npm",
"/opt/homebrew/bin/npm",
}
}
command, err := ax.ResolveCommand("npm", paths...)
if err != nil {
return "", coreerr.E("WailsBuilder.resolveNpmCli", "npm CLI not found. Install Node.js from https://nodejs.org/", err)
}
return command, nil
}
// detectPackageManager detects the frontend package manager based on lock files.
// Returns "bun", "pnpm", "yarn", or "npm" (default).
func detectPackageManager(fs io.Medium, dir string) string {

View file

@ -92,6 +92,12 @@ if [ -n "$log_file" ]; then
printf '%s\n' "$@" > "$log_file"
fi
sequence_file="${BUILD_SEQUENCE_FILE:-}"
if [ -n "$sequence_file" ]; then
printf '%s\n' "wails" >> "$sequence_file"
printf '%s\n' "$@" >> "$sequence_file"
fi
output_dir="build/bin"
binary_name="testapp"
mkdir -p "$output_dir"
@ -103,6 +109,22 @@ chmod +x "$output_dir/$binary_name"
require.NoError(t, err)
}
func setupFakeFrontendCommand(t *testing.T, binDir, name string) {
t.Helper()
script := strings.ReplaceAll(`#!/bin/sh
set -eu
sequence_file="${BUILD_SEQUENCE_FILE:-}"
if [ -n "$sequence_file" ]; then
printf '%s\n' "__NAME__" >> "$sequence_file"
printf '%s\n' "$@" >> "$sequence_file"
fi
`, "__NAME__", name)
require.NoError(t, ax.WriteFile(ax.Join(binDir, name), []byte(script), 0o755))
}
func TestWails_WailsBuilderBuildTaskfile_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
@ -227,6 +249,124 @@ func TestWails_WailsBuilderBuildV2Flags_Good(t *testing.T) {
assert.Contains(t, args, "embed")
}
func TestWails_WailsBuilderPreBuild_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
t.Run("uses deno when deno manifest exists", func(t *testing.T) {
binDir := t.TempDir()
setupFakeFrontendCommand(t, binDir, "deno")
setupFakeFrontendCommand(t, binDir, "npm")
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
projectDir := setupWailsTestProject(t)
frontendDir := ax.Join(projectDir, "frontend")
require.NoError(t, ax.MkdirAll(frontendDir, 0o755))
require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "deno.json"), []byte(`{}`), 0o644))
require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "package.json"), []byte(`{}`), 0o644))
logPath := ax.Join(t.TempDir(), "frontend.log")
t.Setenv("BUILD_SEQUENCE_FILE", logPath)
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
}
require.NoError(t, builder.PreBuild(context.Background(), cfg))
content, err := ax.ReadFile(logPath)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
require.Len(t, lines, 3)
assert.Equal(t, "deno", lines[0])
assert.Equal(t, "task", lines[1])
assert.Equal(t, "build", lines[2])
})
t.Run("falls back to npm when only package.json exists", func(t *testing.T) {
binDir := t.TempDir()
setupFakeFrontendCommand(t, binDir, "deno")
setupFakeFrontendCommand(t, binDir, "npm")
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
projectDir := setupWailsTestProject(t)
require.NoError(t, ax.WriteFile(ax.Join(projectDir, "package.json"), []byte(`{}`), 0o644))
logPath := ax.Join(t.TempDir(), "frontend.log")
t.Setenv("BUILD_SEQUENCE_FILE", logPath)
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
}
require.NoError(t, builder.PreBuild(context.Background(), cfg))
content, err := ax.ReadFile(logPath)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
require.Len(t, lines, 3)
assert.Equal(t, "npm", lines[0])
assert.Equal(t, "run", lines[1])
assert.Equal(t, "build", lines[2])
})
}
func TestWails_WailsBuilderBuildV2PreBuild_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
binDir := t.TempDir()
setupFakeFrontendCommand(t, binDir, "deno")
setupFakeFrontendCommand(t, binDir, "npm")
setupFakeWailsToolchain(t, binDir)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
projectDir := setupWailsV2TestProject(t)
frontendDir := ax.Join(projectDir, "frontend")
require.NoError(t, ax.MkdirAll(frontendDir, 0o755))
require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "deno.json"), []byte(`{}`), 0o644))
require.NoError(t, ax.WriteFile(ax.Join(frontendDir, "package.json"), []byte(`{}`), 0o644))
outputDir := t.TempDir()
sequencePath := ax.Join(t.TempDir(), "build-sequence.log")
wailsLogPath := ax.Join(t.TempDir(), "wails.log")
t.Setenv("BUILD_SEQUENCE_FILE", sequencePath)
t.Setenv("WAILS_BUILD_LOG_FILE", wailsLogPath)
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "testapp",
}
targets := []build.Target{
{OS: runtime.GOOS, Arch: runtime.GOARCH},
}
artifacts, err := builder.Build(context.Background(), cfg, targets)
require.NoError(t, err)
require.Len(t, artifacts, 1)
content, err := ax.ReadFile(sequencePath)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
require.GreaterOrEqual(t, len(lines), 4)
assert.Equal(t, "deno", lines[0])
assert.Equal(t, "task", lines[1])
assert.Equal(t, "build", lines[2])
assert.Equal(t, "wails", lines[3])
}
func TestWails_WailsBuilderResolveWailsCli_Good(t *testing.T) {
builder := NewWailsBuilder()
fallbackDir := t.TempDir()