| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "go/ast" |
| 6 | "go/parser" |
| 7 | "go/token" |
| 8 | "go/types" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "regexp" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | ) |
| 16 | |
| 17 | const wailsRuntimeImport = "github.com/wailsapp/wails/v2/pkg/runtime" |
| 18 | |
| 19 | // shellFilePatterns name desktop Go files that existed only because of the |
| 20 | // retired Wails, WebView2 or WebKitGTK shell. The delete-shell rules are gone |
| 21 | // from the tree with the old shell; the migrate-host rules keep classifying the |
| 22 | // survivors, and any reintroduced file matching a retired name is flagged |
| 23 | // delete-shell again. |
| 24 | var shellFilePatterns = map[string]struct { |
| 25 | class class |
| 26 | owner string |
| 27 | }{ |
| 28 | `^webview2_.*\.go$`: {classDeleteShell, "Chromium renderer supervision in the Electron main process"}, |
| 29 | `^webkit_.*\.go$`: {classDeleteShell, "WebKitGTK diagnostics are not needed under Chromium"}, |
| 30 | `^linux_renderer_recovery\.go$`: {classDeleteShell, "Electron render-process-gone recovery"}, |
| 31 | `^nvidia_wayland_linux\.go$`: {classDeleteShell, "Chromium owns GPU/Wayland selection"}, |
| 32 | `^wails_logger\.go$`: {classDeleteShell, "service stderr log captured by the shell supervisor"}, |
| 33 | `^window_restore_diagnostics.*\.go$`: {classDeleteShell, "Electron window presentation has no restore race"}, |
| 34 | `^web_runtime_.*\.go$`: {classDeleteShell, "renderer identity reported by the shell in hello"}, |
| 35 | `^hang_watchdog.*\.go$`: {classDeleteShell, "no native UI thread in the Go service"}, |
| 36 | `^icon_repair_.*\.go$`: {classDeleteShell, "icons are packaged by the Electron bundle"}, |
| 37 | `^window_icon_.*\.go$`: {classDeleteShell, "icons are packaged by the Electron bundle"}, |
| 38 | `^main\.go$`: {classKeepBusiness, "--host-rpc service entry + shell bootstrap"}, |
| 39 | `^single_instance\.go$`: {classMigrateHost, "Electron requestSingleInstanceLock keyed by canonical home"}, |
| 40 | `^menu\.go$`: {classMigrateHost, "Electron application menu"}, |
| 41 | `^tray.*\.go$`: {classMigrateHost, "Electron Tray through host/tray.*"}, |
| 42 | `^desktop_shell.*\.go$`: {classMigrateHost, "coordinator keeps ordering; presentation via host/window.*"}, |
| 43 | `^window_controls\.go$`: {classMigrateHost, "renderer window controls through the preload"}, |
| 44 | `^window_state\.go$`: {classMigrateHost, "geometry persisted by Go, applied through host/window.*"}, |
| 45 | `^zoom_factor\.go$`: {classMigrateHost, "zoomFactor in the hello window geometry"}, |
| 46 | `^system_quit.*\.go$`: {classMigrateHost, "Electron before-quit → desktop/beforeClose"}, |
| 47 | `^relauncher\.go$`: {classMigrateHost, "host/app.relaunch"}, |
| 48 | `^superseded_relaunch\.go$`: {classMigrateHost, "host/app.relaunch"}, |
| 49 | `^app_identity_windows\.go$`: {classMigrateHost, "Electron app.setAppUserModelId"}, |
| 50 | `^external_opener.*\.go$`: {classMigrateHost, "openers stay in Go; dialogs via host/dialog.*"}, |
| 51 | `^native_host.*\.go$`: {classMigrateHost, "nativeHost boundary"}, |
| 52 | `^host_.*\.go$`: {classMigrateHost, "hostrpc service mode"}, |
| 53 | } |
| 54 | |
| 55 | var persistenceLiteralRe = regexp.MustCompile(`\.(json|jsonl|toml|db|lock|sqlite|log|txt)$`) |
| 56 | |
| 57 | type goScan struct { |
| 58 | fset *token.FileSet |
| 59 | shellOnly map[string]bool |
| 60 | } |
| 61 | |
| 62 | func scanGo(root string, inv *inventory) error { |
| 63 | dir := filepath.Join(root, "desktop") |
| 64 | names, err := filepath.Glob(filepath.Join(dir, "*.go")) |
| 65 | if err != nil { |
| 66 | return err |
| 67 | } |
| 68 | sort.Strings(names) |
| 69 | s := &goScan{fset: token.NewFileSet(), shellOnly: map[string]bool{}} |
| 70 | events := map[string]string{} |
| 71 | persisted := map[string]string{} |
| 72 | for _, path := range names { |
| 73 | base := filepath.Base(path) |
| 74 | if strings.HasSuffix(base, "_test.go") { |
| 75 | continue |
| 76 | } |
| 77 | file, err := parser.ParseFile(s.fset, path, nil, parser.ParseComments) |
| 78 | if err != nil { |
| 79 | return err |
| 80 | } |
| 81 | rel := "desktop/" + base |
| 82 | shellClass, shellOwner, isShell := shellFile(base) |
| 83 | if isShell { |
| 84 | inv.add(entry{Kind: kindShellFile, Name: rel, Class: shellClass, Owner: shellOwner}) |
| 85 | } |
| 86 | alias := wailsAlias(file) |
| 87 | for _, decl := range file.Decls { |
| 88 | fn, ok := decl.(*ast.FuncDecl) |
| 89 | if !ok { |
| 90 | continue |
| 91 | } |
| 92 | s.collectNativeCalls(fn, alias, rel, shellClass, isShell, inv) |
| 93 | s.collectEvents(fn, events, rel) |
| 94 | s.collectPersistence(fn, persisted, rel, isShell && shellClass == classDeleteShell) |
| 95 | if isAppMethod(fn) && fn.Name.IsExported() { |
| 96 | inv.add(s.commandEntry(fn, alias, rel, shellClass, shellOwner, isShell)) |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | for name, loc := range events { |
| 101 | inv.add(entry{Kind: kindEvent, Name: name, Location: loc, Class: classKeepBusiness, Owner: "desktop/event frame (seq + generation), same payload"}) |
| 102 | } |
| 103 | for name, loc := range persisted { |
| 104 | e := entry{Kind: kindPersistence, Name: name, Location: loc, Class: classKeepBusiness, Owner: "format unchanged; read by both shells"} |
| 105 | if s.shellOnly[name] { |
| 106 | e.Class, e.Owner = classDeleteShell, "no longer written; old files are left in place" |
| 107 | } |
| 108 | inv.add(e) |
| 109 | } |
| 110 | return nil |
| 111 | } |
| 112 | |
| 113 | func shellFile(base string) (class, string, bool) { |
| 114 | for pattern, rule := range shellFilePatterns { |
| 115 | if regexp.MustCompile(pattern).MatchString(base) { |
| 116 | return rule.class, rule.owner, true |
| 117 | } |
| 118 | } |
| 119 | return "", "", false |
| 120 | } |
| 121 | |
| 122 | func wailsAlias(file *ast.File) string { |
| 123 | for _, imp := range file.Imports { |
| 124 | path, err := strconv.Unquote(imp.Path.Value) |
| 125 | if err != nil || path != wailsRuntimeImport { |
| 126 | continue |
| 127 | } |
| 128 | if imp.Name != nil { |
| 129 | return imp.Name.Name |
| 130 | } |
| 131 | return "runtime" |
| 132 | } |
| 133 | return "" |
| 134 | } |
| 135 | |
| 136 | func isAppMethod(fn *ast.FuncDecl) bool { |
| 137 | if fn.Recv == nil || len(fn.Recv.List) != 1 { |
| 138 | return false |
| 139 | } |
| 140 | star, ok := fn.Recv.List[0].Type.(*ast.StarExpr) |
| 141 | if !ok { |
| 142 | return false |
| 143 | } |
| 144 | ident, ok := star.X.(*ast.Ident) |
| 145 | return ok && ident.Name == "App" |
| 146 | } |
| 147 | |
| 148 | func (s *goScan) commandEntry(fn *ast.FuncDecl, alias, rel string, shellClass class, shellOwner string, isShell bool) entry { |
| 149 | e := entry{ |
| 150 | Kind: kindCommand, |
| 151 | Name: fn.Name.Name, |
| 152 | Detail: signature(fn), |
| 153 | Location: fmt.Sprintf("%s:%d", rel, s.fset.Position(fn.Pos()).Line), |
| 154 | Class: classKeepBusiness, |
| 155 | Owner: "hostrpc desktop/invoke", |
| 156 | } |
| 157 | switch { |
| 158 | case isShell && shellClass == classDeleteShell: |
| 159 | e.Class, e.Owner = classDeleteShell, shellOwner |
| 160 | case (alias != "" && usesSelector(fn.Body, alias)) || usesNativeHost(fn.Body): |
| 161 | e.Class, e.Owner = classMigrateHost, "business in Go; native step through nativeHost host/*" |
| 162 | } |
| 163 | return e |
| 164 | } |
| 165 | |
| 166 | func signature(fn *ast.FuncDecl) string { |
| 167 | var params, results []string |
| 168 | for _, f := range fn.Type.Params.List { |
| 169 | typ := types.ExprString(f.Type) |
| 170 | if len(f.Names) == 0 { |
| 171 | params = append(params, typ) |
| 172 | } |
| 173 | for _, n := range f.Names { |
| 174 | params = append(params, n.Name+" "+typ) |
| 175 | } |
| 176 | } |
| 177 | if fn.Type.Results != nil { |
| 178 | for _, f := range fn.Type.Results.List { |
| 179 | results = append(results, types.ExprString(f.Type)) |
| 180 | } |
| 181 | } |
| 182 | out := "(" + strings.Join(params, ", ") + ")" |
| 183 | switch len(results) { |
| 184 | case 0: |
| 185 | case 1: |
| 186 | out += " " + results[0] |
| 187 | default: |
| 188 | out += " (" + strings.Join(results, ", ") + ")" |
| 189 | } |
| 190 | return out |
| 191 | } |
| 192 | |
| 193 | func usesSelector(body *ast.BlockStmt, alias string) bool { |
| 194 | found := false |
| 195 | if body == nil { |
| 196 | return false |
| 197 | } |
| 198 | ast.Inspect(body, func(n ast.Node) bool { |
| 199 | if found { |
| 200 | return false |
| 201 | } |
| 202 | sel, ok := n.(*ast.SelectorExpr) |
| 203 | if !ok { |
| 204 | return true |
| 205 | } |
| 206 | if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == alias { |
| 207 | found = true |
| 208 | } |
| 209 | return true |
| 210 | }) |
| 211 | return found |
| 212 | } |
| 213 | |
| 214 | // usesNativeHost reports a method that reaches the shell through the |
| 215 | // nativeHost boundary once the direct Wails calls have been extracted. |
| 216 | func usesNativeHost(body *ast.BlockStmt) bool { |
| 217 | found := false |
| 218 | if body == nil { |
| 219 | return false |
| 220 | } |
| 221 | ast.Inspect(body, func(n ast.Node) bool { |
| 222 | if found { |
| 223 | return false |
| 224 | } |
| 225 | if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "nativeHost" { |
| 226 | found = true |
| 227 | } |
| 228 | return true |
| 229 | }) |
| 230 | return found |
| 231 | } |
| 232 | |
| 233 | func (s *goScan) collectNativeCalls(fn *ast.FuncDecl, alias, rel string, shellClass class, isShell bool, inv *inventory) { |
| 234 | if alias == "" || fn.Body == nil { |
| 235 | return |
| 236 | } |
| 237 | ast.Inspect(fn.Body, func(n ast.Node) bool { |
| 238 | call, ok := n.(*ast.CallExpr) |
| 239 | if !ok { |
| 240 | return true |
| 241 | } |
| 242 | sel, ok := call.Fun.(*ast.SelectorExpr) |
| 243 | if !ok { |
| 244 | return true |
| 245 | } |
| 246 | ident, ok := sel.X.(*ast.Ident) |
| 247 | if !ok || ident.Name != alias { |
| 248 | return true |
| 249 | } |
| 250 | e := entry{ |
| 251 | Kind: kindNativeCall, |
| 252 | Name: "runtime." + sel.Sel.Name, |
| 253 | Detail: "in " + fn.Name.Name, |
| 254 | Location: fmt.Sprintf("%s:%d", rel, s.fset.Position(call.Pos()).Line), |
| 255 | Class: classMigrateHost, |
| 256 | Owner: "nativeHost → " + hostMethodFor(sel.Sel.Name), |
| 257 | } |
| 258 | if isShell && shellClass == classDeleteShell { |
| 259 | e.Class, e.Owner = classDeleteShell, "removed with the old shell" |
| 260 | } |
| 261 | inv.add(e) |
| 262 | return true |
| 263 | }) |
| 264 | } |
| 265 | |
| 266 | func hostMethodFor(name string) string { |
| 267 | switch name { |
| 268 | case "EventsEmit": |
| 269 | return "desktop/event" |
| 270 | case "OpenDirectoryDialog", "OpenFileDialog", "OpenMultipleFilesDialog", "SaveFileDialog", "MessageDialog": |
| 271 | return "host/dialog.*" |
| 272 | case "BrowserOpenURL": |
| 273 | return "host/shell.openExternal" |
| 274 | case "Quit": |
| 275 | return "host/app.quit" |
| 276 | case "Hide", "Show": |
| 277 | return "host/app.hide, host/window.show" |
| 278 | case "ScreenGetAll": |
| 279 | return "host/screen.list" |
| 280 | case "WindowExecJS": |
| 281 | return "host/remoteWindow.navigate, host/devtools.toggle" |
| 282 | default: |
| 283 | return "host/window.*" |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | var eventEmitters = map[string]bool{"emitRuntimeEvent": true, "emitRemoteEvent": true, "EventsEmit": true} |
| 288 | |
| 289 | func (s *goScan) collectEvents(fn *ast.FuncDecl, events map[string]string, rel string) { |
| 290 | if fn.Body == nil { |
| 291 | return |
| 292 | } |
| 293 | ast.Inspect(fn.Body, func(n ast.Node) bool { |
| 294 | call, ok := n.(*ast.CallExpr) |
| 295 | if !ok || len(call.Args) == 0 { |
| 296 | return true |
| 297 | } |
| 298 | name := calleeName(call.Fun) |
| 299 | if !eventEmitters[name] { |
| 300 | return true |
| 301 | } |
| 302 | argIndex := 0 |
| 303 | if name == "EventsEmit" { |
| 304 | argIndex = 1 |
| 305 | } |
| 306 | if argIndex >= len(call.Args) { |
| 307 | return true |
| 308 | } |
| 309 | if lit, ok := call.Args[argIndex].(*ast.BasicLit); ok && lit.Kind == token.STRING { |
| 310 | value, err := strconv.Unquote(lit.Value) |
| 311 | if err == nil { |
| 312 | if _, seen := events[value]; !seen { |
| 313 | events[value] = fmt.Sprintf("%s:%d", rel, s.fset.Position(call.Pos()).Line) |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | return true |
| 318 | }) |
| 319 | } |
| 320 | |
| 321 | func calleeName(fun ast.Expr) string { |
| 322 | switch f := fun.(type) { |
| 323 | case *ast.Ident: |
| 324 | return f.Name |
| 325 | case *ast.SelectorExpr: |
| 326 | return f.Sel.Name |
| 327 | } |
| 328 | return "" |
| 329 | } |
| 330 | |
| 331 | func (s *goScan) collectPersistence(fn *ast.FuncDecl, persisted map[string]string, rel string, shellOnly bool) { |
| 332 | if fn.Body == nil { |
| 333 | return |
| 334 | } |
| 335 | ast.Inspect(fn.Body, func(n ast.Node) bool { |
| 336 | call, ok := n.(*ast.CallExpr) |
| 337 | if !ok || calleeName(call.Fun) != "Join" { |
| 338 | return true |
| 339 | } |
| 340 | for _, arg := range call.Args { |
| 341 | lit, ok := arg.(*ast.BasicLit) |
| 342 | if !ok || lit.Kind != token.STRING { |
| 343 | continue |
| 344 | } |
| 345 | value, err := strconv.Unquote(lit.Value) |
| 346 | if err != nil || !persistenceLiteralRe.MatchString(value) || strings.Contains(value, "*") { |
| 347 | continue |
| 348 | } |
| 349 | if _, seen := persisted[value]; !seen { |
| 350 | persisted[value] = fmt.Sprintf("%s:%d", rel, s.fset.Position(call.Pos()).Line) |
| 351 | s.shellOnly[value] = shellOnly |
| 352 | } |
| 353 | } |
| 354 | return true |
| 355 | }) |
| 356 | } |
| 357 | |
| 358 | func readFile(root, rel string) (string, error) { |
| 359 | data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) |
| 360 | if err != nil { |
| 361 | return "", err |
| 362 | } |
| 363 | return string(data), nil |
| 364 | } |
| 365 |