返回 DeepSeek-Reasonix
gosurface.go
根目录 / tools / desktopinventory / gosurface.go
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
365 lines GO