Skip to content

Commit 3951e61

Browse files
committed
refactor(v3): migrate advises v2 API porting instead of shipping compat layers
Half-migrated code helps nobody, and compatibility shims invite new code onto the old API. The migrate command now draws a hard line: Migrated fully (deterministic): - project scaffold, Taskfile, build assets, config.yml from wails.json - main.go rewritten around application.New()/NewWithOptions with the options mapped; lifecycle hooks wired natively (ApplicationStarted event, WindowRuntimeReady event, Options.OnShutdown, ShouldQuit) - go.mod v2 -> v3; frontend copied with @wailsio/runtime added Documented instead of migrated: - every call into the v2 runtime package, listed in MIGRATION.md by file:line with its concrete v3 replacement; the sources are copied untouched, so the compiler points at exactly the listed locations until they are ported - every frontend wailsjs import, with the @wailsio/runtime equivalent and the generate-bindings workflow; the generated wailsjs directory is not carried over (it is v2 build output and cannot work with v3) Removed: the v2compat runtime bridge and the generated wailsjs shims. go mod tidy is skipped (with a warning) while v2 call sites remain, since tidying would re-add the v2 dependency and let old calls compile only to fail at runtime; MIGRATION.md spells this out.
1 parent e85d8f2 commit 3951e61

27 files changed

Lines changed: 422 additions & 1724 deletions

docs/src/content/docs/migration/v2-to-v3.mdx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,23 @@ The CLI can perform most of this guide for you:
3030
wails3 migrate -d ./myv2project -o ./myv3project
3131
```
3232

33-
This parses your v2 project (wails.json and the `options.App` literal passed to `wails.Run`) and generates a v3 project:
33+
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.
34+
35+
What it migrates for you:
3436

3537
- `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.
36-
- Structs listed in `Bind` become v3 services; the `OnStartup`/`OnDomReady`/`OnShutdown`/`OnBeforeClose` callbacks are bridged automatically.
37-
- Go files calling the v2 `runtime` package are pointed at a `v2compat/runtime` package generated *into your project*: a temporary bridge with the v2 API (context-first functions) implemented on the v3 API. Each bridge function documents its v3 replacement, so you can port call sites incrementally, delete bridge functions as you go, and remove the package when nothing imports it any more.
38-
- The frontend is copied over and `wailsjs/` is regenerated as a thin layer over `@wailsio/runtime`, so existing imports like `../wailsjs/go/main/App` and `../wailsjs/runtime/runtime` keep working.
38+
- Structs listed in `Bind` become v3 services, and the `OnStartup`/`OnDomReady`/`OnShutdown`/`OnBeforeClose` callbacks are wired to their v3 counterparts (application events, `OnShutdown`, `ShouldQuit`).
3939
- `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).
4040
- `go.mod` swaps `wails/v2` for `wails/v3`; everything else is preserved.
41+
- 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.
42+
43+
What it documents for you (in `MIGRATION.md`):
4144

42-
Anything that cannot be migrated automatically (menus, custom loggers, `EnumBind`, ...) is listed with instructions in a generated `MIGRATION.md`. Run `wails3 dev` in the output directory to build and run the migrated app, then work through that file.
45+
- 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.
46+
- Every frontend import of `wailsjs/runtime` or `wailsjs/go/...`, with the `@wailsio/runtime` equivalent and the `wails3 generate bindings` workflow for bindings.
47+
- Options that need a human decision (menus, custom loggers, `EnumBind`, ...), each with instructions.
4348

44-
The rest of this guide explains the underlying changes - useful for finishing the manual steps and for migrating off the compatibility bridge over time.
49+
The rest of this guide explains the underlying changes in depth - use it together with the generated checklist.
4550

4651
## Breaking Changes
4752

v3/internal/commands/migrate.go

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ import (
2020
//
2121
// wails3 migrate -d ./myv2project -o ./myv3project
2222
//
23-
// It parses wails.json and the declarative options.App literal passed to
24-
// wails.Run, generates an equivalent programmatic v3 main file, scaffolds the
25-
// v3 build system (Taskfile + build assets), migrates the frontend (rewriting
26-
// the generated wailsjs modules onto @wailsio/runtime) and rewrites v2
27-
// runtime imports to the v3 compatibility bridge. Everything that cannot be
28-
// migrated automatically is recorded in MIGRATION.md.
23+
// It migrates the parts that map deterministically - wails.json and the
24+
// declarative options.App literal become the v3 build system (Taskfile +
25+
// build assets + config.yml) and a programmatic v3 main file, go.mod swaps to
26+
// v3 and the frontend is carried over - and it deliberately does NOT rewrite
27+
// v2 runtime call sites or generate compatibility layers. Every remaining v2
28+
// API usage (Go runtime calls, frontend wailsjs imports) is enumerated in
29+
// MIGRATION.md with its concrete v3 replacement, and the compiler points at
30+
// exactly those locations until the user ports them.
2931
func Migrate(options *flags.Migrate) error {
3032
DisableFooter = true
3133

@@ -90,15 +92,6 @@ func Migrate(options *flags.Migrate) error {
9092
return err
9193
}
9294

93-
// The compatibility bridge is generated into the project (not shipped as
94-
// part of the v3 module) so that only migrated projects carry it, and its
95-
// owners can delete it as they finish porting to the v3 API.
96-
if proj.UsesV2Runtime || v3opts.NeedsLifecycleService() {
97-
if err := migrate.WriteCompatBridge(proj, outDir); err != nil {
98-
return err
99-
}
100-
}
101-
10295
// go.mod: swap wails/v2 for wails/v3, keep everything else.
10396
// LatestStable is the released tag even in dev builds, so the generated
10497
// require is always resolvable.
@@ -122,7 +115,13 @@ func Migrate(options *flags.Migrate) error {
122115
return err
123116
}
124117

125-
if !options.SkipGoModTidy {
118+
// While v2 API call sites remain, `go mod tidy` would re-add the v2
119+
// dependency (it scans imports). Leave the module intentionally
120+
// non-tidied: the compiler and MIGRATION.md point at the exact call
121+
// sites to port, after which the user runs `go mod tidy` themselves.
122+
if proj.UsesV2Runtime {
123+
term.Warningf("Skipping go mod tidy: v2 API call sites remain. Port them (see MIGRATION.md), then run `go mod tidy`.\n")
124+
} else if !options.SkipGoModTidy {
126125
term.Info("Running go mod tidy...")
127126
cmd := exec.Command("go", "mod", "tidy")
128127
cmd.Dir = outDir
@@ -134,11 +133,15 @@ func Migrate(options *flags.Migrate) error {
134133

135134
term.Infof("Migration complete: %s\n", outDir)
136135
if proj.Report.HasManualSteps() {
137-
term.Warningf("Some options need manual attention - see %s\n", reportPath)
136+
term.Warningf("Work remains - see %s\n", reportPath)
138137
} else {
139138
term.Infof("See %s for the migration summary.\n", reportPath)
140139
}
141-
term.Infof("Next: cd %s && wails3 dev\n", options.OutputDir)
140+
if proj.UsesV2Runtime {
141+
term.Infof("Next: port the call sites listed in MIGRATION.md, then run `wails3 dev` in %s\n", options.OutputDir)
142+
} else {
143+
term.Infof("Next: cd %s && wails3 dev\n", options.OutputDir)
144+
}
142145
return nil
143146
}
144147

v3/internal/commands/migrate_test.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ func main() {
5757
`,
5858
"app.go": `package main
5959
60-
import "context"
60+
import (
61+
"context"
62+
63+
"github.com/wailsapp/wails/v2/pkg/runtime"
64+
)
6165
6266
type App struct {
6367
ctx context.Context
@@ -72,6 +76,7 @@ func (a *App) startup(ctx context.Context) {
7276
}
7377
7478
func (a *App) Greet(name string) string {
79+
runtime.EventsEmit(a.ctx, "greeted", name)
7580
return "Hello " + name
7681
}
7782
`,
@@ -118,11 +123,7 @@ func TestMigrateEndToEnd(t *testing.T) {
118123
"build/darwin/Taskfile.yml",
119124
"build/appicon.png",
120125
"frontend/package.json",
121-
"frontend/wailsjs/runtime/runtime.js",
122-
"frontend/wailsjs/go/main/App.js",
123126
"frontend/dist/.gitkeep",
124-
"v2compat/runtime/window.go",
125-
"v2compat/runtime/lifecycle.go",
126127
}
127128
for _, rel := range mustExist {
128129
if _, err := os.Stat(filepath.Join(outDir, rel)); err != nil {
@@ -133,6 +134,8 @@ func TestMigrateEndToEnd(t *testing.T) {
133134
mustNotExist := []string{
134135
"wails.json",
135136
"go.sum",
137+
"frontend/wailsjs",
138+
"v2compat",
136139
}
137140
for _, rel := range mustNotExist {
138141
if _, err := os.Stat(filepath.Join(outDir, rel)); err == nil {
@@ -177,6 +180,26 @@ func TestMigrateEndToEnd(t *testing.T) {
177180
if !strings.Contains(string(goMod), "github.com/wailsapp/wails/v3") || strings.Contains(string(goMod), "wails/v2") {
178181
t.Errorf("go.mod not transformed:\n%s", goMod)
179182
}
183+
184+
// The runtime call in app.go must be enumerated with its replacement.
185+
report, err := os.ReadFile(filepath.Join(outDir, "MIGRATION.md"))
186+
if err != nil {
187+
t.Fatal(err)
188+
}
189+
for _, want := range []string{"Port these to the v3 API", "app.go:", "runtime.EventsEmit", "app.Event.Emit"} {
190+
if !strings.Contains(string(report), want) {
191+
t.Errorf("MIGRATION.md missing %q\n---\n%s", want, report)
192+
}
193+
}
194+
195+
// app.go is copied untouched: porting it is the user's (documented) job.
196+
appSrc, err := os.ReadFile(filepath.Join(outDir, "app.go"))
197+
if err != nil {
198+
t.Fatal(err)
199+
}
200+
if !strings.Contains(string(appSrc), "github.com/wailsapp/wails/v2/pkg/runtime") {
201+
t.Errorf("app.go should be untouched:\n%s", appSrc)
202+
}
180203
}
181204

182205
func TestMigrateRefusesNonEmptyOutput(t *testing.T) {

v3/internal/migrate/advisor.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package migrate
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"go/ast"
7+
"go/token"
8+
"os"
9+
"path/filepath"
10+
"regexp"
11+
"strings"
12+
)
13+
14+
// The migrator deliberately does not rewrite runtime call sites and does not
15+
// generate compatibility layers: half-migrated code helps nobody. Instead,
16+
// every v2 runtime call and every wailsjs import is recorded here with its
17+
// concrete v3 replacement, so the user gets a precise, project-specific
18+
// checklist in MIGRATION.md and the compiler points at exactly the listed
19+
// locations until they are ported.
20+
21+
// goRuntimeAdvice maps a v2 runtime function name to advice for porting the
22+
// call site to the v3 API. `app` refers to the application instance
23+
// (application.Get() from anywhere).
24+
var goRuntimeAdvice = map[string]string{
25+
"EventsEmit": "`app.Event.Emit(name, data...)`",
26+
"EventsOn": "`app.Event.On(name, func(e *application.CustomEvent) { ... })` - the callback receives the event object; your payload is `e.Data`. Returns an unsubscribe func.",
27+
"EventsOnce": "`app.Event.On(...)` and call the returned unsubscribe func inside the callback (v3 has no Once on the manager)",
28+
"EventsOnMultiple": "`app.Event.OnMultiple(name, callback, counter)`",
29+
"EventsOff": "`app.Event.Off(name)`",
30+
"EventsOffAll": "`app.Event.Reset()`",
31+
32+
"Quit": "`app.Quit()`",
33+
"Hide": "`app.Hide()`",
34+
"Show": "`app.Show()`",
35+
"Environment": "`app.Env.Info()` (returns application.EnvironmentInfo: OS, Arch, Debug)",
36+
37+
"BrowserOpenURL": "`app.Browser.OpenURL(url)`",
38+
39+
"ClipboardGetText": "`app.Clipboard.Text()` (returns (string, bool) instead of (string, error))",
40+
"ClipboardSetText": "`app.Clipboard.SetText(text)` (returns bool instead of error)",
41+
42+
"ScreenGetAll": "`app.Screen.GetAll()` (the v3 Screen struct differs: Size, Bounds, PhysicalBounds, IsPrimary)",
43+
44+
"LogPrint": "`app.Logger.Info(message)`",
45+
"LogPrintf": "`app.Logger.Info(fmt.Sprintf(...))`",
46+
"LogTrace": "`app.Logger.Debug(message)`",
47+
"LogTracef": "`app.Logger.Debug(fmt.Sprintf(...))`",
48+
"LogDebug": "`app.Logger.Debug(message)`",
49+
"LogDebugf": "`app.Logger.Debug(fmt.Sprintf(...))`",
50+
"LogInfo": "`app.Logger.Info(message)`",
51+
"LogInfof": "`app.Logger.Info(fmt.Sprintf(...))`",
52+
"LogWarning": "`app.Logger.Warn(message)`",
53+
"LogWarningf": "`app.Logger.Warn(fmt.Sprintf(...))`",
54+
"LogError": "`app.Logger.Error(message)`",
55+
"LogErrorf": "`app.Logger.Error(fmt.Sprintf(...))`",
56+
"LogFatal": "`app.Logger.Error(message)` + `os.Exit(1)`",
57+
"LogFatalf": "`app.Logger.Error(fmt.Sprintf(...))` + `os.Exit(1)`",
58+
"LogSetLogLevel": "set `application.Options.LogLevel` (log/slog level) at startup",
59+
60+
"MenuSetApplicationMenu": "rebuild the menu with `app.NewMenu()` and apply it with `app.Menu.SetApplicationMenu(menu)`",
61+
"MenuUpdateApplicationMenu": "`app.Menu.UpdateApplicationMenu()`",
62+
63+
"OpenDirectoryDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{CanChooseDirectories: true, CanChooseFiles: false, ...}).PromptForSingleSelection()`",
64+
"OpenFileDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{...}).PromptForSingleSelection()` (field names differ slightly, e.g. DefaultDirectory -> Directory)",
65+
"OpenMultipleFilesDialog": "`app.Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{AllowsMultipleSelection: true, ...}).PromptForMultipleSelection()`",
66+
"SaveFileDialog": "`app.Dialog.SaveFileWithOptions(&application.SaveFileDialogOptions{...}).PromptForSingleSelection()`",
67+
"MessageDialog": "`app.Dialog.Info()/Question()/Warning()/Error()` with `.AddButton(label).OnClick(func(){...})` - the result arrives via button callbacks, not a return value",
68+
69+
"OnFileDrop": "`window.OnWindowEvent(events.Common.WindowFilesDropped, func(e *application.WindowEvent) { e.Context().DroppedFiles() })` - requires `WebviewWindowOptions.EnableFileDrop: true`",
70+
"OnFileDropOff": "call the unsubscribe func returned by `OnWindowEvent`",
71+
}
72+
73+
// windowRuntimeAdvice maps v2 Window* functions to the v3 window method.
74+
// They all operate on a window object: `app.Window.Current()` or a window you
75+
// keep a reference to.
76+
var windowRuntimeAdvice = map[string]string{
77+
"WindowSetTitle": "`window.SetTitle(title)`",
78+
"WindowFullscreen": "`window.Fullscreen()`",
79+
"WindowUnfullscreen": "`window.UnFullscreen()`",
80+
"WindowCenter": "`window.Center()`",
81+
"WindowReload": "`window.Reload()`",
82+
"WindowReloadApp": "`window.ForceReload()`",
83+
"WindowShow": "`window.Show()`",
84+
"WindowHide": "`window.Hide()`",
85+
"WindowSetSize": "`window.SetSize(width, height)`",
86+
"WindowGetSize": "`window.Size()`",
87+
"WindowSetMinSize": "`window.SetMinSize(width, height)`",
88+
"WindowSetMaxSize": "`window.SetMaxSize(width, height)`",
89+
"WindowSetAlwaysOnTop": "`window.SetAlwaysOnTop(b)`",
90+
"WindowSetPosition": "`window.SetRelativePosition(x, y)`",
91+
"WindowGetPosition": "`window.RelativePosition()`",
92+
"WindowMaximise": "`window.Maximise()`",
93+
"WindowToggleMaximise": "`window.ToggleMaximise()`",
94+
"WindowUnmaximise": "`window.UnMaximise()`",
95+
"WindowMinimise": "`window.Minimise()`",
96+
"WindowUnminimise": "`window.UnMinimise()`",
97+
"WindowIsFullscreen": "`window.IsFullscreen()`",
98+
"WindowIsMaximised": "`window.IsMaximised()`",
99+
"WindowIsMinimised": "`window.IsMinimised()`",
100+
"WindowIsNormal": "combine `!window.IsFullscreen() && !window.IsMaximised() && !window.IsMinimised()`",
101+
"WindowExecJS": "`window.ExecJS(js)`",
102+
"WindowSetBackgroundColour": "`window.SetBackgroundColour(application.RGBA{Red: r, Green: g, Blue: b, Alpha: a})`",
103+
"WindowPrint": "`window.Print()`",
104+
"WindowSetSystemDefaultTheme": "set `WebviewWindowOptions.Theme: application.SystemDefault` at window creation (v3 has no runtime theme setter)",
105+
"WindowSetLightTheme": "set `WebviewWindowOptions.Theme: application.Light` at window creation (v3 has no runtime theme setter)",
106+
"WindowSetDarkTheme": "set `WebviewWindowOptions.Theme: application.Dark` at window creation (v3 has no runtime theme setter)",
107+
}
108+
109+
// adviseGoRuntimeCalls records every call into the v2 runtime package with
110+
// its v3 replacement.
111+
func adviseGoRuntimeCalls(fset *token.FileSet, files map[string]*ast.File, proj *V2Project) {
112+
for path, file := range files {
113+
localName := ""
114+
for name, ipath := range importMap(file) {
115+
if ipath == V2RuntimeImport {
116+
localName = name
117+
}
118+
}
119+
if localName == "" {
120+
continue
121+
}
122+
rel, err := filepath.Rel(proj.Dir, path)
123+
if err != nil {
124+
rel = path
125+
}
126+
ast.Inspect(file, func(n ast.Node) bool {
127+
sel, ok := n.(*ast.SelectorExpr)
128+
if !ok {
129+
return true
130+
}
131+
ident, ok := sel.X.(*ast.Ident)
132+
if !ok || ident.Name != localName {
133+
return true
134+
}
135+
name := sel.Sel.Name
136+
advice, ok := goRuntimeAdvice[name]
137+
if !ok {
138+
advice, ok = windowRuntimeAdvice[name]
139+
if ok {
140+
advice += " - get the window with `app.Window.Current()` or keep a reference to the one you create"
141+
}
142+
}
143+
if !ok {
144+
// Type references (runtime.OpenDialogOptions{...}) and
145+
// anything unknown.
146+
advice = "see the v3 application API and https://v3.wails.io/migration/v2-to-v3/"
147+
}
148+
pos := fset.Position(sel.Pos())
149+
proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, pos.Line), "`runtime."+name+"`", advice)
150+
return true
151+
})
152+
}
153+
}
154+
155+
var wailsjsImportRe = regexp.MustCompile(`(?:from\s*|require\s*\(\s*)['"]([^'"]*wailsjs/(runtime|go)/[^'"]*)['"]`)
156+
157+
// frontendSourceExts are the file types scanned for wailsjs imports.
158+
var frontendSourceExts = map[string]bool{
159+
".js": true, ".jsx": true, ".ts": true, ".tsx": true,
160+
".svelte": true, ".vue": true, ".html": true, ".mjs": true, ".cjs": true,
161+
}
162+
163+
// adviseFrontendImports records every wailsjs import in the frontend sources
164+
// with its v3 replacement.
165+
func adviseFrontendImports(proj *V2Project) error {
166+
frontend := proj.FrontendDir
167+
if _, err := os.Stat(frontend); os.IsNotExist(err) {
168+
return nil
169+
}
170+
return filepath.Walk(frontend, func(path string, info os.FileInfo, err error) error {
171+
if err != nil {
172+
return err
173+
}
174+
if info.IsDir() {
175+
switch info.Name() {
176+
case "node_modules", "dist", "wailsjs":
177+
return filepath.SkipDir
178+
}
179+
return nil
180+
}
181+
if !frontendSourceExts[filepath.Ext(path)] {
182+
return nil
183+
}
184+
f, err := os.Open(path)
185+
if err != nil {
186+
return err
187+
}
188+
defer f.Close()
189+
rel, rerr := filepath.Rel(proj.Dir, path)
190+
if rerr != nil {
191+
rel = path
192+
}
193+
scanner := bufio.NewScanner(f)
194+
lineNo := 0
195+
for scanner.Scan() {
196+
lineNo++
197+
m := wailsjsImportRe.FindStringSubmatch(scanner.Text())
198+
if m == nil {
199+
continue
200+
}
201+
var advice string
202+
if m[2] == "runtime" {
203+
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`."
204+
} else {
205+
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"
206+
}
207+
proj.Report.CallSite(fmt.Sprintf("%s:%d", rel, lineNo), "`"+m[1]+"`", advice)
208+
}
209+
return scanner.Err()
210+
})
211+
}
212+
213+
// importedServiceName extracts the bound struct name from a wailsjs/go import
214+
// path such as ../wailsjs/go/main/App.
215+
func importedServiceName(importPath string) string {
216+
base := filepath.Base(importPath)
217+
return strings.TrimSuffix(base, filepath.Ext(base))
218+
}

0 commit comments

Comments
 (0)