|
| 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