From 902e38d8ee1aa33f878454b722ec237e65eac5c3 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Mon, 6 Jul 2026 10:24:31 +1000 Subject: [PATCH 1/4] feat(v3): add `wails3 migrate` command for automated v2 to v3 project migration wails3 migrate -d ./myv2project -o ./myv3project converts a Wails v2 project into a v3 project: - Parses wails.json and the declarative options.App literal passed to wails.Run (syntax-only, no module downloads needed) and generates an equivalent programmatic main.go via application.New() + app.Window.NewWithOptions(), preserving user code and comments through textual surgery on just the wails.Run statement and imports. - Maps v2 options to v3 (window geometry, start state, background colour, platform-specific options, single instance, asset server), converts Bind entries into v3 services and bridges the OnStartup/OnDomReady/OnShutdown/OnBeforeClose lifecycle callbacks. - Adds pkg/v2compat/runtime: the v2 runtime API (context-first functions) implemented on the v3 application API, so migrated code only needs its import path rewritten. Covers events, window control, dialogs, clipboard, browser, screens, logging and app control; every function documents its v3 replacement for incremental migration. - Migrates the frontend: regenerates wailsjs/ as a compatibility layer over @wailsio/runtime (runtime shim + Call.ByName binding shims from the parsed bound-struct methods) and adds the npm dependency. - Scaffolds the v3 build system via the init machinery: Taskfile, build/ assets and build/config.yml populated from the v2 metadata (product info, file associations, protocols, bundle id kept as com.wails. so the app keeps its identity). - Transforms go.mod (wails/v2 -> wails/v3, go directive raised), preserving all other requires. - Writes MIGRATION.md documenting every mapped option and any manual steps (menus, custom loggers, EnumBind, ...). Docs: the v2-to-v3 migration guide now leads with the automated path. --- docs/src/content/docs/migration/v2-to-v3.mdx | 21 + v3/cmd/wails3/main.go | 2 + v3/internal/commands/migrate.go | 376 ++++++++++ v3/internal/commands/migrate_test.go | 233 ++++++ v3/internal/flags/migrate.go | 11 + .../migrate/assets/wailsjs-runtime.d.ts | 136 ++++ v3/internal/migrate/assets/wailsjs-runtime.js | 169 +++++ v3/internal/migrate/copy.go | 139 ++++ v3/internal/migrate/frontend.go | 185 +++++ v3/internal/migrate/gomod.go | 51 ++ v3/internal/migrate/maingen.go | 295 ++++++++ v3/internal/migrate/mapping.go | 681 ++++++++++++++++++ v3/internal/migrate/migrate.go | 102 +++ v3/internal/migrate/migrate_test.go | 442 ++++++++++++ v3/internal/migrate/parse.go | 498 +++++++++++++ v3/internal/migrate/report.go | 88 +++ v3/internal/migrate/wailsjson.go | 111 +++ v3/pkg/v2compat/runtime/app.go | 44 ++ v3/pkg/v2compat/runtime/browser.go | 18 + v3/pkg/v2compat/runtime/clipboard.go | 33 + v3/pkg/v2compat/runtime/dialog.go | 218 ++++++ v3/pkg/v2compat/runtime/doc.go | 14 + v3/pkg/v2compat/runtime/draganddrop.go | 80 ++ v3/pkg/v2compat/runtime/events.go | 118 +++ v3/pkg/v2compat/runtime/lifecycle.go | 68 ++ v3/pkg/v2compat/runtime/log.go | 120 +++ v3/pkg/v2compat/runtime/runtime.go | 56 ++ v3/pkg/v2compat/runtime/screen.go | 58 ++ v3/pkg/v2compat/runtime/window.go | 265 +++++++ 29 files changed, 4632 insertions(+) create mode 100644 v3/internal/commands/migrate.go create mode 100644 v3/internal/commands/migrate_test.go create mode 100644 v3/internal/flags/migrate.go create mode 100644 v3/internal/migrate/assets/wailsjs-runtime.d.ts create mode 100644 v3/internal/migrate/assets/wailsjs-runtime.js create mode 100644 v3/internal/migrate/copy.go create mode 100644 v3/internal/migrate/frontend.go create mode 100644 v3/internal/migrate/gomod.go create mode 100644 v3/internal/migrate/maingen.go create mode 100644 v3/internal/migrate/mapping.go create mode 100644 v3/internal/migrate/migrate.go create mode 100644 v3/internal/migrate/migrate_test.go create mode 100644 v3/internal/migrate/parse.go create mode 100644 v3/internal/migrate/report.go create mode 100644 v3/internal/migrate/wailsjson.go create mode 100644 v3/pkg/v2compat/runtime/app.go create mode 100644 v3/pkg/v2compat/runtime/browser.go create mode 100644 v3/pkg/v2compat/runtime/clipboard.go create mode 100644 v3/pkg/v2compat/runtime/dialog.go create mode 100644 v3/pkg/v2compat/runtime/doc.go create mode 100644 v3/pkg/v2compat/runtime/draganddrop.go create mode 100644 v3/pkg/v2compat/runtime/events.go create mode 100644 v3/pkg/v2compat/runtime/lifecycle.go create mode 100644 v3/pkg/v2compat/runtime/log.go create mode 100644 v3/pkg/v2compat/runtime/runtime.go create mode 100644 v3/pkg/v2compat/runtime/screen.go create mode 100644 v3/pkg/v2compat/runtime/window.go diff --git a/docs/src/content/docs/migration/v2-to-v3.mdx b/docs/src/content/docs/migration/v2-to-v3.mdx index 4910df40f9a..d617f134ce5 100644 --- a/docs/src/content/docs/migration/v2-to-v3.mdx +++ b/docs/src/content/docs/migration/v2-to-v3.mdx @@ -18,6 +18,27 @@ Wails v3 is a **complete rewrite** with significant improvements in architecture **Migration time:** 1-4 hours for typical applications +## Automated Migration + +The CLI can perform most of this guide for you: + +```bash +wails3 migrate -d ./myv2project -o ./myv3project +``` + +This parses your v2 project (wails.json and the `options.App` literal passed to `wails.Run`) and generates a v3 project: + +- `main.go` is rewritten around `application.New()` + `app.Window.NewWithOptions()`, keeping your own code and comments intact. Options are mapped to their v3 equivalents, including platform-specific window options. +- Structs listed in `Bind` become v3 services; the `OnStartup`/`OnDomReady`/`OnShutdown`/`OnBeforeClose` callbacks are bridged automatically. +- Go files calling the v2 `runtime` package are pointed at `github.com/wailsapp/wails/v3/pkg/v2compat/runtime`, a compatibility bridge with the v2 API (context-first functions) implemented on v3. Each bridge function documents its v3 replacement so you can migrate incrementally. +- The frontend is copied over and `wailsjs/` is regenerated as a thin layer over `@wailsio/runtime`, so existing imports like `../wailsjs/go/main/App` and `../wailsjs/runtime/runtime` keep working. +- `wails.json` is replaced by the v3 project files: a Taskfile-based build system and `build/config.yml` populated from your v2 metadata (product info, file associations, protocols). +- `go.mod` swaps `wails/v2` for `wails/v3`; everything else is preserved. + +Anything that cannot be migrated automatically (menus, custom loggers, `EnumBind`, ...) is listed with instructions in a generated `MIGRATION.md`. Run `wails3 dev` in the output directory to build and run the migrated app, then work through that file. + +The rest of this guide explains the underlying changes - useful for finishing the manual steps and for migrating off the compatibility bridge over time. + ## Breaking Changes ### Application Initialisation diff --git a/v3/cmd/wails3/main.go b/v3/cmd/wails3/main.go index 4dc8a329e74..c40d4f8cc2c 100644 --- a/v3/cmd/wails3/main.go +++ b/v3/cmd/wails3/main.go @@ -44,6 +44,8 @@ func main() { app.NewSubCommandFunction("dev", "Run in Dev mode", commands.Dev) + app.NewSubCommandFunction("migrate", "Migrate a Wails v2 project to v3", commands.Migrate) + pkg := app.NewSubCommand("package", "Package application") var pkgFlags flags.Package pkg.AddFlags(&pkgFlags) diff --git a/v3/internal/commands/migrate.go b/v3/internal/commands/migrate.go new file mode 100644 index 00000000000..fd2c3a6bddf --- /dev/null +++ b/v3/internal/commands/migrate.go @@ -0,0 +1,376 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/wailsapp/wails/v3/internal/flags" + "github.com/wailsapp/wails/v3/internal/migrate" + "github.com/wailsapp/wails/v3/internal/templates" + "github.com/wailsapp/wails/v3/internal/term" + "github.com/wailsapp/wails/v3/internal/version" +) + +// Migrate converts a Wails v2 project into a Wails v3 project: +// +// wails3 migrate -d ./myv2project -o ./myv3project +// +// It parses wails.json and the declarative options.App literal passed to +// wails.Run, generates an equivalent programmatic v3 main file, scaffolds the +// v3 build system (Taskfile + build assets), migrates the frontend (rewriting +// the generated wailsjs modules onto @wailsio/runtime) and rewrites v2 +// runtime imports to the v3 compatibility bridge. Everything that cannot be +// migrated automatically is recorded in MIGRATION.md. +func Migrate(options *flags.Migrate) error { + DisableFooter = true + + if options.Quiet { + term.DisableOutput() + } + term.Header("Migrate Wails v2 project") + + if options.OutputDir == "" { + return errors.New("please use the -o flag to specify an output directory for the migrated project") + } + + proj, err := migrate.ParseV2Project(options.V2Dir) + if err != nil { + return err + } + + outDir, err := filepath.Abs(options.OutputDir) + if err != nil { + return err + } + if isSubPath(proj.Dir, outDir) { + return fmt.Errorf("the output directory must not be inside the v2 project directory") + } + if entries, err := os.ReadDir(outDir); err == nil && len(entries) > 0 && !options.Force { + return fmt.Errorf("output directory %s is not empty (use -f to write into it anyway)", outDir) + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + + cfg := proj.Config + term.Infof("Migrating %s (module %s)\n", cfg.Name, proj.ModulePath) + + // Map the declarative v2 options onto the v3 API and rewrite the main file. + v3opts := migrate.MapOptions(proj) + mainSrc, err := migrate.GenerateMain(proj, v3opts) + if err != nil { + return err + } + + // Scaffold the v3 project pieces (Taskfile, .gitignore) from the project + // template, then generate the build assets from the v2 project metadata. + if err := scaffoldV3(proj, outDir, options.Quiet); err != nil { + return err + } + + // Write the migrated Go sources. + mainRel, err := filepath.Rel(proj.Dir, proj.Main.Path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(filepath.Join(outDir, mainRel)), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, mainRel), mainSrc, 0o644); err != nil { + return err + } + if err := migrate.CopyProjectFiles(proj, outDir); err != nil { + return err + } + + // go.mod: swap wails/v2 for wails/v3, keep everything else. + // LatestStable is the released tag even in dev builds, so the generated + // require is always resolvable. + goMod, err := migrate.TransformGoMod(proj, version.LatestStable()) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, "go.mod"), goMod, 0o644); err != nil { + return err + } + + // Frontend: copy, regenerate wailsjs as a compatibility layer, add the + // @wailsio/runtime dependency. + if err := migrate.MigrateFrontend(proj, outDir); err != nil { + return err + } + + // Write the migration report. + reportPath := filepath.Join(outDir, "MIGRATION.md") + if err := os.WriteFile(reportPath, []byte(proj.Report.Markdown()), 0o644); err != nil { + return err + } + + if !options.SkipGoModTidy { + term.Info("Running go mod tidy...") + cmd := exec.Command("go", "mod", "tidy") + cmd.Dir = outDir + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + term.Warningf("go mod tidy failed (%v) - run it manually in %s\n", err, outDir) + } + } + + term.Infof("Migration complete: %s\n", outDir) + if proj.Report.HasManualSteps() { + term.Warningf("Some options need manual attention - see %s\n", reportPath) + } else { + term.Infof("See %s for the migration summary.\n", reportPath) + } + term.Infof("Next: cd %s && wails3 dev\n", options.OutputDir) + return nil +} + +// isSubPath reports whether child is inside parent. +func isSubPath(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)) +} + +// scaffoldV3 creates the v3 project skeleton pieces around the migrated +// sources: the root Taskfile, a .gitignore when the project has none, and +// the build directory (config.yml, platform Taskfiles, icons, packaging +// templates) populated from the v2 project metadata. +func scaffoldV3(proj *V2ProjectAlias, outDir string, quiet bool) error { + cfg := proj.Config + + // Render the project template into a scratch dir and lift the Taskfile + // (and .gitignore if needed) from it. This reuses the exact same + // scaffolding machinery as `wails3 init`. + tmpDir, err := os.MkdirTemp("", "wails3-migrate-scaffold") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + initFlags := &flags.Init{ + ProjectName: cfg.OutputFilename, + TemplateName: "vanilla", + ProjectDir: tmpDir, + ModulePath: proj.ModulePath, + Quiet: true, + SkipGoModTidy: true, + } + // templates.Install prints a project summary (partly via bare fmt.Print), + // scaffolds into / (mutating initFlags.ProjectDir + // to match) and changes the working directory into it. Silence it and + // restore the working directory afterwards - the scratch project is + // deleted. + wd, err := os.Getwd() + if err != nil { + return err + } + term.DisableOutput() + stdout := os.Stdout + if devnull, dnErr := os.OpenFile(os.DevNull, os.O_WRONLY, 0); dnErr == nil { + os.Stdout = devnull + defer devnull.Close() + } + err = templates.Install(initFlags) + os.Stdout = stdout + if !quiet { + term.EnableOutput() + } + if chdirErr := os.Chdir(wd); chdirErr != nil { + return chdirErr + } + if err != nil { + return err + } + scaffoldDir := initFlags.ProjectDir + + taskfile, err := os.ReadFile(filepath.Join(scaffoldDir, "Taskfile.yml")) + if err != nil { + return err + } + // v2 recorded the package manager in the frontend:install command; the v3 + // Taskfile uses the PACKAGE_MANAGER variable. + if pm := cfg.PackageManager(); pm != "npm" { + taskfile = []byte(strings.Replace(string(taskfile), `default "npm"`, `default "`+pm+`"`, 1)) + } + if err := os.WriteFile(filepath.Join(outDir, "Taskfile.yml"), taskfile, 0o644); err != nil { + return err + } + + if _, err := os.Stat(filepath.Join(proj.Dir, ".gitignore")); os.IsNotExist(err) { + gitignore, err := os.ReadFile(filepath.Join(scaffoldDir, "gitignore")) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, ".gitignore"), gitignore, 0o644); err != nil { + return err + } + } else { + proj.Report.Note("Kept the project's .gitignore; the v3 build outputs binaries to `bin/`, so consider adding `bin/` to it.") + } + + // Build assets from v2 metadata. The product identifier keeps v2's + // `com.wails.` convention so the migrated app keeps its identity. + company := cfg.Info.CompanyName + if company == "" { + company = cfg.Author.Name + } + copyright := "" + if cfg.Info.Copyright != nil { + copyright = *cfg.Info.Copyright + } + comments := "" + if cfg.Info.Comments != nil { + comments = *cfg.Info.Comments + } + buildAssetsOptions := &BuildAssetsOptions{ + Dir: filepath.Join(outDir, "build"), + Name: cfg.Name, + BinaryName: cfg.OutputFilename, + ProductName: cfg.Info.ProductName, + ProductVersion: cfg.Info.ProductVersion, + ProductIdentifier: safeBundleID(cfg.Name), + ProductCompany: company, + ProductCopyright: copyright, + ProductComments: comments, + ProductDescription: cfg.Info.ProductName, + Silent: true, + Typescript: isTypescriptFrontend(proj.FrontendDir), + UseInterfaces: true, + } + if err := GenerateBuildAssets(buildAssetsOptions); err != nil { + return err + } + + // Persist the values into build/config.yml so future + // `wails3 task common:update:build-assets` runs keep them. + configFlags := &flags.Init{ + ProjectDir: outDir, + ProductName: buildAssetsOptions.ProductName, + ProductVersion: buildAssetsOptions.ProductVersion, + ProductIdentifier: buildAssetsOptions.ProductIdentifier, + ProductCompany: buildAssetsOptions.ProductCompany, + ProductCopyright: buildAssetsOptions.ProductCopyright, + ProductComments: buildAssetsOptions.ProductComments, + ProductDescription: buildAssetsOptions.ProductDescription, + } + if err := writeProjectConfigYML(configFlags); err != nil { + return err + } + + // File associations and protocols move from wails.json to config.yml. + if len(cfg.Info.FileAssociations) > 0 || len(cfg.Info.Protocols) > 0 { + if err := appendAssociations(proj, outDir); err != nil { + return err + } + updateOptions := &UpdateBuildAssetsOptions{ + Dir: filepath.Join(outDir, "build"), + Config: filepath.Join(outDir, "build", "config.yml"), + Name: cfg.Name, + BinaryName: cfg.OutputFilename, + ProductName: buildAssetsOptions.ProductName, + ProductVersion: buildAssetsOptions.ProductVersion, + ProductIdentifier: buildAssetsOptions.ProductIdentifier, + ProductCompany: buildAssetsOptions.ProductCompany, + ProductCopyright: buildAssetsOptions.ProductCopyright, + ProductComments: buildAssetsOptions.ProductComments, + ProductDescription: buildAssetsOptions.ProductDescription, + Silent: true, + } + if err := UpdateBuildAssets(updateOptions); err != nil { + return err + } + } + + // Keep the application icon. + srcIcon := filepath.Join(proj.Dir, cfg.BuildDir, "appicon.png") + if _, err := os.Stat(srcIcon); err == nil { + data, err := os.ReadFile(srcIcon) + if err == nil { + _ = os.WriteFile(filepath.Join(outDir, "build", "appicon.png"), data, 0o644) + proj.Report.Note("Copied build/appicon.png from the v2 project. Run `wails3 task common:generate:icons` to regenerate the platform icon files (icons.icns / icon.ico) from it.") + } + } + proj.Report.Note("Custom changes to the v2 build assets (Info.plist, wails.exe.manifest, NSIS scripts) were not carried over; the v3 equivalents live in `build/` and are configured through `build/config.yml`.") + + return nil +} + +// V2ProjectAlias keeps the commands package decoupled from the migrate +// package's internals in function signatures. +type V2ProjectAlias = migrate.V2Project + +// safeBundleID mirrors the v2 bundle-id derivation (com.wails. with +// non-alphanumerics replaced) so migrated apps keep their identity. +func safeBundleID(name string) string { + var sb strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + sb.WriteRune(r) + } else { + sb.WriteRune('-') + } + } + return "com.wails." + sb.String() +} + +func isTypescriptFrontend(frontendDir string) bool { + if _, err := os.Stat(filepath.Join(frontendDir, "tsconfig.json")); err == nil { + return true + } + return false +} + +var fileAssociationsRe = regexp.MustCompile(`(?m)^fileAssociations:\s*$`) + +// appendAssociations writes the v2 file associations and protocols into the +// generated build/config.yml. +func appendAssociations(proj *V2ProjectAlias, outDir string) error { + configPath := filepath.Join(outDir, "build", "config.yml") + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + content := string(data) + + if fas := proj.Config.Info.FileAssociations; len(fas) > 0 { + var sb strings.Builder + sb.WriteString("fileAssociations:\n") + for _, fa := range fas { + sb.WriteString(fmt.Sprintf(" - ext: %s\n", fa.Ext)) + sb.WriteString(fmt.Sprintf(" name: %s\n", fa.Name)) + sb.WriteString(fmt.Sprintf(" description: %s\n", fa.Description)) + sb.WriteString(fmt.Sprintf(" iconName: %s\n", fa.IconName)) + sb.WriteString(fmt.Sprintf(" role: %s\n", fa.Role)) + } + if fileAssociationsRe.MatchString(content) { + content = fileAssociationsRe.ReplaceAllString(content, strings.TrimSuffix(sb.String(), "\n")) + } else { + content += "\n" + sb.String() + } + proj.Report.Note("File associations were moved to `build/config.yml`. Copy their icons into `build/` (macOS: `.icns`, Windows: `.ico`).") + } + + if protos := proj.Config.Info.Protocols; len(protos) > 0 { + var sb strings.Builder + sb.WriteString("\nprotocols:\n") + for _, p := range protos { + sb.WriteString(fmt.Sprintf(" - scheme: %s\n", p.Scheme)) + if p.Description != "" { + sb.WriteString(fmt.Sprintf(" description: %s\n", p.Description)) + } + } + content += sb.String() + proj.Report.Note("Custom protocol schemes were moved to `build/config.yml`.") + } + + return os.WriteFile(configPath, []byte(content), 0o644) +} diff --git a/v3/internal/commands/migrate_test.go b/v3/internal/commands/migrate_test.go new file mode 100644 index 00000000000..aa0a16b2bda --- /dev/null +++ b/v3/internal/commands/migrate_test.go @@ -0,0 +1,233 @@ +package commands + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wailsapp/wails/v3/internal/flags" +) + +func writeV2Fixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "wails.json": `{ + "name": "demoapp", + "outputfilename": "demoapp", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "info": { "companyName": "Wails", "productName": "Demo App", "productVersion": "2.0.0" } +}`, + "go.mod": "module demoapp\n\ngo 1.23\n\nrequire github.com/wailsapp/wails/v2 v2.10.2\n", + "main.go": `package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "Demo App", + Width: 800, + Height: 600, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} +`, + "app.go": `package main + +import "context" + +type App struct { + ctx context.Context +} + +func NewApp() *App { + return &App{} +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx +} + +func (a *App) Greet(name string) string { + return "Hello " + name +} +`, + "frontend/package.json": `{"name":"frontend","devDependencies":{"vite":"^3.0.0"}}`, + "frontend/index.html": ``, + "frontend/dist/.gitkeep": "", + "build/appicon.png": "not-really-a-png", + } + for name, content := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestMigrateEndToEnd(t *testing.T) { + v2Dir := writeV2Fixture(t) + outDir := filepath.Join(t.TempDir(), "v3out") + + err := Migrate(&flags.Migrate{ + V2Dir: v2Dir, + OutputDir: outDir, + Quiet: true, + SkipGoModTidy: true, + }) + if err != nil { + t.Fatalf("Migrate: %v", err) + } + + mustExist := []string{ + "main.go", + "app.go", + "go.mod", + "Taskfile.yml", + "MIGRATION.md", + ".gitignore", + "build/config.yml", + "build/Taskfile.yml", + "build/darwin/Taskfile.yml", + "build/appicon.png", + "frontend/package.json", + "frontend/wailsjs/runtime/runtime.js", + "frontend/wailsjs/go/main/App.js", + "frontend/dist/.gitkeep", + } + for _, rel := range mustExist { + if _, err := os.Stat(filepath.Join(outDir, rel)); err != nil { + t.Errorf("expected %s to exist: %v", rel, err) + } + } + + mustNotExist := []string{ + "wails.json", + "go.sum", + } + for _, rel := range mustNotExist { + if _, err := os.Stat(filepath.Join(outDir, rel)); err == nil { + t.Errorf("expected %s to be absent", rel) + } + } + + mainSrc, err := os.ReadFile(filepath.Join(outDir, "main.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(mainSrc), "application.New(application.Options{") { + t.Errorf("main.go not migrated:\n%s", mainSrc) + } + if strings.Contains(string(mainSrc), "wails/v2") { + t.Errorf("main.go still references v2:\n%s", mainSrc) + } + + // The kept v2 appicon must win over the template icon. + icon, err := os.ReadFile(filepath.Join(outDir, "build", "appicon.png")) + if err != nil { + t.Fatal(err) + } + if string(icon) != "not-really-a-png" { + t.Error("v2 appicon.png was not preserved") + } + + configYML, err := os.ReadFile(filepath.Join(outDir, "build", "config.yml")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`productName: "Demo App"`, `version: "2.0.0"`, `companyName: "Wails"`, `productIdentifier: "com.wails.demoapp"`} { + if !strings.Contains(string(configYML), want) { + t.Errorf("config.yml missing %s:\n%s", want, configYML) + } + } + + goMod, err := os.ReadFile(filepath.Join(outDir, "go.mod")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(goMod), "github.com/wailsapp/wails/v3") || strings.Contains(string(goMod), "wails/v2") { + t.Errorf("go.mod not transformed:\n%s", goMod) + } +} + +func TestMigrateRefusesNonEmptyOutput(t *testing.T) { + v2Dir := writeV2Fixture(t) + outDir := t.TempDir() + if err := os.WriteFile(filepath.Join(outDir, "existing.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + err := Migrate(&flags.Migrate{ + V2Dir: v2Dir, + OutputDir: outDir, + Quiet: true, + SkipGoModTidy: true, + }) + if err == nil || !strings.Contains(err.Error(), "not empty") { + t.Fatalf("expected non-empty error, got %v", err) + } +} + +func TestMigrateRefusesNestedOutput(t *testing.T) { + v2Dir := writeV2Fixture(t) + + err := Migrate(&flags.Migrate{ + V2Dir: v2Dir, + OutputDir: filepath.Join(v2Dir, "out"), + Quiet: true, + SkipGoModTidy: true, + }) + if err == nil || !strings.Contains(err.Error(), "must not be inside") { + t.Fatalf("expected nested-output error, got %v", err) + } +} + +func TestMigrateRejectsNonV2Project(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "wails.json": `{"name":"demo"}`, + "go.mod": "module demo\n\ngo 1.23\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + err := Migrate(&flags.Migrate{ + V2Dir: dir, + OutputDir: filepath.Join(t.TempDir(), "out"), + Quiet: true, + SkipGoModTidy: true, + }) + if err == nil || !strings.Contains(err.Error(), "wailsapp/wails/v2") { + t.Fatalf("expected v2-detection error, got %v", err) + } +} diff --git a/v3/internal/flags/migrate.go b/v3/internal/flags/migrate.go new file mode 100644 index 00000000000..67ef78f09db --- /dev/null +++ b/v3/internal/flags/migrate.go @@ -0,0 +1,11 @@ +package flags + +type Migrate struct { + Common + + V2Dir string `name:"d" description:"Path to the Wails v2 project to migrate" default:"."` + OutputDir string `name:"o" description:"Directory to write the migrated Wails v3 project to"` + Force bool `name:"f" description:"Write into a non-empty output directory"` + Quiet bool `name:"q" description:"Suppress output to console"` + SkipGoModTidy bool `name:"skipgomodtidy" description:"Skip running go mod tidy on the migrated project"` +} diff --git a/v3/internal/migrate/assets/wailsjs-runtime.d.ts b/v3/internal/migrate/assets/wailsjs-runtime.d.ts new file mode 100644 index 00000000000..d574048f914 --- /dev/null +++ b/v3/internal/migrate/assets/wailsjs-runtime.d.ts @@ -0,0 +1,136 @@ +// Wails v2 runtime compatibility layer, generated by `wails3 migrate`. +// Type declarations matching the classic wailsjs/runtime API. + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface ScreenSize { + width: number; + height: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width: number; + height: number; + size: ScreenSize; + physicalSize: ScreenSize; +} + +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +export function EventsEmit(eventName: string, ...data: any): void; + +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +export function EventsOffAll(): void; + +export function LogPrint(message: string): void; + +export function LogTrace(message: string): void; + +export function LogDebug(message: string): void; + +export function LogInfo(message: string): void; + +export function LogWarning(message: string): void; + +export function LogError(message: string): void; + +export function LogFatal(message: string): void; + +export function WindowReload(): void; + +export function WindowReloadApp(): void; + +export function WindowSetAlwaysOnTop(b: boolean): void; + +export function WindowSetSystemDefaultTheme(): void; + +export function WindowSetLightTheme(): void; + +export function WindowSetDarkTheme(): void; + +export function WindowCenter(): void; + +export function WindowSetTitle(title: string): void; + +export function WindowFullscreen(): void; + +export function WindowUnfullscreen(): void; + +export function WindowIsFullscreen(): Promise; + +export function WindowSetSize(width: number, height: number): void; + +export function WindowGetSize(): Promise; + +export function WindowSetMaxSize(width: number, height: number): void; + +export function WindowSetMinSize(width: number, height: number): void; + +export function WindowSetPosition(x: number, y: number): void; + +export function WindowGetPosition(): Promise; + +export function WindowHide(): void; + +export function WindowShow(): void; + +export function WindowMaximise(): void; + +export function WindowToggleMaximise(): void; + +export function WindowUnmaximise(): void; + +export function WindowIsMaximised(): Promise; + +export function WindowMinimise(): void; + +export function WindowUnminimise(): void; + +export function WindowIsMinimised(): Promise; + +export function WindowIsNormal(): Promise; + +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +export function WindowPrint(): void; + +export function ScreenGetAll(): Promise; + +export function BrowserOpenURL(url: string): void; + +export function Environment(): Promise; + +export function Quit(): void; + +export function Hide(): void; + +export function Show(): void; + +export function ClipboardGetText(): Promise; + +export function ClipboardSetText(text: string): Promise; + +export function OnFileDrop(callback: (x: number, y: number, paths: string[]) => void, useDropTarget: boolean): void; + +export function OnFileDropOff(): void; diff --git a/v3/internal/migrate/assets/wailsjs-runtime.js b/v3/internal/migrate/assets/wailsjs-runtime.js new file mode 100644 index 00000000000..9f34c8b4ee6 --- /dev/null +++ b/v3/internal/migrate/assets/wailsjs-runtime.js @@ -0,0 +1,169 @@ +// Wails v2 runtime compatibility layer, generated by `wails3 migrate`. +// It implements the classic wailsjs/runtime API on top of @wailsio/runtime +// (the Wails v3 runtime). New code should import '@wailsio/runtime' directly. + +import {Application, Browser, Clipboard, Events, Screens, System, Window} from '@wailsio/runtime'; + +function unpack(callback) { + return (ev) => { + const data = ev ? ev.data : undefined; + if (data === null || data === undefined) { + callback(); + } else if (Array.isArray(data)) { + callback(...data); + } else { + callback(data); + } + }; +} + +/* ------------------------------- Events -------------------------------- */ + +export function EventsOn(eventName, callback) { + return Events.On(eventName, unpack(callback)); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return Events.OnMultiple(eventName, unpack(callback), maxCallbacks); +} + +export function EventsOnce(eventName, callback) { + return Events.Once(eventName, unpack(callback)); +} + +export function EventsOff(eventName, ...additionalEventNames) { + Events.Off(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + Events.OffAll(); +} + +export function EventsEmit(eventName, ...args) { + const data = args.length === 0 ? null : (args.length === 1 ? args[0] : args); + void Events.Emit(eventName, data); +} + +/* -------------------------------- Logs --------------------------------- */ +// v2 forwarded these to the application logger; the v3 bridge logs to the +// browser console instead. Use structured logging in Go for application logs. + +export function LogPrint(message) { console.log(message); } +export function LogTrace(message) { console.debug(message); } +export function LogDebug(message) { console.debug(message); } +export function LogInfo(message) { console.info(message); } +export function LogWarning(message) { console.warn(message); } +export function LogError(message) { console.error(message); } +export function LogFatal(message) { console.error(message); } + +/* ------------------------------- Window -------------------------------- */ + +export function WindowReload() { void Window.Reload(); } +export function WindowReloadApp() { void Window.ForceReload(); } +export function WindowSetAlwaysOnTop(b) { void Window.SetAlwaysOnTop(b); } + +export function WindowSetSystemDefaultTheme() { + console.warn('WindowSetSystemDefaultTheme: not supported by the Wails v3 compatibility layer; the theme is set at window creation.'); +} +export function WindowSetLightTheme() { + console.warn('WindowSetLightTheme: not supported by the Wails v3 compatibility layer; the theme is set at window creation.'); +} +export function WindowSetDarkTheme() { + console.warn('WindowSetDarkTheme: not supported by the Wails v3 compatibility layer; the theme is set at window creation.'); +} + +export function WindowCenter() { void Window.Center(); } +export function WindowSetTitle(title) { void Window.SetTitle(title); } +export function WindowFullscreen() { void Window.Fullscreen(); } +export function WindowUnfullscreen() { void Window.UnFullscreen(); } +export function WindowIsFullscreen() { return Window.IsFullscreen(); } +export function WindowSetSize(width, height) { void Window.SetSize(width, height); } + +export function WindowGetSize() { + return Window.Size().then((s) => ({w: s.width ?? s.Width ?? 0, h: s.height ?? s.Height ?? 0})); +} + +export function WindowSetMaxSize(width, height) { void Window.SetMaxSize(width, height); } +export function WindowSetMinSize(width, height) { void Window.SetMinSize(width, height); } + +// v2 positions were relative to the current screen. +export function WindowSetPosition(x, y) { void Window.SetRelativePosition(x, y); } +export function WindowGetPosition() { + return Window.RelativePosition().then((p) => ({x: p.x ?? p.X ?? 0, y: p.y ?? p.Y ?? 0})); +} + +export function WindowHide() { void Window.Hide(); } +export function WindowShow() { void Window.Show(); } +export function WindowMaximise() { void Window.Maximise(); } +export function WindowToggleMaximise() { void Window.ToggleMaximise(); } +export function WindowUnmaximise() { void Window.UnMaximise(); } +export function WindowIsMaximised() { return Window.IsMaximised(); } +export function WindowMinimise() { void Window.Minimise(); } +export function WindowUnminimise() { void Window.UnMinimise(); } +export function WindowIsMinimised() { return Window.IsMinimised(); } + +export function WindowIsNormal() { + return Promise.all([Window.IsFullscreen(), Window.IsMaximised(), Window.IsMinimised()]) + .then(([f, max, min]) => !f && !max && !min); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + void Window.SetBackgroundColour(R, G, B, A); +} + +export function WindowPrint() { void Window.Print(); } + +/* ------------------------------- Screens ------------------------------- */ + +export function ScreenGetAll() { + return Promise.all([Screens.GetAll(), Screens.GetCurrent().catch(() => null)]) + .then(([screens, current]) => screens.map((s) => { + const size = { + width: (s.Size && (s.Size.width ?? s.Size.Width)) ?? 0, + height: (s.Size && (s.Size.height ?? s.Size.Height)) ?? 0, + }; + const phys = s.PhysicalBounds || {}; + return { + isCurrent: !!current && s.ID === current.ID, + isPrimary: !!s.IsPrimary, + width: size.width, + height: size.height, + size: size, + physicalSize: { + width: phys.Width ?? phys.width ?? size.width, + height: phys.Height ?? phys.height ?? size.height, + }, + }; + })); +} + +/* ----------------------------- Application ----------------------------- */ + +export function BrowserOpenURL(url) { void Browser.OpenURL(url); } + +export function Environment() { + return System.Environment().then((env) => ({ + buildType: env.Debug ? 'dev' : 'production', + platform: (env.OS || '').toLowerCase(), + arch: env.Arch, + })); +} + +export function Quit() { void Application.Quit(); } +export function Hide() { void Application.Hide(); } +export function Show() { void Application.Show(); } + +/* ------------------------------ Clipboard ------------------------------ */ + +export function ClipboardGetText() { return Clipboard.Text(); } +export function ClipboardSetText(text) { return Clipboard.SetText(text).then(() => true); } + +/* ------------------------------ File drop ------------------------------ */ + +export function OnFileDrop(callback, useDropTarget) { + console.warn('OnFileDrop: not supported by the Wails v3 compatibility layer; use the v3 file drop events instead.'); +} + +export function OnFileDropOff() { + console.warn('OnFileDropOff: not supported by the Wails v3 compatibility layer.'); +} diff --git a/v3/internal/migrate/copy.go b/v3/internal/migrate/copy.go new file mode 100644 index 00000000000..8a6c913dd42 --- /dev/null +++ b/v3/internal/migrate/copy.go @@ -0,0 +1,139 @@ +package migrate + +import ( + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// copyTree copies src into dst, creating directories as needed. The include +// callback receives the path relative to src and can veto files and whole +// directories. +func copyTree(src, dst string, include func(rel string, isDir bool) bool) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return os.MkdirAll(dst, 0o755) + } + if include != nil && !include(rel, info.IsDir()) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + return copyFile(path, target, info.Mode().Perm()) + }) +} + +func copyFile(src, dst string, perm os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +// CopyProjectFiles copies the remaining project files (everything except the +// frontend, the v2 build directory, generated/replaced files and VCS +// internals) into the output directory. Go files get their v2 runtime import +// rewritten to the v3 compatibility bridge. +func CopyProjectFiles(proj *V2Project, outDir string) error { + mainRel, err := filepath.Rel(proj.Dir, proj.Main.Path) + if err != nil { + return err + } + frontendRel, err := filepath.Rel(proj.Dir, proj.FrontendDir) + if err != nil { + return err + } + buildRel := proj.Config.BuildDir + + skip := map[string]bool{ + mainRel: true, // regenerated by GenerateMain + "wails.json": true, // superseded by build/config.yml + Taskfile + "go.mod": true, // transformed separately + "go.sum": true, // regenerated by go mod tidy + } + + return filepath.Walk(proj.Dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(proj.Dir, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if info.IsDir() { + name := info.Name() + if name == ".git" || name == "node_modules" || rel == frontendRel || rel == buildRel { + return filepath.SkipDir + } + return nil + } + if skip[rel] || info.Name() == ".DS_Store" { + return nil + } + target := filepath.Join(outDir, rel) + if strings.HasSuffix(path, ".go") { + data, err := os.ReadFile(path) + if err != nil { + return err + } + data = RewriteGoImports(proj, rel, data) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) + } + return copyFile(path, target, info.Mode().Perm()) + }) +} + +// RewriteGoImports rewrites the v2 runtime import to the v3 compatibility +// bridge and records remaining v2 imports as manual steps. +func RewriteGoImports(proj *V2Project, rel string, src []byte) []byte { + content := string(src) + if strings.Contains(content, strconv.Quote(V2RuntimeImport)) { + content = strings.ReplaceAll(content, strconv.Quote(V2RuntimeImport), strconv.Quote(V2CompatRuntimeImport)) + proj.Report.Mapped(rel+": "+V2RuntimeImport, V2CompatRuntimeImport) + } + + // Report any other v2 imports that remain. + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + idx := strings.Index(trimmed, `"github.com/wailsapp/wails/v2`) + if idx < 0 { + continue + } + importPath := strings.Trim(trimmed[idx:], `"`) + proj.Report.Manual(rel+": import "+importPath, + "This Wails v2 package has no automatic v3 replacement; port the code using it to the v3 API (github.com/wailsapp/wails/v3/pkg/application).") + } + + return []byte(content) +} diff --git a/v3/internal/migrate/frontend.go b/v3/internal/migrate/frontend.go new file mode 100644 index 00000000000..58004290dc4 --- /dev/null +++ b/v3/internal/migrate/frontend.go @@ -0,0 +1,185 @@ +package migrate + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +//go:embed assets/wailsjs-runtime.js +var wailsjsRuntimeJS []byte + +//go:embed assets/wailsjs-runtime.d.ts +var wailsjsRuntimeDTS []byte + +// MigrateFrontend copies the v2 frontend into the output project, replaces +// the generated wailsjs directory with a v3-backed compatibility layer and +// adds the @wailsio/runtime dependency to package.json. +func MigrateFrontend(proj *V2Project, outDir string) error { + srcFrontend := proj.FrontendDir + dstFrontend := filepath.Join(outDir, "frontend") + + if _, err := os.Stat(srcFrontend); os.IsNotExist(err) { + proj.Report.Manual("frontend", "No frontend directory was found; create one or point the v3 Taskfile at your assets.") + return nil + } + + err := copyTree(srcFrontend, dstFrontend, func(rel string, isDir bool) bool { + switch { + case rel == "node_modules" || strings.HasPrefix(rel, "node_modules"+string(filepath.Separator)): + return false + case rel == "wailsjs" || strings.HasPrefix(rel, "wailsjs"+string(filepath.Separator)): + return false + } + return true + }) + if err != nil { + return err + } + + // Ensure frontend/dist exists so `go build` can embed it before the first + // frontend build. + distDir := filepath.Join(dstFrontend, "dist") + if err := os.MkdirAll(distDir, 0o755); err != nil { + return err + } + gitkeep := filepath.Join(distDir, ".gitkeep") + if _, err := os.Stat(gitkeep); os.IsNotExist(err) { + if err := os.WriteFile(gitkeep, nil, 0o644); err != nil { + return err + } + } + + if err := writeWailsJSShims(proj, dstFrontend); err != nil { + return err + } + + if err := addRuntimeDependency(proj, dstFrontend); err != nil { + return err + } + + return nil +} + +// writeWailsJSShims regenerates frontend/wailsjs as a compatibility layer: +// the runtime module delegates to @wailsio/runtime and the go bindings +// delegate to Call.ByName. +func writeWailsJSShims(proj *V2Project, frontendDir string) error { + runtimeDir := filepath.Join(frontendDir, "wailsjs", "runtime") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(runtimeDir, "runtime.js"), wailsjsRuntimeJS, 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(runtimeDir, "runtime.d.ts"), wailsjsRuntimeDTS, 0o644); err != nil { + return err + } + + for _, bt := range proj.BoundTypes { + if bt.Name == "" || bt.PkgName == "" || len(bt.Methods) == 0 { + continue + } + dir := filepath.Join(frontendDir, "wailsjs", "go", bt.PkgName) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + js, dts := generateBindingShim(bt) + if err := os.WriteFile(filepath.Join(dir, bt.Name+".js"), []byte(js), 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, bt.Name+".d.ts"), []byte(dts), 0o644); err != nil { + return err + } + } + + if len(proj.BoundTypes) > 0 { + proj.Report.Note("The frontend `wailsjs/` directory was regenerated as a compatibility layer over `@wailsio/runtime`. If your frontend imports `wailsjs/go/models`, port those types to the models generated by `wails3 generate bindings` (in `frontend/bindings`).") + } + + return nil +} + +// generateBindingShim renders the wailsjs/go//.js compatibility +// module and its .d.ts for one bound struct. +func generateBindingShim(bt *BoundType) (js string, dts string) { + var sbJS, sbDTS strings.Builder + + header := "// Wails v2 bindings compatibility layer, generated by `wails3 migrate`.\n" + + "// Calls are routed by name through the Wails v3 runtime.\n" + + "// Prefer the generated v3 bindings in frontend/bindings for new code.\n\n" + sbJS.WriteString(header) + sbJS.WriteString("import {Call} from '@wailsio/runtime';\n") + sbDTS.WriteString(header) + + for _, method := range bt.Methods { + var callArgs, jsParams, tsParams []string + for i, p := range method.Params { + name := p.Name + if name == "" || name == "_" { + name = fmt.Sprintf("arg%d", i+1) + } + jsParams = append(jsParams, name) + tsParams = append(tsParams, fmt.Sprintf("%s: %s", name, p.TSType)) + callArgs = append(callArgs, name) + } + + fqn := bt.PkgPath + "." + bt.Name + "." + method.Name + sbJS.WriteString(fmt.Sprintf("\nexport function %s(%s) {\n", method.Name, strings.Join(jsParams, ", "))) + args := "" + if len(callArgs) > 0 { + args = ", " + strings.Join(callArgs, ", ") + } + sbJS.WriteString(fmt.Sprintf(" return Call.ByName(%q%s);\n}\n", fqn, args)) + + retType := "void" + for _, r := range method.Results { + if r.GoType != "error" { + retType = r.TSType + break + } + } + sbDTS.WriteString(fmt.Sprintf("\nexport function %s(%s): Promise<%s>;\n", method.Name, strings.Join(tsParams, ", "), retType)) + } + + return sbJS.String(), sbDTS.String() +} + +var dependenciesRe = regexp.MustCompile(`("dependencies"\s*:\s*\{)`) + +// addRuntimeDependency inserts "@wailsio/runtime" into the frontend +// package.json dependencies, preserving the file's formatting. +func addRuntimeDependency(proj *V2Project, frontendDir string) error { + path := filepath.Join(frontendDir, "package.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + proj.Report.Manual("frontend/package.json", + "No package.json found. The regenerated wailsjs compatibility layer imports `@wailsio/runtime`, which requires a bundler; plain-JS frontends must load the runtime differently (see the v3 docs).") + return nil + } + return err + } + content := string(data) + if strings.Contains(content, `"@wailsio/runtime"`) { + return nil + } + + if dependenciesRe.MatchString(content) { + content = dependenciesRe.ReplaceAllString(content, "$1\n \"@wailsio/runtime\": \"latest\",") + } else if idx := strings.Index(content, "{"); idx >= 0 { + content = content[:idx+1] + "\n \"dependencies\": {\n \"@wailsio/runtime\": \"latest\"\n }," + content[idx+1:] + } else { + proj.Report.Manual("frontend/package.json", "Could not add the `@wailsio/runtime` dependency automatically; add it and run your package manager's install.") + return nil + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return err + } + proj.Report.Note("Added `@wailsio/runtime` to frontend/package.json; run your package manager's install (or let the Taskfile do it on first build).") + return nil +} diff --git a/v3/internal/migrate/gomod.go b/v3/internal/migrate/gomod.go new file mode 100644 index 00000000000..f49e3dc473e --- /dev/null +++ b/v3/internal/migrate/gomod.go @@ -0,0 +1,51 @@ +package migrate + +import ( + "os" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/semver" +) + +// minGoVersion is the minimum Go directive required by wails v3 projects +// (matches the version in the v3 project template). +const minGoVersion = "1.24" + +// TransformGoMod rewrites the project's go.mod for v3: the wails/v2 require +// is dropped, wails/v3 is added at the given version and the go directive is +// raised to the v3 minimum if needed. All other requires are preserved. +func TransformGoMod(proj *V2Project, wailsVersion string) ([]byte, error) { + data, err := os.ReadFile(proj.GoModPath) + if err != nil { + return nil, err + } + mod, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return nil, err + } + + if err := mod.DropRequire("github.com/wailsapp/wails/v2"); err != nil { + return nil, err + } + if err := mod.AddRequire("github.com/wailsapp/wails/v3", wailsVersion); err != nil { + return nil, err + } + + if mod.Go == nil || semver.Compare("v"+mod.Go.Version, "v"+minGoVersion) < 0 { + if err := mod.AddGoStmt(minGoVersion); err != nil { + return nil, err + } + } + + // Drop any replace directives pointing at wails/v2. + for _, rep := range mod.Replace { + if rep.Old.Path == "github.com/wailsapp/wails/v2" { + if err := mod.DropReplace(rep.Old.Path, rep.Old.Version); err != nil { + return nil, err + } + } + } + + mod.Cleanup() + return mod.Format() +} diff --git a/v3/internal/migrate/maingen.go b/v3/internal/migrate/maingen.go new file mode 100644 index 00000000000..3c8cbea885f --- /dev/null +++ b/v3/internal/migrate/maingen.go @@ -0,0 +1,295 @@ +package migrate + +import ( + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "sort" + "strconv" + "strings" +) + +// v2compatAlias is the local name used for the v2compat runtime import in +// generated code (avoids clashing with a stdlib runtime import). +const v2compatAlias = "v2runtime" + +// GenerateMain performs textual surgery on the file containing wails.Run: +// the import block and the wails.Run statement are replaced with their v3 +// equivalents, everything else is preserved byte-for-byte, and the result is +// gofmt-formatted. +func GenerateMain(proj *V2Project, opts *V3Options) ([]byte, error) { + main := proj.Main + src := main.Source + + appVar := pickIdent(main.File, "app", "wailsApp", "wailsV3App") + + block := buildV3Block(proj, opts, appVar) + + type edit struct { + start, end int + text string + } + var edits []edit + + // Replace the wails.Run statement. + stmtStart := main.Fset.Position(main.RunStmt.Pos()).Offset + stmtEnd := main.Fset.Position(main.RunStmt.End()).Offset + edits = append(edits, edit{stmtStart, stmtEnd, block}) + + // Replace the import declaration(s). + importText := buildImports(proj, opts) + first := true + for _, decl := range main.File.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT { + continue + } + start := main.Fset.Position(gen.Pos()).Offset + end := main.Fset.Position(gen.End()).Offset + if first { + edits = append(edits, edit{start, end, importText}) + first = false + } else { + // Merge extra import decls away (their specs are already included + // in importText via main.File.Imports). + edits = append(edits, edit{start, end, ""}) + } + } + if first { + return nil, fmt.Errorf("no import declaration found in %s", main.Path) + } + + // Apply edits back-to-front so offsets stay valid. + sort.Slice(edits, func(i, j int) bool { return edits[i].start > edits[j].start }) + out := make([]byte, len(src)) + copy(out, src) + for _, e := range edits { + out = append(out[:e.start], append([]byte(e.text), out[e.end:]...)...) + } + + return pruneAndFormat(out) +} + +// pickIdent returns the first candidate not used as an identifier in the file. +func pickIdent(file *ast.File, candidates ...string) string { + used := map[string]bool{} + ast.Inspect(file, func(n ast.Node) bool { + if ident, ok := n.(*ast.Ident); ok { + used[ident.Name] = true + } + return true + }) + for _, c := range candidates { + if !used[c] { + return c + } + } + return candidates[len(candidates)-1] + "2" +} + +func writeFields(sb *strings.Builder, fields []GenField, indent string) { + for _, f := range fields { + sb.WriteString(indent + f.Name + ": " + f.Expr + ",\n") + } +} + +// buildV3Block renders the replacement for the wails.Run statement. +func buildV3Block(proj *V2Project, opts *V3Options, appVar string) string { + var sb strings.Builder + + sb.WriteString(appVar + " := application.New(application.Options{\n") + writeFields(&sb, opts.App, "\t") + + // Services (bound structs first, lifecycle bridge last). + if len(opts.Services) > 0 || opts.NeedsLifecycleService() { + sb.WriteString("\tServices: []application.Service{\n") + for _, svc := range opts.Services { + sb.WriteString("\t\tapplication.NewService(" + svc + "),\n") + } + if opts.NeedsLifecycleService() { + args := []string{"nil", "nil", "nil"} + for i, cb := range []string{opts.OnStartup, opts.OnDomReady, opts.OnShutdown} { + if cb != "" { + args[i] = cb + } + } + sb.WriteString("\t\t// Bridges the v2 OnStartup/OnDomReady/OnShutdown callbacks.\n") + sb.WriteString("\t\t" + v2compatAlias + ".NewLifecycleService(" + strings.Join(args, ", ") + "),\n") + } + sb.WriteString("\t},\n") + } + + if opts.OnBeforeClose != "" { + sb.WriteString("\t// v2 OnBeforeClose returned true to prevent closing; v3 ShouldQuit returns true to allow quitting.\n") + sb.WriteString("\tShouldQuit: func() bool {\n\t\treturn !(" + opts.OnBeforeClose + ")(context.Background())\n\t},\n") + } + + if len(opts.SingleInstance) > 0 { + sb.WriteString("\tSingleInstance: &application.SingleInstanceOptions{\n") + writeFields(&sb, opts.SingleInstance, "\t\t") + sb.WriteString("\t},\n") + } + if len(opts.AppMac) > 0 { + sb.WriteString("\tMac: application.MacOptions{\n") + writeFields(&sb, opts.AppMac, "\t\t") + sb.WriteString("\t},\n") + } + if len(opts.AppWin) > 0 { + sb.WriteString("\tWindows: application.WindowsOptions{\n") + writeFields(&sb, opts.AppWin, "\t\t") + sb.WriteString("\t},\n") + } + if len(opts.AppLinux) > 0 { + sb.WriteString("\tLinux: application.LinuxOptions{\n") + writeFields(&sb, opts.AppLinux, "\t\t") + sb.WriteString("\t},\n") + } + sb.WriteString("})\n\n") + + sb.WriteString(appVar + ".Window.NewWithOptions(application.WebviewWindowOptions{\n") + writeFields(&sb, opts.Win, "\t") + if len(opts.WinMac) > 0 { + sb.WriteString("\tMac: application.MacWindow{\n") + writeFields(&sb, opts.WinMac, "\t\t") + sb.WriteString("\t},\n") + } + if len(opts.WinWin) > 0 { + sb.WriteString("\tWindows: application.WindowsWindow{\n") + writeFields(&sb, opts.WinWin, "\t\t") + sb.WriteString("\t},\n") + } + if len(opts.WinLinux) > 0 { + sb.WriteString("\tLinux: application.LinuxWindow{\n") + writeFields(&sb, opts.WinLinux, "\t\t") + sb.WriteString("\t},\n") + } + sb.WriteString("})\n\n") + + // Preserve the original error-handling shape. + main := proj.Main + switch { + case main.ErrIdent != "" && main.AssignTok == token.DEFINE: + sb.WriteString(main.ErrIdent + " := " + appVar + ".Run()") + case main.ErrIdent != "": + sb.WriteString(main.ErrIdent + " = " + appVar + ".Run()") + default: + sb.WriteString(appVar + ".Run()") + } + + return sb.String() +} + +// buildImports renders the replacement import declaration: the original +// imports minus everything under github.com/wailsapp/wails/v2, plus the v3 +// imports the generated code needs. +func buildImports(proj *V2Project, opts *V3Options) string { + type imp struct{ alias, path string } + var imports []imp + seen := map[string]bool{} + + for _, spec := range proj.Main.File.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil || strings.HasPrefix(path, "github.com/wailsapp/wails/v2") { + continue + } + alias := "" + if spec.Name != nil { + alias = spec.Name.Name + } + imports = append(imports, imp{alias, path}) + seen[path] = true + } + + add := func(alias, path string) { + if !seen[path] { + imports = append(imports, imp{alias, path}) + seen[path] = true + } + } + add("", "github.com/wailsapp/wails/v3/pkg/application") + if opts.NeedsLifecycleService() { + add(v2compatAlias, V2CompatRuntimeImport) + } + if opts.OnBeforeClose != "" { + add("", "context") + } + + sort.Slice(imports, func(i, j int) bool { return imports[i].path < imports[j].path }) + + var sb strings.Builder + sb.WriteString("import (\n") + for _, im := range imports { + sb.WriteString("\t") + if im.alias != "" { + sb.WriteString(im.alias + " ") + } + sb.WriteString(strconv.Quote(im.path) + "\n") + } + sb.WriteString(")") + return sb.String() +} + +// pruneAndFormat removes imports that became unused after surgery (values +// that only appeared inside the replaced statement) and gofmt-formats the +// result. +func pruneAndFormat(src []byte) ([]byte, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "main.go", src, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("generated main file does not parse (%w); this is a bug in wails3 migrate", err) + } + + used := map[string]bool{} + ast.Inspect(file, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + if ident, ok := sel.X.(*ast.Ident); ok { + used[ident.Name] = true + } + } + return true + }) + + type span struct{ start, end int } + var removals []span + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT { + continue + } + for _, spec := range gen.Specs { + imp := spec.(*ast.ImportSpec) + if imp.Name != nil && (imp.Name.Name == "_" || imp.Name.Name == ".") { + continue + } + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + name := "" + if imp.Name != nil { + name = imp.Name.Name + } else { + name = path[strings.LastIndex(path, "/")+1:] + } + if !used[name] { + removals = append(removals, span{ + fset.Position(imp.Pos()).Offset, + fset.Position(imp.End()).Offset, + }) + } + } + } + + sort.Slice(removals, func(i, j int) bool { return removals[i].start > removals[j].start }) + for _, r := range removals { + src = append(src[:r.start], src[r.end:]...) + } + + formatted, err := format.Source(src) + if err != nil { + return nil, fmt.Errorf("could not format the generated main file (%w); this is a bug in wails3 migrate", err) + } + return formatted, nil +} diff --git a/v3/internal/migrate/mapping.go b/v3/internal/migrate/mapping.go new file mode 100644 index 00000000000..e00ea72afaa --- /dev/null +++ b/v3/internal/migrate/mapping.go @@ -0,0 +1,681 @@ +package migrate + +import ( + "go/ast" + "go/token" + "sort" + "strings" +) + +// GenField is one field of a generated composite literal: Name: Expr. +type GenField struct { + Name string + Expr string +} + +// V3Options is the mapped result of a v2 options.App literal, ready for code +// generation. All Expr values are Go source snippets valid in the migrated +// main file. +type V3Options struct { + App []GenField // application.Options + AppMac []GenField // application.Options.Mac + AppWin []GenField // application.Options.Windows + AppLinux []GenField // application.Options.Linux + + Win []GenField // application.WebviewWindowOptions + WinMac []GenField // WebviewWindowOptions.Mac + WinWin []GenField // WebviewWindowOptions.Windows + WinLinux []GenField // WebviewWindowOptions.Linux + + SingleInstance []GenField // *application.SingleInstanceOptions, nil if unset + + // Services holds the Go expressions to wrap in application.NewService(). + Services []string + + // v2 lifecycle callbacks (original source expressions, signature + // func(context.Context)). Empty when unset. + OnStartup string + OnDomReady string + OnShutdown string + // OnBeforeClose (v2: return true to prevent close) is wrapped into + // application.Options.ShouldQuit. + OnBeforeClose string +} + +// NeedsLifecycleService reports whether the generated main needs the +// v2compat lifecycle service. +func (o *V3Options) NeedsLifecycleService() bool { + return o.OnStartup != "" || o.OnDomReady != "" || o.OnShutdown != "" +} + +// mapper carries shared state while walking the options.App literal. +type mapper struct { + proj *V2Project + out *V3Options +} + +// MapOptions converts the parsed v2 options.App literal into V3Options, +// recording everything unmappable in the project report. +func MapOptions(proj *V2Project) *V3Options { + m := &mapper{proj: proj, out: &V3Options{}} + report := proj.Report + + lit := proj.Main.AppLit + if lit == nil { + report.Manual("options.App", + "The value passed to wails.Run was not a literal `&options.App{...}`, so options could not be migrated automatically. A default window configuration was generated; port your options manually to application.Options / application.WebviewWindowOptions.") + return m.out + } + + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok { + continue + } + m.mapAppField(key.Name, kv.Value) + } + + // v2 apps always load the embedded index; v3 needs the URL explicitly. + m.out.Win = append(m.out.Win, GenField{"URL", `"/"`}) + + return m.out +} + +// src returns the original source text of an expression. +func (m *mapper) src(e ast.Expr) string { + return exprText(m.proj.Main.Fset, m.proj.Main.Source, e) +} + +// referencesV2 reports whether the expression mentions any package imported +// from github.com/wailsapp/wails/v2 (such values cannot be carried verbatim). +func (m *mapper) referencesV2(e ast.Expr) bool { + found := false + ast.Inspect(e, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + if path, ok := m.proj.Main.Imports[ident.Name]; ok { + if strings.HasPrefix(path, "github.com/wailsapp/wails/v2") { + found = true + return false + } + } + return true + }) + return found +} + +// carry appends `name: ` to dst when the value is representable in the +// migrated program; otherwise it records a manual step. +func (m *mapper) carry(dst *[]GenField, v2Name, v3Name string, value ast.Expr, v3Target string) { + if m.referencesV2(value) { + m.proj.Report.Manual("options.App."+v2Name, + "The value `"+m.src(value)+"` references Wails v2 packages and could not be carried over. Set `"+v3Target+"` manually.") + return + } + *dst = append(*dst, GenField{v3Name, m.src(value)}) + m.proj.Report.Mapped("options.App."+v2Name, v3Target) +} + +// manual is a shorthand for recording a manual step for an option. +func (m *mapper) manual(v2Name, instructions string) { + m.proj.Report.Manual("options.App."+v2Name, instructions) +} + +// note is a shorthand for adding a note to the report. +func (m *mapper) note(s string) { m.proj.Report.Note(s) } + +// compositeLit unwraps &T{...} / T{...} values to the composite literal, or +// nil when the value has another shape. +func compositeLit(e ast.Expr) *ast.CompositeLit { + if unary, ok := e.(*ast.UnaryExpr); ok && unary.Op == token.AND { + e = unary.X + } + lit, _ := e.(*ast.CompositeLit) + return lit +} + +// litField is a key/value pair of a composite literal, in source order. +type litField struct { + Name string + Value ast.Expr +} + +// orderedFields returns the key/value pairs of a composite literal in source +// order (keeps generated code and reports deterministic). +func orderedFields(lit *ast.CompositeLit) []litField { + var fields []litField + for _, elt := range lit.Elts { + if kv, ok := elt.(*ast.KeyValueExpr); ok { + if key, ok := kv.Key.(*ast.Ident); ok { + fields = append(fields, litField{key.Name, kv.Value}) + } + } + } + return fields +} + +// litFields returns the key/value pairs of a composite literal as a map (for +// single-field lookups). +func litFields(lit *ast.CompositeLit) map[string]ast.Expr { + fields := map[string]ast.Expr{} + for _, f := range orderedFields(lit) { + fields[f.Name] = f.Value + } + return fields +} + +// selectorConst maps a v2 enum selector (e.g. windows.Dark) to a v3 +// application-package expression via a lookup of the selector's final name. +// Returns "" if the value has another shape or the name is not in the table. +func selectorConst(value ast.Expr, table map[string]string) string { + name := "" + switch v := value.(type) { + case *ast.SelectorExpr: + name = v.Sel.Name + case *ast.Ident: + name = v.Name + default: + return "" + } + return table[name] +} + +func isTrue(e ast.Expr) bool { + ident, ok := e.(*ast.Ident) + return ok && ident.Name == "true" +} + +var windowStartStates = map[string]string{ + "Normal": "application.WindowStateNormal", + "Maximised": "application.WindowStateMaximised", + "Minimised": "application.WindowStateMinimised", + "Fullscreen": "application.WindowStateFullscreen", +} + +var windowsThemes = map[string]string{ + "SystemDefault": "application.SystemDefault", + "Dark": "application.Dark", + "Light": "application.Light", +} + +var windowsBackdropTypes = map[string]string{ + "Auto": "application.Auto", + "None": "application.None", + "Mica": "application.Mica", + "Acrylic": "application.Acrylic", + "Tabbed": "application.Tabbed", +} + +var linuxGpuPolicies = map[string]string{ + "WebviewGpuPolicyAlways": "application.WebviewGpuPolicyAlways", + "WebviewGpuPolicyOnDemand": "application.WebviewGpuPolicyOnDemand", + "WebviewGpuPolicyNever": "application.WebviewGpuPolicyNever", +} + +// mac.Appearance* names are identical in v3's MacAppearanceType consts. +var macAppearances = map[string]string{ + "DefaultAppearance": "application.DefaultAppearance", + "NSAppearanceNameAqua": "application.NSAppearanceNameAqua", + "NSAppearanceNameDarkAqua": "application.NSAppearanceNameDarkAqua", + "NSAppearanceNameVibrantLight": "application.NSAppearanceNameVibrantLight", + "NSAppearanceNameAccessibilityHighContrastAqua": "application.NSAppearanceNameAccessibilityHighContrastAqua", + "NSAppearanceNameAccessibilityHighContrastDarkAqua": "application.NSAppearanceNameAccessibilityHighContrastDarkAqua", + "NSAppearanceNameAccessibilityHighContrastVibrantLight": "application.NSAppearanceNameAccessibilityHighContrastVibrantLight", + "NSAppearanceNameAccessibilityHighContrastVibrantDark": "application.NSAppearanceNameAccessibilityHighContrastVibrantDark", +} + +// v2 mac.TitleBar* preset constructors -> v3 preset variables. +var macTitleBarPresets = map[string]string{ + "TitleBarDefault": "application.MacTitleBarDefault", + "TitleBarHidden": "application.MacTitleBarHidden", + "TitleBarHiddenInset": "application.MacTitleBarHiddenInset", +} + +// v2 mac.TitleBar field -> v3 application.MacTitleBar field. +var macTitleBarFields = map[string]string{ + "TitlebarAppearsTransparent": "AppearsTransparent", + "HideTitleBar": "Hide", + "HideTitle": "HideTitle", + "FullSizeContent": "FullSizeContent", + "UseToolbar": "UseToolbar", + "HideToolbarSeparator": "HideToolbarSeparator", +} + +func (m *mapper) mapAppField(name string, value ast.Expr) { + out := m.out + switch name { + case "Title": + m.carry(&out.App, "Title", "Name", value, "application.Options.Name") + out.Win = append(out.Win, GenField{"Title", m.src(value)}) + case "Width": + m.carry(&out.Win, "Width", "Width", value, "WebviewWindowOptions.Width") + case "Height": + m.carry(&out.Win, "Height", "Height", value, "WebviewWindowOptions.Height") + case "MinWidth": + m.carry(&out.Win, "MinWidth", "MinWidth", value, "WebviewWindowOptions.MinWidth") + case "MinHeight": + m.carry(&out.Win, "MinHeight", "MinHeight", value, "WebviewWindowOptions.MinHeight") + case "MaxWidth": + m.carry(&out.Win, "MaxWidth", "MaxWidth", value, "WebviewWindowOptions.MaxWidth") + case "MaxHeight": + m.carry(&out.Win, "MaxHeight", "MaxHeight", value, "WebviewWindowOptions.MaxHeight") + case "DisableResize": + m.carry(&out.Win, "DisableResize", "DisableResize", value, "WebviewWindowOptions.DisableResize") + case "Frameless": + m.carry(&out.Win, "Frameless", "Frameless", value, "WebviewWindowOptions.Frameless") + case "AlwaysOnTop": + m.carry(&out.Win, "AlwaysOnTop", "AlwaysOnTop", value, "WebviewWindowOptions.AlwaysOnTop") + case "StartHidden": + m.carry(&out.Win, "StartHidden", "Hidden", value, "WebviewWindowOptions.Hidden") + case "Fullscreen": + if isTrue(value) { + out.Win = append(out.Win, GenField{"StartState", "application.WindowStateFullscreen"}) + m.proj.Report.Mapped("options.App.Fullscreen", "WebviewWindowOptions.StartState") + } else { + m.manual("Fullscreen", "Non-constant Fullscreen value; set `WebviewWindowOptions.StartState` to `application.WindowStateFullscreen` as needed.") + } + case "WindowStartState": + if v3 := selectorConst(value, windowStartStates); v3 != "" { + out.Win = append(out.Win, GenField{"StartState", v3}) + m.proj.Report.Mapped("options.App.WindowStartState", "WebviewWindowOptions.StartState") + } else { + m.manual("WindowStartState", "Could not map the value `"+m.src(value)+"`; set `WebviewWindowOptions.StartState` manually.") + } + case "BackgroundColour": + m.mapBackgroundColour(value) + case "AssetServer": + m.mapAssetServer(value) + case "Assets": + // Deprecated v2 field: Assets fs.FS. + out.App = append(out.App, GenField{"Assets", "application.AssetOptions{\n\t\t\tHandler: application.AssetFileServerFS(" + m.src(value) + "),\n\t\t}"}) + m.proj.Report.Mapped("options.App.Assets", "application.Options.Assets") + case "AssetsHandler": + out.App = append(out.App, GenField{"Assets", "application.AssetOptions{\n\t\t\tHandler: " + m.src(value) + ",\n\t\t}"}) + m.proj.Report.Mapped("options.App.AssetsHandler", "application.Options.Assets.Handler") + case "Menu": + m.manual("Menu", "v3 menus use a different API: create the menu with `app.NewMenu()` and assign it with `app.Menu.SetApplicationMenu(menu)`. See https://v3.wails.io/learn/menus/.") + case "Logger", "LogLevel", "LogLevelProduction": + m.manual(name, "v3 uses the standard library `log/slog`: set `application.Options.Logger` (a *slog.Logger) and `application.Options.LogLevel`.") + case "OnStartup": + out.OnStartup = m.src(value) + m.proj.Report.Mapped("options.App.OnStartup", "v2compat lifecycle service (ServiceStartup)") + case "OnDomReady": + out.OnDomReady = m.src(value) + m.proj.Report.Mapped("options.App.OnDomReady", "v2compat lifecycle service (WindowRuntimeReady)") + case "OnShutdown": + out.OnShutdown = m.src(value) + m.proj.Report.Mapped("options.App.OnShutdown", "v2compat lifecycle service (ServiceShutdown)") + case "OnBeforeClose": + out.OnBeforeClose = m.src(value) + m.proj.Report.Mapped("options.App.OnBeforeClose", "application.Options.ShouldQuit") + case "Bind": + for _, bt := range m.proj.BoundTypes { + out.Services = append(out.Services, bt.Expr) + } + m.proj.Report.Mapped("options.App.Bind", "application.Options.Services") + case "EnumBind": + m.manual("EnumBind", "v3's binding generator discovers enum types automatically from service method signatures; remove EnumBind and re-run `wails3 generate bindings`.") + case "SingleInstanceLock": + m.mapSingleInstance(value) + case "Windows": + m.mapWindowsOptions(value) + case "Mac": + m.mapMacOptions(value) + case "Linux": + m.mapLinuxOptions(value) + case "Debug": + if lit := compositeLit(value); lit != nil { + if v, ok := litFields(lit)["OpenInspectorOnStartup"]; ok { + m.carry(&out.Win, "Debug.OpenInspectorOnStartup", "OpenInspectorOnStartup", v, "WebviewWindowOptions.OpenInspectorOnStartup") + } + } + case "DragAndDrop": + m.mapDragAndDrop(value) + case "CSSDragProperty", "CSSDragValue": + m.note("`" + name + "` was dropped: v3 always uses the CSS property `--wails-draggable: drag`. Update your styles if you used a custom property.") + case "EnableDefaultContextMenu": + if isTrue(value) { + m.note("`EnableDefaultContextMenu: true` was dropped: the v3 equivalent is `WebviewWindowOptions.DefaultContextMenuDisabled` (default enabled in dev, controllable per window).") + } + case "EnableFraudulentWebsiteDetection": + m.carry(&out.WinMac, "EnableFraudulentWebsiteDetection", "EnableFraudulentWebsiteWarnings", value, "MacWindow.EnableFraudulentWebsiteWarnings") + m.note("`EnableFraudulentWebsiteDetection` only maps to macOS in v3 (`MacWindow.EnableFraudulentWebsiteWarnings`); Windows SmartScreen is controlled by the OS.") + case "HideWindowOnClose": + m.manual("HideWindowOnClose", "Intercept the close in v3: `window.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel(); window.Hide() })`.") + case "ErrorFormatter": + m.manual("ErrorFormatter", "v3 replaces ErrorFormatter with `application.Options.MarshalError func(error) []byte`.") + case "BindingsAllowedOrigins": + m.manual("BindingsAllowedOrigins", "Not needed in v3: binding calls are routed through the internal asset server. Remove any CORS-specific configuration.") + case "DisablePanicRecovery": + m.manual("DisablePanicRecovery", "v3 uses `application.Options.PanicHandler func(*application.PanicDetails)` instead.") + case "Experimental": + // Empty struct in v2; nothing to migrate. + default: + m.manual(name, "This option was not recognised by the migrator; check application.Options / application.WebviewWindowOptions for an equivalent.") + } +} + +// mapBackgroundColour converts &options.RGBA{R: , G: , B: , A: } (keyed or +// positional) to application.NewRGBA(...). +func (m *mapper) mapBackgroundColour(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("BackgroundColour", "Could not parse the colour value `"+m.src(value)+"`; set `WebviewWindowOptions.BackgroundColour` with `application.NewRGBA(r, g, b, a)`.") + return + } + comps := map[string]string{"R": "0", "G": "0", "B": "0", "A": "255"} + order := []string{"R", "G", "B", "A"} + for i, elt := range lit.Elts { + if kv, ok := elt.(*ast.KeyValueExpr); ok { + if key, ok := kv.Key.(*ast.Ident); ok { + comps[key.Name] = m.src(kv.Value) + } + continue + } + if i < len(order) { + comps[order[i]] = m.src(elt) + } + } + expr := "application.NewRGBA(" + comps["R"] + ", " + comps["G"] + ", " + comps["B"] + ", " + comps["A"] + ")" + m.out.Win = append(m.out.Win, GenField{"BackgroundColour", expr}) + m.proj.Report.Mapped("options.App.BackgroundColour", "WebviewWindowOptions.BackgroundColour") +} + +func (m *mapper) mapAssetServer(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("AssetServer", "Could not parse the AssetServer options; configure `application.Options.Assets` manually.") + return + } + fields := litFields(lit) + var gen []string + if assets, ok := fields["Assets"]; ok { + if m.referencesV2(assets) { + m.manual("AssetServer.Assets", "The assets value references v2 packages; set `application.Options.Assets.Handler` manually.") + } else { + gen = append(gen, "Handler: application.AssetFileServerFS("+m.src(assets)+"),") + m.proj.Report.Mapped("options.App.AssetServer.Assets", "application.Options.Assets.Handler (AssetFileServerFS)") + } + if _, alsoHandler := fields["Handler"]; alsoHandler { + m.manual("AssetServer.Handler", "v2 used Handler as a fallback for requests not found in Assets. In v3 there is a single Handler: chain your fallback yourself or serve it via `application.Options.Assets.Middleware`.") + } + } else if handler, ok := fields["Handler"]; ok { + if m.referencesV2(handler) { + m.manual("AssetServer.Handler", "The handler references v2 packages; set `application.Options.Assets.Handler` manually.") + } else { + gen = append(gen, "Handler: "+m.src(handler)+",") + m.proj.Report.Mapped("options.App.AssetServer.Handler", "application.Options.Assets.Handler") + } + } + if mw, ok := fields["Middleware"]; ok { + if m.referencesV2(mw) { + m.manual("AssetServer.Middleware", "The middleware references v2 packages; port it to `application.Options.Assets.Middleware` (same `func(http.Handler) http.Handler` shape).") + } else { + gen = append(gen, "Middleware: application.Middleware("+m.src(mw)+"),") + m.proj.Report.Mapped("options.App.AssetServer.Middleware", "application.Options.Assets.Middleware") + } + } + if len(gen) > 0 { + m.out.App = append(m.out.App, GenField{"Assets", "application.AssetOptions{\n\t\t\t" + strings.Join(gen, "\n\t\t\t") + "\n\t\t}"}) + } +} + +func (m *mapper) mapSingleInstance(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("SingleInstanceLock", "Configure `application.Options.SingleInstance` (*application.SingleInstanceOptions) manually.") + return + } + fields := litFields(lit) + if id, ok := fields["UniqueId"]; ok && !m.referencesV2(id) { + m.out.SingleInstance = append(m.out.SingleInstance, GenField{"UniqueID", m.src(id)}) + m.proj.Report.Mapped("options.App.SingleInstanceLock.UniqueId", "application.SingleInstanceOptions.UniqueID") + } + if _, ok := fields["OnSecondInstanceLaunch"]; ok { + m.manual("SingleInstanceLock.OnSecondInstanceLaunch", + "Port the callback to `application.SingleInstanceOptions.OnSecondInstanceLaunch(data application.SecondInstanceData)`. Note the v3 struct uses `WorkingDir` (v2: `WorkingDirectory`).") + } +} + +func (m *mapper) mapWindowsOptions(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Windows", "Could not parse the Windows options literal; port it manually to application.WindowsOptions / application.WindowsWindow.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "Windows." + name + switch name { + case "WebviewIsTransparent": + if isTrue(v) { + m.out.Win = append(m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTransparent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "WindowIsTranslucent": + if isTrue(v) { + m.out.Win = append(m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTranslucent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "DisableWindowIcon": + m.carry(&m.out.WinWin, prefix, "DisableIcon", v, "WindowsWindow.DisableIcon") + case "DisableFramelessWindowDecorations": + m.carry(&m.out.WinWin, prefix, "DisableFramelessWindowDecorations", v, "WindowsWindow.DisableFramelessWindowDecorations") + case "WebviewUserDataPath": + m.carry(&m.out.AppWin, prefix, "WebviewUserDataPath", v, "application.WindowsOptions.WebviewUserDataPath") + case "WebviewBrowserPath": + m.carry(&m.out.AppWin, prefix, "WebviewBrowserPath", v, "application.WindowsOptions.WebviewBrowserPath") + case "Theme": + if v3 := selectorConst(v, windowsThemes); v3 != "" { + m.out.WinWin = append(m.out.WinWin, GenField{"Theme", v3}) + m.proj.Report.Mapped("options.App."+prefix, "WindowsWindow.Theme") + } else { + m.manual(prefix, "Could not map the theme value `"+m.src(v)+"`; set `WindowsWindow.Theme` manually.") + } + case "CustomTheme": + m.manual(prefix, "The v3 custom theme structure differs (`application.ThemeSettings` with per-mode `WindowTheme`/`MenuBarTheme` colours); port your colours manually.") + case "BackdropType": + if v3 := selectorConst(v, windowsBackdropTypes); v3 != "" { + m.out.WinWin = append(m.out.WinWin, GenField{"BackdropType", v3}) + m.proj.Report.Mapped("options.App."+prefix, "WindowsWindow.BackdropType") + } else { + m.manual(prefix, "Could not map the backdrop value `"+m.src(v)+"`; set `WindowsWindow.BackdropType` manually.") + } + case "Messages": + m.note("`Windows.Messages` was dropped: v3 handles the WebView2 bootstrap flow itself.") + case "ResizeDebounceMS": + m.manual(prefix, "No direct v3 equivalent. `WindowsWindow.WindowDidMoveDebounceMS` debounces move events if that is what you need.") + case "OnSuspend": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.SystemWillSleep, ...)`.") + case "OnResume": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.SystemDidWake, ...)`.") + case "WebviewGpuIsDisabled": + m.manual(prefix, "No direct v3 equivalent; consider `application.WindowsOptions.AdditionalBrowserArgs: []string{\"--disable-gpu\"}`.") + case "WebviewDisableRendererCodeIntegrity": + m.manual(prefix, "No direct v3 equivalent; consider `application.WindowsOptions.AdditionalBrowserArgs`.") + case "EnableSwipeGestures": + m.carry(&m.out.WinWin, prefix, "EnableSwipeGestures", v, "WindowsWindow.EnableSwipeGestures") + case "ZoomFactor": + m.carry(&m.out.Win, prefix, "Zoom", v, "WebviewWindowOptions.Zoom") + case "IsZoomControlEnabled": + m.carry(&m.out.Win, prefix, "ZoomControlEnabled", v, "WebviewWindowOptions.ZoomControlEnabled") + case "DisablePinchZoom": + m.manual(prefix, "No direct v3 equivalent; zoom control is `WebviewWindowOptions.ZoomControlEnabled`.") + case "ContentProtection": + m.carry(&m.out.Win, prefix, "ContentProtectionEnabled", v, "WebviewWindowOptions.ContentProtectionEnabled") + case "WindowClassName": + m.carry(&m.out.AppWin, prefix, "WndClass", v, "application.WindowsOptions.WndClass") + case "DLLSearchPaths": + m.manual(prefix, "No v3 equivalent; v3 loads WebView2Loader statically.") + default: + m.manual(prefix, "This option was not recognised by the migrator; check application.WindowsOptions / application.WindowsWindow for an equivalent.") + } + } +} + +func (m *mapper) mapMacOptions(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Mac", "Could not parse the Mac options literal; port it manually to application.MacOptions / application.MacWindow.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "Mac." + name + switch name { + case "TitleBar": + m.mapMacTitleBar(v) + case "Appearance": + if v3 := selectorConst(v, macAppearances); v3 != "" { + m.out.WinMac = append(m.out.WinMac, GenField{"Appearance", v3}) + m.proj.Report.Mapped("options.App."+prefix, "MacWindow.Appearance") + } else { + m.manual(prefix, "Could not map the appearance value `"+m.src(v)+"`; set `MacWindow.Appearance` manually.") + } + case "WebviewIsTransparent": + if isTrue(v) { + m.out.Win = append(m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTransparent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "WindowIsTranslucent": + if isTrue(v) { + m.out.WinMac = append(m.out.WinMac, GenField{"Backdrop", "application.MacBackdropTranslucent"}) + m.proj.Report.Mapped("options.App."+prefix, "MacWindow.Backdrop") + } + case "Preferences": + m.manual(prefix, "Port to `MacWindow.WebviewPreferences` (application.MacWebviewPreferences) manually.") + case "DisableZoom": + m.manual(prefix, "No direct v3 equivalent; `WebviewWindowOptions.ZoomControlEnabled: false` disables zoom controls.") + case "About": + m.mapMacAbout(v) + case "OnFileOpen": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.ApplicationOpenedWithFile, ...)`, reading the filename from the event context.") + case "OnUrlOpen": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.ApplicationLaunchedWithUrl, ...)`.") + case "DisableEscapeExitsFullscreen": + m.carry(&m.out.WinMac, prefix, "DisableEscapeExitsFullscreen", v, "MacWindow.DisableEscapeExitsFullscreen") + case "ContentProtection": + m.carry(&m.out.Win, prefix, "ContentProtectionEnabled", v, "WebviewWindowOptions.ContentProtectionEnabled") + default: + m.manual(prefix, "This option was not recognised by the migrator; check application.MacOptions / application.MacWindow for an equivalent.") + } + } +} + +func (m *mapper) mapMacTitleBar(value ast.Expr) { + // Preset constructor: mac.TitleBarHiddenInset() + if call, ok := value.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if v3 := macTitleBarPresets[sel.Sel.Name]; v3 != "" { + m.out.WinMac = append(m.out.WinMac, GenField{"TitleBar", v3}) + m.proj.Report.Mapped("options.App.Mac.TitleBar", "MacWindow.TitleBar") + return + } + } + } + // Literal: &mac.TitleBar{...} + if lit := compositeLit(value); lit != nil { + var fields []string + ok := true + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + v3Name := macTitleBarFields[name] + if v3Name == "" || m.referencesV2(v) { + m.manual("Mac.TitleBar."+name, "Could not map this title bar field; check application.MacTitleBar.") + ok = false + continue + } + fields = append(fields, v3Name+": "+m.src(v)+",") + } + if ok && len(fields) > 0 { + sort.Strings(fields) + m.out.WinMac = append(m.out.WinMac, GenField{"TitleBar", "application.MacTitleBar{\n\t\t\t\t" + strings.Join(fields, "\n\t\t\t\t") + "\n\t\t\t}"}) + m.proj.Report.Mapped("options.App.Mac.TitleBar", "MacWindow.TitleBar") + } + return + } + m.manual("Mac.TitleBar", "Could not map the title bar value `"+m.src(value)+"`; set `MacWindow.TitleBar` manually.") +} + +func (m *mapper) mapMacAbout(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Mac.About", "v3 renders the about box from application.Options Name/Description/Icon; set those manually.") + return + } + fields := litFields(lit) + if msg, ok := fields["Message"]; ok && !m.referencesV2(msg) { + m.out.App = append(m.out.App, GenField{"Description", m.src(msg)}) + m.proj.Report.Mapped("options.App.Mac.About.Message", "application.Options.Description") + } + if icon, ok := fields["Icon"]; ok && !m.referencesV2(icon) { + m.out.App = append(m.out.App, GenField{"Icon", m.src(icon)}) + m.proj.Report.Mapped("options.App.Mac.About.Icon", "application.Options.Icon") + } + if _, ok := fields["Title"]; ok { + m.note("`Mac.About.Title` was dropped: the v3 about box title comes from `application.Options.Name`.") + } +} + +func (m *mapper) mapLinuxOptions(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Linux", "Could not parse the Linux options literal; port it manually to application.LinuxOptions / application.LinuxWindow.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "Linux." + name + switch name { + case "Icon": + m.carry(&m.out.WinLinux, prefix, "Icon", v, "LinuxWindow.Icon") + case "WindowIsTranslucent": + m.carry(&m.out.WinLinux, prefix, "WindowIsTranslucent", v, "LinuxWindow.WindowIsTranslucent") + case "WebviewGpuPolicy": + if v3 := selectorConst(v, linuxGpuPolicies); v3 != "" { + m.out.WinLinux = append(m.out.WinLinux, GenField{"WebviewGpuPolicy", v3}) + m.proj.Report.Mapped("options.App."+prefix, "LinuxWindow.WebviewGpuPolicy") + } else { + m.manual(prefix, "Could not map the GPU policy value `"+m.src(v)+"`; set `LinuxWindow.WebviewGpuPolicy` manually.") + } + case "ProgramName": + m.carry(&m.out.AppLinux, prefix, "ProgramName", v, "application.LinuxOptions.ProgramName") + case "Messages": + m.note("`Linux.Messages` was dropped: v3 reports missing webkit dependencies itself.") + default: + m.manual(prefix, "This option was not recognised by the migrator; check application.LinuxOptions / application.LinuxWindow for an equivalent.") + } + } +} + +func (m *mapper) mapDragAndDrop(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("DragAndDrop", "Set `WebviewWindowOptions.EnableFileDrop` manually.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "DragAndDrop." + name + switch name { + case "EnableFileDrop": + m.carry(&m.out.Win, prefix, "EnableFileDrop", v, "WebviewWindowOptions.EnableFileDrop") + case "DisableWebViewDrop": + m.manual(prefix, "No direct v3 equivalent; see `WebviewWindowOptions.EnableFileDrop` and the WindowFilesDropped event.") + case "CSSDropProperty", "CSSDropValue": + m.note("`" + prefix + "` was dropped: v3 controls drop targets with the `--wails-drop-target: drop` CSS property.") + default: + m.manual(prefix, "This option was not recognised by the migrator.") + } + } +} diff --git a/v3/internal/migrate/migrate.go b/v3/internal/migrate/migrate.go new file mode 100644 index 00000000000..7c2da10044c --- /dev/null +++ b/v3/internal/migrate/migrate.go @@ -0,0 +1,102 @@ +// Package migrate contains the engine behind `wails3 migrate`: it parses a +// Wails v2 project (wails.json + the declarative options.App literal passed to +// wails.Run) and produces the pieces of an equivalent v3 project. The +// orchestration (scaffolding via the init machinery, build assets, etc.) lives +// in internal/commands/migrate.go; everything in this package is pure +// parse/transform logic so it can be unit tested in isolation. +package migrate + +import ( + "go/ast" + "go/token" +) + +// V2CompatRuntimeImport is the import path of the v3-hosted implementation of +// the v2 runtime API. Migrated Go files have their v2 runtime import rewritten +// to this path; the package is also named "runtime", so call sites compile +// unchanged. +const V2CompatRuntimeImport = "github.com/wailsapp/wails/v3/pkg/v2compat/runtime" + +// V2RuntimeImport is the Wails v2 runtime package rewritten by the migrator. +const V2RuntimeImport = "github.com/wailsapp/wails/v2/pkg/runtime" + +// V2Project is everything the migrator learned about the source project. +type V2Project struct { + Dir string + Config V2Config // parsed wails.json + + ModulePath string // from go.mod + GoModPath string // absolute path to go.mod + FrontendDir string // absolute path to the frontend directory + + Main *MainInfo // parsed main package (wails.Run call site) + + // BoundTypes are the resolved struct types listed in Bind, in order. + BoundTypes []*BoundType + + // GoFiles are all .go files of the project (absolute paths), excluding + // the file containing wails.Run (handled separately). + GoFiles []string + + // Report accumulates human-readable notes about everything that needs + // manual attention. It is written to MIGRATION.md in the output project. + Report *Report +} + +// MainInfo describes the file containing the wails.Run call. +type MainInfo struct { + Path string // absolute path + Source []byte // original file bytes + File *ast.File + Fset *token.FileSet + + // RunStmt is the statement containing the wails.Run(...) call. + RunStmt ast.Stmt + // RunCall is the wails.Run call expression itself. + RunCall *ast.CallExpr + // AppLit is the &options.App{...} composite literal passed to wails.Run, + // nil if the argument was not a literal. + AppLit *ast.CompositeLit + // ErrIdent is the identifier the wails.Run error was assigned to + // ("err" in `err := wails.Run(...)`), empty for a bare call. + ErrIdent string + // AssignTok is the assignment token used (token.DEFINE or token.ASSIGN) + // when ErrIdent is set. + AssignTok token.Token + + // Imports maps local package names to import paths for this file. + Imports map[string]string +} + +// BoundType is a struct type bound to the frontend via options.App.Bind. +type BoundType struct { + // Expr is the original Go expression used in the Bind slice (e.g. "app"). + Expr string + // PkgName is the Go package name the type is declared in (e.g. "main"). + PkgName string + // PkgPath is the full package path used in binding FQNs. For types in the + // main package this is "main"; for other in-module packages it is + // module/path/to/pkg. + PkgPath string + // Name is the struct type name (e.g. "App"). + Name string + // Methods are the exported methods, in declaration order. + Methods []*BoundMethod +} + +// BoundMethod is one exported method of a bound type. +type BoundMethod struct { + Name string + Params []Param // excluding the receiver + Results []Param +} + +// Param is a parameter or result of a bound method. +type Param struct { + Name string + // GoType is the printed Go type (e.g. "string", "[]int", "*Person"). + GoType string + // TSType is the best-effort TypeScript equivalent used in generated .d.ts + // shims (e.g. "string", "number", "any"). + TSType string +} diff --git a/v3/internal/migrate/migrate_test.go b/v3/internal/migrate/migrate_test.go new file mode 100644 index 00000000000..cc0faa2ebe5 --- /dev/null +++ b/v3/internal/migrate/migrate_test.go @@ -0,0 +1,442 @@ +package migrate + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +const fixtureWailsJSON = `{ + "name": "myv2app", + "outputfilename": "myv2app", + "frontend:install": "pnpm install", + "frontend:build": "pnpm run build", + "author": { "name": "Tester" }, + "info": { + "companyName": "Wails", + "productName": "My V2 App", + "productVersion": "1.2.3" + } +}` + +const fixtureGoMod = `module myv2app + +go 1.23 + +require github.com/wailsapp/wails/v2 v2.10.2 +` + +const fixtureMainGo = `package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/mac" + "github.com/wailsapp/wails/v2/pkg/options/windows" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + // Create an instance of the app structure + app := NewApp() + + err := wails.Run(&options.App{ + Title: "My V2 App", + Width: 1024, + Height: 768, + Frameless: true, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + OnBeforeClose: app.beforeClose, + WindowStartState: options.Maximised, + Bind: []interface{}{ + app, + }, + Windows: &windows.Options{ + Theme: windows.Dark, + }, + Mac: &mac.Options{ + TitleBar: mac.TitleBarHiddenInset(), + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} +` + +const fixtureAppGo = `package main + +import ( + "context" + "fmt" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type App struct { + ctx context.Context +} + +func NewApp() *App { + return &App{} +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx +} + +func (a *App) shutdown(ctx context.Context) {} + +func (a *App) beforeClose(ctx context.Context) bool { return false } + +// Greet returns a greeting for the given name +func (a *App) Greet(name string) string { + runtime.WindowSetTitle(a.ctx, "Greeted "+name) + return fmt.Sprintf("Hello %s!", name) +} + +// Add adds two numbers +func (a *App) Add(x int, y int) int { return x + y } +` + +func writeFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "wails.json": fixtureWailsJSON, + "go.mod": fixtureGoMod, + "main.go": fixtureMainGo, + "app.go": fixtureAppGo, + "frontend/package.json": `{"name":"frontend","devDependencies":{"vite":"^3.0.0"}}`, + "frontend/index.html": ``, + "frontend/dist/.gitkeep": "", + "frontend/wailsjs/runtime/runtime.js": "// old", + } + for name, content := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func parseFixture(t *testing.T) *V2Project { + t.Helper() + proj, err := ParseV2Project(writeFixture(t)) + if err != nil { + t.Fatalf("ParseV2Project: %v", err) + } + return proj +} + +func TestLoadV2ConfigDefaults(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte(`{"name":"demo"}`), 0o644) + if err != nil { + t.Fatal(err) + } + cfg, err := LoadV2Config(dir) + if err != nil { + t.Fatal(err) + } + if cfg.FrontendDir != "frontend" || cfg.BuildDir != "build" || cfg.OutputFilename != "demo" { + t.Errorf("defaults not applied: %+v", cfg) + } + if cfg.Info.ProductName != "demo" || cfg.Info.ProductVersion != "1.0.0" { + t.Errorf("info defaults not applied: %+v", cfg.Info) + } + if cfg.PackageManager() != "npm" { + t.Errorf("expected npm default, got %s", cfg.PackageManager()) + } +} + +func TestParseV2Project(t *testing.T) { + proj := parseFixture(t) + + if proj.ModulePath != "myv2app" { + t.Errorf("module path: got %q", proj.ModulePath) + } + if proj.Config.PackageManager() != "pnpm" { + t.Errorf("package manager: got %q", proj.Config.PackageManager()) + } + if proj.Main == nil || !strings.HasSuffix(proj.Main.Path, "main.go") { + t.Fatalf("main not found: %+v", proj.Main) + } + if proj.Main.AppLit == nil { + t.Fatal("options.App literal not found") + } + if proj.Main.ErrIdent != "err" { + t.Errorf("err ident: got %q", proj.Main.ErrIdent) + } + + if len(proj.BoundTypes) != 1 { + t.Fatalf("bound types: got %d", len(proj.BoundTypes)) + } + bt := proj.BoundTypes[0] + if bt.Name != "App" || bt.Expr != "app" || bt.PkgName != "main" || bt.PkgPath != "main" { + t.Errorf("bound type: %+v", bt) + } + if len(bt.Methods) != 2 || bt.Methods[0].Name != "Greet" || bt.Methods[1].Name != "Add" { + t.Fatalf("methods: %+v", bt.Methods) + } + greet := bt.Methods[0] + if len(greet.Params) != 1 || greet.Params[0].Name != "name" || greet.Params[0].TSType != "string" { + t.Errorf("greet params: %+v", greet.Params) + } + if len(greet.Results) != 1 || greet.Results[0].TSType != "string" { + t.Errorf("greet results: %+v", greet.Results) + } +} + +func TestMapOptions(t *testing.T) { + proj := parseFixture(t) + opts := MapOptions(proj) + + find := func(fields []GenField, name string) string { + for _, f := range fields { + if f.Name == name { + return f.Expr + } + } + return "" + } + + if got := find(opts.App, "Name"); got != `"My V2 App"` { + t.Errorf("Name: got %s", got) + } + if got := find(opts.Win, "Width"); got != "1024" { + t.Errorf("Width: got %s", got) + } + if got := find(opts.Win, "Frameless"); got != "true" { + t.Errorf("Frameless: got %s", got) + } + if got := find(opts.Win, "StartState"); got != "application.WindowStateMaximised" { + t.Errorf("StartState: got %s", got) + } + if got := find(opts.Win, "BackgroundColour"); got != "application.NewRGBA(27, 38, 54, 1)" { + t.Errorf("BackgroundColour: got %s", got) + } + if got := find(opts.Win, "URL"); got != `"/"` { + t.Errorf("URL: got %s", got) + } + if got := find(opts.WinWin, "Theme"); got != "application.Dark" { + t.Errorf("Windows theme: got %s", got) + } + if got := find(opts.WinMac, "TitleBar"); got != "application.MacTitleBarHiddenInset" { + t.Errorf("Mac titlebar: got %s", got) + } + if !strings.Contains(find(opts.App, "Assets"), "application.AssetFileServerFS(assets)") { + t.Errorf("Assets: got %s", find(opts.App, "Assets")) + } + if opts.OnStartup != "app.startup" || opts.OnShutdown != "app.shutdown" { + t.Errorf("lifecycle: %+v", opts) + } + if opts.OnBeforeClose != "app.beforeClose" { + t.Errorf("OnBeforeClose: got %s", opts.OnBeforeClose) + } + if len(opts.Services) != 1 || opts.Services[0] != "app" { + t.Errorf("services: %+v", opts.Services) + } + if !opts.NeedsLifecycleService() { + t.Error("expected lifecycle service") + } +} + +func TestGenerateMain(t *testing.T) { + proj := parseFixture(t) + opts := MapOptions(proj) + out, err := GenerateMain(proj, opts) + if err != nil { + t.Fatalf("GenerateMain: %v", err) + } + src := string(out) + + for _, want := range []string{ + "application.New(application.Options{", + `Name: "My V2 App"`, + "application.NewService(app)", + "v2runtime.NewLifecycleService(app.startup, nil, app.shutdown)", + "ShouldQuit: func() bool {", + "!(app.beforeClose)(context.Background())", + ".Window.NewWithOptions(application.WebviewWindowOptions{", + "//go:embed all:frontend/dist", + "// Create an instance of the app structure", + "err := wailsApp.Run()", + `v2runtime "github.com/wailsapp/wails/v3/pkg/v2compat/runtime"`, + } { + if !strings.Contains(src, want) { + t.Errorf("generated main.go missing %q\n---\n%s", want, src) + } + } + for _, banned := range []string{ + "wailsapp/wails/v2", + "wails.Run", + "options.App", + } { + if strings.Contains(src, banned) { + t.Errorf("generated main.go still contains %q\n---\n%s", banned, src) + } + } + + // Must parse as valid Go. + fset := token.NewFileSet() + if _, err := parser.ParseFile(fset, "main.go", out, parser.SkipObjectResolution); err != nil { + t.Fatalf("generated main.go does not parse: %v\n---\n%s", err, src) + } +} + +func TestGenerateMainBareCall(t *testing.T) { + dir := writeFixture(t) + main := `package main + +import ( + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" +) + +func main() { + wails.Run(&options.App{Title: "bare"}) +} +` + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(main), 0o644); err != nil { + t.Fatal(err) + } + proj, err := ParseV2Project(dir) + if err != nil { + t.Fatal(err) + } + out, err := GenerateMain(proj, MapOptions(proj)) + if err != nil { + t.Fatal(err) + } + src := string(out) + if !strings.Contains(src, "app.Run()") { + t.Errorf("expected bare Run call:\n%s", src) + } + if strings.Contains(src, "err :=") { + t.Errorf("unexpected error assignment:\n%s", src) + } +} + +func TestTransformGoMod(t *testing.T) { + proj := parseFixture(t) + out, err := TransformGoMod(proj, "v3.0.0-alpha2.114") + if err != nil { + t.Fatal(err) + } + src := string(out) + if strings.Contains(src, "wails/v2") { + t.Errorf("v2 require not removed:\n%s", src) + } + if !strings.Contains(src, "github.com/wailsapp/wails/v3 v3.0.0-alpha2.114") { + t.Errorf("v3 require missing:\n%s", src) + } + if !strings.Contains(src, "go 1.24") { + t.Errorf("go directive not raised:\n%s", src) + } +} + +func TestRewriteGoImports(t *testing.T) { + proj := parseFixture(t) + src := []byte(`package main + +import ( + "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/wailsapp/wails/v2/pkg/menu" +) +`) + out := RewriteGoImports(proj, "other.go", src) + if !strings.Contains(string(out), V2CompatRuntimeImport) { + t.Errorf("runtime import not rewritten:\n%s", out) + } + if !strings.Contains(string(out), "wails/v2/pkg/menu") { + t.Errorf("menu import should be left in place for the user to port:\n%s", out) + } + if !proj.Report.HasManualSteps() { + t.Error("expected a manual step for the menu import") + } +} + +func TestGenerateBindingShim(t *testing.T) { + bt := &BoundType{ + Expr: "app", + PkgName: "main", + PkgPath: "main", + Name: "App", + Methods: []*BoundMethod{ + { + Name: "Greet", + Params: []Param{{Name: "name", GoType: "string", TSType: "string"}}, + Results: []Param{{GoType: "string", TSType: "string"}}, + }, + { + Name: "Quit", + Params: nil, + Results: nil, + }, + }, + } + js, dts := generateBindingShim(bt) + if !strings.Contains(js, `Call.ByName("main.App.Greet", name)`) { + t.Errorf("js shim:\n%s", js) + } + if !strings.Contains(js, `Call.ByName("main.App.Quit")`) { + t.Errorf("js shim:\n%s", js) + } + if !strings.Contains(dts, "export function Greet(name: string): Promise;") { + t.Errorf("dts shim:\n%s", dts) + } + if !strings.Contains(dts, "export function Quit(): Promise;") { + t.Errorf("dts shim:\n%s", dts) + } +} + +func TestMigrateFrontend(t *testing.T) { + proj := parseFixture(t) + outDir := t.TempDir() + if err := MigrateFrontend(proj, outDir); err != nil { + t.Fatal(err) + } + + runtimeJS, err := os.ReadFile(filepath.Join(outDir, "frontend", "wailsjs", "runtime", "runtime.js")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(runtimeJS), "@wailsio/runtime") { + t.Error("runtime.js shim not written") + } + + appJS, err := os.ReadFile(filepath.Join(outDir, "frontend", "wailsjs", "go", "main", "App.js")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(appJS), `Call.ByName("main.App.Greet", name)`) { + t.Errorf("App.js shim:\n%s", appJS) + } + + pkgJSON, err := os.ReadFile(filepath.Join(outDir, "frontend", "package.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(pkgJSON), `"@wailsio/runtime"`) { + t.Errorf("package.json missing runtime dep:\n%s", pkgJSON) + } +} diff --git a/v3/internal/migrate/parse.go b/v3/internal/migrate/parse.go new file mode 100644 index 00000000000..24d532c8916 --- /dev/null +++ b/v3/internal/migrate/parse.go @@ -0,0 +1,498 @@ +package migrate + +import ( + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "golang.org/x/mod/modfile" +) + +// ParseV2Project reads a v2 project from dir. It is a syntax-only parse (no +// type checking), so it works without the v2 module being present in the +// module cache. +func ParseV2Project(dir string) (*V2Project, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + cfg, err := LoadV2Config(absDir) + if err != nil { + return nil, err + } + + proj := &V2Project{ + Dir: absDir, + Config: cfg, + FrontendDir: filepath.Join(absDir, cfg.FrontendDir), + Report: NewReport(), + } + + // go.mod + proj.GoModPath = filepath.Join(absDir, "go.mod") + modData, err := os.ReadFile(proj.GoModPath) + if err != nil { + return nil, fmt.Errorf("could not read go.mod: %w", err) + } + mod, err := modfile.Parse("go.mod", modData, nil) + if err != nil { + return nil, fmt.Errorf("could not parse go.mod: %w", err) + } + if mod.Module == nil { + return nil, fmt.Errorf("go.mod has no module directive") + } + proj.ModulePath = mod.Module.Mod.Path + if !requiresWailsV2(mod) { + return nil, fmt.Errorf("go.mod does not require github.com/wailsapp/wails/v2 - is this a Wails v2 project?") + } + + // Parse all Go files in the module (excluding frontend, build dir and + // hidden/vendor directories). + fset := token.NewFileSet() + files := map[string]*ast.File{} // abs path -> file + err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if path != absDir && (strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules") { + return filepath.SkipDir + } + if path == proj.FrontendDir || path == filepath.Join(absDir, cfg.BuildDir) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, perr := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution) + if perr != nil { + return fmt.Errorf("could not parse %s: %w", path, perr) + } + files[path] = file + return nil + }) + if err != nil { + return nil, err + } + if len(files) == 0 { + return nil, fmt.Errorf("no Go files found in %s", absDir) + } + + // Locate the wails.Run call. + for path, file := range files { + info := findRunCall(fset, path, file) + if info == nil { + continue + } + if proj.Main != nil { + return nil, fmt.Errorf("found more than one wails.Run call (%s and %s); cannot migrate automatically", proj.Main.Path, path) + } + proj.Main = info + } + if proj.Main == nil { + return nil, fmt.Errorf("could not find a wails.Run(&options.App{...}) call in %s", absDir) + } + proj.Main.Source, err = os.ReadFile(proj.Main.Path) + if err != nil { + return nil, err + } + + for path := range files { + if path != proj.Main.Path { + proj.GoFiles = append(proj.GoFiles, path) + } + } + + // Resolve bound types from the Bind field, if present. + if proj.Main.AppLit != nil { + if bind := fieldValue(proj.Main.AppLit, "Bind"); bind != nil { + proj.BoundTypes = resolveBoundTypes(fset, files, proj, bind) + } + } + + return proj, nil +} + +func requiresWailsV2(mod *modfile.File) bool { + for _, req := range mod.Require { + if req.Mod.Path == "github.com/wailsapp/wails/v2" { + return true + } + } + return false +} + +// findRunCall looks for a statement of the form +// +// err := wails.Run(&options.App{...}) +// err = wails.Run(...) +// wails.Run(...) +// if err := wails.Run(...); err != nil { ... } +// +// where "wails" is the local name of the github.com/wailsapp/wails/v2 import. +func findRunCall(fset *token.FileSet, path string, file *ast.File) *MainInfo { + imports := importMap(file) + wailsName := "" + for name, ipath := range imports { + if ipath == "github.com/wailsapp/wails/v2" { + wailsName = name + } + } + if wailsName == "" { + return nil + } + + info := &MainInfo{Path: path, File: file, Fset: fset, Imports: imports} + + isRunCall := func(n ast.Node) *ast.CallExpr { + call, ok := n.(*ast.CallExpr) + if !ok { + return nil + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Run" { + return nil + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != wailsName { + return nil + } + return call + } + + ast.Inspect(file, func(n ast.Node) bool { + if info.RunCall != nil { + return false + } + stmt, ok := n.(ast.Stmt) + if !ok { + return true + } + switch s := stmt.(type) { + case *ast.AssignStmt: + if len(s.Rhs) != 1 { + return true + } + call := isRunCall(s.Rhs[0]) + if call == nil { + return true + } + info.RunStmt = s + info.RunCall = call + if len(s.Lhs) == 1 { + if ident, ok := s.Lhs[0].(*ast.Ident); ok { + info.ErrIdent = ident.Name + info.AssignTok = s.Tok + } + } + return false + case *ast.ExprStmt: + call := isRunCall(s.X) + if call == nil { + return true + } + info.RunStmt = s + info.RunCall = call + return false + } + return true + }) + + if info.RunCall == nil { + return nil + } + + // Extract the &options.App{...} literal. + if len(info.RunCall.Args) == 1 { + arg := info.RunCall.Args[0] + if unary, ok := arg.(*ast.UnaryExpr); ok && unary.Op == token.AND { + arg = unary.X + } + if lit, ok := arg.(*ast.CompositeLit); ok { + info.AppLit = lit + } + } + return info +} + +func importMap(file *ast.File) map[string]string { + m := map[string]string{} + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + name := "" + if imp.Name != nil { + name = imp.Name.Name + } else { + // Default local name: last path element, with major-version + // suffixes (vN) resolved to the element before them. This is a + // heuristic (the true name is the package clause), but it is + // correct for the packages the migrator cares about. + name = path[strings.LastIndex(path, "/")+1:] + if len(name) > 1 && name[0] == 'v' { + if _, err := strconv.Atoi(name[1:]); err == nil { + trimmed := path[:strings.LastIndex(path, "/")] + name = trimmed[strings.LastIndex(trimmed, "/")+1:] + } + } + } + if name == "_" || name == "." { + continue + } + m[name] = path + } + return m +} + +// fieldValue returns the value of the named field in a composite literal, or +// nil if not present. +func fieldValue(lit *ast.CompositeLit, name string) ast.Expr { + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok { + continue + } + if key.Name == name { + return kv.Value + } + } + return nil +} + +// exprText returns the original source text for an expression. +func exprText(fset *token.FileSet, src []byte, node ast.Node) string { + start := fset.Position(node.Pos()).Offset + end := fset.Position(node.End()).Offset + if start < 0 || end > len(src) || start >= end { + return "" + } + return string(src[start:end]) +} + +// printExpr renders an AST expression to Go source (used where original +// source bytes are not to hand). +func printExpr(fset *token.FileSet, node ast.Node) string { + var sb strings.Builder + _ = printer.Fprint(&sb, fset, node) + return sb.String() +} + +// resolveBoundTypes maps each element of the Bind slice literal to its struct +// type and collects the exported methods of that type from the parsed files. +func resolveBoundTypes(fset *token.FileSet, files map[string]*ast.File, proj *V2Project, bind ast.Expr) []*BoundType { + lit, ok := bind.(*ast.CompositeLit) + if !ok { + proj.Report.Manual("Bind", "The Bind value is not a slice literal, so bound structs could not be discovered. The generated frontend/wailsjs shims may be incomplete; check frontend imports against the v3 bindings generated into frontend/bindings.") + return nil + } + + mainFile := files[proj.Main.Path] + var result []*BoundType + for _, elt := range lit.Elts { + expr := printExpr(fset, elt) + typeName := resolveElementType(fset, files, mainFile, elt) + bt := &BoundType{Expr: expr, Name: typeName} + if typeName == "" { + proj.Report.Manual("Bind: "+expr, + "Could not statically determine the struct type of this Bind entry. It is still registered as a v3 service, but no frontend/wailsjs shim was generated for it.") + result = append(result, bt) + continue + } + pkgName, pkgPath, methods := collectMethods(fset, files, proj, typeName) + bt.PkgName = pkgName + bt.PkgPath = pkgPath + bt.Methods = methods + if len(methods) == 0 { + proj.Report.Note("Bind: " + expr + " (" + typeName + ") has no exported methods that could be discovered; no frontend/wailsjs shim was generated for it.") + } + result = append(result, bt) + } + return result +} + +// resolveElementType attempts to resolve a Bind element expression to a +// struct type name using purely syntactic information: +// +// &App{...} -> App +// NewApp() -> return type of func NewApp +// app -> declaration of app in the same file (app := NewApp(), +// app := &App{}, var app = ...) +func resolveElementType(fset *token.FileSet, files map[string]*ast.File, mainFile *ast.File, expr ast.Expr) string { + switch e := expr.(type) { + case *ast.UnaryExpr: + if e.Op == token.AND { + if lit, ok := e.X.(*ast.CompositeLit); ok { + if ident, ok := lit.Type.(*ast.Ident); ok { + return ident.Name + } + } + } + case *ast.CallExpr: + if ident, ok := e.Fun.(*ast.Ident); ok { + return constructorReturnType(files, ident.Name) + } + case *ast.Ident: + var typeName string + ast.Inspect(mainFile, func(n ast.Node) bool { + if typeName != "" { + return false + } + assign, ok := n.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != 1 || len(assign.Rhs) != 1 { + return true + } + lhs, ok := assign.Lhs[0].(*ast.Ident) + if !ok || lhs.Name != e.Name { + return true + } + typeName = resolveElementType(fset, files, mainFile, assign.Rhs[0]) + return false + }) + return typeName + } + return "" +} + +// constructorReturnType finds `func Name(...) *T` in the parsed files and +// returns T. +func constructorReturnType(files map[string]*ast.File, name string) string { + for _, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || fn.Name.Name != name { + continue + } + if fn.Type.Results == nil || len(fn.Type.Results.List) == 0 { + return "" + } + t := fn.Type.Results.List[0].Type + if star, ok := t.(*ast.StarExpr); ok { + t = star.X + } + if ident, ok := t.(*ast.Ident); ok { + return ident.Name + } + } + } + return "" +} + +// collectMethods gathers the exported methods declared on *typeName or +// typeName across the parsed files. +func collectMethods(fset *token.FileSet, files map[string]*ast.File, proj *V2Project, typeName string) (pkgName, pkgPath string, methods []*BoundMethod) { + // Sort file paths for deterministic method order across map iteration. + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + + for _, path := range paths { + file := files[path] + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 { + continue + } + recv := fn.Recv.List[0].Type + if star, ok := recv.(*ast.StarExpr); ok { + recv = star.X + } + ident, ok := recv.(*ast.Ident) + if !ok || ident.Name != typeName { + continue + } + if !fn.Name.IsExported() { + continue + } + if pkgName == "" { + pkgName = file.Name.Name + pkgPath = packagePath(proj, path, pkgName) + } + methods = append(methods, &BoundMethod{ + Name: fn.Name.Name, + Params: fieldListParams(fset, fn.Type.Params), + Results: fieldListParams(fset, fn.Type.Results), + }) + } + } + return pkgName, pkgPath, methods +} + +// packagePath computes the binding FQN package path for a file: "main" for +// the main package, otherwise modulePath[/reldir]. +func packagePath(proj *V2Project, filePath, pkgName string) string { + if pkgName == "main" { + return "main" + } + rel, err := filepath.Rel(proj.Dir, filepath.Dir(filePath)) + if err != nil || rel == "." { + return proj.ModulePath + } + return proj.ModulePath + "/" + filepath.ToSlash(rel) +} + +func fieldListParams(fset *token.FileSet, list *ast.FieldList) []Param { + if list == nil { + return nil + } + var params []Param + for _, field := range list.List { + goType := printExpr(fset, field.Type) + tsType := goTypeToTS(goType) + if len(field.Names) == 0 { + params = append(params, Param{GoType: goType, TSType: tsType}) + continue + } + for _, name := range field.Names { + params = append(params, Param{Name: name.Name, GoType: goType, TSType: tsType}) + } + } + return params +} + +// goTypeToTS maps a printed Go type to a best-effort TypeScript type for the +// generated .d.ts shims. Anything unrecognised becomes "any"; the real v3 +// bindings (frontend/bindings) carry full model types. +func goTypeToTS(goType string) string { + goType = strings.TrimPrefix(goType, "*") + switch goType { + case "string": + return "string" + case "bool": + return "boolean" + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", + "float32", "float64", "byte", "rune", "uintptr": + return "number" + case "interface{}", "any": + return "any" + case "error": + return "void" + } + if strings.HasPrefix(goType, "[]") { + return goTypeToTS(goType[2:]) + "[]" + } + if strings.HasPrefix(goType, "map[") { + return "Record" + } + return "any" +} diff --git a/v3/internal/migrate/report.go b/v3/internal/migrate/report.go new file mode 100644 index 00000000000..beb35c55c4a --- /dev/null +++ b/v3/internal/migrate/report.go @@ -0,0 +1,88 @@ +package migrate + +import ( + "fmt" + "sort" + "strings" +) + +// Report collects everything the migrator did (or could not do) so the user +// gets a single MIGRATION.md summarising the state of the new project. +type Report struct { + mapped []string // options that were migrated automatically + manual map[string]string // v2 option/feature -> what the user must do + notes []string // informational notes +} + +func NewReport() *Report { + return &Report{manual: map[string]string{}} +} + +// Mapped records a successfully migrated v2 option: e.g. Mapped("Title", +// "WebviewWindowOptions.Title"). +func (r *Report) Mapped(v2Option, target string) { + r.mapped = append(r.mapped, fmt.Sprintf("| `%s` | `%s` |", v2Option, target)) +} + +// Manual records something that needs the user's attention. +func (r *Report) Manual(what, instructions string) { + r.manual[what] = instructions +} + +// Note records an informational message. +func (r *Report) Note(note string) { + r.notes = append(r.notes, note) +} + +// HasManualSteps reports whether any manual step was recorded. +func (r *Report) HasManualSteps() bool { + return len(r.manual) > 0 +} + +// Markdown renders the report as the MIGRATION.md contents. +func (r *Report) Markdown() string { + var sb strings.Builder + sb.WriteString("# Migration Report\n\n") + sb.WriteString("This project was migrated from Wails v2 by `wails3 migrate`.\n\n") + sb.WriteString("## Next steps\n\n") + sb.WriteString("1. Run `wails3 doctor` to check your environment.\n") + sb.WriteString("2. Run `wails3 dev` to build and run the migrated app.\n") + sb.WriteString("3. Work through the *Manual steps* below, if any.\n") + sb.WriteString("4. Migrate incrementally from the `v2compat/runtime` bridge to the v3 API.\n") + sb.WriteString(" Every function in `github.com/wailsapp/wails/v3/pkg/v2compat/runtime` documents its v3 replacement.\n") + sb.WriteString(" See https://v3.wails.io/migration/v2-to-v3/ for the full guide.\n\n") + + if len(r.manual) > 0 { + sb.WriteString("## Manual steps\n\n") + keys := make([]string, 0, len(r.manual)) + for k := range r.manual { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + sb.WriteString(fmt.Sprintf("- **%s**: %s\n", k, r.manual[k])) + } + sb.WriteString("\n") + } + + if len(r.notes) > 0 { + sb.WriteString("## Notes\n\n") + for _, n := range r.notes { + sb.WriteString("- " + n + "\n") + } + sb.WriteString("\n") + } + + if len(r.mapped) > 0 { + sb.WriteString("## Migrated options\n\n") + sb.WriteString("| Wails v2 | Wails v3 |\n|---|---|\n") + mapped := append([]string(nil), r.mapped...) + sort.Strings(mapped) + for _, m := range mapped { + sb.WriteString(m + "\n") + } + sb.WriteString("\n") + } + + return sb.String() +} diff --git a/v3/internal/migrate/wailsjson.go b/v3/internal/migrate/wailsjson.go new file mode 100644 index 00000000000..07f23577ac8 --- /dev/null +++ b/v3/internal/migrate/wailsjson.go @@ -0,0 +1,111 @@ +package migrate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// V2Config mirrors the subset of a Wails v2 wails.json that matters for +// migration. Field names/JSON tags match v2/internal/project/project.go. +type V2Config struct { + Name string `json:"name"` + AssetDirectory string `json:"assetdir"` + + FrontendInstall string `json:"frontend:install"` + FrontendBuild string `json:"frontend:build"` + FrontendDevWatcher string `json:"frontend:dev:watcher"` + FrontendDevServerUrl string `json:"frontend:dev:serverUrl"` + FrontendDir string `json:"frontend:dir"` + WailsJSDir string `json:"wailsjsdir"` + + BuildDir string `json:"build:dir"` + BuildTags string `json:"build:tags"` + OutputFilename string `json:"outputfilename"` + Obfuscated bool `json:"obfuscated"` + GarbleArgs string `json:"garbleargs"` + + PreBuildHooks map[string]string `json:"preBuildHooks"` + PostBuildHooks map[string]string `json:"postBuildHooks"` + + Author struct { + Name string `json:"name"` + Email string `json:"email"` + } `json:"author"` + + Info struct { + CompanyName string `json:"companyName"` + ProductName string `json:"productName"` + ProductVersion string `json:"productVersion"` + Copyright *string `json:"copyright"` + Comments *string `json:"comments"` + FileAssociations []V2FileAssociation `json:"fileAssociations"` + Protocols []V2Protocol `json:"protocols"` + } `json:"info"` +} + +// V2FileAssociation mirrors v2's file association config. +type V2FileAssociation struct { + Ext string `json:"ext"` + Name string `json:"name"` + Description string `json:"description"` + IconName string `json:"iconName"` + Role string `json:"role"` +} + +// V2Protocol mirrors v2's custom protocol config. +type V2Protocol struct { + Scheme string `json:"scheme"` + Description string `json:"description"` + Role string `json:"role"` +} + +// LoadV2Config reads and parses /wails.json, applying the same defaults +// v2 applies for the fields the migrator uses. +func LoadV2Config(dir string) (V2Config, error) { + var cfg V2Config + data, err := os.ReadFile(filepath.Join(dir, "wails.json")) + if err != nil { + return cfg, fmt.Errorf("could not read wails.json: %w", err) + } + if err := json.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("could not parse wails.json: %w", err) + } + if cfg.Name == "" { + return cfg, fmt.Errorf("wails.json has no 'name' field") + } + if cfg.FrontendDir == "" { + cfg.FrontendDir = "frontend" + } + if cfg.BuildDir == "" { + cfg.BuildDir = "build" + } + if cfg.OutputFilename == "" { + cfg.OutputFilename = cfg.Name + } + cfg.OutputFilename = strings.TrimSuffix(cfg.OutputFilename, ".exe") + if cfg.Info.ProductName == "" { + cfg.Info.ProductName = cfg.Name + } + if cfg.Info.ProductVersion == "" { + cfg.Info.ProductVersion = "1.0.0" + } + return cfg, nil +} + +// PackageManager guesses the frontend package manager from the v2 +// frontend:install command. Returns "npm" when unsure (the v3 Taskfile +// default). +func (c V2Config) PackageManager() string { + fields := strings.Fields(c.FrontendInstall) + if len(fields) == 0 { + return "npm" + } + switch fields[0] { + case "npm", "yarn", "pnpm", "bun": + return fields[0] + } + return "npm" +} diff --git a/v3/pkg/v2compat/runtime/app.go b/v3/pkg/v2compat/runtime/app.go new file mode 100644 index 00000000000..cc0b16feac1 --- /dev/null +++ b/v3/pkg/v2compat/runtime/app.go @@ -0,0 +1,44 @@ +package runtime + +import ( + "errors" + "log/slog" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// errNoApp is returned by functions that cannot silently no-op when the +// application has not been created yet. +var errNoApp = errors.New("no application instance available") + +// app returns the global v3 application instance, or nil if it has not been +// created yet. +func app() *application.App { + return application.Get() +} + +// currentWindow returns the current window, falling back to the first +// window when none is focused. Returns nil when no window exists yet. +func currentWindow() application.Window { + a := application.Get() + if a == nil { + return nil + } + if w := a.Window.Current(); w != nil { + return w + } + all := a.Window.GetAll() + if len(all) > 0 { + return all[0] + } + return nil +} + +// logger returns the application logger, falling back to slog's default +// logger when the application has not been created yet. +func logger() *slog.Logger { + if a := app(); a != nil && a.Logger != nil { + return a.Logger + } + return slog.Default() +} diff --git a/v3/pkg/v2compat/runtime/browser.go b/v3/pkg/v2compat/runtime/browser.go new file mode 100644 index 00000000000..28c2b20e6cb --- /dev/null +++ b/v3/pkg/v2compat/runtime/browser.go @@ -0,0 +1,18 @@ +package runtime + +import ( + "context" +) + +// BrowserOpenURL mirrors the v2 runtime.BrowserOpenURL function. Any error is +// logged as v2 returned nothing. +// v3 equivalent: app.Browser.OpenURL. +func BrowserOpenURL(_ context.Context, url string) { + a := app() + if a == nil { + return + } + if err := a.Browser.OpenURL(url); err != nil { + logger().Warn("v2compat: BrowserOpenURL failed", "url", url, "error", err) + } +} diff --git a/v3/pkg/v2compat/runtime/clipboard.go b/v3/pkg/v2compat/runtime/clipboard.go new file mode 100644 index 00000000000..e7b1a26cdbe --- /dev/null +++ b/v3/pkg/v2compat/runtime/clipboard.go @@ -0,0 +1,33 @@ +package runtime + +import ( + "context" + "errors" +) + +// ClipboardGetText mirrors the v2 runtime.ClipboardGetText function. +// v3 equivalent: app.Clipboard.Text. +func ClipboardGetText(_ context.Context) (string, error) { + a := app() + if a == nil { + return "", errNoApp + } + text, ok := a.Clipboard.Text() + if !ok { + return "", errors.New("no text on clipboard") + } + return text, nil +} + +// ClipboardSetText mirrors the v2 runtime.ClipboardSetText function. +// v3 equivalent: app.Clipboard.SetText. +func ClipboardSetText(_ context.Context, text string) error { + a := app() + if a == nil { + return errNoApp + } + if !a.Clipboard.SetText(text) { + return errors.New("failed to set clipboard text") + } + return nil +} diff --git a/v3/pkg/v2compat/runtime/dialog.go b/v3/pkg/v2compat/runtime/dialog.go new file mode 100644 index 00000000000..f36849a9e6b --- /dev/null +++ b/v3/pkg/v2compat/runtime/dialog.go @@ -0,0 +1,218 @@ +package runtime + +import ( + "context" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// DialogType mirrors the v2 runtime.DialogType type. +type DialogType string + +// Dialog types, mirroring the v2 runtime constants. +const ( + InfoDialog DialogType = "info" + WarningDialog DialogType = "warning" + ErrorDialog DialogType = "error" + QuestionDialog DialogType = "question" +) + +// FileFilter mirrors the v2 runtime.FileFilter type. +// v3 equivalent: application.FileFilter. +type FileFilter struct { + DisplayName string // Filter information EG: "Image Files (*.jpg, *.png)" + Pattern string // semicolon separated list of extensions, EG: "*.jpg;*.png" +} + +// OpenDialogOptions mirrors the v2 runtime.OpenDialogOptions type. +// v3 equivalent: application.OpenFileDialogOptions. +type OpenDialogOptions struct { + DefaultDirectory string + DefaultFilename string + Title string + Filters []FileFilter + ShowHiddenFiles bool + CanCreateDirectories bool + ResolvesAliases bool + TreatPackagesAsDirectories bool +} + +// SaveDialogOptions mirrors the v2 runtime.SaveDialogOptions type. +// v3 equivalent: application.SaveFileDialogOptions. +type SaveDialogOptions struct { + DefaultDirectory string + DefaultFilename string + Title string + Filters []FileFilter + ShowHiddenFiles bool + CanCreateDirectories bool + TreatPackagesAsDirectories bool +} + +// MessageDialogOptions mirrors the v2 runtime.MessageDialogOptions type. +// v3 equivalent: the application.MessageDialog builder API. +type MessageDialogOptions struct { + Type DialogType + Title string + Message string + Buttons []string + DefaultButton string + CancelButton string + Icon []byte +} + +// convertFilters maps v2 file filters onto their v3 equivalent. +func convertFilters(filters []FileFilter) []application.FileFilter { + if len(filters) == 0 { + return nil + } + result := make([]application.FileFilter, 0, len(filters)) + for _, filter := range filters { + result = append(result, application.FileFilter{ + DisplayName: filter.DisplayName, + Pattern: filter.Pattern, + }) + } + return result +} + +// OpenDirectoryDialog mirrors the v2 runtime.OpenDirectoryDialog function. +// v3 equivalent: app.Dialog.OpenFileWithOptions with CanChooseDirectories set. +func OpenDirectoryDialog(_ context.Context, dialogOptions OpenDialogOptions) (string, error) { + a := app() + if a == nil { + return "", errNoApp + } + dialog := a.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{ + CanChooseDirectories: true, + CanChooseFiles: false, + CanCreateDirectories: dialogOptions.CanCreateDirectories, + ShowHiddenFiles: dialogOptions.ShowHiddenFiles, + ResolvesAliases: dialogOptions.ResolvesAliases, + TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, + Filters: convertFilters(dialogOptions.Filters), + Title: dialogOptions.Title, + Directory: dialogOptions.DefaultDirectory, + }) + return dialog.PromptForSingleSelection() +} + +// OpenFileDialog mirrors the v2 runtime.OpenFileDialog function. The v2 +// DefaultFilename option is ignored as v3 open dialogs have no default filename. +// v3 equivalent: app.Dialog.OpenFileWithOptions. +func OpenFileDialog(_ context.Context, dialogOptions OpenDialogOptions) (string, error) { + a := app() + if a == nil { + return "", errNoApp + } + dialog := a.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{ + CanChooseFiles: true, + CanCreateDirectories: dialogOptions.CanCreateDirectories, + ShowHiddenFiles: dialogOptions.ShowHiddenFiles, + ResolvesAliases: dialogOptions.ResolvesAliases, + TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, + Filters: convertFilters(dialogOptions.Filters), + Title: dialogOptions.Title, + Directory: dialogOptions.DefaultDirectory, + }) + return dialog.PromptForSingleSelection() +} + +// OpenMultipleFilesDialog mirrors the v2 runtime.OpenMultipleFilesDialog +// function. The v2 DefaultFilename option is ignored as v3 open dialogs have +// no default filename. +// v3 equivalent: app.Dialog.OpenFileWithOptions with AllowsMultipleSelection set. +func OpenMultipleFilesDialog(_ context.Context, dialogOptions OpenDialogOptions) ([]string, error) { + a := app() + if a == nil { + return nil, errNoApp + } + dialog := a.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{ + CanChooseFiles: true, + AllowsMultipleSelection: true, + CanCreateDirectories: dialogOptions.CanCreateDirectories, + ShowHiddenFiles: dialogOptions.ShowHiddenFiles, + ResolvesAliases: dialogOptions.ResolvesAliases, + TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, + Filters: convertFilters(dialogOptions.Filters), + Title: dialogOptions.Title, + Directory: dialogOptions.DefaultDirectory, + }) + return dialog.PromptForMultipleSelection() +} + +// SaveFileDialog mirrors the v2 runtime.SaveFileDialog function. +// v3 equivalent: app.Dialog.SaveFileWithOptions. +func SaveFileDialog(_ context.Context, dialogOptions SaveDialogOptions) (string, error) { + a := app() + if a == nil { + return "", errNoApp + } + dialog := a.Dialog.SaveFileWithOptions(&application.SaveFileDialogOptions{ + CanCreateDirectories: dialogOptions.CanCreateDirectories, + ShowHiddenFiles: dialogOptions.ShowHiddenFiles, + TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, + Filters: convertFilters(dialogOptions.Filters), + Title: dialogOptions.Title, + Directory: dialogOptions.DefaultDirectory, + Filename: dialogOptions.DefaultFilename, + }) + return dialog.PromptForSingleSelection() +} + +// MessageDialog mirrors the v2 runtime.MessageDialog function. It shows a +// message dialog and blocks until a button is clicked, returning the label of +// the clicked button. +// +// Unlike v2 there is no per-platform default-button fallback: dismissing the +// dialog without clicking a button (where the platform allows it) only +// delivers the cancel button's label if the platform wires the dismissal to +// the cancel button. +// v3 equivalent: the app.Dialog.Info/Question/Warning/Error builder API. +func MessageDialog(_ context.Context, dialogOptions MessageDialogOptions) (string, error) { + a := app() + if a == nil { + return "", errNoApp + } + var dialog *application.MessageDialog + switch dialogOptions.Type { + case QuestionDialog: + dialog = a.Dialog.Question() + case WarningDialog: + dialog = a.Dialog.Warning() + case ErrorDialog: + dialog = a.Dialog.Error() + default: + dialog = a.Dialog.Info() + } + dialog.SetTitle(dialogOptions.Title) + dialog.SetMessage(dialogOptions.Message) + if len(dialogOptions.Icon) > 0 { + dialog.SetIcon(dialogOptions.Icon) + } + + buttons := dialogOptions.Buttons + if len(buttons) == 0 { + buttons = []string{"Ok"} + } + + result := make(chan string, 1) + for _, label := range buttons { + button := dialog.AddButton(label) + button.OnClick(func() { + select { + case result <- label: + default: + } + }) + if label == dialogOptions.DefaultButton { + dialog.SetDefaultButton(button) + } + if label == dialogOptions.CancelButton { + dialog.SetCancelButton(button) + } + } + + dialog.Show() + return <-result, nil +} diff --git a/v3/pkg/v2compat/runtime/doc.go b/v3/pkg/v2compat/runtime/doc.go new file mode 100644 index 00000000000..d72b1d1b19b --- /dev/null +++ b/v3/pkg/v2compat/runtime/doc.go @@ -0,0 +1,14 @@ +// Package runtime is a temporary compatibility bridge for projects migrated +// from Wails v2 via the `wails3 migrate` command. +// +// It mirrors the Wails v2 runtime API (github.com/wailsapp/wails/v2/pkg/runtime), +// a set of context-first free functions, implemented on top of the Wails v3 +// application API (github.com/wailsapp/wails/v3/pkg/application). Migrated v2 +// code keeps calling functions such as runtime.WindowSetTitle(a.ctx, ...) +// unchanged; only the import path changes. The context parameter is accepted +// for source compatibility and ignored. +// +// New code should use github.com/wailsapp/wails/v3/pkg/application directly. +// Each function in this package documents its v3 equivalent so that call +// sites can be migrated incrementally and this package eventually removed. +package runtime diff --git a/v3/pkg/v2compat/runtime/draganddrop.go b/v3/pkg/v2compat/runtime/draganddrop.go new file mode 100644 index 00000000000..21c897ddbd6 --- /dev/null +++ b/v3/pkg/v2compat/runtime/draganddrop.go @@ -0,0 +1,80 @@ +package runtime + +import ( + "context" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +var ( + fileDropLock sync.Mutex + fileDropUnsubscribers []func() + // fileDropGeneration invalidates listeners registered by earlier + // OnFileDrop calls once OnFileDropOff has run, including the window + // creation hooks which cannot be unregistered. + fileDropGeneration int +) + +// OnFileDrop mirrors the v2 runtime.OnFileDrop function. The callback is +// registered on the current window and on any window created afterwards. +// +// The drop coordinates are not exposed to the application side in v3, so the +// callback always receives x=0, y=0. Note that file-drop events only fire for +// windows created with EnableFileDrop: true in their +// application.WebviewWindowOptions. +// v3 equivalent: window.OnWindowEvent(events.Common.WindowFilesDropped, ...). +func OnFileDrop(_ context.Context, callback func(x, y int, paths []string)) { + a := app() + if a == nil || callback == nil { + return + } + + fileDropLock.Lock() + generation := fileDropGeneration + fileDropLock.Unlock() + + register := func(window application.Window) { + unsubscribe := window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) { + callback(0, 0, event.Context().DroppedFiles()) + }) + fileDropLock.Lock() + if fileDropGeneration != generation { + // OnFileDropOff was called in the meantime. + fileDropLock.Unlock() + unsubscribe() + return + } + fileDropUnsubscribers = append(fileDropUnsubscribers, unsubscribe) + fileDropLock.Unlock() + } + + if w := currentWindow(); w != nil { + register(w) + } + a.Window.OnCreate(func(window application.Window) { + fileDropLock.Lock() + stale := fileDropGeneration != generation + fileDropLock.Unlock() + if stale { + return + } + register(window) + }) +} + +// OnFileDropOff mirrors the v2 runtime.OnFileDropOff function. It removes all +// file-drop listeners registered via OnFileDrop. +// v3 equivalent: calling the unsubscribe function returned by window.OnWindowEvent. +func OnFileDropOff(_ context.Context) { + fileDropLock.Lock() + fileDropGeneration++ + unsubscribers := fileDropUnsubscribers + fileDropUnsubscribers = nil + fileDropLock.Unlock() + + for _, unsubscribe := range unsubscribers { + unsubscribe() + } +} diff --git a/v3/pkg/v2compat/runtime/events.go b/v3/pkg/v2compat/runtime/events.go new file mode 100644 index 00000000000..b261c51a495 --- /dev/null +++ b/v3/pkg/v2compat/runtime/events.go @@ -0,0 +1,118 @@ +package runtime + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// eventArgs converts a v3 CustomEvent data payload into the variadic argument +// list expected by v2 event callbacks: nil becomes no arguments, a slice +// emitted from multiple arguments is spread, and any other value is passed +// as a single argument. +func eventArgs(data any) []interface{} { + switch d := data.(type) { + case nil: + return nil + case []interface{}: + return d + default: + return []interface{}{d} + } +} + +// EventsOn mirrors the v2 runtime.EventsOn function. +// v3 equivalent: app.Event.On. +func EventsOn(_ context.Context, eventName string, callback func(optionalData ...interface{})) func() { + a := app() + if a == nil { + return func() {} + } + return a.Event.On(eventName, func(event *application.CustomEvent) { + callback(eventArgs(event.Data)...) + }) +} + +// EventsOff mirrors the v2 runtime.EventsOff function. +// v3 equivalent: app.Event.Off. +func EventsOff(_ context.Context, eventName string, additionalEventNames ...string) { + a := app() + if a == nil { + return + } + a.Event.Off(eventName) + for _, name := range additionalEventNames { + a.Event.Off(name) + } +} + +// EventsOnce mirrors the v2 runtime.EventsOnce function. +// v3 equivalent: app.Event.On combined with unregistering after the first event. +func EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func() { + return EventsOnMultiple(ctx, eventName, callback, 1) +} + +// EventsOnMultiple mirrors the v2 runtime.EventsOnMultiple function. The +// callback is invoked at most counter times; a counter <= 0 means unlimited. +// v3 equivalent: app.Event.On combined with unregistering after counter events. +func EventsOnMultiple(_ context.Context, eventName string, callback func(optionalData ...interface{}), counter int) func() { + a := app() + if a == nil { + return func() {} + } + if counter <= 0 { + return a.Event.On(eventName, func(event *application.CustomEvent) { + callback(eventArgs(event.Data)...) + }) + } + + var remaining atomic.Int64 + remaining.Store(int64(counter)) + + var lock sync.Mutex + var unregister func() + unregistered := false + cancel := func() { + lock.Lock() + defer lock.Unlock() + if unregistered { + return + } + unregistered = true + if unregister != nil { + unregister() + } + } + + off := a.Event.On(eventName, func(event *application.CustomEvent) { + if remaining.Add(-1) < 0 { + return + } + callback(eventArgs(event.Data)...) + if remaining.Load() <= 0 { + cancel() + } + }) + + lock.Lock() + unregister = off + if unregistered { + // The counter was exhausted before registration completed. + off() + } + lock.Unlock() + + return cancel +} + +// EventsEmit mirrors the v2 runtime.EventsEmit function. +// v3 equivalent: app.Event.Emit. +func EventsEmit(_ context.Context, eventName string, optionalData ...interface{}) { + a := app() + if a == nil { + return + } + a.Event.Emit(eventName, optionalData...) +} diff --git a/v3/pkg/v2compat/runtime/lifecycle.go b/v3/pkg/v2compat/runtime/lifecycle.go new file mode 100644 index 00000000000..5a03fa5adb4 --- /dev/null +++ b/v3/pkg/v2compat/runtime/lifecycle.go @@ -0,0 +1,68 @@ +package runtime + +import ( + "context" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// NewLifecycleService bridges the v2 application lifecycle hooks (OnStartup, +// OnDomReady, OnShutdown) onto a v3 service. The wails3 migrate command +// registers it as the last service of a migrated application. +func NewLifecycleService(onStartup, onDomReady, onShutdown func(context.Context)) application.Service { + return application.NewService(&lifecycleService{ + onStartup: onStartup, + onDomReady: onDomReady, + onShutdown: onShutdown, + }) +} + +// lifecycleService implements application.ServiceStartup and +// application.ServiceShutdown to drive the v2 lifecycle hooks. +type lifecycleService struct { + onStartup func(context.Context) + onDomReady func(context.Context) + onShutdown func(context.Context) + ctx context.Context + domReadyOnce sync.Once +} + +// ServiceStartup calls the OnStartup hook and arranges for the OnDomReady +// hook to run exactly once when the first window signals that the runtime +// is ready. +func (s *lifecycleService) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { + s.ctx = ctx + if s.onStartup != nil { + s.onStartup(ctx) + } + if s.onDomReady != nil { + hook := func(window application.Window) { + window.OnWindowEvent(events.Common.WindowRuntimeReady, func(*application.WindowEvent) { + s.domReadyOnce.Do(func() { + s.onDomReady(ctx) + }) + }) + } + if a := app(); a != nil { + for _, window := range a.Window.GetAll() { + hook(window) + } + a.Window.OnCreate(hook) + } + } + return nil +} + +// ServiceShutdown calls the OnShutdown hook. +func (s *lifecycleService) ServiceShutdown() error { + if s.onShutdown != nil { + ctx := s.ctx + if ctx == nil { + ctx = context.Background() + } + s.onShutdown(ctx) + } + return nil +} diff --git a/v3/pkg/v2compat/runtime/log.go b/v3/pkg/v2compat/runtime/log.go new file mode 100644 index 00000000000..f71b0a4c5bc --- /dev/null +++ b/v3/pkg/v2compat/runtime/log.go @@ -0,0 +1,120 @@ +package runtime + +import ( + "context" + "fmt" + "os" + "sync" +) + +// LogLevel mirrors the v2 logger.LogLevel type. +type LogLevel uint8 + +// Log levels, mirroring the v2 logger constants. +const ( + TRACE LogLevel = 1 + DEBUG LogLevel = 2 + INFO LogLevel = 3 + WARNING LogLevel = 4 + ERROR LogLevel = 5 +) + +// logLevelWarnOnce guards the one-time warning emitted by LogSetLogLevel. +var logLevelWarnOnce sync.Once + +// LogPrint mirrors the v2 runtime.LogPrint function. +// v3 equivalent: app.Logger.Info. +func LogPrint(_ context.Context, message string) { + logger().Info(message) +} + +// LogTrace mirrors the v2 runtime.LogTrace function. +// v3 equivalent: app.Logger.Debug. +func LogTrace(_ context.Context, message string) { + logger().Debug(message) +} + +// LogDebug mirrors the v2 runtime.LogDebug function. +// v3 equivalent: app.Logger.Debug. +func LogDebug(_ context.Context, message string) { + logger().Debug(message) +} + +// LogInfo mirrors the v2 runtime.LogInfo function. +// v3 equivalent: app.Logger.Info. +func LogInfo(_ context.Context, message string) { + logger().Info(message) +} + +// LogWarning mirrors the v2 runtime.LogWarning function. +// v3 equivalent: app.Logger.Warn. +func LogWarning(_ context.Context, message string) { + logger().Warn(message) +} + +// LogError mirrors the v2 runtime.LogError function. +// v3 equivalent: app.Logger.Error. +func LogError(_ context.Context, message string) { + logger().Error(message) +} + +// LogFatal mirrors the v2 runtime.LogFatal function. It logs the message at +// error level and exits the process. +// v3 equivalent: app.Logger.Error followed by os.Exit(1). +func LogFatal(_ context.Context, message string) { + logger().Error(message) + os.Exit(1) +} + +// LogPrintf mirrors the v2 runtime.LogPrintf function. +// v3 equivalent: app.Logger.Info. +func LogPrintf(_ context.Context, format string, args ...interface{}) { + logger().Info(fmt.Sprintf(format, args...)) +} + +// LogTracef mirrors the v2 runtime.LogTracef function. +// v3 equivalent: app.Logger.Debug. +func LogTracef(_ context.Context, format string, args ...interface{}) { + logger().Debug(fmt.Sprintf(format, args...)) +} + +// LogDebugf mirrors the v2 runtime.LogDebugf function. +// v3 equivalent: app.Logger.Debug. +func LogDebugf(_ context.Context, format string, args ...interface{}) { + logger().Debug(fmt.Sprintf(format, args...)) +} + +// LogInfof mirrors the v2 runtime.LogInfof function. +// v3 equivalent: app.Logger.Info. +func LogInfof(_ context.Context, format string, args ...interface{}) { + logger().Info(fmt.Sprintf(format, args...)) +} + +// LogWarningf mirrors the v2 runtime.LogWarningf function. +// v3 equivalent: app.Logger.Warn. +func LogWarningf(_ context.Context, format string, args ...interface{}) { + logger().Warn(fmt.Sprintf(format, args...)) +} + +// LogErrorf mirrors the v2 runtime.LogErrorf function. +// v3 equivalent: app.Logger.Error. +func LogErrorf(_ context.Context, format string, args ...interface{}) { + logger().Error(fmt.Sprintf(format, args...)) +} + +// LogFatalf mirrors the v2 runtime.LogFatalf function. It logs the message at +// error level and exits the process. +// v3 equivalent: app.Logger.Error followed by os.Exit(1). +func LogFatalf(_ context.Context, format string, args ...interface{}) { + logger().Error(fmt.Sprintf(format, args...)) + os.Exit(1) +} + +// LogSetLogLevel mirrors the v2 runtime.LogSetLogLevel function. v3 has no +// runtime log level setter: configure the slog level via +// application.Options.LogLevel instead. This is a no-op that logs a warning once. +func LogSetLogLevel(_ context.Context, level LogLevel) { + logLevelWarnOnce.Do(func() { + logger().Warn("v2compat: LogSetLogLevel is a no-op in v3; configure the log level via application.Options.LogLevel", "requestedLevel", level) + }) +} diff --git a/v3/pkg/v2compat/runtime/runtime.go b/v3/pkg/v2compat/runtime/runtime.go new file mode 100644 index 00000000000..29fb2f4e6d7 --- /dev/null +++ b/v3/pkg/v2compat/runtime/runtime.go @@ -0,0 +1,56 @@ +package runtime + +import ( + "context" +) + +// EnvironmentInfo mirrors the v2 runtime.EnvironmentInfo type. +// v3 equivalent: application.EnvironmentInfo. +type EnvironmentInfo struct { + BuildType string `json:"buildType"` + Platform string `json:"platform"` + Arch string `json:"arch"` +} + +// Quit mirrors the v2 runtime.Quit function. +// v3 equivalent: app.Quit. +func Quit(_ context.Context) { + if a := app(); a != nil { + a.Quit() + } +} + +// Hide mirrors the v2 runtime.Hide function. +// v3 equivalent: app.Hide. +func Hide(_ context.Context) { + if a := app(); a != nil { + a.Hide() + } +} + +// Show mirrors the v2 runtime.Show function. +// v3 equivalent: app.Show. +func Show(_ context.Context) { + if a := app(); a != nil { + a.Show() + } +} + +// Environment mirrors the v2 runtime.Environment function. +// v3 equivalent: app.Env.Info. +func Environment(_ context.Context) EnvironmentInfo { + a := app() + if a == nil { + return EnvironmentInfo{} + } + info := a.Env.Info() + buildType := "production" + if info.Debug { + buildType = "dev" + } + return EnvironmentInfo{ + BuildType: buildType, + Platform: info.OS, + Arch: info.Arch, + } +} diff --git a/v3/pkg/v2compat/runtime/screen.go b/v3/pkg/v2compat/runtime/screen.go new file mode 100644 index 00000000000..e467ec97e90 --- /dev/null +++ b/v3/pkg/v2compat/runtime/screen.go @@ -0,0 +1,58 @@ +package runtime + +import ( + "context" +) + +// ScreenSize mirrors the v2 runtime.ScreenSize type. +type ScreenSize struct { + Width int `json:"width"` + Height int `json:"height"` +} + +// Screen mirrors the v2 runtime.Screen type. +// v3 equivalent: application.Screen. +type Screen struct { + IsCurrent bool `json:"isCurrent"` + IsPrimary bool `json:"isPrimary"` + Width int `json:"width"` + Height int `json:"height"` + Size ScreenSize `json:"size"` + PhysicalSize ScreenSize `json:"physicalSize"` +} + +// ScreenGetAll mirrors the v2 runtime.ScreenGetAll function. +// v3 equivalent: app.Screen.GetAll. +func ScreenGetAll(_ context.Context) ([]Screen, error) { + a := app() + if a == nil { + return nil, errNoApp + } + + currentID := "" + if w := currentWindow(); w != nil { + if screen, err := w.GetScreen(); err == nil && screen != nil { + currentID = screen.ID + } + } + + screens := a.Screen.GetAll() + result := make([]Screen, 0, len(screens)) + for _, screen := range screens { + result = append(result, Screen{ + IsCurrent: currentID != "" && screen.ID == currentID, + IsPrimary: screen.IsPrimary, + Width: screen.Size.Width, + Height: screen.Size.Height, + Size: ScreenSize{ + Width: screen.Size.Width, + Height: screen.Size.Height, + }, + PhysicalSize: ScreenSize{ + Width: screen.PhysicalBounds.Width, + Height: screen.PhysicalBounds.Height, + }, + }) + } + return result, nil +} diff --git a/v3/pkg/v2compat/runtime/window.go b/v3/pkg/v2compat/runtime/window.go new file mode 100644 index 00000000000..4111b694f42 --- /dev/null +++ b/v3/pkg/v2compat/runtime/window.go @@ -0,0 +1,265 @@ +package runtime + +import ( + "context" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// themeWarnOnce guards the one-time warning emitted by the theme functions. +var themeWarnOnce sync.Once + +// themeWarn logs a warning the first time any of the theme functions is called. +func themeWarn() { + themeWarnOnce.Do(func() { + logger().Warn("v2compat: WindowSetSystemDefaultTheme/WindowSetLightTheme/WindowSetDarkTheme are no-ops in v3; configure the theme at window creation via application.WebviewWindowOptions") + }) +} + +// WindowSetTitle mirrors the v2 runtime.WindowSetTitle function. +// v3 equivalent: window.SetTitle. +func WindowSetTitle(_ context.Context, title string) { + if w := currentWindow(); w != nil { + w.SetTitle(title) + } +} + +// WindowFullscreen mirrors the v2 runtime.WindowFullscreen function. +// v3 equivalent: window.Fullscreen. +func WindowFullscreen(_ context.Context) { + if w := currentWindow(); w != nil { + w.Fullscreen() + } +} + +// WindowUnfullscreen mirrors the v2 runtime.WindowUnfullscreen function. +// v3 equivalent: window.UnFullscreen. +func WindowUnfullscreen(_ context.Context) { + if w := currentWindow(); w != nil { + w.UnFullscreen() + } +} + +// WindowCenter mirrors the v2 runtime.WindowCenter function. +// v3 equivalent: window.Center. +func WindowCenter(_ context.Context) { + if w := currentWindow(); w != nil { + w.Center() + } +} + +// WindowReload mirrors the v2 runtime.WindowReload function. +// v3 equivalent: window.Reload. +func WindowReload(_ context.Context) { + if w := currentWindow(); w != nil { + w.Reload() + } +} + +// WindowReloadApp mirrors the v2 runtime.WindowReloadApp function. +// v3 equivalent: window.ForceReload. +func WindowReloadApp(_ context.Context) { + if w := currentWindow(); w != nil { + w.ForceReload() + } +} + +// WindowSetSystemDefaultTheme mirrors the v2 runtime.WindowSetSystemDefaultTheme function. +// v3 has no runtime theme setter: the theme is configured at window creation +// via application.WebviewWindowOptions. This is a no-op that logs a warning once. +func WindowSetSystemDefaultTheme(_ context.Context) { + themeWarn() +} + +// WindowSetLightTheme mirrors the v2 runtime.WindowSetLightTheme function. +// v3 has no runtime theme setter: the theme is configured at window creation +// via application.WebviewWindowOptions. This is a no-op that logs a warning once. +func WindowSetLightTheme(_ context.Context) { + themeWarn() +} + +// WindowSetDarkTheme mirrors the v2 runtime.WindowSetDarkTheme function. +// v3 has no runtime theme setter: the theme is configured at window creation +// via application.WebviewWindowOptions. This is a no-op that logs a warning once. +func WindowSetDarkTheme(_ context.Context) { + themeWarn() +} + +// WindowShow mirrors the v2 runtime.WindowShow function. +// v3 equivalent: window.Show. +func WindowShow(_ context.Context) { + if w := currentWindow(); w != nil { + w.Show() + } +} + +// WindowHide mirrors the v2 runtime.WindowHide function. +// v3 equivalent: window.Hide. +func WindowHide(_ context.Context) { + if w := currentWindow(); w != nil { + w.Hide() + } +} + +// WindowSetSize mirrors the v2 runtime.WindowSetSize function. +// v3 equivalent: window.SetSize. +func WindowSetSize(_ context.Context, width int, height int) { + if w := currentWindow(); w != nil { + w.SetSize(width, height) + } +} + +// WindowGetSize mirrors the v2 runtime.WindowGetSize function. +// v3 equivalent: window.Size. +func WindowGetSize(_ context.Context) (int, int) { + if w := currentWindow(); w != nil { + return w.Size() + } + return 0, 0 +} + +// WindowSetMinSize mirrors the v2 runtime.WindowSetMinSize function. +// v3 equivalent: window.SetMinSize. +func WindowSetMinSize(_ context.Context, width int, height int) { + if w := currentWindow(); w != nil { + w.SetMinSize(width, height) + } +} + +// WindowSetMaxSize mirrors the v2 runtime.WindowSetMaxSize function. +// v3 equivalent: window.SetMaxSize. +func WindowSetMaxSize(_ context.Context, width int, height int) { + if w := currentWindow(); w != nil { + w.SetMaxSize(width, height) + } +} + +// WindowSetAlwaysOnTop mirrors the v2 runtime.WindowSetAlwaysOnTop function. +// v3 equivalent: window.SetAlwaysOnTop. +func WindowSetAlwaysOnTop(_ context.Context, b bool) { + if w := currentWindow(); w != nil { + w.SetAlwaysOnTop(b) + } +} + +// WindowSetPosition mirrors the v2 runtime.WindowSetPosition function. +// v3 equivalent: window.SetRelativePosition (v2 positions were relative to +// the window's current screen). +func WindowSetPosition(_ context.Context, x int, y int) { + if w := currentWindow(); w != nil { + w.SetRelativePosition(x, y) + } +} + +// WindowGetPosition mirrors the v2 runtime.WindowGetPosition function. +// v3 equivalent: window.RelativePosition. +func WindowGetPosition(_ context.Context) (int, int) { + if w := currentWindow(); w != nil { + return w.RelativePosition() + } + return 0, 0 +} + +// WindowMaximise mirrors the v2 runtime.WindowMaximise function. +// v3 equivalent: window.Maximise. +func WindowMaximise(_ context.Context) { + if w := currentWindow(); w != nil { + w.Maximise() + } +} + +// WindowToggleMaximise mirrors the v2 runtime.WindowToggleMaximise function. +// v3 equivalent: window.ToggleMaximise. +func WindowToggleMaximise(_ context.Context) { + if w := currentWindow(); w != nil { + w.ToggleMaximise() + } +} + +// WindowUnmaximise mirrors the v2 runtime.WindowUnmaximise function. +// v3 equivalent: window.UnMaximise. +func WindowUnmaximise(_ context.Context) { + if w := currentWindow(); w != nil { + w.UnMaximise() + } +} + +// WindowMinimise mirrors the v2 runtime.WindowMinimise function. +// v3 equivalent: window.Minimise. +func WindowMinimise(_ context.Context) { + if w := currentWindow(); w != nil { + w.Minimise() + } +} + +// WindowUnminimise mirrors the v2 runtime.WindowUnminimise function. +// v3 equivalent: window.UnMinimise. +func WindowUnminimise(_ context.Context) { + if w := currentWindow(); w != nil { + w.UnMinimise() + } +} + +// WindowIsFullscreen mirrors the v2 runtime.WindowIsFullscreen function. +// v3 equivalent: window.IsFullscreen. +func WindowIsFullscreen(_ context.Context) bool { + if w := currentWindow(); w != nil { + return w.IsFullscreen() + } + return false +} + +// WindowIsMaximised mirrors the v2 runtime.WindowIsMaximised function. +// v3 equivalent: window.IsMaximised. +func WindowIsMaximised(_ context.Context) bool { + if w := currentWindow(); w != nil { + return w.IsMaximised() + } + return false +} + +// WindowIsMinimised mirrors the v2 runtime.WindowIsMinimised function. +// v3 equivalent: window.IsMinimised. +func WindowIsMinimised(_ context.Context) bool { + if w := currentWindow(); w != nil { + return w.IsMinimised() + } + return false +} + +// WindowIsNormal mirrors the v2 runtime.WindowIsNormal function. +// v3 equivalent: !window.IsFullscreen() && !window.IsMaximised() && !window.IsMinimised(). +func WindowIsNormal(_ context.Context) bool { + w := currentWindow() + if w == nil { + return false + } + return !w.IsFullscreen() && !w.IsMaximised() && !w.IsMinimised() +} + +// WindowExecJS mirrors the v2 runtime.WindowExecJS function. +// v3 equivalent: window.ExecJS. +func WindowExecJS(_ context.Context, js string) { + if w := currentWindow(); w != nil { + w.ExecJS(js) + } +} + +// WindowSetBackgroundColour mirrors the v2 runtime.WindowSetBackgroundColour function. +// v3 equivalent: window.SetBackgroundColour(application.RGBA{...}). +func WindowSetBackgroundColour(_ context.Context, R, G, B, A uint8) { + if w := currentWindow(); w != nil { + w.SetBackgroundColour(application.RGBA{Red: R, Green: G, Blue: B, Alpha: A}) + } +} + +// WindowPrint mirrors the v2 runtime.WindowPrint function. +// v3 equivalent: window.Print. +func WindowPrint(_ context.Context) { + if w := currentWindow(); w != nil { + if err := w.Print(); err != nil { + logger().Warn("v2compat: WindowPrint failed", "error", err) + } + } +} From 331781ac96342f834322d4e65defe2b81c5b5780 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Mon, 6 Jul 2026 10:45:52 +1000 Subject: [PATCH 2/4] docs(v3): mark wails3 migrate as experimental and ask for feedback The migrate command now announces its experimental status in the CLI output, the generated MIGRATION.md and the migration guide, with a pointer to the issue tracker for reports and contributions. --- docs/src/content/docs/migration/v2-to-v3.mdx | 4 ++++ v3/cmd/wails3/main.go | 2 +- v3/internal/commands/migrate.go | 2 ++ v3/internal/migrate/report.go | 6 ++++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/migration/v2-to-v3.mdx b/docs/src/content/docs/migration/v2-to-v3.mdx index d617f134ce5..f9cc4c6f423 100644 --- a/docs/src/content/docs/migration/v2-to-v3.mdx +++ b/docs/src/content/docs/migration/v2-to-v3.mdx @@ -20,6 +20,10 @@ Wails v3 is a **complete rewrite** with significant improvements in architecture ## Automated Migration +:::caution[Experimental] +The migrate command is experimental. It handles common project shapes well, but your mileage may vary: review the generated code and test your application thoroughly. If it stumbles on your project, please [open an issue](https://github.com/wailsapp/wails/issues) with the details so we can improve it. Pull requests are very welcome. +::: + The CLI can perform most of this guide for you: ```bash diff --git a/v3/cmd/wails3/main.go b/v3/cmd/wails3/main.go index c40d4f8cc2c..8b427586d17 100644 --- a/v3/cmd/wails3/main.go +++ b/v3/cmd/wails3/main.go @@ -44,7 +44,7 @@ func main() { app.NewSubCommandFunction("dev", "Run in Dev mode", commands.Dev) - app.NewSubCommandFunction("migrate", "Migrate a Wails v2 project to v3", commands.Migrate) + app.NewSubCommandFunction("migrate", "Migrate a Wails v2 project to v3 (experimental)", commands.Migrate) pkg := app.NewSubCommand("package", "Package application") var pkgFlags flags.Package diff --git a/v3/internal/commands/migrate.go b/v3/internal/commands/migrate.go index fd2c3a6bddf..297ef4f9706 100644 --- a/v3/internal/commands/migrate.go +++ b/v3/internal/commands/migrate.go @@ -33,6 +33,8 @@ func Migrate(options *flags.Migrate) error { term.DisableOutput() } term.Header("Migrate Wails v2 project") + term.Warningf("This command is experimental. Review the generated project and test it thoroughly.\n") + term.Warningf("Please report problems at https://github.com/wailsapp/wails/issues - PRs improving the tool are very welcome.\n") if options.OutputDir == "" { return errors.New("please use the -o flag to specify an output directory for the migrated project") diff --git a/v3/internal/migrate/report.go b/v3/internal/migrate/report.go index beb35c55c4a..7f0562624a9 100644 --- a/v3/internal/migrate/report.go +++ b/v3/internal/migrate/report.go @@ -44,6 +44,12 @@ func (r *Report) Markdown() string { var sb strings.Builder sb.WriteString("# Migration Report\n\n") sb.WriteString("This project was migrated from Wails v2 by `wails3 migrate`.\n\n") + sb.WriteString("> **The migrate command is experimental.** It handles the common project shapes\n") + sb.WriteString("> well, but your mileage may vary: review the generated code and test your\n") + sb.WriteString("> application thoroughly before relying on it. If migration produced something\n") + sb.WriteString("> wrong or missed something your project needs, please help us improve the tool\n") + sb.WriteString("> by opening an issue at https://github.com/wailsapp/wails/issues with the\n") + sb.WriteString("> details. Pull requests are very welcome.\n\n") sb.WriteString("## Next steps\n\n") sb.WriteString("1. Run `wails3 doctor` to check your environment.\n") sb.WriteString("2. Run `wails3 dev` to build and run the migrated app.\n") From e85d8f27a90c5bf2febe40ec5d579a948142dbc5 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Mon, 6 Jul 2026 12:25:15 +1000 Subject: [PATCH 3/4] refactor(v3): generate the v2 compat bridge into migrated projects The bridge is no longer a public package in the v3 module: its source lives at internal/migrate/v2compat/runtime (compiled, vetted and tested in-repo but not importable) and wails3 migrate copies it into the output project as /v2compat/runtime with a generated-code header explaining it is temporary. This means only migrated projects carry the v2-style API - nobody can adopt it for new code - and there is no sunset obligation on the v3 module: each project deletes its own bridge functions as call sites are ported to the v3 API, and removes the package when nothing imports it. Import rewriting now targets the project-local path, and the bridge is only emitted when the project actually needs it (v2 runtime imports or lifecycle hooks). --- docs/src/content/docs/migration/v2-to-v3.mdx | 2 +- v3/internal/commands/migrate.go | 9 +++ v3/internal/commands/migrate_test.go | 2 + v3/internal/migrate/bridge.go | 59 +++++++++++++++++++ v3/internal/migrate/copy.go | 4 +- v3/internal/migrate/maingen.go | 2 +- v3/internal/migrate/migrate.go | 13 ++-- v3/internal/migrate/migrate_test.go | 4 +- v3/internal/migrate/parse.go | 5 ++ v3/internal/migrate/report.go | 5 +- .../migrate}/v2compat/runtime/app.go | 0 .../migrate}/v2compat/runtime/browser.go | 0 .../migrate}/v2compat/runtime/clipboard.go | 0 .../migrate}/v2compat/runtime/dialog.go | 0 .../migrate}/v2compat/runtime/doc.go | 0 .../migrate}/v2compat/runtime/draganddrop.go | 0 .../migrate}/v2compat/runtime/events.go | 0 .../migrate}/v2compat/runtime/lifecycle.go | 0 .../migrate}/v2compat/runtime/log.go | 0 .../migrate}/v2compat/runtime/runtime.go | 0 .../migrate}/v2compat/runtime/screen.go | 0 .../migrate}/v2compat/runtime/window.go | 0 22 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 v3/internal/migrate/bridge.go rename v3/{pkg => internal/migrate}/v2compat/runtime/app.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/browser.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/clipboard.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/dialog.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/doc.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/draganddrop.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/events.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/lifecycle.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/log.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/runtime.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/screen.go (100%) rename v3/{pkg => internal/migrate}/v2compat/runtime/window.go (100%) diff --git a/docs/src/content/docs/migration/v2-to-v3.mdx b/docs/src/content/docs/migration/v2-to-v3.mdx index f9cc4c6f423..240e8fd12c8 100644 --- a/docs/src/content/docs/migration/v2-to-v3.mdx +++ b/docs/src/content/docs/migration/v2-to-v3.mdx @@ -34,7 +34,7 @@ This parses your v2 project (wails.json and the `options.App` literal passed to - `main.go` is rewritten around `application.New()` + `app.Window.NewWithOptions()`, keeping your own code and comments intact. Options are mapped to their v3 equivalents, including platform-specific window options. - Structs listed in `Bind` become v3 services; the `OnStartup`/`OnDomReady`/`OnShutdown`/`OnBeforeClose` callbacks are bridged automatically. -- Go files calling the v2 `runtime` package are pointed at `github.com/wailsapp/wails/v3/pkg/v2compat/runtime`, a compatibility bridge with the v2 API (context-first functions) implemented on v3. Each bridge function documents its v3 replacement so you can migrate incrementally. +- Go files calling the v2 `runtime` package are pointed at a `v2compat/runtime` package generated *into your project*: a temporary bridge with the v2 API (context-first functions) implemented on the v3 API. Each bridge function documents its v3 replacement, so you can port call sites incrementally, delete bridge functions as you go, and remove the package when nothing imports it any more. - The frontend is copied over and `wailsjs/` is regenerated as a thin layer over `@wailsio/runtime`, so existing imports like `../wailsjs/go/main/App` and `../wailsjs/runtime/runtime` keep working. - `wails.json` is replaced by the v3 project files: a Taskfile-based build system and `build/config.yml` populated from your v2 metadata (product info, file associations, protocols). - `go.mod` swaps `wails/v2` for `wails/v3`; everything else is preserved. diff --git a/v3/internal/commands/migrate.go b/v3/internal/commands/migrate.go index 297ef4f9706..ec80a5c8a6b 100644 --- a/v3/internal/commands/migrate.go +++ b/v3/internal/commands/migrate.go @@ -90,6 +90,15 @@ func Migrate(options *flags.Migrate) error { return err } + // The compatibility bridge is generated into the project (not shipped as + // part of the v3 module) so that only migrated projects carry it, and its + // owners can delete it as they finish porting to the v3 API. + if proj.UsesV2Runtime || v3opts.NeedsLifecycleService() { + if err := migrate.WriteCompatBridge(proj, outDir); err != nil { + return err + } + } + // go.mod: swap wails/v2 for wails/v3, keep everything else. // LatestStable is the released tag even in dev builds, so the generated // require is always resolvable. diff --git a/v3/internal/commands/migrate_test.go b/v3/internal/commands/migrate_test.go index aa0a16b2bda..53a4399d51b 100644 --- a/v3/internal/commands/migrate_test.go +++ b/v3/internal/commands/migrate_test.go @@ -121,6 +121,8 @@ func TestMigrateEndToEnd(t *testing.T) { "frontend/wailsjs/runtime/runtime.js", "frontend/wailsjs/go/main/App.js", "frontend/dist/.gitkeep", + "v2compat/runtime/window.go", + "v2compat/runtime/lifecycle.go", } for _, rel := range mustExist { if _, err := os.Stat(filepath.Join(outDir, rel)); err != nil { diff --git a/v3/internal/migrate/bridge.go b/v3/internal/migrate/bridge.go new file mode 100644 index 00000000000..0f43cc2b882 --- /dev/null +++ b/v3/internal/migrate/bridge.go @@ -0,0 +1,59 @@ +package migrate + +import ( + "embed" + "io/fs" + "os" + "path/filepath" +) + +// The v2 compatibility bridge lives in this repository as an internal package +// so it is compiled, vetted and testable, but it is deliberately NOT part of +// the public v3 API: `wails3 migrate` copies its source into the migrated +// project instead. The user owns the copy and deletes functions (and finally +// the whole package) as call sites are ported to the v3 API. +// +//go:embed v2compat/runtime/*.go +var compatBridgeFS embed.FS + +// compatBridgeHeader is prepended to every generated bridge file. +const compatBridgeHeader = `// Code generated by wails3 migrate. +// +// This package is a TEMPORARY bridge that implements the Wails v2 runtime API +// on top of Wails v3. It is part of your project: as you port call sites to +// the v3 API (github.com/wailsapp/wails/v3/pkg/application), delete the +// functions they used, and delete the whole package when nothing imports it +// any more. Each function documents its v3 replacement. + +` + +// CompatRuntimeImport returns the project-local import path the bridge is +// generated under. +func (p *V2Project) CompatRuntimeImport() string { + return p.ModulePath + "/v2compat/runtime" +} + +// WriteCompatBridge copies the v2 compatibility bridge sources into the +// output project under v2compat/runtime. +func WriteCompatBridge(proj *V2Project, outDir string) error { + targetDir := filepath.Join(outDir, "v2compat", "runtime") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return err + } + entries, err := fs.ReadDir(compatBridgeFS, "v2compat/runtime") + if err != nil { + return err + } + for _, entry := range entries { + data, err := fs.ReadFile(compatBridgeFS, "v2compat/runtime/"+entry.Name()) + if err != nil { + return err + } + out := append([]byte(compatBridgeHeader), data...) + if err := os.WriteFile(filepath.Join(targetDir, entry.Name()), out, 0o644); err != nil { + return err + } + } + proj.Report.Note("A v2 runtime compatibility bridge was generated at `v2compat/runtime` in your project. It is yours: delete its functions as you port call sites to the v3 API, and remove the package when nothing imports it any more.") + return nil +} diff --git a/v3/internal/migrate/copy.go b/v3/internal/migrate/copy.go index 8a6c913dd42..1e96221f3fe 100644 --- a/v3/internal/migrate/copy.go +++ b/v3/internal/migrate/copy.go @@ -119,8 +119,8 @@ func CopyProjectFiles(proj *V2Project, outDir string) error { func RewriteGoImports(proj *V2Project, rel string, src []byte) []byte { content := string(src) if strings.Contains(content, strconv.Quote(V2RuntimeImport)) { - content = strings.ReplaceAll(content, strconv.Quote(V2RuntimeImport), strconv.Quote(V2CompatRuntimeImport)) - proj.Report.Mapped(rel+": "+V2RuntimeImport, V2CompatRuntimeImport) + content = strings.ReplaceAll(content, strconv.Quote(V2RuntimeImport), strconv.Quote(proj.CompatRuntimeImport())) + proj.Report.Mapped(rel+": "+V2RuntimeImport, proj.CompatRuntimeImport()+" (generated bridge)") } // Report any other v2 imports that remain. diff --git a/v3/internal/migrate/maingen.go b/v3/internal/migrate/maingen.go index 3c8cbea885f..06028498872 100644 --- a/v3/internal/migrate/maingen.go +++ b/v3/internal/migrate/maingen.go @@ -210,7 +210,7 @@ func buildImports(proj *V2Project, opts *V3Options) string { } add("", "github.com/wailsapp/wails/v3/pkg/application") if opts.NeedsLifecycleService() { - add(v2compatAlias, V2CompatRuntimeImport) + add(v2compatAlias, proj.CompatRuntimeImport()) } if opts.OnBeforeClose != "" { add("", "context") diff --git a/v3/internal/migrate/migrate.go b/v3/internal/migrate/migrate.go index 7c2da10044c..2157577e429 100644 --- a/v3/internal/migrate/migrate.go +++ b/v3/internal/migrate/migrate.go @@ -11,13 +11,10 @@ import ( "go/token" ) -// V2CompatRuntimeImport is the import path of the v3-hosted implementation of -// the v2 runtime API. Migrated Go files have their v2 runtime import rewritten -// to this path; the package is also named "runtime", so call sites compile -// unchanged. -const V2CompatRuntimeImport = "github.com/wailsapp/wails/v3/pkg/v2compat/runtime" - // V2RuntimeImport is the Wails v2 runtime package rewritten by the migrator. +// It is replaced with the project-local compatibility bridge (see +// CompatRuntimeImport and WriteCompatBridge); the generated package is also +// named "runtime", so call sites compile unchanged. const V2RuntimeImport = "github.com/wailsapp/wails/v2/pkg/runtime" // V2Project is everything the migrator learned about the source project. @@ -38,6 +35,10 @@ type V2Project struct { // the file containing wails.Run (handled separately). GoFiles []string + // UsesV2Runtime is true when any project file imports the v2 runtime + // package (the migrated project then needs the compatibility bridge). + UsesV2Runtime bool + // Report accumulates human-readable notes about everything that needs // manual attention. It is written to MIGRATION.md in the output project. Report *Report diff --git a/v3/internal/migrate/migrate_test.go b/v3/internal/migrate/migrate_test.go index cc0faa2ebe5..dc4bf8b517d 100644 --- a/v3/internal/migrate/migrate_test.go +++ b/v3/internal/migrate/migrate_test.go @@ -280,7 +280,7 @@ func TestGenerateMain(t *testing.T) { "//go:embed all:frontend/dist", "// Create an instance of the app structure", "err := wailsApp.Run()", - `v2runtime "github.com/wailsapp/wails/v3/pkg/v2compat/runtime"`, + `v2runtime "myv2app/v2compat/runtime"`, } { if !strings.Contains(src, want) { t.Errorf("generated main.go missing %q\n---\n%s", want, src) @@ -364,7 +364,7 @@ import ( ) `) out := RewriteGoImports(proj, "other.go", src) - if !strings.Contains(string(out), V2CompatRuntimeImport) { + if !strings.Contains(string(out), `"myv2app/v2compat/runtime"`) { t.Errorf("runtime import not rewritten:\n%s", out) } if !strings.Contains(string(out), "wails/v2/pkg/menu") { diff --git a/v3/internal/migrate/parse.go b/v3/internal/migrate/parse.go index 24d532c8916..ebca9d858c1 100644 --- a/v3/internal/migrate/parse.go +++ b/v3/internal/migrate/parse.go @@ -80,6 +80,11 @@ func ParseV2Project(dir string) (*V2Project, error) { if perr != nil { return fmt.Errorf("could not parse %s: %w", path, perr) } + for _, imp := range file.Imports { + if imp.Path.Value == strconv.Quote(V2RuntimeImport) { + proj.UsesV2Runtime = true + } + } files[path] = file return nil }) diff --git a/v3/internal/migrate/report.go b/v3/internal/migrate/report.go index 7f0562624a9..6fd2884358d 100644 --- a/v3/internal/migrate/report.go +++ b/v3/internal/migrate/report.go @@ -54,8 +54,9 @@ func (r *Report) Markdown() string { sb.WriteString("1. Run `wails3 doctor` to check your environment.\n") sb.WriteString("2. Run `wails3 dev` to build and run the migrated app.\n") sb.WriteString("3. Work through the *Manual steps* below, if any.\n") - sb.WriteString("4. Migrate incrementally from the `v2compat/runtime` bridge to the v3 API.\n") - sb.WriteString(" Every function in `github.com/wailsapp/wails/v3/pkg/v2compat/runtime` documents its v3 replacement.\n") + sb.WriteString("4. Migrate incrementally from the generated `v2compat/runtime` bridge in this\n") + sb.WriteString(" project to the v3 API. Every bridge function documents its v3 replacement;\n") + sb.WriteString(" delete functions as you go and remove the package when nothing imports it.\n") sb.WriteString(" See https://v3.wails.io/migration/v2-to-v3/ for the full guide.\n\n") if len(r.manual) > 0 { diff --git a/v3/pkg/v2compat/runtime/app.go b/v3/internal/migrate/v2compat/runtime/app.go similarity index 100% rename from v3/pkg/v2compat/runtime/app.go rename to v3/internal/migrate/v2compat/runtime/app.go diff --git a/v3/pkg/v2compat/runtime/browser.go b/v3/internal/migrate/v2compat/runtime/browser.go similarity index 100% rename from v3/pkg/v2compat/runtime/browser.go rename to v3/internal/migrate/v2compat/runtime/browser.go diff --git a/v3/pkg/v2compat/runtime/clipboard.go b/v3/internal/migrate/v2compat/runtime/clipboard.go similarity index 100% rename from v3/pkg/v2compat/runtime/clipboard.go rename to v3/internal/migrate/v2compat/runtime/clipboard.go diff --git a/v3/pkg/v2compat/runtime/dialog.go b/v3/internal/migrate/v2compat/runtime/dialog.go similarity index 100% rename from v3/pkg/v2compat/runtime/dialog.go rename to v3/internal/migrate/v2compat/runtime/dialog.go diff --git a/v3/pkg/v2compat/runtime/doc.go b/v3/internal/migrate/v2compat/runtime/doc.go similarity index 100% rename from v3/pkg/v2compat/runtime/doc.go rename to v3/internal/migrate/v2compat/runtime/doc.go diff --git a/v3/pkg/v2compat/runtime/draganddrop.go b/v3/internal/migrate/v2compat/runtime/draganddrop.go similarity index 100% rename from v3/pkg/v2compat/runtime/draganddrop.go rename to v3/internal/migrate/v2compat/runtime/draganddrop.go diff --git a/v3/pkg/v2compat/runtime/events.go b/v3/internal/migrate/v2compat/runtime/events.go similarity index 100% rename from v3/pkg/v2compat/runtime/events.go rename to v3/internal/migrate/v2compat/runtime/events.go diff --git a/v3/pkg/v2compat/runtime/lifecycle.go b/v3/internal/migrate/v2compat/runtime/lifecycle.go similarity index 100% rename from v3/pkg/v2compat/runtime/lifecycle.go rename to v3/internal/migrate/v2compat/runtime/lifecycle.go diff --git a/v3/pkg/v2compat/runtime/log.go b/v3/internal/migrate/v2compat/runtime/log.go similarity index 100% rename from v3/pkg/v2compat/runtime/log.go rename to v3/internal/migrate/v2compat/runtime/log.go diff --git a/v3/pkg/v2compat/runtime/runtime.go b/v3/internal/migrate/v2compat/runtime/runtime.go similarity index 100% rename from v3/pkg/v2compat/runtime/runtime.go rename to v3/internal/migrate/v2compat/runtime/runtime.go diff --git a/v3/pkg/v2compat/runtime/screen.go b/v3/internal/migrate/v2compat/runtime/screen.go similarity index 100% rename from v3/pkg/v2compat/runtime/screen.go rename to v3/internal/migrate/v2compat/runtime/screen.go diff --git a/v3/pkg/v2compat/runtime/window.go b/v3/internal/migrate/v2compat/runtime/window.go similarity index 100% rename from v3/pkg/v2compat/runtime/window.go rename to v3/internal/migrate/v2compat/runtime/window.go From 3951e61144df4aa2a931b4d427d142299274a1d1 Mon Sep 17 00:00:00 2001 From: taliesin-ai Date: Mon, 6 Jul 2026 14:38:47 +1000 Subject: [PATCH 4/4] refactor(v3): migrate advises v2 API porting instead of shipping compat layers Half-migrated code helps nobody, and compatibility shims invite new code onto the old API. The migrate command now draws a hard line: Migrated fully (deterministic): - project scaffold, Taskfile, build assets, config.yml from wails.json - main.go rewritten around application.New()/NewWithOptions with the options mapped; lifecycle hooks wired natively (ApplicationStarted event, WindowRuntimeReady event, Options.OnShutdown, ShouldQuit) - go.mod v2 -> v3; frontend copied with @wailsio/runtime added Documented instead of migrated: - every call into the v2 runtime package, listed in MIGRATION.md by file:line with its concrete v3 replacement; the sources are copied untouched, so the compiler points at exactly the listed locations until they are ported - every frontend wailsjs import, with the @wailsio/runtime equivalent and the generate-bindings workflow; the generated wailsjs directory is not carried over (it is v2 build output and cannot work with v3) Removed: the v2compat runtime bridge and the generated wailsjs shims. go mod tidy is skipped (with a warning) while v2 call sites remain, since tidying would re-add the v2 dependency and let old calls compile only to fail at runtime; MIGRATION.md spells this out. --- docs/src/content/docs/migration/v2-to-v3.mdx | 17 +- v3/internal/commands/migrate.go | 39 +-- v3/internal/commands/migrate_test.go | 33 ++- v3/internal/migrate/advisor.go | 218 ++++++++++++++ .../migrate/assets/wailsjs-runtime.d.ts | 136 --------- v3/internal/migrate/assets/wailsjs-runtime.js | 169 ----------- v3/internal/migrate/bridge.go | 59 ---- v3/internal/migrate/copy.go | 37 --- v3/internal/migrate/frontend.go | 114 ++------ v3/internal/migrate/maingen.go | 54 ++-- v3/internal/migrate/mapping.go | 6 +- v3/internal/migrate/migrate.go | 10 +- v3/internal/migrate/migrate_test.go | 115 ++++---- v3/internal/migrate/parse.go | 6 + v3/internal/migrate/report.go | 41 ++- v3/internal/migrate/v2compat/runtime/app.go | 44 --- .../migrate/v2compat/runtime/browser.go | 18 -- .../migrate/v2compat/runtime/clipboard.go | 33 --- .../migrate/v2compat/runtime/dialog.go | 218 -------------- v3/internal/migrate/v2compat/runtime/doc.go | 14 - .../migrate/v2compat/runtime/draganddrop.go | 80 ------ .../migrate/v2compat/runtime/events.go | 118 -------- .../migrate/v2compat/runtime/lifecycle.go | 68 ----- v3/internal/migrate/v2compat/runtime/log.go | 120 -------- .../migrate/v2compat/runtime/runtime.go | 56 ---- .../migrate/v2compat/runtime/screen.go | 58 ---- .../migrate/v2compat/runtime/window.go | 265 ------------------ 27 files changed, 422 insertions(+), 1724 deletions(-) create mode 100644 v3/internal/migrate/advisor.go delete mode 100644 v3/internal/migrate/assets/wailsjs-runtime.d.ts delete mode 100644 v3/internal/migrate/assets/wailsjs-runtime.js delete mode 100644 v3/internal/migrate/bridge.go delete mode 100644 v3/internal/migrate/v2compat/runtime/app.go delete mode 100644 v3/internal/migrate/v2compat/runtime/browser.go delete mode 100644 v3/internal/migrate/v2compat/runtime/clipboard.go delete mode 100644 v3/internal/migrate/v2compat/runtime/dialog.go delete mode 100644 v3/internal/migrate/v2compat/runtime/doc.go delete mode 100644 v3/internal/migrate/v2compat/runtime/draganddrop.go delete mode 100644 v3/internal/migrate/v2compat/runtime/events.go delete mode 100644 v3/internal/migrate/v2compat/runtime/lifecycle.go delete mode 100644 v3/internal/migrate/v2compat/runtime/log.go delete mode 100644 v3/internal/migrate/v2compat/runtime/runtime.go delete mode 100644 v3/internal/migrate/v2compat/runtime/screen.go delete mode 100644 v3/internal/migrate/v2compat/runtime/window.go diff --git a/docs/src/content/docs/migration/v2-to-v3.mdx b/docs/src/content/docs/migration/v2-to-v3.mdx index 240e8fd12c8..8612d676ac3 100644 --- a/docs/src/content/docs/migration/v2-to-v3.mdx +++ b/docs/src/content/docs/migration/v2-to-v3.mdx @@ -30,18 +30,23 @@ The CLI can perform most of this guide for you: wails3 migrate -d ./myv2project -o ./myv3project ``` -This parses your v2 project (wails.json and the `options.App` literal passed to `wails.Run`) and generates a v3 project: +The command migrates what maps deterministically and documents the rest. It does not rewrite your application logic and it does not generate compatibility layers: code that still uses the v2 API is left untouched, and every such location is listed in a generated `MIGRATION.md` with its concrete v3 replacement, so the remaining work is a clear checklist rather than a half-migrated codebase. + +What it migrates for you: - `main.go` is rewritten around `application.New()` + `app.Window.NewWithOptions()`, keeping your own code and comments intact. Options are mapped to their v3 equivalents, including platform-specific window options. -- Structs listed in `Bind` become v3 services; the `OnStartup`/`OnDomReady`/`OnShutdown`/`OnBeforeClose` callbacks are bridged automatically. -- Go files calling the v2 `runtime` package are pointed at a `v2compat/runtime` package generated *into your project*: a temporary bridge with the v2 API (context-first functions) implemented on the v3 API. Each bridge function documents its v3 replacement, so you can port call sites incrementally, delete bridge functions as you go, and remove the package when nothing imports it any more. -- The frontend is copied over and `wailsjs/` is regenerated as a thin layer over `@wailsio/runtime`, so existing imports like `../wailsjs/go/main/App` and `../wailsjs/runtime/runtime` keep working. +- Structs listed in `Bind` become v3 services, and the `OnStartup`/`OnDomReady`/`OnShutdown`/`OnBeforeClose` callbacks are wired to their v3 counterparts (application events, `OnShutdown`, `ShouldQuit`). - `wails.json` is replaced by the v3 project files: a Taskfile-based build system and `build/config.yml` populated from your v2 metadata (product info, file associations, protocols). - `go.mod` swaps `wails/v2` for `wails/v3`; everything else is preserved. +- The frontend is copied over and `@wailsio/runtime` is added to its dependencies. The generated `wailsjs/` directory is not carried over - it is v2 build output that cannot work with v3. + +What it documents for you (in `MIGRATION.md`): -Anything that cannot be migrated automatically (menus, custom loggers, `EnumBind`, ...) is listed with instructions in a generated `MIGRATION.md`. Run `wails3 dev` in the output directory to build and run the migrated app, then work through that file. +- Every call into the v2 `runtime` package, listed by file and line with the v3 replacement (for example `runtime.EventsEmit(ctx, ...)` becomes `app.Event.Emit(...)`). The project intentionally does not compile until these are ported - the compiler points at exactly the listed locations. +- Every frontend import of `wailsjs/runtime` or `wailsjs/go/...`, with the `@wailsio/runtime` equivalent and the `wails3 generate bindings` workflow for bindings. +- Options that need a human decision (menus, custom loggers, `EnumBind`, ...), each with instructions. -The rest of this guide explains the underlying changes - useful for finishing the manual steps and for migrating off the compatibility bridge over time. +The rest of this guide explains the underlying changes in depth - use it together with the generated checklist. ## Breaking Changes diff --git a/v3/internal/commands/migrate.go b/v3/internal/commands/migrate.go index ec80a5c8a6b..f20bc78d4ab 100644 --- a/v3/internal/commands/migrate.go +++ b/v3/internal/commands/migrate.go @@ -20,12 +20,14 @@ import ( // // wails3 migrate -d ./myv2project -o ./myv3project // -// It parses wails.json and the declarative options.App literal passed to -// wails.Run, generates an equivalent programmatic v3 main file, scaffolds the -// v3 build system (Taskfile + build assets), migrates the frontend (rewriting -// the generated wailsjs modules onto @wailsio/runtime) and rewrites v2 -// runtime imports to the v3 compatibility bridge. Everything that cannot be -// migrated automatically is recorded in MIGRATION.md. +// It migrates the parts that map deterministically - wails.json and the +// declarative options.App literal become the v3 build system (Taskfile + +// build assets + config.yml) and a programmatic v3 main file, go.mod swaps to +// v3 and the frontend is carried over - and it deliberately does NOT rewrite +// v2 runtime call sites or generate compatibility layers. Every remaining v2 +// API usage (Go runtime calls, frontend wailsjs imports) is enumerated in +// MIGRATION.md with its concrete v3 replacement, and the compiler points at +// exactly those locations until the user ports them. func Migrate(options *flags.Migrate) error { DisableFooter = true @@ -90,15 +92,6 @@ func Migrate(options *flags.Migrate) error { return err } - // The compatibility bridge is generated into the project (not shipped as - // part of the v3 module) so that only migrated projects carry it, and its - // owners can delete it as they finish porting to the v3 API. - if proj.UsesV2Runtime || v3opts.NeedsLifecycleService() { - if err := migrate.WriteCompatBridge(proj, outDir); err != nil { - return err - } - } - // go.mod: swap wails/v2 for wails/v3, keep everything else. // LatestStable is the released tag even in dev builds, so the generated // require is always resolvable. @@ -122,7 +115,13 @@ func Migrate(options *flags.Migrate) error { return err } - if !options.SkipGoModTidy { + // While v2 API call sites remain, `go mod tidy` would re-add the v2 + // dependency (it scans imports). Leave the module intentionally + // non-tidied: the compiler and MIGRATION.md point at the exact call + // sites to port, after which the user runs `go mod tidy` themselves. + if proj.UsesV2Runtime { + term.Warningf("Skipping go mod tidy: v2 API call sites remain. Port them (see MIGRATION.md), then run `go mod tidy`.\n") + } else if !options.SkipGoModTidy { term.Info("Running go mod tidy...") cmd := exec.Command("go", "mod", "tidy") cmd.Dir = outDir @@ -134,11 +133,15 @@ func Migrate(options *flags.Migrate) error { term.Infof("Migration complete: %s\n", outDir) if proj.Report.HasManualSteps() { - term.Warningf("Some options need manual attention - see %s\n", reportPath) + term.Warningf("Work remains - see %s\n", reportPath) } else { term.Infof("See %s for the migration summary.\n", reportPath) } - term.Infof("Next: cd %s && wails3 dev\n", options.OutputDir) + if proj.UsesV2Runtime { + term.Infof("Next: port the call sites listed in MIGRATION.md, then run `wails3 dev` in %s\n", options.OutputDir) + } else { + term.Infof("Next: cd %s && wails3 dev\n", options.OutputDir) + } return nil } diff --git a/v3/internal/commands/migrate_test.go b/v3/internal/commands/migrate_test.go index 53a4399d51b..6512197e192 100644 --- a/v3/internal/commands/migrate_test.go +++ b/v3/internal/commands/migrate_test.go @@ -57,7 +57,11 @@ func main() { `, "app.go": `package main -import "context" +import ( + "context" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) type App struct { ctx context.Context @@ -72,6 +76,7 @@ func (a *App) startup(ctx context.Context) { } func (a *App) Greet(name string) string { + runtime.EventsEmit(a.ctx, "greeted", name) return "Hello " + name } `, @@ -118,11 +123,7 @@ func TestMigrateEndToEnd(t *testing.T) { "build/darwin/Taskfile.yml", "build/appicon.png", "frontend/package.json", - "frontend/wailsjs/runtime/runtime.js", - "frontend/wailsjs/go/main/App.js", "frontend/dist/.gitkeep", - "v2compat/runtime/window.go", - "v2compat/runtime/lifecycle.go", } for _, rel := range mustExist { if _, err := os.Stat(filepath.Join(outDir, rel)); err != nil { @@ -133,6 +134,8 @@ func TestMigrateEndToEnd(t *testing.T) { mustNotExist := []string{ "wails.json", "go.sum", + "frontend/wailsjs", + "v2compat", } for _, rel := range mustNotExist { if _, err := os.Stat(filepath.Join(outDir, rel)); err == nil { @@ -177,6 +180,26 @@ func TestMigrateEndToEnd(t *testing.T) { if !strings.Contains(string(goMod), "github.com/wailsapp/wails/v3") || strings.Contains(string(goMod), "wails/v2") { t.Errorf("go.mod not transformed:\n%s", goMod) } + + // The runtime call in app.go must be enumerated with its replacement. + report, err := os.ReadFile(filepath.Join(outDir, "MIGRATION.md")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Port these to the v3 API", "app.go:", "runtime.EventsEmit", "app.Event.Emit"} { + if !strings.Contains(string(report), want) { + t.Errorf("MIGRATION.md missing %q\n---\n%s", want, report) + } + } + + // app.go is copied untouched: porting it is the user's (documented) job. + appSrc, err := os.ReadFile(filepath.Join(outDir, "app.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(appSrc), "github.com/wailsapp/wails/v2/pkg/runtime") { + t.Errorf("app.go should be untouched:\n%s", appSrc) + } } func TestMigrateRefusesNonEmptyOutput(t *testing.T) { diff --git a/v3/internal/migrate/advisor.go b/v3/internal/migrate/advisor.go new file mode 100644 index 00000000000..f706456ece1 --- /dev/null +++ b/v3/internal/migrate/advisor.go @@ -0,0 +1,218 @@ +package migrate + +import ( + "bufio" + "fmt" + "go/ast" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +// The migrator deliberately does not rewrite runtime call sites and does not +// generate compatibility layers: half-migrated code helps nobody. Instead, +// every v2 runtime call and every wailsjs import is recorded here with its +// concrete v3 replacement, so the user gets a precise, project-specific +// checklist in MIGRATION.md and the compiler points at exactly the listed +// locations until they are ported. + +// goRuntimeAdvice maps a v2 runtime function name to advice for porting the +// call site to the v3 API. `app` refers to the application instance +// (application.Get() from anywhere). +var goRuntimeAdvice = map[string]string{ + "EventsEmit": "`app.Event.Emit(name, data...)`", + "EventsOn": "`app.Event.On(name, func(e *application.CustomEvent) { ... })` - the callback receives the event object; your payload is `e.Data`. Returns an unsubscribe func.", + "EventsOnce": "`app.Event.On(...)` and call the returned unsubscribe func inside the callback (v3 has no Once on the manager)", + "EventsOnMultiple": "`app.Event.OnMultiple(name, callback, counter)`", + "EventsOff": "`app.Event.Off(name)`", + "EventsOffAll": "`app.Event.Reset()`", + + "Quit": "`app.Quit()`", + "Hide": "`app.Hide()`", + "Show": "`app.Show()`", + "Environment": "`app.Env.Info()` (returns application.EnvironmentInfo: OS, Arch, Debug)", + + "BrowserOpenURL": "`app.Browser.OpenURL(url)`", + + "ClipboardGetText": "`app.Clipboard.Text()` (returns (string, bool) instead of (string, error))", + "ClipboardSetText": "`app.Clipboard.SetText(text)` (returns bool instead of error)", + + "ScreenGetAll": "`app.Screen.GetAll()` (the v3 Screen struct differs: Size, Bounds, PhysicalBounds, IsPrimary)", + + "LogPrint": "`app.Logger.Info(message)`", + "LogPrintf": "`app.Logger.Info(fmt.Sprintf(...))`", + "LogTrace": "`app.Logger.Debug(message)`", + "LogTracef": "`app.Logger.Debug(fmt.Sprintf(...))`", + "LogDebug": "`app.Logger.Debug(message)`", + "LogDebugf": "`app.Logger.Debug(fmt.Sprintf(...))`", + "LogInfo": "`app.Logger.Info(message)`", + "LogInfof": "`app.Logger.Info(fmt.Sprintf(...))`", + "LogWarning": "`app.Logger.Warn(message)`", + "LogWarningf": "`app.Logger.Warn(fmt.Sprintf(...))`", + "LogError": "`app.Logger.Error(message)`", + "LogErrorf": "`app.Logger.Error(fmt.Sprintf(...))`", + "LogFatal": "`app.Logger.Error(message)` + `os.Exit(1)`", + "LogFatalf": "`app.Logger.Error(fmt.Sprintf(...))` + `os.Exit(1)`", + "LogSetLogLevel": "set `application.Options.LogLevel` (log/slog level) at startup", + + "MenuSetApplicationMenu": "rebuild the menu with `app.NewMenu()` and apply it with `app.Menu.SetApplicationMenu(menu)`", + "MenuUpdateApplicationMenu": "`app.Menu.UpdateApplicationMenu()`", + + "OpenDirectoryDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{CanChooseDirectories: true, CanChooseFiles: false, ...}).PromptForSingleSelection()`", + "OpenFileDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{...}).PromptForSingleSelection()` (field names differ slightly, e.g. DefaultDirectory -> Directory)", + "OpenMultipleFilesDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{AllowsMultipleSelection: true, ...}).PromptForMultipleSelection()`", + "SaveFileDialog": "`app.Dialog.SaveFileWithOptions(&application.SaveFileDialogOptions{...}).PromptForSingleSelection()`", + "MessageDialog": "`app.Dialog.Info()/Question()/Warning()/Error()` with `.AddButton(label).OnClick(func(){...})` - the result arrives via button callbacks, not a return value", + + "OnFileDrop": "`window.OnWindowEvent(events.Common.WindowFilesDropped, func(e *application.WindowEvent) { e.Context().DroppedFiles() })` - requires `WebviewWindowOptions.EnableFileDrop: true`", + "OnFileDropOff": "call the unsubscribe func returned by `OnWindowEvent`", +} + +// windowRuntimeAdvice maps v2 Window* functions to the v3 window method. +// They all operate on a window object: `app.Window.Current()` or a window you +// keep a reference to. +var windowRuntimeAdvice = map[string]string{ + "WindowSetTitle": "`window.SetTitle(title)`", + "WindowFullscreen": "`window.Fullscreen()`", + "WindowUnfullscreen": "`window.UnFullscreen()`", + "WindowCenter": "`window.Center()`", + "WindowReload": "`window.Reload()`", + "WindowReloadApp": "`window.ForceReload()`", + "WindowShow": "`window.Show()`", + "WindowHide": "`window.Hide()`", + "WindowSetSize": "`window.SetSize(width, height)`", + "WindowGetSize": "`window.Size()`", + "WindowSetMinSize": "`window.SetMinSize(width, height)`", + "WindowSetMaxSize": "`window.SetMaxSize(width, height)`", + "WindowSetAlwaysOnTop": "`window.SetAlwaysOnTop(b)`", + "WindowSetPosition": "`window.SetRelativePosition(x, y)`", + "WindowGetPosition": "`window.RelativePosition()`", + "WindowMaximise": "`window.Maximise()`", + "WindowToggleMaximise": "`window.ToggleMaximise()`", + "WindowUnmaximise": "`window.UnMaximise()`", + "WindowMinimise": "`window.Minimise()`", + "WindowUnminimise": "`window.UnMinimise()`", + "WindowIsFullscreen": "`window.IsFullscreen()`", + "WindowIsMaximised": "`window.IsMaximised()`", + "WindowIsMinimised": "`window.IsMinimised()`", + "WindowIsNormal": "combine `!window.IsFullscreen() && !window.IsMaximised() && !window.IsMinimised()`", + "WindowExecJS": "`window.ExecJS(js)`", + "WindowSetBackgroundColour": "`window.SetBackgroundColour(application.RGBA{Red: r, Green: g, Blue: b, Alpha: a})`", + "WindowPrint": "`window.Print()`", + "WindowSetSystemDefaultTheme": "set `WebviewWindowOptions.Theme: application.SystemDefault` at window creation (v3 has no runtime theme setter)", + "WindowSetLightTheme": "set `WebviewWindowOptions.Theme: application.Light` at window creation (v3 has no runtime theme setter)", + "WindowSetDarkTheme": "set `WebviewWindowOptions.Theme: application.Dark` at window creation (v3 has no runtime theme setter)", +} + +// adviseGoRuntimeCalls records every call into the v2 runtime package with +// its v3 replacement. +func adviseGoRuntimeCalls(fset *token.FileSet, files map[string]*ast.File, proj *V2Project) { + for path, file := range files { + localName := "" + for name, ipath := range importMap(file) { + if ipath == V2RuntimeImport { + localName = name + } + } + if localName == "" { + continue + } + rel, err := filepath.Rel(proj.Dir, path) + if err != nil { + rel = path + } + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != localName { + return true + } + name := sel.Sel.Name + advice, ok := goRuntimeAdvice[name] + if !ok { + advice, ok = windowRuntimeAdvice[name] + if ok { + advice += " - get the window with `app.Window.Current()` or keep a reference to the one you create" + } + } + if !ok { + // Type references (runtime.OpenDialogOptions{...}) and + // anything unknown. + advice = "see the v3 application API and https://v3.wails.io/migration/v2-to-v3/" + } + pos := fset.Position(sel.Pos()) + proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, pos.Line), "`runtime."+name+"`", advice) + return true + }) + } +} + +var wailsjsImportRe = regexp.MustCompile(`(?:from\s*|require\s*\(\s*)['"]([^'"]*wailsjs/(runtime|go)/[^'"]*)['"]`) + +// frontendSourceExts are the file types scanned for wailsjs imports. +var frontendSourceExts = map[string]bool{ + ".js": true, ".jsx": true, ".ts": true, ".tsx": true, + ".svelte": true, ".vue": true, ".html": true, ".mjs": true, ".cjs": true, +} + +// adviseFrontendImports records every wailsjs import in the frontend sources +// with its v3 replacement. +func adviseFrontendImports(proj *V2Project) error { + frontend := proj.FrontendDir + if _, err := os.Stat(frontend); os.IsNotExist(err) { + return nil + } + return filepath.Walk(frontend, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + switch info.Name() { + case "node_modules", "dist", "wailsjs": + return filepath.SkipDir + } + return nil + } + if !frontendSourceExts[filepath.Ext(path)] { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + rel, rerr := filepath.Rel(proj.Dir, path) + if rerr != nil { + rel = path + } + scanner := bufio.NewScanner(f) + lineNo := 0 + for scanner.Scan() { + lineNo++ + m := wailsjsImportRe.FindStringSubmatch(scanner.Text()) + if m == nil { + continue + } + var advice string + if m[2] == "runtime" { + advice = "import from `@wailsio/runtime` instead: `import {Events, Window, Dialogs, ...} from '@wailsio/runtime'`. Function names change, e.g. `EventsOn(name, cb)` -> `Events.On(name, cb)` (the callback receives an event object; your payload is `event.data`), `WindowSetTitle` -> `Window.SetTitle`, `Quit` -> `Application.Quit`." + } else { + advice = "run `wails3 generate bindings`, then import the service from `frontend/bindings`: `import {" + importedServiceName(m[1]) + "} from './bindings/" + proj.ModulePath + "'` and call methods on it" + } + proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, lineNo), "`"+m[1]+"`", advice) + } + return scanner.Err() + }) +} + +// importedServiceName extracts the bound struct name from a wailsjs/go import +// path such as ../wailsjs/go/main/App. +func importedServiceName(importPath string) string { + base := filepath.Base(importPath) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/v3/internal/migrate/assets/wailsjs-runtime.d.ts b/v3/internal/migrate/assets/wailsjs-runtime.d.ts deleted file mode 100644 index d574048f914..00000000000 --- a/v3/internal/migrate/assets/wailsjs-runtime.d.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Wails v2 runtime compatibility layer, generated by `wails3 migrate`. -// Type declarations matching the classic wailsjs/runtime API. - -export interface Position { - x: number; - y: number; -} - -export interface Size { - w: number; - h: number; -} - -export interface ScreenSize { - width: number; - height: number; -} - -export interface Screen { - isCurrent: boolean; - isPrimary: boolean; - width: number; - height: number; - size: ScreenSize; - physicalSize: ScreenSize; -} - -export interface EnvironmentInfo { - buildType: string; - platform: string; - arch: string; -} - -export function EventsEmit(eventName: string, ...data: any): void; - -export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; - -export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; - -export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; - -export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; - -export function EventsOffAll(): void; - -export function LogPrint(message: string): void; - -export function LogTrace(message: string): void; - -export function LogDebug(message: string): void; - -export function LogInfo(message: string): void; - -export function LogWarning(message: string): void; - -export function LogError(message: string): void; - -export function LogFatal(message: string): void; - -export function WindowReload(): void; - -export function WindowReloadApp(): void; - -export function WindowSetAlwaysOnTop(b: boolean): void; - -export function WindowSetSystemDefaultTheme(): void; - -export function WindowSetLightTheme(): void; - -export function WindowSetDarkTheme(): void; - -export function WindowCenter(): void; - -export function WindowSetTitle(title: string): void; - -export function WindowFullscreen(): void; - -export function WindowUnfullscreen(): void; - -export function WindowIsFullscreen(): Promise; - -export function WindowSetSize(width: number, height: number): void; - -export function WindowGetSize(): Promise; - -export function WindowSetMaxSize(width: number, height: number): void; - -export function WindowSetMinSize(width: number, height: number): void; - -export function WindowSetPosition(x: number, y: number): void; - -export function WindowGetPosition(): Promise; - -export function WindowHide(): void; - -export function WindowShow(): void; - -export function WindowMaximise(): void; - -export function WindowToggleMaximise(): void; - -export function WindowUnmaximise(): void; - -export function WindowIsMaximised(): Promise; - -export function WindowMinimise(): void; - -export function WindowUnminimise(): void; - -export function WindowIsMinimised(): Promise; - -export function WindowIsNormal(): Promise; - -export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; - -export function WindowPrint(): void; - -export function ScreenGetAll(): Promise; - -export function BrowserOpenURL(url: string): void; - -export function Environment(): Promise; - -export function Quit(): void; - -export function Hide(): void; - -export function Show(): void; - -export function ClipboardGetText(): Promise; - -export function ClipboardSetText(text: string): Promise; - -export function OnFileDrop(callback: (x: number, y: number, paths: string[]) => void, useDropTarget: boolean): void; - -export function OnFileDropOff(): void; diff --git a/v3/internal/migrate/assets/wailsjs-runtime.js b/v3/internal/migrate/assets/wailsjs-runtime.js deleted file mode 100644 index 9f34c8b4ee6..00000000000 --- a/v3/internal/migrate/assets/wailsjs-runtime.js +++ /dev/null @@ -1,169 +0,0 @@ -// Wails v2 runtime compatibility layer, generated by `wails3 migrate`. -// It implements the classic wailsjs/runtime API on top of @wailsio/runtime -// (the Wails v3 runtime). New code should import '@wailsio/runtime' directly. - -import {Application, Browser, Clipboard, Events, Screens, System, Window} from '@wailsio/runtime'; - -function unpack(callback) { - return (ev) => { - const data = ev ? ev.data : undefined; - if (data === null || data === undefined) { - callback(); - } else if (Array.isArray(data)) { - callback(...data); - } else { - callback(data); - } - }; -} - -/* ------------------------------- Events -------------------------------- */ - -export function EventsOn(eventName, callback) { - return Events.On(eventName, unpack(callback)); -} - -export function EventsOnMultiple(eventName, callback, maxCallbacks) { - return Events.OnMultiple(eventName, unpack(callback), maxCallbacks); -} - -export function EventsOnce(eventName, callback) { - return Events.Once(eventName, unpack(callback)); -} - -export function EventsOff(eventName, ...additionalEventNames) { - Events.Off(eventName, ...additionalEventNames); -} - -export function EventsOffAll() { - Events.OffAll(); -} - -export function EventsEmit(eventName, ...args) { - const data = args.length === 0 ? null : (args.length === 1 ? args[0] : args); - void Events.Emit(eventName, data); -} - -/* -------------------------------- Logs --------------------------------- */ -// v2 forwarded these to the application logger; the v3 bridge logs to the -// browser console instead. Use structured logging in Go for application logs. - -export function LogPrint(message) { console.log(message); } -export function LogTrace(message) { console.debug(message); } -export function LogDebug(message) { console.debug(message); } -export function LogInfo(message) { console.info(message); } -export function LogWarning(message) { console.warn(message); } -export function LogError(message) { console.error(message); } -export function LogFatal(message) { console.error(message); } - -/* ------------------------------- Window -------------------------------- */ - -export function WindowReload() { void Window.Reload(); } -export function WindowReloadApp() { void Window.ForceReload(); } -export function WindowSetAlwaysOnTop(b) { void Window.SetAlwaysOnTop(b); } - -export function WindowSetSystemDefaultTheme() { - console.warn('WindowSetSystemDefaultTheme: not supported by the Wails v3 compatibility layer; the theme is set at window creation.'); -} -export function WindowSetLightTheme() { - console.warn('WindowSetLightTheme: not supported by the Wails v3 compatibility layer; the theme is set at window creation.'); -} -export function WindowSetDarkTheme() { - console.warn('WindowSetDarkTheme: not supported by the Wails v3 compatibility layer; the theme is set at window creation.'); -} - -export function WindowCenter() { void Window.Center(); } -export function WindowSetTitle(title) { void Window.SetTitle(title); } -export function WindowFullscreen() { void Window.Fullscreen(); } -export function WindowUnfullscreen() { void Window.UnFullscreen(); } -export function WindowIsFullscreen() { return Window.IsFullscreen(); } -export function WindowSetSize(width, height) { void Window.SetSize(width, height); } - -export function WindowGetSize() { - return Window.Size().then((s) => ({w: s.width ?? s.Width ?? 0, h: s.height ?? s.Height ?? 0})); -} - -export function WindowSetMaxSize(width, height) { void Window.SetMaxSize(width, height); } -export function WindowSetMinSize(width, height) { void Window.SetMinSize(width, height); } - -// v2 positions were relative to the current screen. -export function WindowSetPosition(x, y) { void Window.SetRelativePosition(x, y); } -export function WindowGetPosition() { - return Window.RelativePosition().then((p) => ({x: p.x ?? p.X ?? 0, y: p.y ?? p.Y ?? 0})); -} - -export function WindowHide() { void Window.Hide(); } -export function WindowShow() { void Window.Show(); } -export function WindowMaximise() { void Window.Maximise(); } -export function WindowToggleMaximise() { void Window.ToggleMaximise(); } -export function WindowUnmaximise() { void Window.UnMaximise(); } -export function WindowIsMaximised() { return Window.IsMaximised(); } -export function WindowMinimise() { void Window.Minimise(); } -export function WindowUnminimise() { void Window.UnMinimise(); } -export function WindowIsMinimised() { return Window.IsMinimised(); } - -export function WindowIsNormal() { - return Promise.all([Window.IsFullscreen(), Window.IsMaximised(), Window.IsMinimised()]) - .then(([f, max, min]) => !f && !max && !min); -} - -export function WindowSetBackgroundColour(R, G, B, A) { - void Window.SetBackgroundColour(R, G, B, A); -} - -export function WindowPrint() { void Window.Print(); } - -/* ------------------------------- Screens ------------------------------- */ - -export function ScreenGetAll() { - return Promise.all([Screens.GetAll(), Screens.GetCurrent().catch(() => null)]) - .then(([screens, current]) => screens.map((s) => { - const size = { - width: (s.Size && (s.Size.width ?? s.Size.Width)) ?? 0, - height: (s.Size && (s.Size.height ?? s.Size.Height)) ?? 0, - }; - const phys = s.PhysicalBounds || {}; - return { - isCurrent: !!current && s.ID === current.ID, - isPrimary: !!s.IsPrimary, - width: size.width, - height: size.height, - size: size, - physicalSize: { - width: phys.Width ?? phys.width ?? size.width, - height: phys.Height ?? phys.height ?? size.height, - }, - }; - })); -} - -/* ----------------------------- Application ----------------------------- */ - -export function BrowserOpenURL(url) { void Browser.OpenURL(url); } - -export function Environment() { - return System.Environment().then((env) => ({ - buildType: env.Debug ? 'dev' : 'production', - platform: (env.OS || '').toLowerCase(), - arch: env.Arch, - })); -} - -export function Quit() { void Application.Quit(); } -export function Hide() { void Application.Hide(); } -export function Show() { void Application.Show(); } - -/* ------------------------------ Clipboard ------------------------------ */ - -export function ClipboardGetText() { return Clipboard.Text(); } -export function ClipboardSetText(text) { return Clipboard.SetText(text).then(() => true); } - -/* ------------------------------ File drop ------------------------------ */ - -export function OnFileDrop(callback, useDropTarget) { - console.warn('OnFileDrop: not supported by the Wails v3 compatibility layer; use the v3 file drop events instead.'); -} - -export function OnFileDropOff() { - console.warn('OnFileDropOff: not supported by the Wails v3 compatibility layer.'); -} diff --git a/v3/internal/migrate/bridge.go b/v3/internal/migrate/bridge.go deleted file mode 100644 index 0f43cc2b882..00000000000 --- a/v3/internal/migrate/bridge.go +++ /dev/null @@ -1,59 +0,0 @@ -package migrate - -import ( - "embed" - "io/fs" - "os" - "path/filepath" -) - -// The v2 compatibility bridge lives in this repository as an internal package -// so it is compiled, vetted and testable, but it is deliberately NOT part of -// the public v3 API: `wails3 migrate` copies its source into the migrated -// project instead. The user owns the copy and deletes functions (and finally -// the whole package) as call sites are ported to the v3 API. -// -//go:embed v2compat/runtime/*.go -var compatBridgeFS embed.FS - -// compatBridgeHeader is prepended to every generated bridge file. -const compatBridgeHeader = `// Code generated by wails3 migrate. -// -// This package is a TEMPORARY bridge that implements the Wails v2 runtime API -// on top of Wails v3. It is part of your project: as you port call sites to -// the v3 API (github.com/wailsapp/wails/v3/pkg/application), delete the -// functions they used, and delete the whole package when nothing imports it -// any more. Each function documents its v3 replacement. - -` - -// CompatRuntimeImport returns the project-local import path the bridge is -// generated under. -func (p *V2Project) CompatRuntimeImport() string { - return p.ModulePath + "/v2compat/runtime" -} - -// WriteCompatBridge copies the v2 compatibility bridge sources into the -// output project under v2compat/runtime. -func WriteCompatBridge(proj *V2Project, outDir string) error { - targetDir := filepath.Join(outDir, "v2compat", "runtime") - if err := os.MkdirAll(targetDir, 0o755); err != nil { - return err - } - entries, err := fs.ReadDir(compatBridgeFS, "v2compat/runtime") - if err != nil { - return err - } - for _, entry := range entries { - data, err := fs.ReadFile(compatBridgeFS, "v2compat/runtime/"+entry.Name()) - if err != nil { - return err - } - out := append([]byte(compatBridgeHeader), data...) - if err := os.WriteFile(filepath.Join(targetDir, entry.Name()), out, 0o644); err != nil { - return err - } - } - proj.Report.Note("A v2 runtime compatibility bridge was generated at `v2compat/runtime` in your project. It is yours: delete its functions as you port call sites to the v3 API, and remove the package when nothing imports it any more.") - return nil -} diff --git a/v3/internal/migrate/copy.go b/v3/internal/migrate/copy.go index 1e96221f3fe..6768b94618f 100644 --- a/v3/internal/migrate/copy.go +++ b/v3/internal/migrate/copy.go @@ -4,8 +4,6 @@ import ( "io" "os" "path/filepath" - "strconv" - "strings" ) // copyTree copies src into dst, creating directories as needed. The include @@ -99,41 +97,6 @@ func CopyProjectFiles(proj *V2Project, outDir string) error { return nil } target := filepath.Join(outDir, rel) - if strings.HasSuffix(path, ".go") { - data, err := os.ReadFile(path) - if err != nil { - return err - } - data = RewriteGoImports(proj, rel, data) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - return os.WriteFile(target, data, info.Mode().Perm()) - } return copyFile(path, target, info.Mode().Perm()) }) } - -// RewriteGoImports rewrites the v2 runtime import to the v3 compatibility -// bridge and records remaining v2 imports as manual steps. -func RewriteGoImports(proj *V2Project, rel string, src []byte) []byte { - content := string(src) - if strings.Contains(content, strconv.Quote(V2RuntimeImport)) { - content = strings.ReplaceAll(content, strconv.Quote(V2RuntimeImport), strconv.Quote(proj.CompatRuntimeImport())) - proj.Report.Mapped(rel+": "+V2RuntimeImport, proj.CompatRuntimeImport()+" (generated bridge)") - } - - // Report any other v2 imports that remain. - for _, line := range strings.Split(content, "\n") { - trimmed := strings.TrimSpace(line) - idx := strings.Index(trimmed, `"github.com/wailsapp/wails/v2`) - if idx < 0 { - continue - } - importPath := strings.Trim(trimmed[idx:], `"`) - proj.Report.Manual(rel+": import "+importPath, - "This Wails v2 package has no automatic v3 replacement; port the code using it to the v3 API (github.com/wailsapp/wails/v3/pkg/application).") - } - - return []byte(content) -} diff --git a/v3/internal/migrate/frontend.go b/v3/internal/migrate/frontend.go index 58004290dc4..d05479fd538 100644 --- a/v3/internal/migrate/frontend.go +++ b/v3/internal/migrate/frontend.go @@ -1,23 +1,20 @@ package migrate import ( - _ "embed" - "fmt" "os" "path/filepath" "regexp" "strings" ) -//go:embed assets/wailsjs-runtime.js -var wailsjsRuntimeJS []byte - -//go:embed assets/wailsjs-runtime.d.ts -var wailsjsRuntimeDTS []byte - -// MigrateFrontend copies the v2 frontend into the output project, replaces -// the generated wailsjs directory with a v3-backed compatibility layer and -// adds the @wailsio/runtime dependency to package.json. +// MigrateFrontend copies the v2 frontend into the output project and adds the +// @wailsio/runtime dependency to package.json. +// +// The generated wailsjs directory is NOT copied and NOT replaced with a +// compatibility layer: it is v2 build output that cannot work against a v3 +// backend, and shipping a lookalike would encourage code to stay on the v2 +// API. Every import of it is listed in the migration report with its v3 +// replacement instead. func MigrateFrontend(proj *V2Project, outDir string) error { srcFrontend := proj.FrontendDir dstFrontend := filepath.Join(outDir, "frontend") @@ -27,6 +24,11 @@ func MigrateFrontend(proj *V2Project, outDir string) error { return nil } + hadWailsJS := false + if _, err := os.Stat(filepath.Join(srcFrontend, "wailsjs")); err == nil { + hadWailsJS = true + } + err := copyTree(srcFrontend, dstFrontend, func(rel string, isDir bool) bool { switch { case rel == "node_modules" || strings.HasPrefix(rel, "node_modules"+string(filepath.Separator)): @@ -53,8 +55,8 @@ func MigrateFrontend(proj *V2Project, outDir string) error { } } - if err := writeWailsJSShims(proj, dstFrontend); err != nil { - return err + if hadWailsJS { + proj.Report.Note("The generated `frontend/wailsjs` directory was not copied: it is v2 build output and cannot work with v3. Imports of it are listed in *Port these to the v3 API*; run `wails3 generate bindings` to generate the v3 bindings into `frontend/bindings`.") } if err := addRuntimeDependency(proj, dstFrontend); err != nil { @@ -64,90 +66,6 @@ func MigrateFrontend(proj *V2Project, outDir string) error { return nil } -// writeWailsJSShims regenerates frontend/wailsjs as a compatibility layer: -// the runtime module delegates to @wailsio/runtime and the go bindings -// delegate to Call.ByName. -func writeWailsJSShims(proj *V2Project, frontendDir string) error { - runtimeDir := filepath.Join(frontendDir, "wailsjs", "runtime") - if err := os.MkdirAll(runtimeDir, 0o755); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(runtimeDir, "runtime.js"), wailsjsRuntimeJS, 0o644); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(runtimeDir, "runtime.d.ts"), wailsjsRuntimeDTS, 0o644); err != nil { - return err - } - - for _, bt := range proj.BoundTypes { - if bt.Name == "" || bt.PkgName == "" || len(bt.Methods) == 0 { - continue - } - dir := filepath.Join(frontendDir, "wailsjs", "go", bt.PkgName) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - js, dts := generateBindingShim(bt) - if err := os.WriteFile(filepath.Join(dir, bt.Name+".js"), []byte(js), 0o644); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(dir, bt.Name+".d.ts"), []byte(dts), 0o644); err != nil { - return err - } - } - - if len(proj.BoundTypes) > 0 { - proj.Report.Note("The frontend `wailsjs/` directory was regenerated as a compatibility layer over `@wailsio/runtime`. If your frontend imports `wailsjs/go/models`, port those types to the models generated by `wails3 generate bindings` (in `frontend/bindings`).") - } - - return nil -} - -// generateBindingShim renders the wailsjs/go//.js compatibility -// module and its .d.ts for one bound struct. -func generateBindingShim(bt *BoundType) (js string, dts string) { - var sbJS, sbDTS strings.Builder - - header := "// Wails v2 bindings compatibility layer, generated by `wails3 migrate`.\n" + - "// Calls are routed by name through the Wails v3 runtime.\n" + - "// Prefer the generated v3 bindings in frontend/bindings for new code.\n\n" - sbJS.WriteString(header) - sbJS.WriteString("import {Call} from '@wailsio/runtime';\n") - sbDTS.WriteString(header) - - for _, method := range bt.Methods { - var callArgs, jsParams, tsParams []string - for i, p := range method.Params { - name := p.Name - if name == "" || name == "_" { - name = fmt.Sprintf("arg%d", i+1) - } - jsParams = append(jsParams, name) - tsParams = append(tsParams, fmt.Sprintf("%s: %s", name, p.TSType)) - callArgs = append(callArgs, name) - } - - fqn := bt.PkgPath + "." + bt.Name + "." + method.Name - sbJS.WriteString(fmt.Sprintf("\nexport function %s(%s) {\n", method.Name, strings.Join(jsParams, ", "))) - args := "" - if len(callArgs) > 0 { - args = ", " + strings.Join(callArgs, ", ") - } - sbJS.WriteString(fmt.Sprintf(" return Call.ByName(%q%s);\n}\n", fqn, args)) - - retType := "void" - for _, r := range method.Results { - if r.GoType != "error" { - retType = r.TSType - break - } - } - sbDTS.WriteString(fmt.Sprintf("\nexport function %s(%s): Promise<%s>;\n", method.Name, strings.Join(tsParams, ", "), retType)) - } - - return sbJS.String(), sbDTS.String() -} - var dependenciesRe = regexp.MustCompile(`("dependencies"\s*:\s*\{)`) // addRuntimeDependency inserts "@wailsio/runtime" into the frontend @@ -158,7 +76,7 @@ func addRuntimeDependency(proj *V2Project, frontendDir string) error { if err != nil { if os.IsNotExist(err) { proj.Report.Manual("frontend/package.json", - "No package.json found. The regenerated wailsjs compatibility layer imports `@wailsio/runtime`, which requires a bundler; plain-JS frontends must load the runtime differently (see the v3 docs).") + "No package.json found. The v3 frontend runtime is the `@wailsio/runtime` npm package, which requires a bundler; plain-JS frontends must load the runtime differently (see the v3 docs).") return nil } return err diff --git a/v3/internal/migrate/maingen.go b/v3/internal/migrate/maingen.go index 06028498872..e744b763063 100644 --- a/v3/internal/migrate/maingen.go +++ b/v3/internal/migrate/maingen.go @@ -11,10 +11,6 @@ import ( "strings" ) -// v2compatAlias is the local name used for the v2compat runtime import in -// generated code (avoids clashing with a stdlib runtime import). -const v2compatAlias = "v2runtime" - // GenerateMain performs textual surgery on the file containing wails.Run: // the import block and the wails.Run statement are replaced with their v3 // equivalents, everything else is preserved byte-for-byte, and the result is @@ -24,8 +20,9 @@ func GenerateMain(proj *V2Project, opts *V3Options) ([]byte, error) { src := main.Source appVar := pickIdent(main.File, "app", "wailsApp", "wailsV3App") + winVar := pickIdent(main.File, "mainWindow", "wailsWindow") - block := buildV3Block(proj, opts, appVar) + block := buildV3Block(proj, opts, appVar, winVar) type edit struct { start, end int @@ -96,31 +93,25 @@ func writeFields(sb *strings.Builder, fields []GenField, indent string) { } // buildV3Block renders the replacement for the wails.Run statement. -func buildV3Block(proj *V2Project, opts *V3Options, appVar string) string { +func buildV3Block(proj *V2Project, opts *V3Options, appVar, winVar string) string { var sb strings.Builder sb.WriteString(appVar + " := application.New(application.Options{\n") writeFields(&sb, opts.App, "\t") - // Services (bound structs first, lifecycle bridge last). - if len(opts.Services) > 0 || opts.NeedsLifecycleService() { + if len(opts.Services) > 0 { sb.WriteString("\tServices: []application.Service{\n") for _, svc := range opts.Services { sb.WriteString("\t\tapplication.NewService(" + svc + "),\n") } - if opts.NeedsLifecycleService() { - args := []string{"nil", "nil", "nil"} - for i, cb := range []string{opts.OnStartup, opts.OnDomReady, opts.OnShutdown} { - if cb != "" { - args[i] = cb - } - } - sb.WriteString("\t\t// Bridges the v2 OnStartup/OnDomReady/OnShutdown callbacks.\n") - sb.WriteString("\t\t" + v2compatAlias + ".NewLifecycleService(" + strings.Join(args, ", ") + "),\n") - } sb.WriteString("\t},\n") } + if opts.OnShutdown != "" { + sb.WriteString("\t// v2 OnShutdown. The context is a placeholder: v3 has no context-based runtime.\n") + sb.WriteString("\tOnShutdown: func() {\n\t\t(" + opts.OnShutdown + ")(context.Background())\n\t},\n") + } + if opts.OnBeforeClose != "" { sb.WriteString("\t// v2 OnBeforeClose returned true to prevent closing; v3 ShouldQuit returns true to allow quitting.\n") sb.WriteString("\tShouldQuit: func() bool {\n\t\treturn !(" + opts.OnBeforeClose + ")(context.Background())\n\t},\n") @@ -148,7 +139,19 @@ func buildV3Block(proj *V2Project, opts *V3Options, appVar string) string { } sb.WriteString("})\n\n") - sb.WriteString(appVar + ".Window.NewWithOptions(application.WebviewWindowOptions{\n") + if opts.OnStartup != "" { + sb.WriteString("// v2 OnStartup. Consider converting your struct into a v3 service with a\n") + sb.WriteString("// ServiceStartup method instead - see MIGRATION.md.\n") + sb.WriteString(appVar + ".Event.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {\n") + sb.WriteString("\t(" + opts.OnStartup + ")(context.Background())\n") + sb.WriteString("})\n\n") + } + + windowAssign := "" + if opts.OnDomReady != "" { + windowAssign = winVar + " := " + } + sb.WriteString(windowAssign + appVar + ".Window.NewWithOptions(application.WebviewWindowOptions{\n") writeFields(&sb, opts.Win, "\t") if len(opts.WinMac) > 0 { sb.WriteString("\tMac: application.MacWindow{\n") @@ -167,6 +170,13 @@ func buildV3Block(proj *V2Project, opts *V3Options, appVar string) string { } sb.WriteString("})\n\n") + if opts.OnDomReady != "" { + sb.WriteString("// v2 OnDomReady: fires when the frontend runtime is ready in the window.\n") + sb.WriteString(winVar + ".OnWindowEvent(events.Common.WindowRuntimeReady, func(event *application.WindowEvent) {\n") + sb.WriteString("\t(" + opts.OnDomReady + ")(context.Background())\n") + sb.WriteString("})\n\n") + } + // Preserve the original error-handling shape. main := proj.Main switch { @@ -209,10 +219,10 @@ func buildImports(proj *V2Project, opts *V3Options) string { } } add("", "github.com/wailsapp/wails/v3/pkg/application") - if opts.NeedsLifecycleService() { - add(v2compatAlias, proj.CompatRuntimeImport()) + if opts.OnStartup != "" || opts.OnDomReady != "" { + add("", "github.com/wailsapp/wails/v3/pkg/events") } - if opts.OnBeforeClose != "" { + if opts.NeedsLifecycleService() || opts.OnBeforeClose != "" { add("", "context") } diff --git a/v3/internal/migrate/mapping.go b/v3/internal/migrate/mapping.go index e00ea72afaa..f86116bf6c2 100644 --- a/v3/internal/migrate/mapping.go +++ b/v3/internal/migrate/mapping.go @@ -308,13 +308,13 @@ func (m *mapper) mapAppField(name string, value ast.Expr) { m.manual(name, "v3 uses the standard library `log/slog`: set `application.Options.Logger` (a *slog.Logger) and `application.Options.LogLevel`.") case "OnStartup": out.OnStartup = m.src(value) - m.proj.Report.Mapped("options.App.OnStartup", "v2compat lifecycle service (ServiceStartup)") + m.proj.Report.Mapped("options.App.OnStartup", "wired via events.Common.ApplicationStarted (consider a v3 ServiceStartup)") case "OnDomReady": out.OnDomReady = m.src(value) - m.proj.Report.Mapped("options.App.OnDomReady", "v2compat lifecycle service (WindowRuntimeReady)") + m.proj.Report.Mapped("options.App.OnDomReady", "wired via events.Common.WindowRuntimeReady") case "OnShutdown": out.OnShutdown = m.src(value) - m.proj.Report.Mapped("options.App.OnShutdown", "v2compat lifecycle service (ServiceShutdown)") + m.proj.Report.Mapped("options.App.OnShutdown", "application.Options.OnShutdown") case "OnBeforeClose": out.OnBeforeClose = m.src(value) m.proj.Report.Mapped("options.App.OnBeforeClose", "application.Options.ShouldQuit") diff --git a/v3/internal/migrate/migrate.go b/v3/internal/migrate/migrate.go index 2157577e429..ce689064aff 100644 --- a/v3/internal/migrate/migrate.go +++ b/v3/internal/migrate/migrate.go @@ -11,10 +11,9 @@ import ( "go/token" ) -// V2RuntimeImport is the Wails v2 runtime package rewritten by the migrator. -// It is replaced with the project-local compatibility bridge (see -// CompatRuntimeImport and WriteCompatBridge); the generated package is also -// named "runtime", so call sites compile unchanged. +// V2RuntimeImport is the Wails v2 runtime package. Call sites into it are +// not rewritten: they are enumerated in the migration report with their v3 +// replacements, and the compiler flags them until the user ports them. const V2RuntimeImport = "github.com/wailsapp/wails/v2/pkg/runtime" // V2Project is everything the migrator learned about the source project. @@ -36,7 +35,8 @@ type V2Project struct { GoFiles []string // UsesV2Runtime is true when any project file imports the v2 runtime - // package (the migrated project then needs the compatibility bridge). + // package (the migrated project will not compile until those call sites + // are ported; see the report's call-site list). UsesV2Runtime bool // Report accumulates human-readable notes about everything that needs diff --git a/v3/internal/migrate/migrate_test.go b/v3/internal/migrate/migrate_test.go index dc4bf8b517d..f20cd93fb68 100644 --- a/v3/internal/migrate/migrate_test.go +++ b/v3/internal/migrate/migrate_test.go @@ -117,11 +117,15 @@ func writeFixture(t *testing.T) string { t.Helper() dir := t.TempDir() files := map[string]string{ - "wails.json": fixtureWailsJSON, - "go.mod": fixtureGoMod, - "main.go": fixtureMainGo, - "app.go": fixtureAppGo, - "frontend/package.json": `{"name":"frontend","devDependencies":{"vite":"^3.0.0"}}`, + "wails.json": fixtureWailsJSON, + "go.mod": fixtureGoMod, + "main.go": fixtureMainGo, + "app.go": fixtureAppGo, + "frontend/package.json": `{"name":"frontend","devDependencies":{"vite":"^3.0.0"}}`, + "frontend/src/main.js": `import {Greet} from '../wailsjs/go/main/App'; +import {EventsOn} from '../wailsjs/runtime/runtime'; +EventsOn('x', () => Greet('y')); +`, "frontend/index.html": ``, "frontend/dist/.gitkeep": "", "frontend/wailsjs/runtime/runtime.js": "// old", @@ -273,14 +277,17 @@ func TestGenerateMain(t *testing.T) { "application.New(application.Options{", `Name: "My V2 App"`, "application.NewService(app)", - "v2runtime.NewLifecycleService(app.startup, nil, app.shutdown)", + "OnShutdown: func() {", + "(app.shutdown)(context.Background())", + "wailsApp.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {", + "(app.startup)(context.Background())", "ShouldQuit: func() bool {", "!(app.beforeClose)(context.Background())", ".Window.NewWithOptions(application.WebviewWindowOptions{", "//go:embed all:frontend/dist", "// Create an instance of the app structure", "err := wailsApp.Run()", - `v2runtime "myv2app/v2compat/runtime"`, + `"github.com/wailsapp/wails/v3/pkg/events"`, } { if !strings.Contains(src, want) { t.Errorf("generated main.go missing %q\n---\n%s", want, src) @@ -354,58 +361,37 @@ func TestTransformGoMod(t *testing.T) { } } -func TestRewriteGoImports(t *testing.T) { +func TestAdvisor(t *testing.T) { proj := parseFixture(t) - src := []byte(`package main - -import ( - "github.com/wailsapp/wails/v2/pkg/runtime" - "github.com/wailsapp/wails/v2/pkg/menu" -) -`) - out := RewriteGoImports(proj, "other.go", src) - if !strings.Contains(string(out), `"myv2app/v2compat/runtime"`) { - t.Errorf("runtime import not rewritten:\n%s", out) - } - if !strings.Contains(string(out), "wails/v2/pkg/menu") { - t.Errorf("menu import should be left in place for the user to port:\n%s", out) - } - if !proj.Report.HasManualSteps() { - t.Error("expected a manual step for the menu import") - } -} - -func TestGenerateBindingShim(t *testing.T) { - bt := &BoundType{ - Expr: "app", - PkgName: "main", - PkgPath: "main", - Name: "App", - Methods: []*BoundMethod{ - { - Name: "Greet", - Params: []Param{{Name: "name", GoType: "string", TSType: "string"}}, - Results: []Param{{GoType: "string", TSType: "string"}}, - }, - { - Name: "Quit", - Params: nil, - Results: nil, - }, - }, - } - js, dts := generateBindingShim(bt) - if !strings.Contains(js, `Call.ByName("main.App.Greet", name)`) { - t.Errorf("js shim:\n%s", js) + if !proj.UsesV2Runtime { + t.Fatal("expected UsesV2Runtime") } - if !strings.Contains(js, `Call.ByName("main.App.Quit")`) { - t.Errorf("js shim:\n%s", js) - } - if !strings.Contains(dts, "export function Greet(name: string): Promise;") { - t.Errorf("dts shim:\n%s", dts) + md := proj.Report.Markdown() + for _, want := range []string{ + "## Port these to the v3 API", + "`app.go:", // Go call site location + "`runtime.WindowSetTitle`", + "window.SetTitle(title)", + "`src/main.js` should not appear", + } { + if want == "`src/main.js` should not appear" { + continue + } + if !strings.Contains(md, want) { + t.Errorf("report missing %q\n---\n%s", want, md) + } } - if !strings.Contains(dts, "export function Quit(): Promise;") { - t.Errorf("dts shim:\n%s", dts) + // Frontend wailsjs imports are listed too. + for _, want := range []string{ + "main.js:1", + "wailsjs/go/main/App", + "wails3 generate bindings", + "main.js:2", + "@wailsio/runtime", + } { + if !strings.Contains(md, want) { + t.Errorf("report missing frontend advice %q\n---\n%s", want, md) + } } } @@ -416,20 +402,19 @@ func TestMigrateFrontend(t *testing.T) { t.Fatal(err) } - runtimeJS, err := os.ReadFile(filepath.Join(outDir, "frontend", "wailsjs", "runtime", "runtime.js")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(runtimeJS), "@wailsio/runtime") { - t.Error("runtime.js shim not written") + // The generated v2 wailsjs directory must not be carried over or + // replaced by a lookalike. + if _, err := os.Stat(filepath.Join(outDir, "frontend", "wailsjs")); err == nil { + t.Error("frontend/wailsjs should not exist in the migrated project") } - appJS, err := os.ReadFile(filepath.Join(outDir, "frontend", "wailsjs", "go", "main", "App.js")) + // The user's own sources are copied untouched. + mainJS, err := os.ReadFile(filepath.Join(outDir, "frontend", "src", "main.js")) if err != nil { t.Fatal(err) } - if !strings.Contains(string(appJS), `Call.ByName("main.App.Greet", name)`) { - t.Errorf("App.js shim:\n%s", appJS) + if !strings.Contains(string(mainJS), "../wailsjs/go/main/App") { + t.Errorf("user source was modified:\n%s", mainJS) } pkgJSON, err := os.ReadFile(filepath.Join(outDir, "frontend", "package.json")) diff --git a/v3/internal/migrate/parse.go b/v3/internal/migrate/parse.go index ebca9d858c1..5122df0e5b1 100644 --- a/v3/internal/migrate/parse.go +++ b/v3/internal/migrate/parse.go @@ -127,6 +127,12 @@ func ParseV2Project(dir string) (*V2Project, error) { } } + // Enumerate every v2 API call site with its v3 replacement. + adviseGoRuntimeCalls(fset, files, proj) + if err := adviseFrontendImports(proj); err != nil { + return nil, err + } + return proj, nil } diff --git a/v3/internal/migrate/report.go b/v3/internal/migrate/report.go index 6fd2884358d..0622227472f 100644 --- a/v3/internal/migrate/report.go +++ b/v3/internal/migrate/report.go @@ -9,9 +9,10 @@ import ( // Report collects everything the migrator did (or could not do) so the user // gets a single MIGRATION.md summarising the state of the new project. type Report struct { - mapped []string // options that were migrated automatically - manual map[string]string // v2 option/feature -> what the user must do - notes []string // informational notes + mapped []string // options that were migrated automatically + manual map[string]string // v2 option/feature -> what the user must do + notes []string // informational notes + callSites []string // v2 API call sites the user must port } func NewReport() *Report { @@ -29,6 +30,12 @@ func (r *Report) Manual(what, instructions string) { r.manual[what] = instructions } +// CallSite records a v2 API call site that must be ported to v3, with its +// replacement. +func (r *Report) CallSite(location, v2Call, advice string) { + r.callSites = append(r.callSites, fmt.Sprintf("| `%s` | %s | %s |", location, v2Call, advice)) +} + // Note records an informational message. func (r *Report) Note(note string) { r.notes = append(r.notes, note) @@ -36,7 +43,7 @@ func (r *Report) Note(note string) { // HasManualSteps reports whether any manual step was recorded. func (r *Report) HasManualSteps() bool { - return len(r.manual) > 0 + return len(r.manual) > 0 || len(r.callSites) > 0 } // Markdown renders the report as the MIGRATION.md contents. @@ -52,13 +59,29 @@ func (r *Report) Markdown() string { sb.WriteString("> details. Pull requests are very welcome.\n\n") sb.WriteString("## Next steps\n\n") sb.WriteString("1. Run `wails3 doctor` to check your environment.\n") - sb.WriteString("2. Run `wails3 dev` to build and run the migrated app.\n") - sb.WriteString("3. Work through the *Manual steps* below, if any.\n") - sb.WriteString("4. Migrate incrementally from the generated `v2compat/runtime` bridge in this\n") - sb.WriteString(" project to the v3 API. Every bridge function documents its v3 replacement;\n") - sb.WriteString(" delete functions as you go and remove the package when nothing imports it.\n") + sb.WriteString("2. Port the call sites listed below to the v3 API. The compiler will point at\n") + sb.WriteString(" them: the project intentionally does not build until they are ported.\n") + sb.WriteString(" Do not run `go mod tidy` before this is done - it would re-add the v2\n") + sb.WriteString(" dependency and make the old calls compile, but they cannot work inside a\n") + sb.WriteString(" v3 application and will fail at runtime.\n") + sb.WriteString("3. Run `go mod tidy`, then `wails3 generate bindings` for the frontend bindings.\n") + sb.WriteString("4. Run `wails3 dev` to build and run the migrated app.\n") + sb.WriteString("5. Work through the *Manual steps* below, if any.\n") sb.WriteString(" See https://v3.wails.io/migration/v2-to-v3/ for the full guide.\n\n") + if len(r.callSites) > 0 { + sb.WriteString("## Port these to the v3 API\n\n") + sb.WriteString("In v3 there is no context-based runtime package: you call methods on the\n") + sb.WriteString("application (`application.Get()` from anywhere) and on window objects.\n") + sb.WriteString("The migrator does not rewrite these call sites for you - a half-migrated\n") + sb.WriteString("file is worse than a clear list - so each one is enumerated here:\n\n") + sb.WriteString("| Location | v2 API | v3 replacement |\n|---|---|---|\n") + for _, cs := range r.callSites { + sb.WriteString(cs + "\n") + } + sb.WriteString("\n") + } + if len(r.manual) > 0 { sb.WriteString("## Manual steps\n\n") keys := make([]string, 0, len(r.manual)) diff --git a/v3/internal/migrate/v2compat/runtime/app.go b/v3/internal/migrate/v2compat/runtime/app.go deleted file mode 100644 index cc0b16feac1..00000000000 --- a/v3/internal/migrate/v2compat/runtime/app.go +++ /dev/null @@ -1,44 +0,0 @@ -package runtime - -import ( - "errors" - "log/slog" - - "github.com/wailsapp/wails/v3/pkg/application" -) - -// errNoApp is returned by functions that cannot silently no-op when the -// application has not been created yet. -var errNoApp = errors.New("no application instance available") - -// app returns the global v3 application instance, or nil if it has not been -// created yet. -func app() *application.App { - return application.Get() -} - -// currentWindow returns the current window, falling back to the first -// window when none is focused. Returns nil when no window exists yet. -func currentWindow() application.Window { - a := application.Get() - if a == nil { - return nil - } - if w := a.Window.Current(); w != nil { - return w - } - all := a.Window.GetAll() - if len(all) > 0 { - return all[0] - } - return nil -} - -// logger returns the application logger, falling back to slog's default -// logger when the application has not been created yet. -func logger() *slog.Logger { - if a := app(); a != nil && a.Logger != nil { - return a.Logger - } - return slog.Default() -} diff --git a/v3/internal/migrate/v2compat/runtime/browser.go b/v3/internal/migrate/v2compat/runtime/browser.go deleted file mode 100644 index 28c2b20e6cb..00000000000 --- a/v3/internal/migrate/v2compat/runtime/browser.go +++ /dev/null @@ -1,18 +0,0 @@ -package runtime - -import ( - "context" -) - -// BrowserOpenURL mirrors the v2 runtime.BrowserOpenURL function. Any error is -// logged as v2 returned nothing. -// v3 equivalent: app.Browser.OpenURL. -func BrowserOpenURL(_ context.Context, url string) { - a := app() - if a == nil { - return - } - if err := a.Browser.OpenURL(url); err != nil { - logger().Warn("v2compat: BrowserOpenURL failed", "url", url, "error", err) - } -} diff --git a/v3/internal/migrate/v2compat/runtime/clipboard.go b/v3/internal/migrate/v2compat/runtime/clipboard.go deleted file mode 100644 index e7b1a26cdbe..00000000000 --- a/v3/internal/migrate/v2compat/runtime/clipboard.go +++ /dev/null @@ -1,33 +0,0 @@ -package runtime - -import ( - "context" - "errors" -) - -// ClipboardGetText mirrors the v2 runtime.ClipboardGetText function. -// v3 equivalent: app.Clipboard.Text. -func ClipboardGetText(_ context.Context) (string, error) { - a := app() - if a == nil { - return "", errNoApp - } - text, ok := a.Clipboard.Text() - if !ok { - return "", errors.New("no text on clipboard") - } - return text, nil -} - -// ClipboardSetText mirrors the v2 runtime.ClipboardSetText function. -// v3 equivalent: app.Clipboard.SetText. -func ClipboardSetText(_ context.Context, text string) error { - a := app() - if a == nil { - return errNoApp - } - if !a.Clipboard.SetText(text) { - return errors.New("failed to set clipboard text") - } - return nil -} diff --git a/v3/internal/migrate/v2compat/runtime/dialog.go b/v3/internal/migrate/v2compat/runtime/dialog.go deleted file mode 100644 index f36849a9e6b..00000000000 --- a/v3/internal/migrate/v2compat/runtime/dialog.go +++ /dev/null @@ -1,218 +0,0 @@ -package runtime - -import ( - "context" - - "github.com/wailsapp/wails/v3/pkg/application" -) - -// DialogType mirrors the v2 runtime.DialogType type. -type DialogType string - -// Dialog types, mirroring the v2 runtime constants. -const ( - InfoDialog DialogType = "info" - WarningDialog DialogType = "warning" - ErrorDialog DialogType = "error" - QuestionDialog DialogType = "question" -) - -// FileFilter mirrors the v2 runtime.FileFilter type. -// v3 equivalent: application.FileFilter. -type FileFilter struct { - DisplayName string // Filter information EG: "Image Files (*.jpg, *.png)" - Pattern string // semicolon separated list of extensions, EG: "*.jpg;*.png" -} - -// OpenDialogOptions mirrors the v2 runtime.OpenDialogOptions type. -// v3 equivalent: application.OpenFileDialogOptions. -type OpenDialogOptions struct { - DefaultDirectory string - DefaultFilename string - Title string - Filters []FileFilter - ShowHiddenFiles bool - CanCreateDirectories bool - ResolvesAliases bool - TreatPackagesAsDirectories bool -} - -// SaveDialogOptions mirrors the v2 runtime.SaveDialogOptions type. -// v3 equivalent: application.SaveFileDialogOptions. -type SaveDialogOptions struct { - DefaultDirectory string - DefaultFilename string - Title string - Filters []FileFilter - ShowHiddenFiles bool - CanCreateDirectories bool - TreatPackagesAsDirectories bool -} - -// MessageDialogOptions mirrors the v2 runtime.MessageDialogOptions type. -// v3 equivalent: the application.MessageDialog builder API. -type MessageDialogOptions struct { - Type DialogType - Title string - Message string - Buttons []string - DefaultButton string - CancelButton string - Icon []byte -} - -// convertFilters maps v2 file filters onto their v3 equivalent. -func convertFilters(filters []FileFilter) []application.FileFilter { - if len(filters) == 0 { - return nil - } - result := make([]application.FileFilter, 0, len(filters)) - for _, filter := range filters { - result = append(result, application.FileFilter{ - DisplayName: filter.DisplayName, - Pattern: filter.Pattern, - }) - } - return result -} - -// OpenDirectoryDialog mirrors the v2 runtime.OpenDirectoryDialog function. -// v3 equivalent: app.Dialog.OpenFileWithOptions with CanChooseDirectories set. -func OpenDirectoryDialog(_ context.Context, dialogOptions OpenDialogOptions) (string, error) { - a := app() - if a == nil { - return "", errNoApp - } - dialog := a.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{ - CanChooseDirectories: true, - CanChooseFiles: false, - CanCreateDirectories: dialogOptions.CanCreateDirectories, - ShowHiddenFiles: dialogOptions.ShowHiddenFiles, - ResolvesAliases: dialogOptions.ResolvesAliases, - TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, - Filters: convertFilters(dialogOptions.Filters), - Title: dialogOptions.Title, - Directory: dialogOptions.DefaultDirectory, - }) - return dialog.PromptForSingleSelection() -} - -// OpenFileDialog mirrors the v2 runtime.OpenFileDialog function. The v2 -// DefaultFilename option is ignored as v3 open dialogs have no default filename. -// v3 equivalent: app.Dialog.OpenFileWithOptions. -func OpenFileDialog(_ context.Context, dialogOptions OpenDialogOptions) (string, error) { - a := app() - if a == nil { - return "", errNoApp - } - dialog := a.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{ - CanChooseFiles: true, - CanCreateDirectories: dialogOptions.CanCreateDirectories, - ShowHiddenFiles: dialogOptions.ShowHiddenFiles, - ResolvesAliases: dialogOptions.ResolvesAliases, - TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, - Filters: convertFilters(dialogOptions.Filters), - Title: dialogOptions.Title, - Directory: dialogOptions.DefaultDirectory, - }) - return dialog.PromptForSingleSelection() -} - -// OpenMultipleFilesDialog mirrors the v2 runtime.OpenMultipleFilesDialog -// function. The v2 DefaultFilename option is ignored as v3 open dialogs have -// no default filename. -// v3 equivalent: app.Dialog.OpenFileWithOptions with AllowsMultipleSelection set. -func OpenMultipleFilesDialog(_ context.Context, dialogOptions OpenDialogOptions) ([]string, error) { - a := app() - if a == nil { - return nil, errNoApp - } - dialog := a.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{ - CanChooseFiles: true, - AllowsMultipleSelection: true, - CanCreateDirectories: dialogOptions.CanCreateDirectories, - ShowHiddenFiles: dialogOptions.ShowHiddenFiles, - ResolvesAliases: dialogOptions.ResolvesAliases, - TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, - Filters: convertFilters(dialogOptions.Filters), - Title: dialogOptions.Title, - Directory: dialogOptions.DefaultDirectory, - }) - return dialog.PromptForMultipleSelection() -} - -// SaveFileDialog mirrors the v2 runtime.SaveFileDialog function. -// v3 equivalent: app.Dialog.SaveFileWithOptions. -func SaveFileDialog(_ context.Context, dialogOptions SaveDialogOptions) (string, error) { - a := app() - if a == nil { - return "", errNoApp - } - dialog := a.Dialog.SaveFileWithOptions(&application.SaveFileDialogOptions{ - CanCreateDirectories: dialogOptions.CanCreateDirectories, - ShowHiddenFiles: dialogOptions.ShowHiddenFiles, - TreatsFilePackagesAsDirectories: dialogOptions.TreatPackagesAsDirectories, - Filters: convertFilters(dialogOptions.Filters), - Title: dialogOptions.Title, - Directory: dialogOptions.DefaultDirectory, - Filename: dialogOptions.DefaultFilename, - }) - return dialog.PromptForSingleSelection() -} - -// MessageDialog mirrors the v2 runtime.MessageDialog function. It shows a -// message dialog and blocks until a button is clicked, returning the label of -// the clicked button. -// -// Unlike v2 there is no per-platform default-button fallback: dismissing the -// dialog without clicking a button (where the platform allows it) only -// delivers the cancel button's label if the platform wires the dismissal to -// the cancel button. -// v3 equivalent: the app.Dialog.Info/Question/Warning/Error builder API. -func MessageDialog(_ context.Context, dialogOptions MessageDialogOptions) (string, error) { - a := app() - if a == nil { - return "", errNoApp - } - var dialog *application.MessageDialog - switch dialogOptions.Type { - case QuestionDialog: - dialog = a.Dialog.Question() - case WarningDialog: - dialog = a.Dialog.Warning() - case ErrorDialog: - dialog = a.Dialog.Error() - default: - dialog = a.Dialog.Info() - } - dialog.SetTitle(dialogOptions.Title) - dialog.SetMessage(dialogOptions.Message) - if len(dialogOptions.Icon) > 0 { - dialog.SetIcon(dialogOptions.Icon) - } - - buttons := dialogOptions.Buttons - if len(buttons) == 0 { - buttons = []string{"Ok"} - } - - result := make(chan string, 1) - for _, label := range buttons { - button := dialog.AddButton(label) - button.OnClick(func() { - select { - case result <- label: - default: - } - }) - if label == dialogOptions.DefaultButton { - dialog.SetDefaultButton(button) - } - if label == dialogOptions.CancelButton { - dialog.SetCancelButton(button) - } - } - - dialog.Show() - return <-result, nil -} diff --git a/v3/internal/migrate/v2compat/runtime/doc.go b/v3/internal/migrate/v2compat/runtime/doc.go deleted file mode 100644 index d72b1d1b19b..00000000000 --- a/v3/internal/migrate/v2compat/runtime/doc.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package runtime is a temporary compatibility bridge for projects migrated -// from Wails v2 via the `wails3 migrate` command. -// -// It mirrors the Wails v2 runtime API (github.com/wailsapp/wails/v2/pkg/runtime), -// a set of context-first free functions, implemented on top of the Wails v3 -// application API (github.com/wailsapp/wails/v3/pkg/application). Migrated v2 -// code keeps calling functions such as runtime.WindowSetTitle(a.ctx, ...) -// unchanged; only the import path changes. The context parameter is accepted -// for source compatibility and ignored. -// -// New code should use github.com/wailsapp/wails/v3/pkg/application directly. -// Each function in this package documents its v3 equivalent so that call -// sites can be migrated incrementally and this package eventually removed. -package runtime diff --git a/v3/internal/migrate/v2compat/runtime/draganddrop.go b/v3/internal/migrate/v2compat/runtime/draganddrop.go deleted file mode 100644 index 21c897ddbd6..00000000000 --- a/v3/internal/migrate/v2compat/runtime/draganddrop.go +++ /dev/null @@ -1,80 +0,0 @@ -package runtime - -import ( - "context" - "sync" - - "github.com/wailsapp/wails/v3/pkg/application" - "github.com/wailsapp/wails/v3/pkg/events" -) - -var ( - fileDropLock sync.Mutex - fileDropUnsubscribers []func() - // fileDropGeneration invalidates listeners registered by earlier - // OnFileDrop calls once OnFileDropOff has run, including the window - // creation hooks which cannot be unregistered. - fileDropGeneration int -) - -// OnFileDrop mirrors the v2 runtime.OnFileDrop function. The callback is -// registered on the current window and on any window created afterwards. -// -// The drop coordinates are not exposed to the application side in v3, so the -// callback always receives x=0, y=0. Note that file-drop events only fire for -// windows created with EnableFileDrop: true in their -// application.WebviewWindowOptions. -// v3 equivalent: window.OnWindowEvent(events.Common.WindowFilesDropped, ...). -func OnFileDrop(_ context.Context, callback func(x, y int, paths []string)) { - a := app() - if a == nil || callback == nil { - return - } - - fileDropLock.Lock() - generation := fileDropGeneration - fileDropLock.Unlock() - - register := func(window application.Window) { - unsubscribe := window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) { - callback(0, 0, event.Context().DroppedFiles()) - }) - fileDropLock.Lock() - if fileDropGeneration != generation { - // OnFileDropOff was called in the meantime. - fileDropLock.Unlock() - unsubscribe() - return - } - fileDropUnsubscribers = append(fileDropUnsubscribers, unsubscribe) - fileDropLock.Unlock() - } - - if w := currentWindow(); w != nil { - register(w) - } - a.Window.OnCreate(func(window application.Window) { - fileDropLock.Lock() - stale := fileDropGeneration != generation - fileDropLock.Unlock() - if stale { - return - } - register(window) - }) -} - -// OnFileDropOff mirrors the v2 runtime.OnFileDropOff function. It removes all -// file-drop listeners registered via OnFileDrop. -// v3 equivalent: calling the unsubscribe function returned by window.OnWindowEvent. -func OnFileDropOff(_ context.Context) { - fileDropLock.Lock() - fileDropGeneration++ - unsubscribers := fileDropUnsubscribers - fileDropUnsubscribers = nil - fileDropLock.Unlock() - - for _, unsubscribe := range unsubscribers { - unsubscribe() - } -} diff --git a/v3/internal/migrate/v2compat/runtime/events.go b/v3/internal/migrate/v2compat/runtime/events.go deleted file mode 100644 index b261c51a495..00000000000 --- a/v3/internal/migrate/v2compat/runtime/events.go +++ /dev/null @@ -1,118 +0,0 @@ -package runtime - -import ( - "context" - "sync" - "sync/atomic" - - "github.com/wailsapp/wails/v3/pkg/application" -) - -// eventArgs converts a v3 CustomEvent data payload into the variadic argument -// list expected by v2 event callbacks: nil becomes no arguments, a slice -// emitted from multiple arguments is spread, and any other value is passed -// as a single argument. -func eventArgs(data any) []interface{} { - switch d := data.(type) { - case nil: - return nil - case []interface{}: - return d - default: - return []interface{}{d} - } -} - -// EventsOn mirrors the v2 runtime.EventsOn function. -// v3 equivalent: app.Event.On. -func EventsOn(_ context.Context, eventName string, callback func(optionalData ...interface{})) func() { - a := app() - if a == nil { - return func() {} - } - return a.Event.On(eventName, func(event *application.CustomEvent) { - callback(eventArgs(event.Data)...) - }) -} - -// EventsOff mirrors the v2 runtime.EventsOff function. -// v3 equivalent: app.Event.Off. -func EventsOff(_ context.Context, eventName string, additionalEventNames ...string) { - a := app() - if a == nil { - return - } - a.Event.Off(eventName) - for _, name := range additionalEventNames { - a.Event.Off(name) - } -} - -// EventsOnce mirrors the v2 runtime.EventsOnce function. -// v3 equivalent: app.Event.On combined with unregistering after the first event. -func EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func() { - return EventsOnMultiple(ctx, eventName, callback, 1) -} - -// EventsOnMultiple mirrors the v2 runtime.EventsOnMultiple function. The -// callback is invoked at most counter times; a counter <= 0 means unlimited. -// v3 equivalent: app.Event.On combined with unregistering after counter events. -func EventsOnMultiple(_ context.Context, eventName string, callback func(optionalData ...interface{}), counter int) func() { - a := app() - if a == nil { - return func() {} - } - if counter <= 0 { - return a.Event.On(eventName, func(event *application.CustomEvent) { - callback(eventArgs(event.Data)...) - }) - } - - var remaining atomic.Int64 - remaining.Store(int64(counter)) - - var lock sync.Mutex - var unregister func() - unregistered := false - cancel := func() { - lock.Lock() - defer lock.Unlock() - if unregistered { - return - } - unregistered = true - if unregister != nil { - unregister() - } - } - - off := a.Event.On(eventName, func(event *application.CustomEvent) { - if remaining.Add(-1) < 0 { - return - } - callback(eventArgs(event.Data)...) - if remaining.Load() <= 0 { - cancel() - } - }) - - lock.Lock() - unregister = off - if unregistered { - // The counter was exhausted before registration completed. - off() - } - lock.Unlock() - - return cancel -} - -// EventsEmit mirrors the v2 runtime.EventsEmit function. -// v3 equivalent: app.Event.Emit. -func EventsEmit(_ context.Context, eventName string, optionalData ...interface{}) { - a := app() - if a == nil { - return - } - a.Event.Emit(eventName, optionalData...) -} diff --git a/v3/internal/migrate/v2compat/runtime/lifecycle.go b/v3/internal/migrate/v2compat/runtime/lifecycle.go deleted file mode 100644 index 5a03fa5adb4..00000000000 --- a/v3/internal/migrate/v2compat/runtime/lifecycle.go +++ /dev/null @@ -1,68 +0,0 @@ -package runtime - -import ( - "context" - "sync" - - "github.com/wailsapp/wails/v3/pkg/application" - "github.com/wailsapp/wails/v3/pkg/events" -) - -// NewLifecycleService bridges the v2 application lifecycle hooks (OnStartup, -// OnDomReady, OnShutdown) onto a v3 service. The wails3 migrate command -// registers it as the last service of a migrated application. -func NewLifecycleService(onStartup, onDomReady, onShutdown func(context.Context)) application.Service { - return application.NewService(&lifecycleService{ - onStartup: onStartup, - onDomReady: onDomReady, - onShutdown: onShutdown, - }) -} - -// lifecycleService implements application.ServiceStartup and -// application.ServiceShutdown to drive the v2 lifecycle hooks. -type lifecycleService struct { - onStartup func(context.Context) - onDomReady func(context.Context) - onShutdown func(context.Context) - ctx context.Context - domReadyOnce sync.Once -} - -// ServiceStartup calls the OnStartup hook and arranges for the OnDomReady -// hook to run exactly once when the first window signals that the runtime -// is ready. -func (s *lifecycleService) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error { - s.ctx = ctx - if s.onStartup != nil { - s.onStartup(ctx) - } - if s.onDomReady != nil { - hook := func(window application.Window) { - window.OnWindowEvent(events.Common.WindowRuntimeReady, func(*application.WindowEvent) { - s.domReadyOnce.Do(func() { - s.onDomReady(ctx) - }) - }) - } - if a := app(); a != nil { - for _, window := range a.Window.GetAll() { - hook(window) - } - a.Window.OnCreate(hook) - } - } - return nil -} - -// ServiceShutdown calls the OnShutdown hook. -func (s *lifecycleService) ServiceShutdown() error { - if s.onShutdown != nil { - ctx := s.ctx - if ctx == nil { - ctx = context.Background() - } - s.onShutdown(ctx) - } - return nil -} diff --git a/v3/internal/migrate/v2compat/runtime/log.go b/v3/internal/migrate/v2compat/runtime/log.go deleted file mode 100644 index f71b0a4c5bc..00000000000 --- a/v3/internal/migrate/v2compat/runtime/log.go +++ /dev/null @@ -1,120 +0,0 @@ -package runtime - -import ( - "context" - "fmt" - "os" - "sync" -) - -// LogLevel mirrors the v2 logger.LogLevel type. -type LogLevel uint8 - -// Log levels, mirroring the v2 logger constants. -const ( - TRACE LogLevel = 1 - DEBUG LogLevel = 2 - INFO LogLevel = 3 - WARNING LogLevel = 4 - ERROR LogLevel = 5 -) - -// logLevelWarnOnce guards the one-time warning emitted by LogSetLogLevel. -var logLevelWarnOnce sync.Once - -// LogPrint mirrors the v2 runtime.LogPrint function. -// v3 equivalent: app.Logger.Info. -func LogPrint(_ context.Context, message string) { - logger().Info(message) -} - -// LogTrace mirrors the v2 runtime.LogTrace function. -// v3 equivalent: app.Logger.Debug. -func LogTrace(_ context.Context, message string) { - logger().Debug(message) -} - -// LogDebug mirrors the v2 runtime.LogDebug function. -// v3 equivalent: app.Logger.Debug. -func LogDebug(_ context.Context, message string) { - logger().Debug(message) -} - -// LogInfo mirrors the v2 runtime.LogInfo function. -// v3 equivalent: app.Logger.Info. -func LogInfo(_ context.Context, message string) { - logger().Info(message) -} - -// LogWarning mirrors the v2 runtime.LogWarning function. -// v3 equivalent: app.Logger.Warn. -func LogWarning(_ context.Context, message string) { - logger().Warn(message) -} - -// LogError mirrors the v2 runtime.LogError function. -// v3 equivalent: app.Logger.Error. -func LogError(_ context.Context, message string) { - logger().Error(message) -} - -// LogFatal mirrors the v2 runtime.LogFatal function. It logs the message at -// error level and exits the process. -// v3 equivalent: app.Logger.Error followed by os.Exit(1). -func LogFatal(_ context.Context, message string) { - logger().Error(message) - os.Exit(1) -} - -// LogPrintf mirrors the v2 runtime.LogPrintf function. -// v3 equivalent: app.Logger.Info. -func LogPrintf(_ context.Context, format string, args ...interface{}) { - logger().Info(fmt.Sprintf(format, args...)) -} - -// LogTracef mirrors the v2 runtime.LogTracef function. -// v3 equivalent: app.Logger.Debug. -func LogTracef(_ context.Context, format string, args ...interface{}) { - logger().Debug(fmt.Sprintf(format, args...)) -} - -// LogDebugf mirrors the v2 runtime.LogDebugf function. -// v3 equivalent: app.Logger.Debug. -func LogDebugf(_ context.Context, format string, args ...interface{}) { - logger().Debug(fmt.Sprintf(format, args...)) -} - -// LogInfof mirrors the v2 runtime.LogInfof function. -// v3 equivalent: app.Logger.Info. -func LogInfof(_ context.Context, format string, args ...interface{}) { - logger().Info(fmt.Sprintf(format, args...)) -} - -// LogWarningf mirrors the v2 runtime.LogWarningf function. -// v3 equivalent: app.Logger.Warn. -func LogWarningf(_ context.Context, format string, args ...interface{}) { - logger().Warn(fmt.Sprintf(format, args...)) -} - -// LogErrorf mirrors the v2 runtime.LogErrorf function. -// v3 equivalent: app.Logger.Error. -func LogErrorf(_ context.Context, format string, args ...interface{}) { - logger().Error(fmt.Sprintf(format, args...)) -} - -// LogFatalf mirrors the v2 runtime.LogFatalf function. It logs the message at -// error level and exits the process. -// v3 equivalent: app.Logger.Error followed by os.Exit(1). -func LogFatalf(_ context.Context, format string, args ...interface{}) { - logger().Error(fmt.Sprintf(format, args...)) - os.Exit(1) -} - -// LogSetLogLevel mirrors the v2 runtime.LogSetLogLevel function. v3 has no -// runtime log level setter: configure the slog level via -// application.Options.LogLevel instead. This is a no-op that logs a warning once. -func LogSetLogLevel(_ context.Context, level LogLevel) { - logLevelWarnOnce.Do(func() { - logger().Warn("v2compat: LogSetLogLevel is a no-op in v3; configure the log level via application.Options.LogLevel", "requestedLevel", level) - }) -} diff --git a/v3/internal/migrate/v2compat/runtime/runtime.go b/v3/internal/migrate/v2compat/runtime/runtime.go deleted file mode 100644 index 29fb2f4e6d7..00000000000 --- a/v3/internal/migrate/v2compat/runtime/runtime.go +++ /dev/null @@ -1,56 +0,0 @@ -package runtime - -import ( - "context" -) - -// EnvironmentInfo mirrors the v2 runtime.EnvironmentInfo type. -// v3 equivalent: application.EnvironmentInfo. -type EnvironmentInfo struct { - BuildType string `json:"buildType"` - Platform string `json:"platform"` - Arch string `json:"arch"` -} - -// Quit mirrors the v2 runtime.Quit function. -// v3 equivalent: app.Quit. -func Quit(_ context.Context) { - if a := app(); a != nil { - a.Quit() - } -} - -// Hide mirrors the v2 runtime.Hide function. -// v3 equivalent: app.Hide. -func Hide(_ context.Context) { - if a := app(); a != nil { - a.Hide() - } -} - -// Show mirrors the v2 runtime.Show function. -// v3 equivalent: app.Show. -func Show(_ context.Context) { - if a := app(); a != nil { - a.Show() - } -} - -// Environment mirrors the v2 runtime.Environment function. -// v3 equivalent: app.Env.Info. -func Environment(_ context.Context) EnvironmentInfo { - a := app() - if a == nil { - return EnvironmentInfo{} - } - info := a.Env.Info() - buildType := "production" - if info.Debug { - buildType = "dev" - } - return EnvironmentInfo{ - BuildType: buildType, - Platform: info.OS, - Arch: info.Arch, - } -} diff --git a/v3/internal/migrate/v2compat/runtime/screen.go b/v3/internal/migrate/v2compat/runtime/screen.go deleted file mode 100644 index e467ec97e90..00000000000 --- a/v3/internal/migrate/v2compat/runtime/screen.go +++ /dev/null @@ -1,58 +0,0 @@ -package runtime - -import ( - "context" -) - -// ScreenSize mirrors the v2 runtime.ScreenSize type. -type ScreenSize struct { - Width int `json:"width"` - Height int `json:"height"` -} - -// Screen mirrors the v2 runtime.Screen type. -// v3 equivalent: application.Screen. -type Screen struct { - IsCurrent bool `json:"isCurrent"` - IsPrimary bool `json:"isPrimary"` - Width int `json:"width"` - Height int `json:"height"` - Size ScreenSize `json:"size"` - PhysicalSize ScreenSize `json:"physicalSize"` -} - -// ScreenGetAll mirrors the v2 runtime.ScreenGetAll function. -// v3 equivalent: app.Screen.GetAll. -func ScreenGetAll(_ context.Context) ([]Screen, error) { - a := app() - if a == nil { - return nil, errNoApp - } - - currentID := "" - if w := currentWindow(); w != nil { - if screen, err := w.GetScreen(); err == nil && screen != nil { - currentID = screen.ID - } - } - - screens := a.Screen.GetAll() - result := make([]Screen, 0, len(screens)) - for _, screen := range screens { - result = append(result, Screen{ - IsCurrent: currentID != "" && screen.ID == currentID, - IsPrimary: screen.IsPrimary, - Width: screen.Size.Width, - Height: screen.Size.Height, - Size: ScreenSize{ - Width: screen.Size.Width, - Height: screen.Size.Height, - }, - PhysicalSize: ScreenSize{ - Width: screen.PhysicalBounds.Width, - Height: screen.PhysicalBounds.Height, - }, - }) - } - return result, nil -} diff --git a/v3/internal/migrate/v2compat/runtime/window.go b/v3/internal/migrate/v2compat/runtime/window.go deleted file mode 100644 index 4111b694f42..00000000000 --- a/v3/internal/migrate/v2compat/runtime/window.go +++ /dev/null @@ -1,265 +0,0 @@ -package runtime - -import ( - "context" - "sync" - - "github.com/wailsapp/wails/v3/pkg/application" -) - -// themeWarnOnce guards the one-time warning emitted by the theme functions. -var themeWarnOnce sync.Once - -// themeWarn logs a warning the first time any of the theme functions is called. -func themeWarn() { - themeWarnOnce.Do(func() { - logger().Warn("v2compat: WindowSetSystemDefaultTheme/WindowSetLightTheme/WindowSetDarkTheme are no-ops in v3; configure the theme at window creation via application.WebviewWindowOptions") - }) -} - -// WindowSetTitle mirrors the v2 runtime.WindowSetTitle function. -// v3 equivalent: window.SetTitle. -func WindowSetTitle(_ context.Context, title string) { - if w := currentWindow(); w != nil { - w.SetTitle(title) - } -} - -// WindowFullscreen mirrors the v2 runtime.WindowFullscreen function. -// v3 equivalent: window.Fullscreen. -func WindowFullscreen(_ context.Context) { - if w := currentWindow(); w != nil { - w.Fullscreen() - } -} - -// WindowUnfullscreen mirrors the v2 runtime.WindowUnfullscreen function. -// v3 equivalent: window.UnFullscreen. -func WindowUnfullscreen(_ context.Context) { - if w := currentWindow(); w != nil { - w.UnFullscreen() - } -} - -// WindowCenter mirrors the v2 runtime.WindowCenter function. -// v3 equivalent: window.Center. -func WindowCenter(_ context.Context) { - if w := currentWindow(); w != nil { - w.Center() - } -} - -// WindowReload mirrors the v2 runtime.WindowReload function. -// v3 equivalent: window.Reload. -func WindowReload(_ context.Context) { - if w := currentWindow(); w != nil { - w.Reload() - } -} - -// WindowReloadApp mirrors the v2 runtime.WindowReloadApp function. -// v3 equivalent: window.ForceReload. -func WindowReloadApp(_ context.Context) { - if w := currentWindow(); w != nil { - w.ForceReload() - } -} - -// WindowSetSystemDefaultTheme mirrors the v2 runtime.WindowSetSystemDefaultTheme function. -// v3 has no runtime theme setter: the theme is configured at window creation -// via application.WebviewWindowOptions. This is a no-op that logs a warning once. -func WindowSetSystemDefaultTheme(_ context.Context) { - themeWarn() -} - -// WindowSetLightTheme mirrors the v2 runtime.WindowSetLightTheme function. -// v3 has no runtime theme setter: the theme is configured at window creation -// via application.WebviewWindowOptions. This is a no-op that logs a warning once. -func WindowSetLightTheme(_ context.Context) { - themeWarn() -} - -// WindowSetDarkTheme mirrors the v2 runtime.WindowSetDarkTheme function. -// v3 has no runtime theme setter: the theme is configured at window creation -// via application.WebviewWindowOptions. This is a no-op that logs a warning once. -func WindowSetDarkTheme(_ context.Context) { - themeWarn() -} - -// WindowShow mirrors the v2 runtime.WindowShow function. -// v3 equivalent: window.Show. -func WindowShow(_ context.Context) { - if w := currentWindow(); w != nil { - w.Show() - } -} - -// WindowHide mirrors the v2 runtime.WindowHide function. -// v3 equivalent: window.Hide. -func WindowHide(_ context.Context) { - if w := currentWindow(); w != nil { - w.Hide() - } -} - -// WindowSetSize mirrors the v2 runtime.WindowSetSize function. -// v3 equivalent: window.SetSize. -func WindowSetSize(_ context.Context, width int, height int) { - if w := currentWindow(); w != nil { - w.SetSize(width, height) - } -} - -// WindowGetSize mirrors the v2 runtime.WindowGetSize function. -// v3 equivalent: window.Size. -func WindowGetSize(_ context.Context) (int, int) { - if w := currentWindow(); w != nil { - return w.Size() - } - return 0, 0 -} - -// WindowSetMinSize mirrors the v2 runtime.WindowSetMinSize function. -// v3 equivalent: window.SetMinSize. -func WindowSetMinSize(_ context.Context, width int, height int) { - if w := currentWindow(); w != nil { - w.SetMinSize(width, height) - } -} - -// WindowSetMaxSize mirrors the v2 runtime.WindowSetMaxSize function. -// v3 equivalent: window.SetMaxSize. -func WindowSetMaxSize(_ context.Context, width int, height int) { - if w := currentWindow(); w != nil { - w.SetMaxSize(width, height) - } -} - -// WindowSetAlwaysOnTop mirrors the v2 runtime.WindowSetAlwaysOnTop function. -// v3 equivalent: window.SetAlwaysOnTop. -func WindowSetAlwaysOnTop(_ context.Context, b bool) { - if w := currentWindow(); w != nil { - w.SetAlwaysOnTop(b) - } -} - -// WindowSetPosition mirrors the v2 runtime.WindowSetPosition function. -// v3 equivalent: window.SetRelativePosition (v2 positions were relative to -// the window's current screen). -func WindowSetPosition(_ context.Context, x int, y int) { - if w := currentWindow(); w != nil { - w.SetRelativePosition(x, y) - } -} - -// WindowGetPosition mirrors the v2 runtime.WindowGetPosition function. -// v3 equivalent: window.RelativePosition. -func WindowGetPosition(_ context.Context) (int, int) { - if w := currentWindow(); w != nil { - return w.RelativePosition() - } - return 0, 0 -} - -// WindowMaximise mirrors the v2 runtime.WindowMaximise function. -// v3 equivalent: window.Maximise. -func WindowMaximise(_ context.Context) { - if w := currentWindow(); w != nil { - w.Maximise() - } -} - -// WindowToggleMaximise mirrors the v2 runtime.WindowToggleMaximise function. -// v3 equivalent: window.ToggleMaximise. -func WindowToggleMaximise(_ context.Context) { - if w := currentWindow(); w != nil { - w.ToggleMaximise() - } -} - -// WindowUnmaximise mirrors the v2 runtime.WindowUnmaximise function. -// v3 equivalent: window.UnMaximise. -func WindowUnmaximise(_ context.Context) { - if w := currentWindow(); w != nil { - w.UnMaximise() - } -} - -// WindowMinimise mirrors the v2 runtime.WindowMinimise function. -// v3 equivalent: window.Minimise. -func WindowMinimise(_ context.Context) { - if w := currentWindow(); w != nil { - w.Minimise() - } -} - -// WindowUnminimise mirrors the v2 runtime.WindowUnminimise function. -// v3 equivalent: window.UnMinimise. -func WindowUnminimise(_ context.Context) { - if w := currentWindow(); w != nil { - w.UnMinimise() - } -} - -// WindowIsFullscreen mirrors the v2 runtime.WindowIsFullscreen function. -// v3 equivalent: window.IsFullscreen. -func WindowIsFullscreen(_ context.Context) bool { - if w := currentWindow(); w != nil { - return w.IsFullscreen() - } - return false -} - -// WindowIsMaximised mirrors the v2 runtime.WindowIsMaximised function. -// v3 equivalent: window.IsMaximised. -func WindowIsMaximised(_ context.Context) bool { - if w := currentWindow(); w != nil { - return w.IsMaximised() - } - return false -} - -// WindowIsMinimised mirrors the v2 runtime.WindowIsMinimised function. -// v3 equivalent: window.IsMinimised. -func WindowIsMinimised(_ context.Context) bool { - if w := currentWindow(); w != nil { - return w.IsMinimised() - } - return false -} - -// WindowIsNormal mirrors the v2 runtime.WindowIsNormal function. -// v3 equivalent: !window.IsFullscreen() && !window.IsMaximised() && !window.IsMinimised(). -func WindowIsNormal(_ context.Context) bool { - w := currentWindow() - if w == nil { - return false - } - return !w.IsFullscreen() && !w.IsMaximised() && !w.IsMinimised() -} - -// WindowExecJS mirrors the v2 runtime.WindowExecJS function. -// v3 equivalent: window.ExecJS. -func WindowExecJS(_ context.Context, js string) { - if w := currentWindow(); w != nil { - w.ExecJS(js) - } -} - -// WindowSetBackgroundColour mirrors the v2 runtime.WindowSetBackgroundColour function. -// v3 equivalent: window.SetBackgroundColour(application.RGBA{...}). -func WindowSetBackgroundColour(_ context.Context, R, G, B, A uint8) { - if w := currentWindow(); w != nil { - w.SetBackgroundColour(application.RGBA{Red: R, Green: G, Blue: B, Alpha: A}) - } -} - -// WindowPrint mirrors the v2 runtime.WindowPrint function. -// v3 equivalent: window.Print. -func WindowPrint(_ context.Context) { - if w := currentWindow(); w != nil { - if err := w.Print(); err != nil { - logger().Warn("v2compat: WindowPrint failed", "error", err) - } - } -}