diff --git a/docs/src/content/docs/migration/v2-to-v3.mdx b/docs/src/content/docs/migration/v2-to-v3.mdx index 4910df40f9a..8612d676ac3 100644 --- a/docs/src/content/docs/migration/v2-to-v3.mdx +++ b/docs/src/content/docs/migration/v2-to-v3.mdx @@ -18,6 +18,36 @@ Wails v3 is a **complete rewrite** with significant improvements in architecture **Migration time:** 1-4 hours for typical applications +## Automated Migration + +:::caution[Experimental] +The migrate command is experimental. It handles common project shapes well, but your mileage may vary: review the generated code and test your application thoroughly. If it stumbles on your project, please [open an issue](https://github.com/wailsapp/wails/issues) with the details so we can improve it. Pull requests are very welcome. +::: + +The CLI can perform most of this guide for you: + +```bash +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 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..f20bc78d4ab --- /dev/null +++ b/v3/internal/commands/migrate.go @@ -0,0 +1,390 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/wailsapp/wails/v3/internal/flags" + "github.com/wailsapp/wails/v3/internal/migrate" + "github.com/wailsapp/wails/v3/internal/templates" + "github.com/wailsapp/wails/v3/internal/term" + "github.com/wailsapp/wails/v3/internal/version" +) + +// Migrate converts a Wails v2 project into a Wails v3 project: +// +// wails3 migrate -d ./myv2project -o ./myv3project +// +// It 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 entries, err := os.ReadDir(outDir); err == nil && len(entries) > 0 && !options.Force { + return fmt.Errorf("output directory %s is not empty (use -f to write into it anyway)", outDir) + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + + cfg := proj.Config + term.Infof("Migrating %s (module %s)\n", cfg.Name, proj.ModulePath) + + // Map the declarative v2 options onto the v3 API and rewrite the main file. + v3opts := migrate.MapOptions(proj) + mainSrc, err := migrate.GenerateMain(proj, v3opts) + if err != nil { + return err + } + + // Scaffold the v3 project pieces (Taskfile, .gitignore) from the project + // template, then generate the build assets from the v2 project metadata. + if err := scaffoldV3(proj, outDir, options.Quiet); err != nil { + return err + } + + // Write the migrated Go sources. + mainRel, err := filepath.Rel(proj.Dir, proj.Main.Path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(filepath.Join(outDir, mainRel)), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, mainRel), mainSrc, 0o644); err != nil { + return err + } + if err := migrate.CopyProjectFiles(proj, outDir); err != nil { + return err + } + + // go.mod: swap wails/v2 for wails/v3, keep everything else. + // LatestStable is the released tag even in dev builds, so the generated + // require is always resolvable. + goMod, err := migrate.TransformGoMod(proj, version.LatestStable()) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, "go.mod"), goMod, 0o644); err != nil { + return err + } + + // Frontend: copy, regenerate wailsjs as a compatibility layer, add the + // @wailsio/runtime dependency. + if err := migrate.MigrateFrontend(proj, outDir); err != nil { + return err + } + + // Write the migration report. + reportPath := filepath.Join(outDir, "MIGRATION.md") + if err := os.WriteFile(reportPath, []byte(proj.Report.Markdown()), 0o644); err != nil { + return err + } + + // 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*$`) + +// appendAssociations writes the v2 file associations and protocols into the +// generated build/config.yml. +func appendAssociations(proj *V2ProjectAlias, outDir string) error { + configPath := filepath.Join(outDir, "build", "config.yml") + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + content := string(data) + + if fas := proj.Config.Info.FileAssociations; len(fas) > 0 { + var sb strings.Builder + sb.WriteString("fileAssociations:\n") + for _, fa := range fas { + sb.WriteString(fmt.Sprintf(" - ext: %s\n", fa.Ext)) + sb.WriteString(fmt.Sprintf(" name: %s\n", fa.Name)) + sb.WriteString(fmt.Sprintf(" description: %s\n", fa.Description)) + sb.WriteString(fmt.Sprintf(" iconName: %s\n", fa.IconName)) + sb.WriteString(fmt.Sprintf(" role: %s\n", fa.Role)) + } + if fileAssociationsRe.MatchString(content) { + content = fileAssociationsRe.ReplaceAllString(content, strings.TrimSuffix(sb.String(), "\n")) + } else { + content += "\n" + sb.String() + } + proj.Report.Note("File associations were moved to `build/config.yml`. Copy their icons into `build/` (macOS: `.icns`, Windows: `.ico`).") + } + + if protos := proj.Config.Info.Protocols; len(protos) > 0 { + var sb strings.Builder + sb.WriteString("\nprotocols:\n") + for _, p := range protos { + sb.WriteString(fmt.Sprintf(" - scheme: %s\n", p.Scheme)) + if p.Description != "" { + sb.WriteString(fmt.Sprintf(" description: %s\n", p.Description)) + } + } + content += sb.String() + proj.Report.Note("Custom protocol schemes were moved to `build/config.yml`.") + } + + return os.WriteFile(configPath, []byte(content), 0o644) +} diff --git a/v3/internal/commands/migrate_test.go b/v3/internal/commands/migrate_test.go new file mode 100644 index 00000000000..6512197e192 --- /dev/null +++ b/v3/internal/commands/migrate_test.go @@ -0,0 +1,258 @@ +package commands + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wailsapp/wails/v3/internal/flags" +) + +func writeV2Fixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "wails.json": `{ + "name": "demoapp", + "outputfilename": "demoapp", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "info": { "companyName": "Wails", "productName": "Demo App", "productVersion": "2.0.0" } +}`, + "go.mod": "module demoapp\n\ngo 1.23\n\nrequire github.com/wailsapp/wails/v2 v2.10.2\n", + "main.go": `package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "Demo App", + Width: 800, + Height: 600, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} +`, + "app.go": `package main + +import ( + "context" + + "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) + } +} + +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..f706456ece1 --- /dev/null +++ b/v3/internal/migrate/advisor.go @@ -0,0 +1,218 @@ +package migrate + +import ( + "bufio" + "fmt" + "go/ast" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +// The migrator deliberately does not rewrite runtime call sites and does not +// generate compatibility layers: half-migrated code helps nobody. Instead, +// every v2 runtime call and every wailsjs import is recorded here with its +// concrete v3 replacement, so the user gets a precise, project-specific +// checklist in MIGRATION.md and the compiler points at exactly the listed +// locations until they are ported. + +// goRuntimeAdvice maps a v2 runtime function name to advice for porting the +// call site to the v3 API. `app` refers to the application instance +// (application.Get() from anywhere). +var goRuntimeAdvice = map[string]string{ + "EventsEmit": "`app.Event.Emit(name, data...)`", + "EventsOn": "`app.Event.On(name, func(e *application.CustomEvent) { ... })` - the callback receives the event object; your payload is `e.Data`. Returns an unsubscribe func.", + "EventsOnce": "`app.Event.On(...)` and call the returned unsubscribe func inside the callback (v3 has no Once on the manager)", + "EventsOnMultiple": "`app.Event.OnMultiple(name, callback, counter)`", + "EventsOff": "`app.Event.Off(name)`", + "EventsOffAll": "`app.Event.Reset()`", + + "Quit": "`app.Quit()`", + "Hide": "`app.Hide()`", + "Show": "`app.Show()`", + "Environment": "`app.Env.Info()` (returns application.EnvironmentInfo: OS, Arch, Debug)", + + "BrowserOpenURL": "`app.Browser.OpenURL(url)`", + + "ClipboardGetText": "`app.Clipboard.Text()` (returns (string, bool) instead of (string, error))", + "ClipboardSetText": "`app.Clipboard.SetText(text)` (returns bool instead of error)", + + "ScreenGetAll": "`app.Screen.GetAll()` (the v3 Screen struct differs: Size, Bounds, PhysicalBounds, IsPrimary)", + + "LogPrint": "`app.Logger.Info(message)`", + "LogPrintf": "`app.Logger.Info(fmt.Sprintf(...))`", + "LogTrace": "`app.Logger.Debug(message)`", + "LogTracef": "`app.Logger.Debug(fmt.Sprintf(...))`", + "LogDebug": "`app.Logger.Debug(message)`", + "LogDebugf": "`app.Logger.Debug(fmt.Sprintf(...))`", + "LogInfo": "`app.Logger.Info(message)`", + "LogInfof": "`app.Logger.Info(fmt.Sprintf(...))`", + "LogWarning": "`app.Logger.Warn(message)`", + "LogWarningf": "`app.Logger.Warn(fmt.Sprintf(...))`", + "LogError": "`app.Logger.Error(message)`", + "LogErrorf": "`app.Logger.Error(fmt.Sprintf(...))`", + "LogFatal": "`app.Logger.Error(message)` + `os.Exit(1)`", + "LogFatalf": "`app.Logger.Error(fmt.Sprintf(...))` + `os.Exit(1)`", + "LogSetLogLevel": "set `application.Options.LogLevel` (log/slog level) at startup", + + "MenuSetApplicationMenu": "rebuild the menu with `app.NewMenu()` and apply it with `app.Menu.SetApplicationMenu(menu)`", + "MenuUpdateApplicationMenu": "`app.Menu.UpdateApplicationMenu()`", + + "OpenDirectoryDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{CanChooseDirectories: true, CanChooseFiles: false, ...}).PromptForSingleSelection()`", + "OpenFileDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{...}).PromptForSingleSelection()` (field names differ slightly, e.g. DefaultDirectory -> Directory)", + "OpenMultipleFilesDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{AllowsMultipleSelection: true, ...}).PromptForMultipleSelection()`", + "SaveFileDialog": "`app.Dialog.SaveFileWithOptions(&application.SaveFileDialogOptions{...}).PromptForSingleSelection()`", + "MessageDialog": "`app.Dialog.Info()/Question()/Warning()/Error()` with `.AddButton(label).OnClick(func(){...})` - the result arrives via button callbacks, not a return value", + + "OnFileDrop": "`window.OnWindowEvent(events.Common.WindowFilesDropped, func(e *application.WindowEvent) { e.Context().DroppedFiles() })` - requires `WebviewWindowOptions.EnableFileDrop: true`", + "OnFileDropOff": "call the unsubscribe func returned by `OnWindowEvent`", +} + +// windowRuntimeAdvice maps v2 Window* functions to the v3 window method. +// They all operate on a window object: `app.Window.Current()` or a window you +// keep a reference to. +var windowRuntimeAdvice = map[string]string{ + "WindowSetTitle": "`window.SetTitle(title)`", + "WindowFullscreen": "`window.Fullscreen()`", + "WindowUnfullscreen": "`window.UnFullscreen()`", + "WindowCenter": "`window.Center()`", + "WindowReload": "`window.Reload()`", + "WindowReloadApp": "`window.ForceReload()`", + "WindowShow": "`window.Show()`", + "WindowHide": "`window.Hide()`", + "WindowSetSize": "`window.SetSize(width, height)`", + "WindowGetSize": "`window.Size()`", + "WindowSetMinSize": "`window.SetMinSize(width, height)`", + "WindowSetMaxSize": "`window.SetMaxSize(width, height)`", + "WindowSetAlwaysOnTop": "`window.SetAlwaysOnTop(b)`", + "WindowSetPosition": "`window.SetRelativePosition(x, y)`", + "WindowGetPosition": "`window.RelativePosition()`", + "WindowMaximise": "`window.Maximise()`", + "WindowToggleMaximise": "`window.ToggleMaximise()`", + "WindowUnmaximise": "`window.UnMaximise()`", + "WindowMinimise": "`window.Minimise()`", + "WindowUnminimise": "`window.UnMinimise()`", + "WindowIsFullscreen": "`window.IsFullscreen()`", + "WindowIsMaximised": "`window.IsMaximised()`", + "WindowIsMinimised": "`window.IsMinimised()`", + "WindowIsNormal": "combine `!window.IsFullscreen() && !window.IsMaximised() && !window.IsMinimised()`", + "WindowExecJS": "`window.ExecJS(js)`", + "WindowSetBackgroundColour": "`window.SetBackgroundColour(application.RGBA{Red: r, Green: g, Blue: b, Alpha: a})`", + "WindowPrint": "`window.Print()`", + "WindowSetSystemDefaultTheme": "set `WebviewWindowOptions.Theme: application.SystemDefault` at window creation (v3 has no runtime theme setter)", + "WindowSetLightTheme": "set `WebviewWindowOptions.Theme: application.Light` at window creation (v3 has no runtime theme setter)", + "WindowSetDarkTheme": "set `WebviewWindowOptions.Theme: application.Dark` at window creation (v3 has no runtime theme setter)", +} + +// adviseGoRuntimeCalls records every call into the v2 runtime package with +// its v3 replacement. +func adviseGoRuntimeCalls(fset *token.FileSet, files map[string]*ast.File, proj *V2Project) { + for path, file := range files { + localName := "" + for name, ipath := range importMap(file) { + if ipath == V2RuntimeImport { + localName = name + } + } + if localName == "" { + continue + } + rel, err := filepath.Rel(proj.Dir, path) + if err != nil { + rel = path + } + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != localName { + return true + } + name := sel.Sel.Name + advice, ok := goRuntimeAdvice[name] + if !ok { + advice, ok = windowRuntimeAdvice[name] + if ok { + advice += " - get the window with `app.Window.Current()` or keep a reference to the one you create" + } + } + if !ok { + // Type references (runtime.OpenDialogOptions{...}) and + // anything unknown. + advice = "see the v3 application API and https://v3.wails.io/migration/v2-to-v3/" + } + pos := fset.Position(sel.Pos()) + proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, pos.Line), "`runtime."+name+"`", advice) + return true + }) + } +} + +var wailsjsImportRe = regexp.MustCompile(`(?:from\s*|require\s*\(\s*)['"]([^'"]*wailsjs/(runtime|go)/[^'"]*)['"]`) + +// frontendSourceExts are the file types scanned for wailsjs imports. +var frontendSourceExts = map[string]bool{ + ".js": true, ".jsx": true, ".ts": true, ".tsx": true, + ".svelte": true, ".vue": true, ".html": true, ".mjs": true, ".cjs": true, +} + +// adviseFrontendImports records every wailsjs import in the frontend sources +// with its v3 replacement. +func adviseFrontendImports(proj *V2Project) error { + frontend := proj.FrontendDir + if _, err := os.Stat(frontend); os.IsNotExist(err) { + return nil + } + return filepath.Walk(frontend, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + switch info.Name() { + case "node_modules", "dist", "wailsjs": + return filepath.SkipDir + } + return nil + } + if !frontendSourceExts[filepath.Ext(path)] { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + rel, rerr := filepath.Rel(proj.Dir, path) + if rerr != nil { + rel = path + } + scanner := bufio.NewScanner(f) + lineNo := 0 + for scanner.Scan() { + lineNo++ + m := wailsjsImportRe.FindStringSubmatch(scanner.Text()) + if m == nil { + continue + } + var advice string + if m[2] == "runtime" { + advice = "import from `@wailsio/runtime` instead: `import {Events, Window, Dialogs, ...} from '@wailsio/runtime'`. Function names change, e.g. `EventsOn(name, cb)` -> `Events.On(name, cb)` (the callback receives an event object; your payload is `event.data`), `WindowSetTitle` -> `Window.SetTitle`, `Quit` -> `Application.Quit`." + } else { + advice = "run `wails3 generate bindings`, then import the service from `frontend/bindings`: `import {" + importedServiceName(m[1]) + "} from './bindings/" + proj.ModulePath + "'` and call methods on it" + } + proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, lineNo), "`"+m[1]+"`", advice) + } + return scanner.Err() + }) +} + +// importedServiceName extracts the bound struct name from a wailsjs/go import +// path such as ../wailsjs/go/main/App. +func importedServiceName(importPath string) string { + base := filepath.Base(importPath) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/v3/internal/migrate/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..d05479fd538 --- /dev/null +++ b/v3/internal/migrate/frontend.go @@ -0,0 +1,103 @@ +package migrate + +import ( + "os" + "path/filepath" + "regexp" + "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 +} + +var dependenciesRe = regexp.MustCompile(`("dependencies"\s*:\s*\{)`) + +// addRuntimeDependency inserts "@wailsio/runtime" into the frontend +// package.json dependencies, preserving the file's formatting. +func addRuntimeDependency(proj *V2Project, frontendDir string) error { + path := filepath.Join(frontendDir, "package.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + proj.Report.Manual("frontend/package.json", + "No package.json found. The 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 + } + content := string(data) + if strings.Contains(content, `"@wailsio/runtime"`) { + return nil + } + + if dependenciesRe.MatchString(content) { + content = dependenciesRe.ReplaceAllString(content, "$1\n \"@wailsio/runtime\": \"latest\",") + } else if idx := strings.Index(content, "{"); idx >= 0 { + content = content[:idx+1] + "\n \"dependencies\": {\n \"@wailsio/runtime\": \"latest\"\n }," + content[idx+1:] + } else { + proj.Report.Manual("frontend/package.json", "Could not add the `@wailsio/runtime` dependency automatically; add it and run your package manager's install.") + return nil + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return err + } + proj.Report.Note("Added `@wailsio/runtime` to frontend/package.json; run your package manager's install (or let the Taskfile do it on first build).") + return nil +} diff --git a/v3/internal/migrate/gomod.go b/v3/internal/migrate/gomod.go new file mode 100644 index 00000000000..f49e3dc473e --- /dev/null +++ b/v3/internal/migrate/gomod.go @@ -0,0 +1,51 @@ +package migrate + +import ( + "os" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/semver" +) + +// minGoVersion is the minimum Go directive required by wails v3 projects +// (matches the version in the v3 project template). +const minGoVersion = "1.24" + +// TransformGoMod rewrites the project's go.mod for v3: the wails/v2 require +// is dropped, wails/v3 is added at the given version and the go directive is +// raised to the v3 minimum if needed. All other requires are preserved. +func TransformGoMod(proj *V2Project, wailsVersion string) ([]byte, error) { + data, err := os.ReadFile(proj.GoModPath) + if err != nil { + return nil, err + } + mod, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return nil, err + } + + if err := mod.DropRequire("github.com/wailsapp/wails/v2"); err != nil { + return nil, err + } + if err := mod.AddRequire("github.com/wailsapp/wails/v3", wailsVersion); err != nil { + return nil, err + } + + if mod.Go == nil || semver.Compare("v"+mod.Go.Version, "v"+minGoVersion) < 0 { + if err := mod.AddGoStmt(minGoVersion); err != nil { + return nil, err + } + } + + // Drop any replace directives pointing at wails/v2. + for _, rep := range mod.Replace { + if rep.Old.Path == "github.com/wailsapp/wails/v2" { + if err := mod.DropReplace(rep.Old.Path, rep.Old.Version); err != nil { + return nil, err + } + } + } + + mod.Cleanup() + return mod.Format() +} diff --git a/v3/internal/migrate/maingen.go b/v3/internal/migrate/maingen.go new file mode 100644 index 00000000000..e744b763063 --- /dev/null +++ b/v3/internal/migrate/maingen.go @@ -0,0 +1,305 @@ +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) { + for _, f := range fields { + sb.WriteString(indent + f.Name + ": " + f.Expr + ",\n") + } +} + +// buildV3Block renders the replacement for the wails.Run statement. +func buildV3Block(proj *V2Project, opts *V3Options, appVar, 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.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 = path[strings.LastIndex(path, "/")+1:] + } + if !used[name] { + removals = append(removals, span{ + fset.Position(imp.Pos()).Offset, + fset.Position(imp.End()).Offset, + }) + } + } + } + + sort.Slice(removals, func(i, j int) bool { return removals[i].start > removals[j].start }) + for _, r := range removals { + src = append(src[:r.start], src[r.end:]...) + } + + formatted, err := format.Source(src) + if err != nil { + return nil, fmt.Errorf("could not format the generated main file (%w); this is a bug in wails3 migrate", err) + } + return formatted, nil +} diff --git a/v3/internal/migrate/mapping.go b/v3/internal/migrate/mapping.go new file mode 100644 index 00000000000..f86116bf6c2 --- /dev/null +++ b/v3/internal/migrate/mapping.go @@ -0,0 +1,681 @@ +package migrate + +import ( + "go/ast" + "go/token" + "sort" + "strings" +) + +// GenField is one field of a generated composite literal: Name: Expr. +type GenField struct { + Name string + Expr string +} + +// V3Options is the mapped result of a v2 options.App literal, ready for code +// generation. All Expr values are Go source snippets valid in the migrated +// main file. +type V3Options struct { + App []GenField // application.Options + AppMac []GenField // application.Options.Mac + AppWin []GenField // application.Options.Windows + AppLinux []GenField // application.Options.Linux + + Win []GenField // application.WebviewWindowOptions + WinMac []GenField // WebviewWindowOptions.Mac + WinWin []GenField // WebviewWindowOptions.Windows + WinLinux []GenField // WebviewWindowOptions.Linux + + SingleInstance []GenField // *application.SingleInstanceOptions, nil if unset + + // Services holds the Go expressions to wrap in application.NewService(). + Services []string + + // v2 lifecycle callbacks (original source expressions, signature + // func(context.Context)). Empty when unset. + OnStartup string + OnDomReady string + OnShutdown string + // OnBeforeClose (v2: return true to prevent close) is wrapped into + // application.Options.ShouldQuit. + OnBeforeClose string +} + +// NeedsLifecycleService reports whether the generated main needs the +// v2compat lifecycle service. +func (o *V3Options) NeedsLifecycleService() bool { + return o.OnStartup != "" || o.OnDomReady != "" || o.OnShutdown != "" +} + +// mapper carries shared state while walking the options.App literal. +type mapper struct { + proj *V2Project + out *V3Options +} + +// MapOptions converts the parsed v2 options.App literal into V3Options, +// recording everything unmappable in the project report. +func MapOptions(proj *V2Project) *V3Options { + m := &mapper{proj: proj, out: &V3Options{}} + report := proj.Report + + lit := proj.Main.AppLit + if lit == nil { + report.Manual("options.App", + "The value passed to wails.Run was not a literal `&options.App{...}`, so options could not be migrated automatically. A default window configuration was generated; port your options manually to application.Options / application.WebviewWindowOptions.") + return m.out + } + + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok { + continue + } + m.mapAppField(key.Name, kv.Value) + } + + // v2 apps always load the embedded index; v3 needs the URL explicitly. + m.out.Win = append(m.out.Win, GenField{"URL", `"/"`}) + + return m.out +} + +// src returns the original source text of an expression. +func (m *mapper) src(e ast.Expr) string { + return exprText(m.proj.Main.Fset, m.proj.Main.Source, e) +} + +// referencesV2 reports whether the expression mentions any package imported +// from github.com/wailsapp/wails/v2 (such values cannot be carried verbatim). +func (m *mapper) referencesV2(e ast.Expr) bool { + found := false + ast.Inspect(e, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + if path, ok := m.proj.Main.Imports[ident.Name]; ok { + if strings.HasPrefix(path, "github.com/wailsapp/wails/v2") { + found = true + return false + } + } + return true + }) + return found +} + +// carry appends `name: ` to dst when the value is representable in the +// migrated program; otherwise it records a manual step. +func (m *mapper) carry(dst *[]GenField, v2Name, v3Name string, value ast.Expr, v3Target string) { + if m.referencesV2(value) { + m.proj.Report.Manual("options.App."+v2Name, + "The value `"+m.src(value)+"` references Wails v2 packages and could not be carried over. Set `"+v3Target+"` manually.") + return + } + *dst = append(*dst, GenField{v3Name, m.src(value)}) + m.proj.Report.Mapped("options.App."+v2Name, v3Target) +} + +// manual is a shorthand for recording a manual step for an option. +func (m *mapper) manual(v2Name, instructions string) { + m.proj.Report.Manual("options.App."+v2Name, instructions) +} + +// note is a shorthand for adding a note to the report. +func (m *mapper) note(s string) { m.proj.Report.Note(s) } + +// compositeLit unwraps &T{...} / T{...} values to the composite literal, or +// nil when the value has another shape. +func compositeLit(e ast.Expr) *ast.CompositeLit { + if unary, ok := e.(*ast.UnaryExpr); ok && unary.Op == token.AND { + e = unary.X + } + lit, _ := e.(*ast.CompositeLit) + return lit +} + +// litField is a key/value pair of a composite literal, in source order. +type litField struct { + Name string + Value ast.Expr +} + +// orderedFields returns the key/value pairs of a composite literal in source +// order (keeps generated code and reports deterministic). +func orderedFields(lit *ast.CompositeLit) []litField { + var fields []litField + for _, elt := range lit.Elts { + if kv, ok := elt.(*ast.KeyValueExpr); ok { + if key, ok := kv.Key.(*ast.Ident); ok { + fields = append(fields, litField{key.Name, kv.Value}) + } + } + } + return fields +} + +// litFields returns the key/value pairs of a composite literal as a map (for +// single-field lookups). +func litFields(lit *ast.CompositeLit) map[string]ast.Expr { + fields := map[string]ast.Expr{} + for _, f := range orderedFields(lit) { + fields[f.Name] = f.Value + } + return fields +} + +// selectorConst maps a v2 enum selector (e.g. windows.Dark) to a v3 +// application-package expression via a lookup of the selector's final name. +// Returns "" if the value has another shape or the name is not in the table. +func selectorConst(value ast.Expr, table map[string]string) string { + name := "" + switch v := value.(type) { + case *ast.SelectorExpr: + name = v.Sel.Name + case *ast.Ident: + name = v.Name + default: + return "" + } + return table[name] +} + +func isTrue(e ast.Expr) bool { + ident, ok := e.(*ast.Ident) + return ok && ident.Name == "true" +} + +var windowStartStates = map[string]string{ + "Normal": "application.WindowStateNormal", + "Maximised": "application.WindowStateMaximised", + "Minimised": "application.WindowStateMinimised", + "Fullscreen": "application.WindowStateFullscreen", +} + +var windowsThemes = map[string]string{ + "SystemDefault": "application.SystemDefault", + "Dark": "application.Dark", + "Light": "application.Light", +} + +var windowsBackdropTypes = map[string]string{ + "Auto": "application.Auto", + "None": "application.None", + "Mica": "application.Mica", + "Acrylic": "application.Acrylic", + "Tabbed": "application.Tabbed", +} + +var linuxGpuPolicies = map[string]string{ + "WebviewGpuPolicyAlways": "application.WebviewGpuPolicyAlways", + "WebviewGpuPolicyOnDemand": "application.WebviewGpuPolicyOnDemand", + "WebviewGpuPolicyNever": "application.WebviewGpuPolicyNever", +} + +// mac.Appearance* names are identical in v3's MacAppearanceType consts. +var macAppearances = map[string]string{ + "DefaultAppearance": "application.DefaultAppearance", + "NSAppearanceNameAqua": "application.NSAppearanceNameAqua", + "NSAppearanceNameDarkAqua": "application.NSAppearanceNameDarkAqua", + "NSAppearanceNameVibrantLight": "application.NSAppearanceNameVibrantLight", + "NSAppearanceNameAccessibilityHighContrastAqua": "application.NSAppearanceNameAccessibilityHighContrastAqua", + "NSAppearanceNameAccessibilityHighContrastDarkAqua": "application.NSAppearanceNameAccessibilityHighContrastDarkAqua", + "NSAppearanceNameAccessibilityHighContrastVibrantLight": "application.NSAppearanceNameAccessibilityHighContrastVibrantLight", + "NSAppearanceNameAccessibilityHighContrastVibrantDark": "application.NSAppearanceNameAccessibilityHighContrastVibrantDark", +} + +// v2 mac.TitleBar* preset constructors -> v3 preset variables. +var macTitleBarPresets = map[string]string{ + "TitleBarDefault": "application.MacTitleBarDefault", + "TitleBarHidden": "application.MacTitleBarHidden", + "TitleBarHiddenInset": "application.MacTitleBarHiddenInset", +} + +// v2 mac.TitleBar field -> v3 application.MacTitleBar field. +var macTitleBarFields = map[string]string{ + "TitlebarAppearsTransparent": "AppearsTransparent", + "HideTitleBar": "Hide", + "HideTitle": "HideTitle", + "FullSizeContent": "FullSizeContent", + "UseToolbar": "UseToolbar", + "HideToolbarSeparator": "HideToolbarSeparator", +} + +func (m *mapper) mapAppField(name string, value ast.Expr) { + out := m.out + switch name { + case "Title": + m.carry(&out.App, "Title", "Name", value, "application.Options.Name") + out.Win = append(out.Win, GenField{"Title", m.src(value)}) + case "Width": + m.carry(&out.Win, "Width", "Width", value, "WebviewWindowOptions.Width") + case "Height": + m.carry(&out.Win, "Height", "Height", value, "WebviewWindowOptions.Height") + case "MinWidth": + m.carry(&out.Win, "MinWidth", "MinWidth", value, "WebviewWindowOptions.MinWidth") + case "MinHeight": + m.carry(&out.Win, "MinHeight", "MinHeight", value, "WebviewWindowOptions.MinHeight") + case "MaxWidth": + m.carry(&out.Win, "MaxWidth", "MaxWidth", value, "WebviewWindowOptions.MaxWidth") + case "MaxHeight": + m.carry(&out.Win, "MaxHeight", "MaxHeight", value, "WebviewWindowOptions.MaxHeight") + case "DisableResize": + m.carry(&out.Win, "DisableResize", "DisableResize", value, "WebviewWindowOptions.DisableResize") + case "Frameless": + m.carry(&out.Win, "Frameless", "Frameless", value, "WebviewWindowOptions.Frameless") + case "AlwaysOnTop": + m.carry(&out.Win, "AlwaysOnTop", "AlwaysOnTop", value, "WebviewWindowOptions.AlwaysOnTop") + case "StartHidden": + m.carry(&out.Win, "StartHidden", "Hidden", value, "WebviewWindowOptions.Hidden") + case "Fullscreen": + if isTrue(value) { + out.Win = append(out.Win, GenField{"StartState", "application.WindowStateFullscreen"}) + m.proj.Report.Mapped("options.App.Fullscreen", "WebviewWindowOptions.StartState") + } else { + m.manual("Fullscreen", "Non-constant Fullscreen value; set `WebviewWindowOptions.StartState` to `application.WindowStateFullscreen` as needed.") + } + case "WindowStartState": + if v3 := selectorConst(value, windowStartStates); v3 != "" { + out.Win = append(out.Win, GenField{"StartState", v3}) + m.proj.Report.Mapped("options.App.WindowStartState", "WebviewWindowOptions.StartState") + } else { + m.manual("WindowStartState", "Could not map the value `"+m.src(value)+"`; set `WebviewWindowOptions.StartState` manually.") + } + case "BackgroundColour": + m.mapBackgroundColour(value) + case "AssetServer": + m.mapAssetServer(value) + case "Assets": + // Deprecated v2 field: Assets fs.FS. + out.App = append(out.App, GenField{"Assets", "application.AssetOptions{\n\t\t\tHandler: application.AssetFileServerFS(" + m.src(value) + "),\n\t\t}"}) + m.proj.Report.Mapped("options.App.Assets", "application.Options.Assets") + case "AssetsHandler": + out.App = append(out.App, GenField{"Assets", "application.AssetOptions{\n\t\t\tHandler: " + m.src(value) + ",\n\t\t}"}) + m.proj.Report.Mapped("options.App.AssetsHandler", "application.Options.Assets.Handler") + case "Menu": + m.manual("Menu", "v3 menus use a different API: create the menu with `app.NewMenu()` and assign it with `app.Menu.SetApplicationMenu(menu)`. See https://v3.wails.io/learn/menus/.") + case "Logger", "LogLevel", "LogLevelProduction": + m.manual(name, "v3 uses the standard library `log/slog`: set `application.Options.Logger` (a *slog.Logger) and `application.Options.LogLevel`.") + case "OnStartup": + out.OnStartup = m.src(value) + m.proj.Report.Mapped("options.App.OnStartup", "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.out.App = append(m.out.App, GenField{"Assets", "application.AssetOptions{\n\t\t\t" + strings.Join(gen, "\n\t\t\t") + "\n\t\t}"}) + } +} + +func (m *mapper) mapSingleInstance(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("SingleInstanceLock", "Configure `application.Options.SingleInstance` (*application.SingleInstanceOptions) manually.") + return + } + fields := litFields(lit) + if id, ok := fields["UniqueId"]; ok && !m.referencesV2(id) { + m.out.SingleInstance = append(m.out.SingleInstance, GenField{"UniqueID", m.src(id)}) + m.proj.Report.Mapped("options.App.SingleInstanceLock.UniqueId", "application.SingleInstanceOptions.UniqueID") + } + if _, ok := fields["OnSecondInstanceLaunch"]; ok { + m.manual("SingleInstanceLock.OnSecondInstanceLaunch", + "Port the callback to `application.SingleInstanceOptions.OnSecondInstanceLaunch(data application.SecondInstanceData)`. Note the v3 struct uses `WorkingDir` (v2: `WorkingDirectory`).") + } +} + +func (m *mapper) mapWindowsOptions(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Windows", "Could not parse the Windows options literal; port it manually to application.WindowsOptions / application.WindowsWindow.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "Windows." + name + switch name { + case "WebviewIsTransparent": + if isTrue(v) { + m.out.Win = append(m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTransparent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "WindowIsTranslucent": + if isTrue(v) { + m.out.Win = append(m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTranslucent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "DisableWindowIcon": + m.carry(&m.out.WinWin, prefix, "DisableIcon", v, "WindowsWindow.DisableIcon") + case "DisableFramelessWindowDecorations": + m.carry(&m.out.WinWin, prefix, "DisableFramelessWindowDecorations", v, "WindowsWindow.DisableFramelessWindowDecorations") + case "WebviewUserDataPath": + m.carry(&m.out.AppWin, prefix, "WebviewUserDataPath", v, "application.WindowsOptions.WebviewUserDataPath") + case "WebviewBrowserPath": + m.carry(&m.out.AppWin, prefix, "WebviewBrowserPath", v, "application.WindowsOptions.WebviewBrowserPath") + case "Theme": + if v3 := selectorConst(v, windowsThemes); v3 != "" { + m.out.WinWin = append(m.out.WinWin, GenField{"Theme", v3}) + m.proj.Report.Mapped("options.App."+prefix, "WindowsWindow.Theme") + } else { + m.manual(prefix, "Could not map the theme value `"+m.src(v)+"`; set `WindowsWindow.Theme` manually.") + } + case "CustomTheme": + m.manual(prefix, "The v3 custom theme structure differs (`application.ThemeSettings` with per-mode `WindowTheme`/`MenuBarTheme` colours); port your colours manually.") + case "BackdropType": + if v3 := selectorConst(v, windowsBackdropTypes); v3 != "" { + m.out.WinWin = append(m.out.WinWin, GenField{"BackdropType", v3}) + m.proj.Report.Mapped("options.App."+prefix, "WindowsWindow.BackdropType") + } else { + m.manual(prefix, "Could not map the backdrop value `"+m.src(v)+"`; set `WindowsWindow.BackdropType` manually.") + } + case "Messages": + m.note("`Windows.Messages` was dropped: v3 handles the WebView2 bootstrap flow itself.") + case "ResizeDebounceMS": + m.manual(prefix, "No direct v3 equivalent. `WindowsWindow.WindowDidMoveDebounceMS` debounces move events if that is what you need.") + case "OnSuspend": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.SystemWillSleep, ...)`.") + case "OnResume": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.SystemDidWake, ...)`.") + case "WebviewGpuIsDisabled": + m.manual(prefix, "No direct v3 equivalent; consider `application.WindowsOptions.AdditionalBrowserArgs: []string{\"--disable-gpu\"}`.") + case "WebviewDisableRendererCodeIntegrity": + m.manual(prefix, "No direct v3 equivalent; consider `application.WindowsOptions.AdditionalBrowserArgs`.") + case "EnableSwipeGestures": + m.carry(&m.out.WinWin, prefix, "EnableSwipeGestures", v, "WindowsWindow.EnableSwipeGestures") + case "ZoomFactor": + m.carry(&m.out.Win, prefix, "Zoom", v, "WebviewWindowOptions.Zoom") + case "IsZoomControlEnabled": + m.carry(&m.out.Win, prefix, "ZoomControlEnabled", v, "WebviewWindowOptions.ZoomControlEnabled") + case "DisablePinchZoom": + m.manual(prefix, "No direct v3 equivalent; zoom control is `WebviewWindowOptions.ZoomControlEnabled`.") + case "ContentProtection": + m.carry(&m.out.Win, prefix, "ContentProtectionEnabled", v, "WebviewWindowOptions.ContentProtectionEnabled") + case "WindowClassName": + m.carry(&m.out.AppWin, prefix, "WndClass", v, "application.WindowsOptions.WndClass") + case "DLLSearchPaths": + m.manual(prefix, "No v3 equivalent; v3 loads WebView2Loader statically.") + default: + m.manual(prefix, "This option was not recognised by the migrator; check application.WindowsOptions / application.WindowsWindow for an equivalent.") + } + } +} + +func (m *mapper) mapMacOptions(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Mac", "Could not parse the Mac options literal; port it manually to application.MacOptions / application.MacWindow.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "Mac." + name + switch name { + case "TitleBar": + m.mapMacTitleBar(v) + case "Appearance": + if v3 := selectorConst(v, macAppearances); v3 != "" { + m.out.WinMac = append(m.out.WinMac, GenField{"Appearance", v3}) + m.proj.Report.Mapped("options.App."+prefix, "MacWindow.Appearance") + } else { + m.manual(prefix, "Could not map the appearance value `"+m.src(v)+"`; set `MacWindow.Appearance` manually.") + } + case "WebviewIsTransparent": + if isTrue(v) { + m.out.Win = append(m.out.Win, GenField{"BackgroundType", "application.BackgroundTypeTransparent"}) + m.proj.Report.Mapped("options.App."+prefix, "WebviewWindowOptions.BackgroundType") + } + case "WindowIsTranslucent": + if isTrue(v) { + m.out.WinMac = append(m.out.WinMac, GenField{"Backdrop", "application.MacBackdropTranslucent"}) + m.proj.Report.Mapped("options.App."+prefix, "MacWindow.Backdrop") + } + case "Preferences": + m.manual(prefix, "Port to `MacWindow.WebviewPreferences` (application.MacWebviewPreferences) manually.") + case "DisableZoom": + m.manual(prefix, "No direct v3 equivalent; `WebviewWindowOptions.ZoomControlEnabled: false` disables zoom controls.") + case "About": + m.mapMacAbout(v) + case "OnFileOpen": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.ApplicationOpenedWithFile, ...)`, reading the filename from the event context.") + case "OnUrlOpen": + m.manual(prefix, "Subscribe to the v3 event instead: `app.Event.OnApplicationEvent(events.Common.ApplicationLaunchedWithUrl, ...)`.") + case "DisableEscapeExitsFullscreen": + m.carry(&m.out.WinMac, prefix, "DisableEscapeExitsFullscreen", v, "MacWindow.DisableEscapeExitsFullscreen") + case "ContentProtection": + m.carry(&m.out.Win, prefix, "ContentProtectionEnabled", v, "WebviewWindowOptions.ContentProtectionEnabled") + default: + m.manual(prefix, "This option was not recognised by the migrator; check application.MacOptions / application.MacWindow for an equivalent.") + } + } +} + +func (m *mapper) mapMacTitleBar(value ast.Expr) { + // Preset constructor: mac.TitleBarHiddenInset() + if call, ok := value.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if v3 := macTitleBarPresets[sel.Sel.Name]; v3 != "" { + m.out.WinMac = append(m.out.WinMac, GenField{"TitleBar", v3}) + m.proj.Report.Mapped("options.App.Mac.TitleBar", "MacWindow.TitleBar") + return + } + } + } + // Literal: &mac.TitleBar{...} + if lit := compositeLit(value); lit != nil { + var fields []string + ok := true + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + v3Name := macTitleBarFields[name] + if v3Name == "" || m.referencesV2(v) { + m.manual("Mac.TitleBar."+name, "Could not map this title bar field; check application.MacTitleBar.") + ok = false + continue + } + fields = append(fields, v3Name+": "+m.src(v)+",") + } + if ok && len(fields) > 0 { + sort.Strings(fields) + m.out.WinMac = append(m.out.WinMac, GenField{"TitleBar", "application.MacTitleBar{\n\t\t\t\t" + strings.Join(fields, "\n\t\t\t\t") + "\n\t\t\t}"}) + m.proj.Report.Mapped("options.App.Mac.TitleBar", "MacWindow.TitleBar") + } + return + } + m.manual("Mac.TitleBar", "Could not map the title bar value `"+m.src(value)+"`; set `MacWindow.TitleBar` manually.") +} + +func (m *mapper) mapMacAbout(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Mac.About", "v3 renders the about box from application.Options Name/Description/Icon; set those manually.") + return + } + fields := litFields(lit) + if msg, ok := fields["Message"]; ok && !m.referencesV2(msg) { + m.out.App = append(m.out.App, GenField{"Description", m.src(msg)}) + m.proj.Report.Mapped("options.App.Mac.About.Message", "application.Options.Description") + } + if icon, ok := fields["Icon"]; ok && !m.referencesV2(icon) { + m.out.App = append(m.out.App, GenField{"Icon", m.src(icon)}) + m.proj.Report.Mapped("options.App.Mac.About.Icon", "application.Options.Icon") + } + if _, ok := fields["Title"]; ok { + m.note("`Mac.About.Title` was dropped: the v3 about box title comes from `application.Options.Name`.") + } +} + +func (m *mapper) mapLinuxOptions(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("Linux", "Could not parse the Linux options literal; port it manually to application.LinuxOptions / application.LinuxWindow.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "Linux." + name + switch name { + case "Icon": + m.carry(&m.out.WinLinux, prefix, "Icon", v, "LinuxWindow.Icon") + case "WindowIsTranslucent": + m.carry(&m.out.WinLinux, prefix, "WindowIsTranslucent", v, "LinuxWindow.WindowIsTranslucent") + case "WebviewGpuPolicy": + if v3 := selectorConst(v, linuxGpuPolicies); v3 != "" { + m.out.WinLinux = append(m.out.WinLinux, GenField{"WebviewGpuPolicy", v3}) + m.proj.Report.Mapped("options.App."+prefix, "LinuxWindow.WebviewGpuPolicy") + } else { + m.manual(prefix, "Could not map the GPU policy value `"+m.src(v)+"`; set `LinuxWindow.WebviewGpuPolicy` manually.") + } + case "ProgramName": + m.carry(&m.out.AppLinux, prefix, "ProgramName", v, "application.LinuxOptions.ProgramName") + case "Messages": + m.note("`Linux.Messages` was dropped: v3 reports missing webkit dependencies itself.") + default: + m.manual(prefix, "This option was not recognised by the migrator; check application.LinuxOptions / application.LinuxWindow for an equivalent.") + } + } +} + +func (m *mapper) mapDragAndDrop(value ast.Expr) { + lit := compositeLit(value) + if lit == nil { + m.manual("DragAndDrop", "Set `WebviewWindowOptions.EnableFileDrop` manually.") + return + } + for _, f := range orderedFields(lit) { + name, v := f.Name, f.Value + prefix := "DragAndDrop." + name + switch name { + case "EnableFileDrop": + m.carry(&m.out.Win, prefix, "EnableFileDrop", v, "WebviewWindowOptions.EnableFileDrop") + case "DisableWebViewDrop": + m.manual(prefix, "No direct v3 equivalent; see `WebviewWindowOptions.EnableFileDrop` and the WindowFilesDropped event.") + case "CSSDropProperty", "CSSDropValue": + m.note("`" + prefix + "` was dropped: v3 controls drop targets with the `--wails-drop-target: drop` CSS property.") + default: + m.manual(prefix, "This option was not recognised by the migrator.") + } + } +} diff --git a/v3/internal/migrate/migrate.go b/v3/internal/migrate/migrate.go new file mode 100644 index 00000000000..ce689064aff --- /dev/null +++ b/v3/internal/migrate/migrate.go @@ -0,0 +1,103 @@ +// 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 + // 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..f20cd93fb68 --- /dev/null +++ b/v3/internal/migrate/migrate_test.go @@ -0,0 +1,427 @@ +package migrate + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +const fixtureWailsJSON = `{ + "name": "myv2app", + "outputfilename": "myv2app", + "frontend:install": "pnpm install", + "frontend:build": "pnpm run build", + "author": { "name": "Tester" }, + "info": { + "companyName": "Wails", + "productName": "My V2 App", + "productVersion": "1.2.3" + } +}` + +const fixtureGoMod = `module myv2app + +go 1.23 + +require github.com/wailsapp/wails/v2 v2.10.2 +` + +const fixtureMainGo = `package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/mac" + "github.com/wailsapp/wails/v2/pkg/options/windows" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + // Create an instance of the app structure + app := NewApp() + + err := wails.Run(&options.App{ + Title: "My V2 App", + Width: 1024, + Height: 768, + Frameless: true, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + OnBeforeClose: app.beforeClose, + WindowStartState: options.Maximised, + Bind: []interface{}{ + app, + }, + Windows: &windows.Options{ + Theme: windows.Dark, + }, + Mac: &mac.Options{ + TitleBar: mac.TitleBarHiddenInset(), + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} +` + +const fixtureAppGo = `package main + +import ( + "context" + "fmt" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type App struct { + ctx context.Context +} + +func NewApp() *App { + return &App{} +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx +} + +func (a *App) shutdown(ctx context.Context) {} + +func (a *App) beforeClose(ctx context.Context) bool { return false } + +// Greet returns a greeting for the given name +func (a *App) Greet(name string) string { + runtime.WindowSetTitle(a.ctx, "Greeted "+name) + return fmt.Sprintf("Hello %s!", name) +} + +// Add adds two numbers +func (a *App) Add(x int, y int) int { return x + y } +` + +func writeFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "wails.json": fixtureWailsJSON, + "go.mod": fixtureGoMod, + "main.go": fixtureMainGo, + "app.go": fixtureAppGo, + "frontend/package.json": `{"name":"frontend","devDependencies":{"vite":"^3.0.0"}}`, + "frontend/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 TestTransformGoMod(t *testing.T) { + proj := parseFixture(t) + out, err := TransformGoMod(proj, "v3.0.0-alpha2.114") + if err != nil { + t.Fatal(err) + } + src := string(out) + if strings.Contains(src, "wails/v2") { + t.Errorf("v2 require not removed:\n%s", src) + } + if !strings.Contains(src, "github.com/wailsapp/wails/v3 v3.0.0-alpha2.114") { + t.Errorf("v3 require missing:\n%s", src) + } + if !strings.Contains(src, "go 1.24") { + t.Errorf("go directive not raised:\n%s", src) + } +} + +func 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) + } +} diff --git a/v3/internal/migrate/parse.go b/v3/internal/migrate/parse.go new file mode 100644 index 00000000000..5122df0e5b1 --- /dev/null +++ b/v3/internal/migrate/parse.go @@ -0,0 +1,509 @@ +package migrate + +import ( + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "golang.org/x/mod/modfile" +) + +// ParseV2Project reads a v2 project from dir. It is a syntax-only parse (no +// type checking), so it works without the v2 module being present in the +// module cache. +func ParseV2Project(dir string) (*V2Project, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + cfg, err := LoadV2Config(absDir) + if err != nil { + return nil, err + } + + proj := &V2Project{ + Dir: absDir, + Config: cfg, + FrontendDir: filepath.Join(absDir, cfg.FrontendDir), + Report: NewReport(), + } + + // go.mod + proj.GoModPath = filepath.Join(absDir, "go.mod") + modData, err := os.ReadFile(proj.GoModPath) + if err != nil { + return nil, fmt.Errorf("could not read go.mod: %w", err) + } + mod, err := modfile.Parse("go.mod", modData, nil) + if err != nil { + return nil, fmt.Errorf("could not parse go.mod: %w", err) + } + if mod.Module == nil { + return nil, fmt.Errorf("go.mod has no module directive") + } + proj.ModulePath = mod.Module.Mod.Path + if !requiresWailsV2(mod) { + return nil, fmt.Errorf("go.mod does not require github.com/wailsapp/wails/v2 - is this a Wails v2 project?") + } + + // Parse all Go files in the module (excluding frontend, build dir and + // hidden/vendor directories). + fset := token.NewFileSet() + files := map[string]*ast.File{} // abs path -> file + err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if path != absDir && (strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules") { + return filepath.SkipDir + } + if path == proj.FrontendDir || path == filepath.Join(absDir, cfg.BuildDir) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, perr := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution) + if perr != nil { + return fmt.Errorf("could not parse %s: %w", path, perr) + } + 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 { + info := findRunCall(fset, path, file) + if info == nil { + continue + } + if proj.Main != nil { + return nil, fmt.Errorf("found more than one wails.Run call (%s and %s); cannot migrate automatically", proj.Main.Path, path) + } + proj.Main = info + } + if proj.Main == nil { + return nil, fmt.Errorf("could not find a wails.Run(&options.App{...}) call in %s", absDir) + } + proj.Main.Source, err = os.ReadFile(proj.Main.Path) + if err != nil { + return nil, err + } + + for path := range files { + if path != proj.Main.Path { + proj.GoFiles = append(proj.GoFiles, path) + } + } + + // Resolve bound types from the Bind field, if present. + if proj.Main.AppLit != nil { + if bind := fieldValue(proj.Main.AppLit, "Bind"); bind != nil { + proj.BoundTypes = resolveBoundTypes(fset, files, proj, bind) + } + } + + // 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 +} + +// findRunCall looks for a statement of the form +// +// err := wails.Run(&options.App{...}) +// err = wails.Run(...) +// wails.Run(...) +// if err := wails.Run(...); err != nil { ... } +// +// where "wails" is the local name of the github.com/wailsapp/wails/v2 import. +func findRunCall(fset *token.FileSet, path string, file *ast.File) *MainInfo { + imports := importMap(file) + wailsName := "" + for name, ipath := range imports { + if ipath == "github.com/wailsapp/wails/v2" { + wailsName = name + } + } + if wailsName == "" { + return nil + } + + info := &MainInfo{Path: path, File: file, Fset: fset, Imports: imports} + + isRunCall := func(n ast.Node) *ast.CallExpr { + call, ok := n.(*ast.CallExpr) + if !ok { + return nil + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Run" { + return nil + } + ident, ok := sel.X.(*ast.Ident) + if !ok || ident.Name != wailsName { + return nil + } + return call + } + + ast.Inspect(file, func(n ast.Node) bool { + if info.RunCall != nil { + return false + } + stmt, ok := n.(ast.Stmt) + if !ok { + return true + } + switch s := stmt.(type) { + case *ast.AssignStmt: + if len(s.Rhs) != 1 { + return true + } + call := isRunCall(s.Rhs[0]) + if call == nil { + return true + } + info.RunStmt = s + info.RunCall = call + if len(s.Lhs) == 1 { + if ident, ok := s.Lhs[0].(*ast.Ident); ok { + info.ErrIdent = ident.Name + info.AssignTok = s.Tok + } + } + return false + case *ast.ExprStmt: + call := isRunCall(s.X) + if call == nil { + return true + } + info.RunStmt = s + info.RunCall = call + return false + } + return true + }) + + if info.RunCall == nil { + return nil + } + + // Extract the &options.App{...} literal. + if len(info.RunCall.Args) == 1 { + arg := info.RunCall.Args[0] + if unary, ok := arg.(*ast.UnaryExpr); ok && unary.Op == token.AND { + arg = unary.X + } + if lit, ok := arg.(*ast.CompositeLit); ok { + info.AppLit = lit + } + } + return info +} + +func importMap(file *ast.File) map[string]string { + m := map[string]string{} + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + name := "" + if imp.Name != nil { + name = imp.Name.Name + } else { + // Default local name: last path element, with major-version + // suffixes (vN) resolved to the element before them. This is a + // heuristic (the true name is the package clause), but it is + // correct for the packages the migrator cares about. + name = path[strings.LastIndex(path, "/")+1:] + if len(name) > 1 && name[0] == 'v' { + if _, err := strconv.Atoi(name[1:]); err == nil { + trimmed := path[:strings.LastIndex(path, "/")] + name = trimmed[strings.LastIndex(trimmed, "/")+1:] + } + } + } + if name == "_" || name == "." { + continue + } + m[name] = path + } + return m +} + +// fieldValue returns the value of the named field in a composite literal, or +// nil if not present. +func fieldValue(lit *ast.CompositeLit, name string) ast.Expr { + for _, elt := range lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok { + continue + } + if key.Name == name { + return kv.Value + } + } + return nil +} + +// exprText returns the original source text for an expression. +func exprText(fset *token.FileSet, src []byte, node ast.Node) string { + start := fset.Position(node.Pos()).Offset + end := fset.Position(node.End()).Offset + if start < 0 || end > len(src) || start >= end { + return "" + } + return string(src[start:end]) +} + +// printExpr renders an AST expression to Go source (used where original +// source bytes are not to hand). +func printExpr(fset *token.FileSet, node ast.Node) string { + var sb strings.Builder + _ = printer.Fprint(&sb, fset, node) + return sb.String() +} + +// resolveBoundTypes maps each element of the Bind slice literal to its struct +// type and collects the exported methods of that type from the parsed files. +func resolveBoundTypes(fset *token.FileSet, files map[string]*ast.File, proj *V2Project, bind ast.Expr) []*BoundType { + lit, ok := bind.(*ast.CompositeLit) + if !ok { + proj.Report.Manual("Bind", "The Bind value is not a slice literal, so bound structs could not be discovered. The generated frontend/wailsjs shims may be incomplete; check frontend imports against the v3 bindings generated into frontend/bindings.") + return nil + } + + mainFile := files[proj.Main.Path] + var result []*BoundType + for _, elt := range lit.Elts { + expr := printExpr(fset, elt) + typeName := resolveElementType(fset, files, mainFile, elt) + bt := &BoundType{Expr: expr, Name: typeName} + if typeName == "" { + proj.Report.Manual("Bind: "+expr, + "Could not statically determine the struct type of this Bind entry. It is still registered as a v3 service, but no frontend/wailsjs shim was generated for it.") + result = append(result, bt) + continue + } + pkgName, pkgPath, methods := collectMethods(fset, files, proj, typeName) + bt.PkgName = pkgName + bt.PkgPath = pkgPath + bt.Methods = methods + if len(methods) == 0 { + proj.Report.Note("Bind: " + expr + " (" + typeName + ") has no exported methods that could be discovered; no frontend/wailsjs shim was generated for it.") + } + result = append(result, bt) + } + return result +} + +// resolveElementType attempts to resolve a Bind element expression to a +// struct type name using purely syntactic information: +// +// &App{...} -> App +// NewApp() -> return type of func NewApp +// app -> declaration of app in the same file (app := NewApp(), +// app := &App{}, var app = ...) +func resolveElementType(fset *token.FileSet, files map[string]*ast.File, mainFile *ast.File, expr ast.Expr) string { + switch e := expr.(type) { + case *ast.UnaryExpr: + if e.Op == token.AND { + if lit, ok := e.X.(*ast.CompositeLit); ok { + if ident, ok := lit.Type.(*ast.Ident); ok { + return ident.Name + } + } + } + case *ast.CallExpr: + if ident, ok := e.Fun.(*ast.Ident); ok { + return constructorReturnType(files, ident.Name) + } + case *ast.Ident: + var typeName string + ast.Inspect(mainFile, func(n ast.Node) bool { + if typeName != "" { + return false + } + assign, ok := n.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != 1 || len(assign.Rhs) != 1 { + return true + } + lhs, ok := assign.Lhs[0].(*ast.Ident) + if !ok || lhs.Name != e.Name { + return true + } + typeName = resolveElementType(fset, files, mainFile, assign.Rhs[0]) + return false + }) + return typeName + } + return "" +} + +// constructorReturnType finds `func Name(...) *T` in the parsed files and +// returns T. +func constructorReturnType(files map[string]*ast.File, name string) string { + for _, file := range files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || fn.Name.Name != name { + continue + } + if fn.Type.Results == nil || len(fn.Type.Results.List) == 0 { + return "" + } + t := fn.Type.Results.List[0].Type + if star, ok := t.(*ast.StarExpr); ok { + t = star.X + } + if ident, ok := t.(*ast.Ident); ok { + return ident.Name + } + } + } + return "" +} + +// collectMethods gathers the exported methods declared on *typeName or +// typeName across the parsed files. +func collectMethods(fset *token.FileSet, files map[string]*ast.File, proj *V2Project, typeName string) (pkgName, pkgPath string, methods []*BoundMethod) { + // Sort file paths for deterministic method order across map iteration. + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + + for _, path := range paths { + file := files[path] + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 { + continue + } + recv := fn.Recv.List[0].Type + if star, ok := recv.(*ast.StarExpr); ok { + recv = star.X + } + ident, ok := recv.(*ast.Ident) + if !ok || ident.Name != typeName { + continue + } + if !fn.Name.IsExported() { + continue + } + if pkgName == "" { + pkgName = file.Name.Name + pkgPath = packagePath(proj, path, pkgName) + } + methods = append(methods, &BoundMethod{ + Name: fn.Name.Name, + Params: fieldListParams(fset, fn.Type.Params), + Results: fieldListParams(fset, fn.Type.Results), + }) + } + } + return pkgName, pkgPath, methods +} + +// packagePath computes the binding FQN package path for a file: "main" for +// the main package, otherwise modulePath[/reldir]. +func packagePath(proj *V2Project, filePath, pkgName string) string { + if pkgName == "main" { + return "main" + } + rel, err := filepath.Rel(proj.Dir, filepath.Dir(filePath)) + if err != nil || rel == "." { + return proj.ModulePath + } + return proj.ModulePath + "/" + filepath.ToSlash(rel) +} + +func fieldListParams(fset *token.FileSet, list *ast.FieldList) []Param { + if list == nil { + return nil + } + var params []Param + for _, field := range list.List { + goType := printExpr(fset, field.Type) + tsType := goTypeToTS(goType) + if len(field.Names) == 0 { + params = append(params, Param{GoType: goType, TSType: tsType}) + continue + } + for _, name := range field.Names { + params = append(params, Param{Name: name.Name, GoType: goType, TSType: tsType}) + } + } + return params +} + +// goTypeToTS maps a printed Go type to a best-effort TypeScript type for the +// generated .d.ts shims. Anything unrecognised becomes "any"; the real v3 +// bindings (frontend/bindings) carry full model types. +func goTypeToTS(goType string) string { + goType = strings.TrimPrefix(goType, "*") + switch goType { + case "string": + return "string" + case "bool": + return "boolean" + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", + "float32", "float64", "byte", "rune", "uintptr": + return "number" + case "interface{}", "any": + return "any" + case "error": + return "void" + } + if strings.HasPrefix(goType, "[]") { + return goTypeToTS(goType[2:]) + "[]" + } + if strings.HasPrefix(goType, "map[") { + return "Record" + } + return "any" +} diff --git a/v3/internal/migrate/report.go b/v3/internal/migrate/report.go new file mode 100644 index 00000000000..0622227472f --- /dev/null +++ b/v3/internal/migrate/report.go @@ -0,0 +1,118 @@ +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") + sb.WriteString("1. Run `wails3 doctor` to check your environment.\n") + sb.WriteString("2. Port the call sites listed below to the v3 API. The compiler will point at\n") + sb.WriteString(" them: the project intentionally does not build until they are ported.\n") + sb.WriteString(" Do not run `go mod tidy` before this is done - it would re-add the v2\n") + sb.WriteString(" dependency and make the old calls compile, but they cannot work inside a\n") + sb.WriteString(" v3 application and will fail at runtime.\n") + sb.WriteString("3. Run `go mod tidy`, then `wails3 generate bindings` for the frontend bindings.\n") + sb.WriteString("4. Run `wails3 dev` to build and run the migrated app.\n") + sb.WriteString("5. Work through the *Manual steps* below, if any.\n") + sb.WriteString(" See https://v3.wails.io/migration/v2-to-v3/ for the full guide.\n\n") + + if len(r.callSites) > 0 { + sb.WriteString("## Port these to the v3 API\n\n") + sb.WriteString("In v3 there is no context-based runtime package: you call methods on the\n") + sb.WriteString("application (`application.Get()` from anywhere) and on window objects.\n") + sb.WriteString("The migrator does not rewrite these call sites for you - a half-migrated\n") + sb.WriteString("file is worse than a clear list - so each one is enumerated here:\n\n") + sb.WriteString("| Location | v2 API | v3 replacement |\n|---|---|---|\n") + for _, cs := range r.callSites { + sb.WriteString(cs + "\n") + } + sb.WriteString("\n") + } + + if len(r.manual) > 0 { + sb.WriteString("## Manual steps\n\n") + keys := make([]string, 0, len(r.manual)) + 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" +}