diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 383209da550..c6b0dac0ec7 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -70,6 +70,16 @@ This document tracks the implementation of WebKitGTK 6.0 (GTK4) support for Wail **Decision**: No changes needed - system tray is already GTK-agnostic. +### Decision 6: Go 1.25 Minimum for v3 Migration (2026-08-09) +**Context**: Wails v3 requires Go 1.25+, and the migration command generates a +new v3 module for the user's project. + +**Decision**: `wails3 migrate` raises generated modules to `go 1.25` when the +source project declares an older version. + +**Rationale**: Migrated projects must declare the same minimum toolchain as the +v3 project and its released dependencies so the generated module is buildable. + ## Implementation Progress ### Phase 1: Build Infrastructure ✅ COMPLETE diff --git a/docs/src/content/docs/blog/2026-08-02-wails-v3-beta.md b/docs/src/content/docs/blog/2026-08-02-wails-v3-beta.md index 5e0685eca6e..aa56aa7ff7d 100644 --- a/docs/src/content/docs/blog/2026-08-02-wails-v3-beta.md +++ b/docs/src/content/docs/blog/2026-08-02-wails-v3-beta.md @@ -134,9 +134,11 @@ expect every v2 project to convert without review: test the result, port your runtime calls deliberately, and keep v2 in place until the new application is ready. -We are also evaluating an experimental migration assistant. It is not part of -this beta release, and we will only recommend it once it has been validated -against representative real-world v2 projects. +The beta CLI also includes an experimental migration assistant: +`wails3 migrate -d /path/to/v2-project -o /path/to/v3-project`. It creates a +separate V3 project and a `MIGRATION.md` checklist, but it does not promise a +zero-touch conversion. Review the generated project, port the listed V2 API +calls, and report reproducible problems in the issue tracker. ## A candid note on the journey diff --git a/docs/src/content/docs/guides/cli.mdx b/docs/src/content/docs/guides/cli.mdx index 4005f244e64..68cabb0ca27 100644 --- a/docs/src/content/docs/guides/cli.mdx +++ b/docs/src/content/docs/guides/cli.mdx @@ -53,6 +53,28 @@ When provided, this flag will: 3. Update the module name in `go.mod` to match the repository URL 4. Add all files +### `migrate` + +Migrates a Wails v2 project into a new Wails v3 project. This command is +experimental and always requires review of the generated project. + +```bash +wails3 migrate -d /path/to/v2-project -o /path/to/v3-project +``` + +The source project is left unchanged. The command maps deterministic project +metadata, build assets, frontend setup, and configuration, then writes a +`MIGRATION.md` report to the output directory. It does not rewrite application +logic or remaining V2 runtime calls. Port the calls listed in the report, +then run `go mod tidy`, `wails3 doctor`, `wails3 generate bindings`, and +`wails3 dev`. + +Use `wails3 migrate --help` for all flags. Report reproducible migration +failures in the [issue tracker](https://github.com/wailsapp/wails/issues) with +the command, CLI version, operating system, `wails3 doctor` output, and the +generated `MIGRATION.md`. Proposals to change migration behavior belong in a +[Wails Enhancement Proposal](https://github.com/wailsapp/wails/tree/master/v3/wep). + ### `dev` Runs the application in development mode. This will give you a live view of your frontend code, and you can make changes and see them reflected in the running application without having to rebuild the entire application. Changes to your Go code will also be detected and the application diff --git a/docs/src/content/docs/migration/v2-to-v3.mdx b/docs/src/content/docs/migration/v2-to-v3.mdx index 4591e9650f3..2859e11f3ce 100644 --- a/docs/src/content/docs/migration/v2-to-v3.mdx +++ b/docs/src/content/docs/migration/v2-to-v3.mdx @@ -18,6 +18,40 @@ Wails v3 is a **complete rewrite** with significant improvements in architecture **Migration time:** 1-4 hours for typical applications +## Automated Migration + +:::caution[Experimental] +The `wails3 migrate` command is included in the V3 CLI, but it 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 +wails3 migrate -d ./myv2project -o ./myv3project +``` + +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, 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`): + +- 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 in depth - use it together with the generated checklist. + ## Breaking Changes ### Application Initialisation @@ -480,21 +514,24 @@ Events.Emit("action", data) } ``` -**v3 (wails.json):** +**v3 (`build/config.yml`):** -```json -{ - "name": "myapp", - "frontend": { - "dir": "./frontend", - "install": "npm install", - "build": "npm run build", - "dev": "npm run dev", - "devServerUrl": "http://localhost:5173" - } -} +```yaml +version: '3' + +info: + productName: "myapp" + productIdentifier: "com.example.myapp" + +dev_mode: + root_path: . ``` +V3 project-level development and build commands are defined in the root +`Taskfile.yml`. The generated project keeps frontend commands there and uses +`build/config.yml` for application and packaging configuration. Do not create +a V3 `wails.json` by copying the V2 file. + ## Feature Mapping ### dialogs @@ -673,6 +710,28 @@ wails3 generate bindings ## Getting Help +### Reporting migration problems + +Migration tool failures are bugs. Before opening an issue, reduce the problem +to the smallest reproducible V2 project you can and include: + +- the Wails V2 version and the V3 CLI version or commit; +- the operating system and architecture; +- the exact `wails3 migrate` command; +- the command output and generated `MIGRATION.md`; +- `wails3 doctor` output; and +- a redacted project or minimal reproduction, if the project contains private + code. + +Open the report in the [Wails issue tracker](https://github.com/wailsapp/wails/issues) +using the bug report template. Do not attach secrets, signing credentials, or +private source code. + +If you want to propose a change to the migration behavior or V3 API, submit a +[Wails Enhancement Proposal](https://github.com/wailsapp/wails/tree/master/wep) +as a pull request. Product proposals should not be filed as ordinary feature +requests. + ### Resources - [Documentation](/quick-start/why-wails) diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 62cd453bd40..bdda4636f02 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -21,6 +21,7 @@ wails3 --help | Command | Description | |---------|-------------| | `wails3 init` | Create a new project from a template. Flags: `-n` (project name), `-t` (template, default `vanilla`), `-p` (Go package name, default `main`), `-d` (project directory, default `.`), `-q` (quiet), `-l` (list templates), `-mod` (Go module path), `--git` (Git repository URL), `--skipgomodtidy`, `-s` (skip remote-template warning), `--productname`/`--productdescription`/`--productversion`/`--productcompany`/`--productcopyright`/`--productcomments`/`--productidentifier`. | +| `wails3 migrate` | Migrate a V2 project into a separate V3 output directory. Experimental: use `-d` for the V2 project, `-o` for the new output directory, `-f` to allow a non-empty output directory, `-q` for quiet output, and `-skipgomodtidy` to skip dependency cleanup. Review the generated `MIGRATION.md`; application logic and remaining V2 API calls require manual porting. | | `wails3 dev` | Run the application in development mode with frontend hot reload. Flags: `--config` (default `./build/config.yml`), `--port` (Vite dev port), `-s` (enable HTTPS). | | `wails3 build` | Build the project. Thin wrapper around the Taskfile `build` task. Flags: `--tags` (forwarded as `EXTRA_TAGS=`), `--obfuscated` (build with Garble; see [Obfuscated Builds](/guides/build/obfuscation)), `--garbleargs` (extra flags forwarded to `garble` before the `build` subcommand). | | `wails3 package` | Run the platform-specific `package` Taskfile task. | diff --git a/v3/cmd/wails3/main.go b/v3/cmd/wails3/main.go index 4dc8a329e74..8b427586d17 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 (experimental)", 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..977ae47c2d8 --- /dev/null +++ b/v3/internal/commands/migrate.go @@ -0,0 +1,406 @@ +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" + "gopkg.in/yaml.v3" +) + +// Migrate converts a Wails v2 project into a Wails v3 project: +// +// wails3 migrate -d ./myv2project -o ./myv3project +// +// 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 + + if options.Quiet { + 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") + } + + 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 isSubPath(outDir, proj.Dir) { + return fmt.Errorf("the output directory must not contain 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 the source tree without v2-generated wailsjs output and 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 + } + + // 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 + 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("Work remains - see %s\n", reportPath) + } else { + term.Infof("See %s for the migration summary.\n", reportPath) + } + 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 +} + +// 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*$`) + +// yamlString renders a value as a valid YAML scalar, including values that +// contain YAML punctuation or would otherwise be interpreted as a type. +func yamlString(value string) string { + data, err := yaml.Marshal(value) + if err != nil { + return fmt.Sprintf("%q", value) + } + return strings.TrimSpace(string(data)) +} + +// 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", yamlString(fa.Ext))) + sb.WriteString(fmt.Sprintf(" name: %s\n", yamlString(fa.Name))) + if fa.Description != "" { + sb.WriteString(fmt.Sprintf(" description: %s\n", yamlString(fa.Description))) + } + sb.WriteString(fmt.Sprintf(" iconName: %s\n", yamlString(fa.IconName))) + sb.WriteString(fmt.Sprintf(" role: %s\n", yamlString(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", yamlString(p.Scheme))) + if p.Description != "" { + sb.WriteString(fmt.Sprintf(" description: %s\n", yamlString(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..4044739dc63 --- /dev/null +++ b/v3/internal/commands/migrate_test.go @@ -0,0 +1,311 @@ +package commands + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wailsapp/wails/v3/internal/flags" + "github.com/wailsapp/wails/v3/internal/migrate" + "gopkg.in/yaml.v3" +) + +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" + + "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) Greet(name string) string { + runtime.EventsEmit(a.ctx, "greeted", name) + 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/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", + "frontend/wailsjs", + "v2compat", + } + 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) + } + + // 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) { + 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) + } + + err = Migrate(&flags.Migrate{ + V2Dir: v2Dir, + OutputDir: filepath.Dir(v2Dir), + Force: true, + Quiet: true, + SkipGoModTidy: true, + }) + if err == nil || !strings.Contains(err.Error(), "must not contain") { + t.Fatalf("expected containing-output error, got %v", err) + } +} + +func TestAppendAssociationsEscapesYAML(t *testing.T) { + outDir := t.TempDir() + buildDir := filepath.Join(outDir, "build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(buildDir, "config.yml") + if err := os.WriteFile(configPath, []byte("fileAssociations:\n"), 0o644); err != nil { + t.Fatal(err) + } + proj := &V2ProjectAlias{Config: migrate.V2Config{}, Report: migrate.NewReport()} + proj.Config.Info.FileAssociations = []migrate.V2FileAssociation{{ + Ext: `a:b#c`, + Name: `- app`, + Description: `say "hello"`, + IconName: `icon`, + Role: `Editor`, + }} + proj.Config.Info.Protocols = []migrate.V2Protocol{{Scheme: `my:scheme#1`}} + if err := appendAssociations(proj, outDir); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("generated config.yml is invalid: %v\n%s", err, data) + } + protocols, ok := config["protocols"].([]any) + if !ok || len(protocols) != 1 { + t.Fatalf("protocols were not emitted: %#v", config["protocols"]) + } + protocol, ok := protocols[0].(map[string]any) + if !ok || protocol["scheme"] != "my:scheme#1" { + t.Fatalf("protocol scalar changed during YAML round-trip: %#v", protocols[0]) + } +} + +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/advisor.go b/v3/internal/migrate/advisor.go new file mode 100644 index 00000000000..1b6b8a81ee4 --- /dev/null +++ b/v3/internal/migrate/advisor.go @@ -0,0 +1,248 @@ +package migrate + +import ( + "bufio" + "fmt" + "go/ast" + "go/token" + "os" + "path/filepath" + "regexp" + "strconv" + "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 _, imp := range file.Imports { + ipath, err := strconv.Unquote(imp.Path.Value) + if err != nil || ipath != V2RuntimeImport { + continue + } + if imp.Name != nil { + localName = imp.Name.Name + } else { + localName = defaultImportName(ipath) + } + } + if localName == "" { + continue + } + rel, err := filepath.Rel(proj.Dir, path) + if err != nil { + rel = path + } + ast.Inspect(file, func(n ast.Node) bool { + if localName == "." { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + ident, ok := call.Fun.(*ast.Ident) + if !ok { + return true + } + advice, ok := goRuntimeAdvice[ident.Name] + if !ok { + advice, ok = windowRuntimeAdvice[ident.Name] + if ok { + advice += " - get the window with `app.Window.Current()` or keep a reference to the one you create" + } + } + if !ok { + advice = "see the v3 application API and https://v3.wails.io/migration/v2-to-v3/" + } + pos := fset.Position(ident.Pos()) + proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, pos.Line), "`runtime."+ident.Name+"`", advice) + return true + } + 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*|import\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 + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + lineNo++ + matches := wailsjsImportRe.FindAllStringSubmatch(scanner.Text(), -1) + for _, m := range matches { + 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/copy.go b/v3/internal/migrate/copy.go new file mode 100644 index 00000000000..6768b94618f --- /dev/null +++ b/v3/internal/migrate/copy.go @@ -0,0 +1,102 @@ +package migrate + +import ( + "io" + "os" + "path/filepath" +) + +// 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) + return copyFile(path, target, info.Mode().Perm()) + }) +} diff --git a/v3/internal/migrate/frontend.go b/v3/internal/migrate/frontend.go new file mode 100644 index 00000000000..12fc6ad238d --- /dev/null +++ b/v3/internal/migrate/frontend.go @@ -0,0 +1,114 @@ +package migrate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// 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") + + 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 + } + + 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)): + 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 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 { + return err + } + + return nil +} + +// addRuntimeDependency inserts "@wailsio/runtime" into the frontend +// package.json dependencies as structured JSON so empty objects and files +// remain valid JSON. +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 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 + } + var packageJSON map[string]any + if err := json.Unmarshal(data, &packageJSON); err != nil { + return fmt.Errorf("could not parse frontend/package.json: %w", err) + } + + deps := map[string]any{} + if raw, ok := packageJSON["dependencies"]; ok { + var valid bool + deps, valid = raw.(map[string]any) + if !valid { + proj.Report.Manual("frontend/package.json", "The dependencies field is not a JSON object; add `@wailsio/runtime` manually and run your package manager's install.") + return nil + } + } + if _, ok := deps["@wailsio/runtime"]; ok { + return nil + } + deps["@wailsio/runtime"] = "latest" + packageJSON["dependencies"] = deps + updated, err := json.MarshalIndent(packageJSON, "", " ") + if err != nil { + return fmt.Errorf("could not encode frontend/package.json: %w", err) + } + updated = append(updated, '\n') + + if err := os.WriteFile(path, updated, 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..ac3a400aa39 --- /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.25" + +// 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..ae077d2b2ed --- /dev/null +++ b/v3/internal/migrate/maingen.go @@ -0,0 +1,331 @@ +package migrate + +import ( + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "sort" + "strconv" + "strings" +) + +// 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") + winVar := pickIdent(main.File, "mainWindow", "wailsWindow") + + block := buildV3Block(proj, opts, appVar, winVar) + + 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) { + written := make(map[string]bool, len(fields)) + for _, f := range fields { + if written[f.Name] { + continue + } + written[f.Name] = true + sb.WriteString(indent + f.Name + ": " + f.Expr + ",\n") + } +} + +// defaultImportName returns the conventional local name for an import path. +// Go module major-version suffixes are part of the module path, not normally +// part of the package name (for example, /foo/v2 is usually imported as foo). +func defaultImportName(path string) string { + name := path[strings.LastIndex(path, "/")+1:] + if len(name) > 1 && name[0] == 'v' { + if _, err := strconv.Atoi(name[1:]); err == nil { + parent := path[:strings.LastIndex(path, "/")] + name = parent[strings.LastIndex(parent, "/")+1:] + } + } + return name +} + +// buildV3Block renders the replacement for the wails.Run statement. +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") + + if len(opts.Services) > 0 { + sb.WriteString("\tServices: []application.Service{\n") + for _, svc := range opts.Services { + sb.WriteString("\t\tapplication.NewService(" + svc + "),\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") + } + + 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") + + 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") + 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") + + 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 { + case main.RunIf != nil: + sb.WriteString("if " + main.ErrIdent + " := " + appVar + ".Run(); " + main.ErrIdent + " != nil ") + sb.WriteString(exprText(main.Fset, main.Source, main.RunIf.Body)) + if main.RunIf.Else != nil { + sb.WriteString(" else ") + sb.WriteString(exprText(main.Fset, main.Source, main.RunIf.Else)) + } + 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.OnStartup != "" || opts.OnDomReady != "" { + add("", "github.com/wailsapp/wails/v3/pkg/events") + } + if opts.NeedsLifecycleService() || 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 = defaultImportName(path) + } + 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..e0862b8f4fd --- /dev/null +++ b/v3/internal/migrate/mapping.go @@ -0,0 +1,698 @@ +package migrate + +import ( + "fmt" + "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) } + +// setField keeps generated struct literals valid when multiple v2 options +// map to the same v3 field. The later option wins, matching the source order. +func (m *mapper) setField(dst *[]GenField, field GenField) { + for i, existing := range *dst { + if existing.Name != field.Name { + continue + } + if existing.Expr != field.Expr { + m.note(fmt.Sprintf("The v3 field `%s` was set more than once; the later value replaced the earlier one.", field.Name)) + } + (*dst)[i] = field + return + } + *dst = append(*dst, field) +} + +// 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 if ident, ok := value.(*ast.Ident); !ok || ident.Name != "false" { + 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. + m.setField(&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": + m.setField(&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", "wired via events.Common.ApplicationStarted (consider a v3 ServiceStartup)") + case "OnDomReady": + out.OnDomReady = m.src(value) + 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", "application.Options.OnShutdown") + 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.setField(&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.setField(&m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTransparent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "WindowIsTranslucent": + if isTrue(v) { + m.setField(&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.setField(&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..b3f542fb94b --- /dev/null +++ b/v3/internal/migrate/migrate.go @@ -0,0 +1,106 @@ +// 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" +) + +// 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. +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 + + // UsesV2Runtime is true when any project file imports the v2 runtime + // 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 + // 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 + // RunIf is set when RunStmt is an if statement of the form + // `if err := wails.Run(...); err != nil { ... }`. + RunIf *ast.IfStmt + // 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..0440de8b0aa --- /dev/null +++ b/v3/internal/migrate/migrate_test.go @@ -0,0 +1,578 @@ +package migrate + +import ( + "bytes" + "encoding/json" + "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/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", + } + 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)", + "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()", + `"github.com/wailsapp/wails/v3/pkg/events"`, + } { + 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 TestGenerateMainIfRunForm(t *testing.T) { + dir := writeFixture(t) + main := `package main + +import ( + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" +) + +func main() { + if err := wails.Run(&options.App{Title: "if form"}); err != nil { + println("Error:", err.Error()) + } +} +` + 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) + } + if proj.Main.RunIf == nil { + t.Fatal("expected if statement run context") + } + out, err := GenerateMain(proj, MapOptions(proj)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "if err := app.Run(); err != nil") { + t.Fatalf("error handling was not preserved:\n%s", out) + } + if _, err := parser.ParseFile(token.NewFileSet(), "main.go", out, parser.SkipObjectResolution); err != nil { + t.Fatalf("generated main.go does not parse: %v\n%s", err, out) + } +} + +func TestParseRejectsMultipleRunCalls(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: "one"}) + wails.Run(&options.App{Title: "two"}) +} +` + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(main), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ParseV2Project(dir); err == nil || !strings.Contains(err.Error(), "more than one") { + t.Fatalf("expected multiple-run error, got %v", err) + } +} + +func TestParseHonoursBuildConstraintsAndTestdata(t *testing.T) { + dir := writeFixture(t) + if err := os.WriteFile(filepath.Join(dir, "disabled_windows.go"), []byte("//go:build windows\n\npackage main\n\nimport \"github.com/wailsapp/wails/v2\"\n\nfunc disabled() { wails.Run(nil) }\n"), 0o644); err != nil { + + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "testdata"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "testdata", "invalid.go"), []byte("not Go"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ParseV2Project(dir); err != nil { + t.Fatalf("build constraints should exclude disabled files: %v", err) + } +} + +func TestResolveBindUsesLexicalAndPackageScope(t *testing.T) { + dir := writeFixture(t) + mainPath := filepath.Join(dir, "main.go") + mainSrc, err := os.ReadFile(mainPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app.go"), []byte(fixtureAppGo+"\n\ntype PackageApp struct{}\nfunc (a *PackageApp) Wrong() {}\n\nvar app = &PackageApp{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "other"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "other", "other.go"), []byte("package other\n\ntype App struct{}\nfunc (a *App) Wrong() {}\nfunc NewApp() *App { return &App{} }\n"), 0o644); err != nil { + t.Fatal(err) + } + + proj, err := ParseV2Project(dir) + if err != nil { + t.Fatal(err) + } + if len(proj.BoundTypes) != 1 || proj.BoundTypes[0].Name != "App" { + t.Fatalf("unexpected bound types: %+v", proj.BoundTypes) + } + for _, method := range proj.BoundTypes[0].Methods { + if method.Name == "Wrong" { + t.Fatal("method from duplicate PackageApp receiver was included") + } + } + if err := os.WriteFile(mainPath, bytes.Replace(mainSrc, []byte("app := NewApp()"), nil, 1), 0o644); err != nil { + t.Fatal(err) + } + proj, err = ParseV2Project(dir) + if err != nil { + t.Fatal(err) + } + if len(proj.BoundTypes) != 1 || proj.BoundTypes[0].Name != "PackageApp" { + t.Fatalf("package-level Bind declaration was not resolved: %+v", proj.BoundTypes) + } +} + +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.25") { + t.Errorf("go directive not raised:\n%s", src) + } +} + +func TestAdvisor(t *testing.T) { + proj := parseFixture(t) + if !proj.UsesV2Runtime { + t.Fatal("expected UsesV2Runtime") + } + 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) + } + } + // 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) + } + } +} + +func TestMigrateFrontend(t *testing.T) { + proj := parseFixture(t) + outDir := t.TempDir() + if err := MigrateFrontend(proj, outDir); err != nil { + t.Fatal(err) + } + + // 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") + } + + // 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(mainJS), "../wailsjs/go/main/App") { + t.Errorf("user source was modified:\n%s", mainJS) + } + + 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) + } +} + +func TestAddRuntimeDependencyUsesStructuredJSON(t *testing.T) { + for _, input := range []string{ + `{}`, + `{"dependencies": {}}`, + `{"scripts": {"note": "@wailsio/runtime"}}`, + } { + t.Run(input, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "package.json") + if err := os.WriteFile(path, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + proj := &V2Project{Report: NewReport()} + if err := addRuntimeDependency(proj, dir); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var packageJSON map[string]any + if err := json.Unmarshal(data, &packageJSON); err != nil { + t.Fatalf("invalid package.json: %v\n%s", err, data) + } + deps, ok := packageJSON["dependencies"].(map[string]any) + if !ok || deps["@wailsio/runtime"] != "latest" { + t.Fatalf("runtime dependency missing: %#v", packageJSON["dependencies"]) + } + }) + } +} diff --git a/v3/internal/migrate/parse.go b/v3/internal/migrate/parse.go new file mode 100644 index 00000000000..aec9af9e882 --- /dev/null +++ b/v3/internal/migrate/parse.go @@ -0,0 +1,742 @@ +package migrate + +import ( + "fmt" + "go/ast" + "go/build" + "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() + buildContext := build.Default + 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" || name == "testdata") { + 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 + } + matched, merr := buildContext.MatchFile(filepath.Dir(path), d.Name()) + if merr != nil { + return fmt.Errorf("could not evaluate build constraints for %s: %w", path, merr) + } + if !matched { + 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) + } + for _, imp := range file.Imports { + if imp.Path.Value == strconv.Quote(V2RuntimeImport) { + proj.UsesV2Runtime = true + } + } + 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 { + candidates, ferr := findRunCalls(fset, path, file) + if ferr != nil { + return nil, ferr + } + for _, info := range candidates { + 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) + } + } + sort.Strings(proj.GoFiles) + + // 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) + } + } + + // 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 +} + +func requiresWailsV2(mod *modfile.File) bool { + for _, req := range mod.Require { + if req.Mod.Path == "github.com/wailsapp/wails/v2" { + return true + } + } + return false +} + +// findRunCalls returns every rewriteable wails.Run call in a file. Calls +// must be direct expression statements, assignments, or the supported +// `if err := wails.Run(...); err != nil { ... }` form so GenerateMain can +// replace a complete statement without leaving invalid Go behind. +func findRunCalls(fset *token.FileSet, path string, file *ast.File) ([]*MainInfo, error) { + imports := importMap(file) + wailsName := "" + for name, ipath := range imports { + if ipath == "github.com/wailsapp/wails/v2" { + wailsName = name + } + } + if wailsName == "" { + return nil, nil + } + + 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 + } + + isDirectAssignment := func(stmt *ast.AssignStmt, call *ast.CallExpr) bool { + return len(stmt.Lhs) == 1 && len(stmt.Rhs) == 1 && stmt.Rhs[0] == call + } + isErrorCondition := func(ifStmt *ast.IfStmt, errName string) bool { + cond, ok := ifStmt.Cond.(*ast.BinaryExpr) + if !ok || cond.Op != token.NEQ { + return false + } + matches := func(left, right ast.Expr) bool { + ident, lok := left.(*ast.Ident) + nilIdent, rok := right.(*ast.Ident) + return lok && rok && ident.Name == errName && nilIdent.Name == "nil" + } + return matches(cond.X, cond.Y) || matches(cond.Y, cond.X) + } + + var candidates []*MainInfo + var stack []ast.Node + var walkErr error + ast.Inspect(file, func(n ast.Node) bool { + if walkErr != nil { + return false + } + if n == nil { + stack = stack[:len(stack)-1] + return false + } + call := isRunCall(n) + if call != nil { + var runStmt ast.Stmt + var runIf *ast.IfStmt + errIdent := "" + assignTok := token.ILLEGAL + + for i := len(stack) - 1; i >= 0; i-- { + switch parent := stack[i].(type) { + case *ast.IfStmt: + assign, ok := parent.Init.(*ast.AssignStmt) + if !ok || !isDirectAssignment(assign, call) { + continue + } + ident, ok := assign.Lhs[0].(*ast.Ident) + if !ok || !isErrorCondition(parent, ident.Name) { + walkErr = fmt.Errorf("unsupported wails.Run context at %s:%d; expected `if err := wails.Run(...); err != nil`", path, fset.Position(call.Pos()).Line) + return false + } + runStmt = parent + runIf = parent + errIdent = ident.Name + assignTok = assign.Tok + case *ast.AssignStmt: + if i > 0 { + if parentIf, ok := stack[i-1].(*ast.IfStmt); ok && parentIf.Init == parent { + continue + } + } + if !isDirectAssignment(parent, call) { + continue + } + ident, ok := parent.Lhs[0].(*ast.Ident) + if !ok { + walkErr = fmt.Errorf("unsupported wails.Run assignment at %s:%d", path, fset.Position(call.Pos()).Line) + return false + } + runStmt = parent + errIdent = ident.Name + assignTok = parent.Tok + case *ast.ExprStmt: + if parent.X != call { + continue + } + runStmt = parent + } + if runStmt != nil { + break + } + } + + if runStmt == nil { + walkErr = fmt.Errorf("unsupported wails.Run context at %s:%d; use a direct call, assignment, or `if err := wails.Run(...); err != nil`", path, fset.Position(call.Pos()).Line) + return false + } + info := &MainInfo{ + Path: path, File: file, Fset: fset, Imports: imports, + RunStmt: runStmt, RunIf: runIf, RunCall: call, + ErrIdent: errIdent, AssignTok: assignTok, + } + if len(call.Args) == 1 { + arg := call.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 + } + } + candidates = append(candidates, info) + } + stack = append(stack, n) + return true + }) + if walkErr != nil { + return nil, walkErr + } + return candidates, nil +} + +// findRunCall is kept as a small compatibility wrapper for package-local +// callers that only need the first candidate. +func findRunCall(fset *token.FileSet, path string, file *ast.File) *MainInfo { + candidates, err := findRunCalls(fset, path, file) + if err != nil || len(candidates) == 0 { + return nil + } + return candidates[0] +} + +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 { + name = defaultImportName(path) + } + 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 + } + + var result []*BoundType + for _, elt := range lit.Elts { + expr := printExpr(fset, elt) + resolved := resolveElementInfo(fset, files, proj, proj.Main, elt) + bt := &BoundType{Expr: expr, Name: resolved.Name, PkgName: resolved.PkgName} + if resolved.Name == "" { + 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 + } + bt.PkgPath = resolved.PkgPath + bt.Methods = collectMethods(fset, files, proj, resolved.PkgDir, resolved.PkgName, resolved.Name) + if len(bt.Methods) == 0 { + proj.Report.Note("Bind: " + expr + " (" + resolved.Name + ") has no exported methods that could be discovered; no frontend/wailsjs shim was generated for it.") + } + result = append(result, bt) + } + return result +} + +type resolvedElement struct { + Name string + PkgName string + PkgDir string + PkgPath string +} + +func resolveElementInfo(fset *token.FileSet, files map[string]*ast.File, proj *V2Project, main *MainInfo, expr ast.Expr) resolvedElement { + return resolveElementInfoAt(fset, files, proj, main.File, main.RunCall.Pos(), expr, map[string]bool{}) +} + +func resolveElementInfoAt(fset *token.FileSet, files map[string]*ast.File, proj *V2Project, mainFile *ast.File, target token.Pos, expr ast.Expr, seen map[string]bool) resolvedElement { + packageName := mainFile.Name.Name + packageDir := filepath.Dir(proj.Main.Path) + pkgPath := packagePath(proj, proj.Main.Path, packageName) + fromPackage := resolvedElement{PkgName: packageName, PkgDir: packageDir, PkgPath: pkgPath} + imports := importMap(mainFile) + + resolveType := func(name string) resolvedElement { + return resolvedElement{Name: name, PkgName: packageName, PkgDir: packageDir, PkgPath: pkgPath} + } + 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 resolveType(ident.Name) + } + } + } + case *ast.CompositeLit: + if ident, ok := e.Type.(*ast.Ident); ok { + return resolveType(ident.Name) + } + case *ast.CallExpr: + switch fun := e.Fun.(type) { + case *ast.Ident: + return constructorReturnTypeInPackage(files, packageDir, packageName, fun.Name, resolveType) + case *ast.SelectorExpr: + if pkgIdent, ok := fun.X.(*ast.Ident); ok { + if importedPath, ok := imports[pkgIdent.Name]; ok { + for path, file := range files { + if packagePath(proj, path, file.Name.Name) != importedPath { + continue + } + return constructorReturnTypeInPackage(files, filepath.Dir(path), file.Name.Name, fun.Sel.Name, func(name string) resolvedElement { + return resolvedElement{Name: name, PkgName: file.Name.Name, PkgDir: filepath.Dir(path), PkgPath: importedPath} + }) + } + } + } + } + case *ast.Ident: + if seen[e.Name] { + return resolvedElement{} + } + seen[e.Name] = true + declaration := findLexicalValue(mainFile, e.Name, target) + if declaration == nil { + declaration = findPackageValue(files, packageDir, packageName, e.Name) + } + if declaration != nil { + return resolveElementInfoAt(fset, files, proj, mainFile, target, declaration, seen) + } + return resolvedElement{} + } + return fromPackage +} + +func constructorReturnTypeInPackage(files map[string]*ast.File, packageDir, packageName, name string, makeType func(string) resolvedElement) resolvedElement { + paths := sortedFilePaths(files) + for _, path := range paths { + file := files[path] + if filepath.Dir(path) != packageDir || file.Name.Name != packageName { + continue + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || fn.Name.Name != name || fn.Type.Results == nil || len(fn.Type.Results.List) == 0 { + continue + } + if resultName := typeName(fn.Type.Results.List[0].Type); resultName != "" { + return makeType(resultName) + } + } + } + return resolvedElement{} +} + +func typeName(expr ast.Expr) string { + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + switch value := expr.(type) { + case *ast.Ident: + return value.Name + case *ast.SelectorExpr: + return value.Sel.Name + } + return "" +} + +func sortedFilePaths(files map[string]*ast.File) []string { + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func findLexicalValue(file *ast.File, name string, target token.Pos) ast.Expr { + var blocks []*ast.BlockStmt + ast.Inspect(file, func(node ast.Node) bool { + if block, ok := node.(*ast.BlockStmt); ok && block.Pos() <= target && target <= block.End() { + blocks = append(blocks, block) + } + return true + }) + for i := len(blocks) - 1; i >= 0; i-- { + if value := findValueInBlock(blocks[i], name, target); value != nil { + return value + } + } + if fn := enclosingFunc(file, target); fn != nil { + for _, field := range fn.Type.Params.List { + for _, param := range field.Names { + if param.Name == name { + return nil + } + } + } + } + return nil +} + +func enclosingFunc(file *ast.File, target token.Pos) *ast.FuncDecl { + var result *ast.FuncDecl + ast.Inspect(file, func(node ast.Node) bool { + fn, ok := node.(*ast.FuncDecl) + if ok && fn.Body.Pos() <= target && target <= fn.Body.End() { + result = fn + } + return true + }) + return result +} + +func findValueInBlock(block *ast.BlockStmt, name string, target token.Pos) ast.Expr { + var result ast.Expr + for _, stmt := range block.List { + if stmt.Pos() >= target { + break + } + switch statement := stmt.(type) { + case *ast.DeclStmt: + if decl, ok := statement.Decl.(*ast.GenDecl); ok && decl.Tok == token.VAR { + for _, spec := range decl.Specs { + valueSpec := spec.(*ast.ValueSpec) + if value := valueForName(valueSpec, name); value != nil { + result = value + } + } + } + case *ast.AssignStmt: + if statement.Tok != token.DEFINE { + continue + } + for i, lhs := range statement.Lhs { + ident, ok := lhs.(*ast.Ident) + if ok && ident.Name == name && i < len(statement.Rhs) { + result = statement.Rhs[i] + } else if ok && ident.Name == name && len(statement.Rhs) == 1 { + result = statement.Rhs[0] + } + } + } + } + return result +} + +func findPackageValue(files map[string]*ast.File, packageDir, packageName, name string) ast.Expr { + for _, path := range sortedFilePaths(files) { + file := files[path] + if filepath.Dir(path) != packageDir || file.Name.Name != packageName { + continue + } + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.VAR { + continue + } + for _, spec := range gen.Specs { + if value := valueForName(spec.(*ast.ValueSpec), name); value != nil { + return value + } + } + } + } + return nil +} + +func valueForName(spec *ast.ValueSpec, name string) ast.Expr { + for i, ident := range spec.Names { + if ident.Name != name { + continue + } + if i < len(spec.Values) { + return spec.Values[i] + } + if len(spec.Values) == 1 { + return spec.Values[0] + } + } + return nil +} + +// 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 { + return resolveElementInfoAt(fset, files, &V2Project{Main: &MainInfo{Path: "main.go"}}, mainFile, expr.Pos(), expr, map[string]bool{}).Name +} + +// constructorReturnType finds `func Name(...) *T` in the parsed files and +// returns T. +func constructorReturnType(files map[string]*ast.File, name string) string { + for _, path := range sortedFilePaths(files) { + file := files[path] + 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, packageDir, packageName, typeName string) (methods []*BoundMethod) { + for _, path := range sortedFilePaths(files) { + file := files[path] + if filepath.Dir(path) != packageDir || file.Name.Name != packageName { + continue + } + 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 + } + methods = append(methods, &BoundMethod{ + Name: fn.Name.Name, + Params: fieldListParams(fset, fn.Type.Params), + Results: fieldListParams(fset, fn.Type.Results), + }) + } + } + return 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..0d719dfcf6f --- /dev/null +++ b/v3/internal/migrate/report.go @@ -0,0 +1,127 @@ +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 + callSites []string // v2 API call sites the user must port +} + +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 +} + +// 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) +} + +// HasManualSteps reports whether any manual step was recorded. +func (r *Report) HasManualSteps() bool { + return len(r.manual) > 0 || len(r.callSites) > 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("> **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") + step := 1 + sb.WriteString(fmt.Sprintf("%d. Run `wails3 doctor` to check your environment.\n", step)) + if len(r.callSites) > 0 { + step++ + sb.WriteString(fmt.Sprintf("%d. Port the call sites listed below to the v3 API. The compiler will point at\n", step)) + 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") + } + step++ + sb.WriteString(fmt.Sprintf("%d. Run `go mod tidy`, then `wails3 generate bindings` for the frontend bindings.\n", step)) + step++ + sb.WriteString(fmt.Sprintf("%d. Run `wails3 dev` to build and run the migrated app.\n", step)) + step++ + sb.WriteString(fmt.Sprintf("%d. Work through the *Manual steps* below, if any.\n", step)) + 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") + callSites := append([]string(nil), r.callSites...) + sort.Strings(callSites) + for _, cs := range 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)) + 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" +}