mirror of
https://github.com/wailsapp/wails.git
synced 2025-05-02 15:11:53 +08:00

* Fix -compiler flag for build, dev and generate commands * Update changelog and docs --------- Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
535 lines
15 KiB
Go
535 lines
15 KiB
Go
package dev
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/samber/lo"
|
|
"github.com/wailsapp/wails/v2/cmd/wails/flags"
|
|
"github.com/wailsapp/wails/v2/cmd/wails/internal/gomod"
|
|
"github.com/wailsapp/wails/v2/cmd/wails/internal/logutils"
|
|
"golang.org/x/mod/semver"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/commands/buildtags"
|
|
|
|
"github.com/google/shlex"
|
|
|
|
"github.com/pkg/browser"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/wailsapp/wails/v2/internal/fs"
|
|
"github.com/wailsapp/wails/v2/internal/process"
|
|
"github.com/wailsapp/wails/v2/pkg/clilogger"
|
|
"github.com/wailsapp/wails/v2/pkg/commands/build"
|
|
)
|
|
|
|
const (
|
|
viteMinVersion = "v3.0.0"
|
|
)
|
|
|
|
func sliceToMap(input []string) map[string]struct{} {
|
|
result := map[string]struct{}{}
|
|
for _, value := range input {
|
|
result[value] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Application runs the application in dev mode
|
|
func Application(f *flags.Dev, logger *clilogger.CLILogger) error {
|
|
cwd := lo.Must(os.Getwd())
|
|
|
|
// Update go.mod to use current wails version
|
|
err := gomod.SyncGoMod(logger, !f.NoSyncGoMod)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Run go mod tidy to ensure we're up-to-date
|
|
err = runCommand(cwd, false, f.Compiler, "mod", "tidy")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
buildOptions := f.GenerateBuildOptions()
|
|
buildOptions.Logger = logger
|
|
|
|
userTags, err := buildtags.Parse(f.Tags)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
buildOptions.UserTags = userTags
|
|
|
|
projectConfig := f.ProjectConfig()
|
|
|
|
// Setup signal handler
|
|
quitChannel := make(chan os.Signal, 1)
|
|
signal.Notify(quitChannel, os.Interrupt, syscall.SIGTERM)
|
|
exitCodeChannel := make(chan int, 1)
|
|
|
|
// Build the frontend if requested, but ignore building the application itself.
|
|
ignoreFrontend := buildOptions.IgnoreFrontend
|
|
if !ignoreFrontend {
|
|
buildOptions.IgnoreApplication = true
|
|
if _, err := build.Build(buildOptions); err != nil {
|
|
return err
|
|
}
|
|
buildOptions.IgnoreApplication = false
|
|
}
|
|
|
|
legacyUseDevServerInsteadofCustomScheme := false
|
|
// frontend:dev:watcher command.
|
|
frontendDevAutoDiscovery := projectConfig.IsFrontendDevServerURLAutoDiscovery()
|
|
if command := projectConfig.DevWatcherCommand; command != "" {
|
|
closer, devServerURL, devServerViteVersion, err := runFrontendDevWatcherCommand(projectConfig.GetFrontendDir(), command, frontendDevAutoDiscovery)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if devServerURL != "" {
|
|
projectConfig.FrontendDevServerURL = devServerURL
|
|
f.FrontendDevServerURL = devServerURL
|
|
}
|
|
defer closer()
|
|
|
|
if devServerViteVersion != "" && semver.Compare(devServerViteVersion, viteMinVersion) < 0 {
|
|
logutils.LogRed("Please upgrade your Vite Server to at least '%s' future Wails versions will require at least Vite '%s'", viteMinVersion, viteMinVersion)
|
|
time.Sleep(3 * time.Second)
|
|
legacyUseDevServerInsteadofCustomScheme = true
|
|
}
|
|
} else if frontendDevAutoDiscovery {
|
|
return fmt.Errorf("unable to auto discover frontend:dev:serverUrl without a frontend:dev:watcher command, please either set frontend:dev:watcher or remove the auto discovery from frontend:dev:serverUrl")
|
|
}
|
|
|
|
// Do initial build but only for the application.
|
|
logger.Println("Building application for development...")
|
|
buildOptions.IgnoreFrontend = true
|
|
debugBinaryProcess, appBinary, err := restartApp(buildOptions, nil, f, exitCodeChannel, legacyUseDevServerInsteadofCustomScheme)
|
|
buildOptions.IgnoreFrontend = ignoreFrontend || f.FrontendDevServerURL != ""
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if err := killProcessAndCleanupBinary(debugBinaryProcess, appBinary); err != nil {
|
|
logutils.LogDarkYellow("Unable to kill process and cleanup binary: %s", err)
|
|
}
|
|
}()
|
|
|
|
// open browser
|
|
if f.Browser {
|
|
err = browser.OpenURL(f.DevServerURL().String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
logutils.LogGreen("Using DevServer URL: %s", f.DevServerURL())
|
|
if f.FrontendDevServerURL != "" {
|
|
logutils.LogGreen("Using Frontend DevServer URL: %s", f.FrontendDevServerURL)
|
|
}
|
|
logutils.LogGreen("Using reload debounce setting of %d milliseconds", f.Debounce)
|
|
|
|
// Show dev server URL in terminal after 3 seconds
|
|
go func() {
|
|
time.Sleep(3 * time.Second)
|
|
logutils.LogGreen("\n\nTo develop in the browser and call your bound Go methods from Javascript, navigate to: %s", f.DevServerURL())
|
|
}()
|
|
|
|
// Watch for changes and trigger restartApp()
|
|
debugBinaryProcess, err = doWatcherLoop(cwd, buildOptions, debugBinaryProcess, f, exitCodeChannel, quitChannel, f.DevServerURL(), legacyUseDevServerInsteadofCustomScheme)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Kill the current program if running and remove dev binary
|
|
if err := killProcessAndCleanupBinary(debugBinaryProcess, appBinary); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Reset the process and the binary so defer knows about it and is a nop.
|
|
debugBinaryProcess = nil
|
|
appBinary = ""
|
|
|
|
logutils.LogGreen("Development mode exited")
|
|
|
|
return nil
|
|
}
|
|
|
|
func killProcessAndCleanupBinary(process *process.Process, binary string) error {
|
|
if process != nil && process.Running {
|
|
if err := process.Kill(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if binary != "" {
|
|
err := os.Remove(binary)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runCommand(dir string, exitOnError bool, command string, args ...string) error {
|
|
logutils.LogGreen("Executing: " + command + " " + strings.Join(args, " "))
|
|
cmd := exec.Command(command, args...)
|
|
cmd.Dir = dir
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
println(string(output))
|
|
println(err.Error())
|
|
if exitOnError {
|
|
os.Exit(1)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runFrontendDevWatcherCommand will run the `frontend:dev:watcher` command if it was given, ex- `npm run dev`
|
|
func runFrontendDevWatcherCommand(frontendDirectory string, devCommand string, discoverViteServerURL bool) (func(), string, string, error) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
scanner := NewStdoutScanner()
|
|
cmdSlice := strings.Split(devCommand, " ")
|
|
cmd := exec.CommandContext(ctx, cmdSlice[0], cmdSlice[1:]...)
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdout = scanner
|
|
cmd.Dir = frontendDirectory
|
|
setParentGID(cmd)
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return nil, "", "", fmt.Errorf("unable to start frontend DevWatcher: %w", err)
|
|
}
|
|
|
|
var viteServerURL string
|
|
if discoverViteServerURL {
|
|
select {
|
|
case serverURL := <-scanner.ViteServerURLChan:
|
|
viteServerURL = serverURL
|
|
case <-time.After(time.Second * 10):
|
|
cancel()
|
|
return nil, "", "", errors.New("failed to find Vite server URL")
|
|
}
|
|
}
|
|
|
|
viteVersion := ""
|
|
select {
|
|
case version := <-scanner.ViteServerVersionC:
|
|
viteVersion = version
|
|
|
|
case <-time.After(time.Second * 5):
|
|
// That's fine, then most probably it was not vite that was running
|
|
}
|
|
|
|
logutils.LogGreen("Running frontend DevWatcher command: '%s'", devCommand)
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
|
|
const (
|
|
stateRunning int32 = 0
|
|
stateCanceling int32 = 1
|
|
stateStopped int32 = 2
|
|
)
|
|
state := stateRunning
|
|
go func() {
|
|
if err := cmd.Wait(); err != nil {
|
|
wasRunning := atomic.CompareAndSwapInt32(&state, stateRunning, stateStopped)
|
|
if err.Error() != "exit status 1" && wasRunning {
|
|
logutils.LogRed("Error from DevWatcher '%s': %s", devCommand, err.Error())
|
|
}
|
|
}
|
|
atomic.StoreInt32(&state, stateStopped)
|
|
wg.Done()
|
|
}()
|
|
|
|
return func() {
|
|
if atomic.CompareAndSwapInt32(&state, stateRunning, stateCanceling) {
|
|
killProc(cmd, devCommand)
|
|
}
|
|
cancel()
|
|
wg.Wait()
|
|
}, viteServerURL, viteVersion, nil
|
|
}
|
|
|
|
func isWsl() bool {
|
|
version, err := os.ReadFile("/proc/version")
|
|
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return strings.Contains(strings.ToLower(string(version)), "wsl")
|
|
}
|
|
|
|
// restartApp does the actual rebuilding of the application when files change
|
|
func restartApp(buildOptions *build.Options, debugBinaryProcess *process.Process, f *flags.Dev, exitCodeChannel chan int, legacyUseDevServerInsteadofCustomScheme bool) (*process.Process, string, error) {
|
|
appBinary, err := build.Build(buildOptions)
|
|
println()
|
|
if err != nil {
|
|
logutils.LogRed("Build error - " + err.Error())
|
|
|
|
msg := "Continuing to run current version"
|
|
if debugBinaryProcess == nil {
|
|
msg = "No version running, build will be retriggered as soon as changes have been detected"
|
|
}
|
|
logutils.LogDarkYellow(msg)
|
|
return nil, "", nil
|
|
}
|
|
|
|
// Kill existing binary if need be
|
|
if debugBinaryProcess != nil {
|
|
killError := debugBinaryProcess.Kill()
|
|
|
|
if killError != nil {
|
|
buildOptions.Logger.Fatal("Unable to kill debug binary (PID: %d)!", debugBinaryProcess.PID())
|
|
}
|
|
|
|
debugBinaryProcess = nil
|
|
}
|
|
|
|
// parse appargs if any
|
|
args, err := shlex.Split(f.AppArgs)
|
|
if err != nil {
|
|
buildOptions.Logger.Fatal("Unable to parse appargs: %s", err.Error())
|
|
}
|
|
|
|
// Set environment variables accordingly
|
|
os.Setenv("loglevel", f.LogLevel)
|
|
os.Setenv("assetdir", f.AssetDir)
|
|
os.Setenv("devserver", f.DevServer)
|
|
os.Setenv("frontenddevserverurl", f.FrontendDevServerURL)
|
|
|
|
if buildOptions.IsWindowsTargetPlatform() && isWsl() {
|
|
// In the case of building a Windows executable under WSL, we need to specify this variable with a list of
|
|
// variables that will be passed through
|
|
os.Setenv("WSLENV", "loglevel/w:frontenddevserverurl/w:devserver/w:assetdir/w")
|
|
}
|
|
|
|
// Start up new binary with correct args
|
|
newProcess := process.NewProcess(appBinary, args...)
|
|
err = newProcess.Start(exitCodeChannel)
|
|
if err != nil {
|
|
// Remove binary
|
|
if fs.FileExists(appBinary) {
|
|
deleteError := fs.DeleteFile(appBinary)
|
|
if deleteError != nil {
|
|
buildOptions.Logger.Fatal("Unable to delete app binary: " + appBinary)
|
|
}
|
|
}
|
|
buildOptions.Logger.Fatal("Unable to start application: %s", err.Error())
|
|
}
|
|
|
|
return newProcess, appBinary, nil
|
|
}
|
|
|
|
// doWatcherLoop is the main watch loop that runs while dev is active
|
|
func doWatcherLoop(cwd string, buildOptions *build.Options, debugBinaryProcess *process.Process, f *flags.Dev, exitCodeChannel chan int, quitChannel chan os.Signal, devServerURL *url.URL, legacyUseDevServerInsteadofCustomScheme bool) (*process.Process, error) {
|
|
// create the project files watcher
|
|
watcher, err := initialiseWatcher(cwd)
|
|
if err != nil {
|
|
logutils.LogRed("Unable to create filesystem watcher. Reloads will not occur.")
|
|
return nil, err
|
|
}
|
|
|
|
defer func(watcher *fsnotify.Watcher) {
|
|
err := watcher.Close()
|
|
if err != nil {
|
|
log.Fatal(err.Error())
|
|
}
|
|
}(watcher)
|
|
|
|
logutils.LogGreen("Watching (sub)/directory: %s", cwd)
|
|
|
|
// Main Loop
|
|
extensionsThatTriggerARebuild := sliceToMap(strings.Split(f.Extensions, ","))
|
|
var dirsThatTriggerAReload []string
|
|
for _, dir := range strings.Split(f.ReloadDirs, ",") {
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
thePath, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
logutils.LogRed("Unable to expand reloadDir '%s': %s", dir, err)
|
|
continue
|
|
}
|
|
dirsThatTriggerAReload = append(dirsThatTriggerAReload, thePath)
|
|
err = watcher.Add(thePath)
|
|
if err != nil {
|
|
logutils.LogRed("Unable to watch path: %s due to error %v", thePath, err)
|
|
} else {
|
|
logutils.LogGreen("Watching (sub)/directory: %s", thePath)
|
|
}
|
|
}
|
|
|
|
quit := false
|
|
interval := time.Duration(f.Debounce) * time.Millisecond
|
|
timer := time.NewTimer(interval)
|
|
rebuild := false
|
|
reload := false
|
|
assetDir := ""
|
|
changedPaths := map[string]struct{}{}
|
|
|
|
// If we are using an external dev server, the reloading of the frontend part can be skipped or if the user requested it
|
|
skipAssetsReload := f.FrontendDevServerURL != "" || f.NoReload
|
|
|
|
assetDirURL := joinPath(devServerURL, "/wails/assetdir")
|
|
reloadURL := joinPath(devServerURL, "/wails/reload")
|
|
for !quit {
|
|
// reload := false
|
|
select {
|
|
case exitCode := <-exitCodeChannel:
|
|
if exitCode == 0 {
|
|
quit = true
|
|
}
|
|
case err := <-watcher.Errors:
|
|
logutils.LogDarkYellow(err.Error())
|
|
case item := <-watcher.Events:
|
|
isEligibleFile := func(fileName string) bool {
|
|
// Iterate all file patterns
|
|
ext := filepath.Ext(fileName)
|
|
if ext != "" {
|
|
ext = ext[1:]
|
|
if _, exists := extensionsThatTriggerARebuild[ext]; exists {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Handle write operations
|
|
if item.Op&fsnotify.Write == fsnotify.Write {
|
|
// Ignore directories
|
|
itemName := item.Name
|
|
if fs.DirExists(itemName) {
|
|
continue
|
|
}
|
|
|
|
if isEligibleFile(itemName) {
|
|
rebuild = true
|
|
timer.Reset(interval)
|
|
continue
|
|
}
|
|
|
|
for _, reloadDir := range dirsThatTriggerAReload {
|
|
if strings.HasPrefix(itemName, reloadDir) {
|
|
reload = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !reload {
|
|
changedPaths[filepath.Dir(itemName)] = struct{}{}
|
|
}
|
|
|
|
timer.Reset(interval)
|
|
}
|
|
|
|
// Handle new fs entries that are created
|
|
if item.Op&fsnotify.Create == fsnotify.Create {
|
|
// If this is a folder, add it to our watch list
|
|
if fs.DirExists(item.Name) {
|
|
// node_modules is BANNED!
|
|
if !strings.Contains(item.Name, "node_modules") {
|
|
err := watcher.Add(item.Name)
|
|
if err != nil {
|
|
buildOptions.Logger.Fatal("%s", err.Error())
|
|
}
|
|
logutils.LogGreen("Added new directory to watcher: %s", item.Name)
|
|
}
|
|
} else if isEligibleFile(item.Name) {
|
|
// Handle creation of new file.
|
|
// Note: On some platforms an update to a file is represented as
|
|
// REMOVE -> CREATE instead of WRITE, so this is not only new files
|
|
// but also updates to existing files
|
|
rebuild = true
|
|
timer.Reset(interval)
|
|
continue
|
|
}
|
|
}
|
|
case <-timer.C:
|
|
if rebuild {
|
|
rebuild = false
|
|
if f.NoGoRebuild {
|
|
logutils.LogGreen("[Rebuild triggered] skipping due to flag -nogorebuild")
|
|
} else {
|
|
logutils.LogGreen("[Rebuild triggered] files updated")
|
|
// Try and build the app
|
|
|
|
newBinaryProcess, _, err := restartApp(buildOptions, debugBinaryProcess, f, exitCodeChannel, legacyUseDevServerInsteadofCustomScheme)
|
|
if err != nil {
|
|
logutils.LogRed("Error during build: %s", err.Error())
|
|
continue
|
|
}
|
|
// If we have a new process, saveConfig it
|
|
if newBinaryProcess != nil {
|
|
debugBinaryProcess = newBinaryProcess
|
|
}
|
|
}
|
|
}
|
|
|
|
if !skipAssetsReload && len(changedPaths) != 0 {
|
|
if assetDir == "" {
|
|
resp, err := http.Get(assetDirURL)
|
|
if err != nil {
|
|
logutils.LogRed("Error during retrieving assetdir: %s", err.Error())
|
|
} else {
|
|
content, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
logutils.LogRed("Error reading assetdir from devserver: %s", err.Error())
|
|
} else {
|
|
assetDir = string(content)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
if assetDir != "" {
|
|
for thePath := range changedPaths {
|
|
if strings.HasPrefix(thePath, assetDir) {
|
|
reload = true
|
|
break
|
|
}
|
|
}
|
|
} else if len(dirsThatTriggerAReload) == 0 {
|
|
logutils.LogRed("Reloading couldn't be triggered: Please specify -assetdir or -reloaddirs")
|
|
}
|
|
}
|
|
if reload {
|
|
reload = false
|
|
_, err := http.Get(reloadURL)
|
|
if err != nil {
|
|
logutils.LogRed("Error during refresh: %s", err.Error())
|
|
}
|
|
}
|
|
changedPaths = map[string]struct{}{}
|
|
case <-quitChannel:
|
|
logutils.LogGreen("\nCaught quit")
|
|
quit = true
|
|
}
|
|
}
|
|
return debugBinaryProcess, nil
|
|
}
|
|
|
|
func joinPath(url *url.URL, subPath string) string {
|
|
u := *url
|
|
u.Path = path.Join(u.Path, subPath)
|
|
return u.String()
|
|
}
|