diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 33638e7fc86..52686f598ce 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -20,6 +20,27 @@ After processing, the content will be moved to the main changelog and this file ## Changed +- Start the native application in `wails3 dev` only after the frontend development server is accepting connections. Existing alpha projects must add a `dev:wait` task to `build/Taskfile.yml`: + + ```yaml + dev:wait: + summary: Waits for the frontend development server + vars: + TIMEOUT: 60 + cmds: + - wails3 tool waitport --timeout {{.TIMEOUT}} + ``` + + Then insert the readiness step in `build/config.yml` after the background frontend task and before the primary application task: + + ```yaml + - cmd: wails3 task common:dev:frontend + type: background + - cmd: wails3 task common:dev:wait + type: once + - cmd: wails3 task run + type: primary + ``` ## Fixed diff --git a/v3/cmd/wails3/main.go b/v3/cmd/wails3/main.go index 1badfe24ee3..f54b973a1b2 100644 --- a/v3/cmd/wails3/main.go +++ b/v3/cmd/wails3/main.go @@ -99,6 +99,7 @@ func main() { tool := app.NewSubCommand("tool", "Various tools") tool.NewSubCommandFunction("checkport", "Checks if a port is open. Useful for testing if vite is running.", commands.ToolCheckPort) + tool.NewSubCommandFunction("waitport", "Waits for a port to open. Useful for gating dependent development tasks.", commands.ToolWaitPort) tool.NewSubCommandFunction("watcher", "Watches files and runs a command when they change", commands.Watcher) tool.NewSubCommandFunction("cp", "Copy files", commands.Cp) tool.NewSubCommandFunction("buildinfo", "Show Build Info", commands.BuildInfo) diff --git a/v3/go.mod b/v3/go.mod index c2e637ed48c..aecc386a005 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -6,7 +6,7 @@ require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/adrg/xdg v0.5.3 github.com/atotto/clipboard v0.1.4 - github.com/atterpac/refresh v1.0.0 + github.com/atterpac/refresh v1.1.2 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 diff --git a/v3/go.sum b/v3/go.sum index f466e9964b6..e79a446e3f2 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -61,8 +61,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/atterpac/refresh v1.0.0 h1:IK/rh3w5cD7nb6GuqzfScIdNuAz/E0sZz10k1pioIFE= -github.com/atterpac/refresh v1.0.0/go.mod h1:+vQ8OHgGmZ7wwoZfxxkT6Nr/gKA8j78Rbt+qcLLDEoc= +github.com/atterpac/refresh v1.1.2 h1:NvukuugqyZZ/vCArMVssBWSfg3dwfDbbTdav97d+leM= +github.com/atterpac/refresh v1.1.2/go.mod h1:+vQ8OHgGmZ7wwoZfxxkT6Nr/gKA8j78Rbt+qcLLDEoc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/v3/internal/commands/build_assets/Taskfile.tmpl.yml b/v3/internal/commands/build_assets/Taskfile.tmpl.yml index 65e1eb78a8e..2ffe9f327a2 100644 --- a/v3/internal/commands/build_assets/Taskfile.tmpl.yml +++ b/v3/internal/commands/build_assets/Taskfile.tmpl.yml @@ -210,6 +210,13 @@ tasks: cmds: - task: frontend:dev:{{.Opn}}.PACKAGE_MANAGER{{.Cls}} + dev:wait: + summary: Waits for the frontend development server + vars: + TIMEOUT: 60 + cmds: + - wails3 tool waitport --timeout {{.Opn}}.TIMEOUT{{.Cls}} + frontend:dev:npm: dir: frontend cmds: diff --git a/v3/internal/commands/build_assets/config.yml b/v3/internal/commands/build_assets/config.yml index 9d49291fdff..9359e3e8834 100644 --- a/v3/internal/commands/build_assets/config.yml +++ b/v3/internal/commands/build_assets/config.yml @@ -56,6 +56,8 @@ dev_mode: type: blocking - cmd: wails3 task common:dev:frontend type: background + - cmd: wails3 task common:dev:wait + type: once - cmd: wails3 task run type: primary @@ -76,4 +78,4 @@ fileAssociations: # Other data other: - - name: My Other Data \ No newline at end of file + - name: My Other Data diff --git a/v3/internal/commands/dev_config_test.go b/v3/internal/commands/dev_config_test.go new file mode 100644 index 00000000000..1e2444756ee --- /dev/null +++ b/v3/internal/commands/dev_config_test.go @@ -0,0 +1,47 @@ +package commands + +import ( + "testing" + + "github.com/atterpac/refresh/process" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDevConfigGatesApplicationOnFrontendReadiness(t *testing.T) { + data, err := buildAssets.ReadFile("build_assets/config.yml") + require.NoError(t, err) + + var config struct { + DevMode struct { + Executes []process.Execute `yaml:"executes"` + } `yaml:"dev_mode"` + } + require.NoError(t, yaml.Unmarshal(data, &config)) + + require.Len(t, config.DevMode.Executes, 4) + require.Equal(t, process.Background, config.DevMode.Executes[1].Type) + require.Equal(t, "wails3 task common:dev:wait", config.DevMode.Executes[2].Cmd) + require.Equal(t, process.Once, config.DevMode.Executes[2].Type) + require.Equal(t, process.Primary, config.DevMode.Executes[3].Type) +} + +func TestFrontendDevServerTimeoutIsTaskLocal(t *testing.T) { + data, err := buildAssets.ReadFile("build_assets/Taskfile.tmpl.yml") + require.NoError(t, err) + + var taskfile struct { + Tasks map[string]yaml.Node `yaml:"tasks"` + } + require.NoError(t, yaml.Unmarshal(data, &taskfile)) + + waitTaskNode, exists := taskfile.Tasks["dev:wait"] + require.True(t, exists) + var waitTask struct { + Vars map[string]int `yaml:"vars"` + Cmds []string `yaml:"cmds"` + } + require.NoError(t, waitTaskNode.Decode(&waitTask)) + require.Equal(t, 60, waitTask.Vars["TIMEOUT"]) + require.Equal(t, []string{"wails3 tool waitport --timeout {{.Opn}}.TIMEOUT{{.Cls}}"}, waitTask.Cmds) +} diff --git a/v3/internal/commands/tool_waitport.go b/v3/internal/commands/tool_waitport.go new file mode 100644 index 00000000000..e7a533a1fb0 --- /dev/null +++ b/v3/internal/commands/tool_waitport.go @@ -0,0 +1,69 @@ +package commands + +import ( + "fmt" + "os" + "strconv" + "time" +) + +const portWaitInterval = 100 * time.Millisecond + +type ToolWaitPortOptions struct { + Host string `name:"h" description:"Host to check" default:"localhost"` + Port int `name:"p" description:"Port to check; defaults to WAILS_VITE_PORT when set"` + Timeout int `name:"timeout" description:"Maximum number of seconds to wait for the port to open" default:"60"` +} + +func waitForPort(check func() bool, timeout time.Duration) bool { + if check() { + return true + } + if timeout <= 0 { + return false + } + + ticker := time.NewTicker(portWaitInterval) + defer ticker.Stop() + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case <-ticker.C: + if check() { + return true + } + case <-timer.C: + return false + } + } +} + +func ToolWaitPort(options *ToolWaitPortOptions) error { + DisableFooter = true + + if options.Port == 0 { + port := os.Getenv(wailsVitePort) + if port == "" { + return fmt.Errorf("please use the -p flag to specify a port or set %s", wailsVitePort) + } + var err error + options.Port, err = strconv.Atoi(port) + if err != nil { + return fmt.Errorf("invalid %s value %q: %w", wailsVitePort, port, err) + } + } + if options.Port < 1 || options.Port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + if options.Timeout <= 0 { + return fmt.Errorf("timeout must be greater than zero") + } + + timeout := time.Duration(options.Timeout) * time.Second + if !waitForPort(func() bool { return isPortOpen(options.Host, options.Port) }, timeout) { + return fmt.Errorf("timed out after %s waiting for port %d to open on %s", timeout, options.Port, options.Host) + } + return nil +} diff --git a/v3/internal/commands/tool_waitport_test.go b/v3/internal/commands/tool_waitport_test.go new file mode 100644 index 00000000000..b66210340e6 --- /dev/null +++ b/v3/internal/commands/tool_waitport_test.go @@ -0,0 +1,36 @@ +package commands + +import ( + "testing" + "time" +) + +func TestWaitForPortRetriesUntilReady(t *testing.T) { + attempts := 0 + ready := waitForPort(func() bool { + attempts++ + return attempts == 3 + }, time.Second) + + if !ready { + t.Fatal("waitForPort reported that the port was unavailable") + } + if attempts != 3 { + t.Fatalf("attempt count = %d, want 3", attempts) + } +} + +func TestWaitForPortDoesNotRetryWithoutTimeout(t *testing.T) { + attempts := 0 + ready := waitForPort(func() bool { + attempts++ + return false + }, 0) + + if ready { + t.Fatal("waitForPort reported that the port was available") + } + if attempts != 1 { + t.Fatalf("attempt count = %d, want 1", attempts) + } +} diff --git a/v3/internal/commands/watcher_test.go b/v3/internal/commands/watcher_test.go index 0f48f0d8523..635060ec7a5 100644 --- a/v3/internal/commands/watcher_test.go +++ b/v3/internal/commands/watcher_test.go @@ -3,28 +3,28 @@ package commands import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEnsureIgnored(t *testing.T) { t.Run("adds pattern when not present", func(t *testing.T) { list := []string{".gitignore", ".DS_Store"} ensureIgnored(&list, "*_test.go") - assert.Contains(t, list, "*_test.go") - assert.Len(t, list, 3) + require.Contains(t, list, "*_test.go") + require.Len(t, list, 3) }) t.Run("does not duplicate pattern when already present", func(t *testing.T) { list := []string{".gitignore", "*_test.go"} ensureIgnored(&list, "*_test.go") - assert.Contains(t, list, "*_test.go") - assert.Len(t, list, 2) + require.Contains(t, list, "*_test.go") + require.Len(t, list, 2) }) t.Run("adds to empty list", func(t *testing.T) { var list []string ensureIgnored(&list, "*_test.go") - assert.Contains(t, list, "*_test.go") - assert.Len(t, list, 1) + require.Contains(t, list, "*_test.go") + require.Len(t, list, 1) }) } diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index cd801a1bb94..a7d383b6575 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -650,12 +650,6 @@ func (a *App) Run() error { // Ensure application context is cancelled in case of failures. defer a.cancel() - // Call post-create hooks - err := a.preRun() - if err != nil { - return err - } - a.impl = newPlatformApp(a) // Ensure services are shut down in case of failures. diff --git a/v3/pkg/application/application_dev.go b/v3/pkg/application/application_dev.go index e12033e33e8..7f890eb1c37 100644 --- a/v3/pkg/application/application_dev.go +++ b/v3/pkg/application/application_dev.go @@ -2,50 +2,13 @@ package application -import ( - "net/http" - "time" - - "github.com/wailsapp/wails/v3/internal/assetserver" -) - -var devMode = false - -func (a *App) preRun() error { - // Check for frontend server url - frontendURL := assetserver.GetDevServerURL() - if frontendURL != "" { - devMode = true - // We want to check if the frontend server is running by trying to http get the url - // and if it is not, we wait 500ms and try again for a maximum of 10 times. If it is - // still not available, we return an error. - // This is to allow the frontend server to start up before the backend server. - client := http.Client{} - a.Logger.Info("Waiting for frontend dev server to start...", "url", frontendURL) - for i := 0; i < 10; i++ { - _, err := client.Get(frontendURL) - if err == nil { - a.Logger.Info("Connected to frontend dev server!") - return nil - } - // Wait 500ms - time.Sleep(500 * time.Millisecond) - if i%2 == 0 { - a.Logger.Info("Retrying...") - } - } - a.fatal("unable to connect to frontend server. Please check it is running - FRONTEND_DEVSERVER_URL='%s'", frontendURL) - } - return nil -} +import "github.com/wailsapp/wails/v3/internal/assetserver" func (a *App) postQuit() { - if devMode { - a.Logger.Info("The application has terminated, but the watcher is still running.") - a.Logger.Info("To terminate the watcher, press CTRL+C") + if assetserver.GetDevServerURL() == "" { + return } -} - -func (a *App) enableDevTools() { + a.Logger.Info("The application has terminated, but the watcher is still running.") + a.Logger.Info("To terminate the watcher, press CTRL+C") } diff --git a/v3/pkg/application/application_production.go b/v3/pkg/application/application_production.go index 75f86b44d9c..4cbe5a79848 100644 --- a/v3/pkg/application/application_production.go +++ b/v3/pkg/application/application_production.go @@ -13,6 +13,4 @@ func newApplication(options Options) *App { func (a *App) logStartup() {} -func (a *App) preRun() error { return nil } - -func (a *App) postQuit() error { return nil } +func (a *App) postQuit() {}