返回 DeepSeek-Reasonix
native_windows.go
根目录 / internal / desktopinstance / native_windows.go
1 //go:build windows
2
3 package desktopinstance
4
5 import (
6 "bytes"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "syscall"
14 "time"
15 "unsafe"
16
17 "golang.org/x/sys/windows"
18
19 "reasonix/internal/pathidentity"
20 )
21
22 var user32 = windows.NewLazySystemDLL("user32.dll")
23
24 type process struct {
25 pid uint32
26 parent uint32
27 handle windows.Handle
28 image string
29 created windows.Filetime
30 status *Status
31 legacyProfile string
32 }
33
34 func canonical(path string) (string, error) {
35 absolute, err := filepath.Abs(path)
36 if err != nil {
37 return "", err
38 }
39 // Instance/process validation requires an existing path; the shared
40 // resolver also supports absent tails for creation workflows.
41 if _, err := os.Stat(absolute); err != nil {
42 return "", err
43 }
44 resolved, err := pathidentity.Resolve(absolute, pathidentity.Options{FollowLeaf: true})
45 if err != nil {
46 return "", err
47 }
48 return resolved.PhysicalPath, nil
49 }
50
51 func sameUser(handle windows.Handle) (bool, error) {
52 var token windows.Token
53 if err := windows.OpenProcessToken(handle, windows.TOKEN_QUERY, &token); err != nil {
54 return false, err
55 }
56 defer token.Close()
57 theirs, err := token.GetTokenUser()
58 if err != nil {
59 return false, err
60 }
61 ours, err := windows.GetCurrentProcessToken().GetTokenUser()
62 if err != nil {
63 return false, err
64 }
65 return theirs.User.Sid.Equals(ours.User.Sid), nil
66 }
67
68 func openProcess(pid, parent uint32) (*process, error) {
69 h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, pid)
70 if err != nil {
71 return nil, err
72 }
73 p := &process{pid: pid, parent: parent, handle: h}
74 ok := false
75 defer func() {
76 if !ok {
77 windows.CloseHandle(h)
78 }
79 }()
80 own, err := sameUser(h)
81 if err != nil {
82 return nil, fmt.Errorf("cannot verify process user: %w", err)
83 }
84 if !own {
85 return nil, errors.New("cannot verify process user")
86 }
87 buffer := make([]uint16, 32768)
88 size := uint32(len(buffer))
89 if err := windows.QueryFullProcessImageName(h, 0, &buffer[0], &size); err != nil {
90 return nil, err
91 }
92 p.image, err = canonical(windows.UTF16ToString(buffer[:size]))
93 if err != nil {
94 return nil, err
95 }
96 var exited, kernel, user windows.Filetime
97 if err := windows.GetProcessTimes(h, &p.created, &exited, &kernel, &user); err != nil {
98 return nil, err
99 }
100 if !p.alive() {
101 return nil, errors.New("process exited during inspection")
102 }
103 ok = true
104 return p, nil
105 }
106
107 func (p *process) alive() bool {
108 result, err := windows.WaitForSingleObject(p.handle, 0)
109 return err == nil && result == uint32(windows.WAIT_TIMEOUT)
110 }
111 func (p *process) close() { windows.CloseHandle(p.handle) }
112
113 func ordinaryProduct(image string) bool {
114 size, err := windows.GetFileVersionInfoSize(image, nil)
115 if err != nil || size == 0 || size > 1024*1024 {
116 return false
117 }
118 data := make([]byte, size)
119 if windows.GetFileVersionInfo(image, 0, size, unsafe.Pointer(&data[0])) != nil {
120 return false
121 }
122 var translations *uint16
123 var count uint32
124 if windows.VerQueryValue(unsafe.Pointer(&data[0]), `\VarFileInfo\Translation`, unsafe.Pointer(&translations), &count) != nil || count < 4 {
125 return false
126 }
127 values := unsafe.Slice(translations, int(count/2))
128 for i := 0; i+1 < len(values); i += 2 {
129 var value *uint16
130 var length uint32
131 key := fmt.Sprintf(`\StringFileInfo\%04x%04x\ProductName`, values[i], values[i+1])
132 if windows.VerQueryValue(unsafe.Pointer(&data[0]), key, unsafe.Pointer(&value), &length) == nil && value != nil && length > 0 {
133 name := windows.UTF16ToString(unsafe.Slice(value, int(length)))
134 runtime.KeepAlive(data)
135 return name == "Reasonix"
136 }
137 }
138 return false
139 }
140
141 func (p *process) terminate() error {
142 if !p.alive() {
143 return nil
144 }
145 h, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, p.pid)
146 if err != nil {
147 return err
148 }
149 defer windows.CloseHandle(h)
150 var created, exited, kernel, user windows.Filetime
151 if err := windows.GetProcessTimes(h, &created, &exited, &kernel, &user); err != nil {
152 return err
153 }
154 if created != p.created || !p.alive() {
155 return outcome(UnknownOwner, "process identity changed")
156 }
157 return windows.TerminateProcess(h, 1)
158 }
159
160 func readStatus(p *process) (Status, error) {
161 var zero Status
162 name, _ := windows.UTF16PtrFromString(fmt.Sprintf(`\\.\pipe\reasonix-shell-v1-%d`, p.pid))
163 pipe, err := windows.CreateFile(name, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0)
164 connectDeadline := time.Now().Add(2 * time.Second)
165 for errors.Is(err, windows.ERROR_PIPE_BUSY) && time.Now().Before(connectDeadline) {
166 time.Sleep(20 * time.Millisecond)
167 pipe, err = windows.CreateFile(name, windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0)
168 }
169 if err != nil {
170 return zero, err
171 }
172 defer windows.CloseHandle(pipe)
173 var owner uint32
174 if err := windows.GetNamedPipeServerProcessId(pipe, &owner); err != nil {
175 return zero, err
176 }
177 if owner != p.pid || !p.alive() {
178 return zero, outcome(UnknownOwner, "status pipe owner changed")
179 }
180 event, err := windows.CreateEvent(nil, 1, 0, nil)
181 if err != nil {
182 return zero, err
183 }
184 defer windows.CloseHandle(event)
185 data := make([]byte, 0, StatusLimit+1)
186 deadline := time.Now().Add(2 * time.Second)
187 for len(data) <= StatusLimit {
188 buffer := make([]byte, StatusLimit+1-len(data))
189 var n uint32
190 if err := windows.ResetEvent(event); err != nil {
191 return zero, err
192 }
193 ov := windows.Overlapped{HEvent: event}
194 err = windows.ReadFile(pipe, buffer, &n, &ov)
195 if errors.Is(err, windows.ERROR_IO_PENDING) {
196 remaining := max(time.Until(deadline), 0)
197 wait, waitErr := windows.WaitForSingleObject(event, uint32(remaining.Milliseconds()))
198 if waitErr != nil || wait != windows.WAIT_OBJECT_0 {
199 _ = windows.CancelIoEx(pipe, &ov)
200 _, _ = windows.WaitForSingleObject(event, windows.INFINITE)
201 return zero, errors.New("shell status read timeout")
202 }
203 err = windows.GetOverlappedResult(pipe, &ov, &n, false)
204 }
205 if n > 0 {
206 data = append(data, buffer[:n]...)
207 }
208 if bytes.Contains(data, []byte{'\n'}) {
209 break
210 }
211 if err != nil {
212 return zero, err
213 }
214 if n == 0 {
215 return zero, errors.New("empty shell status")
216 }
217 }
218 if !p.alive() {
219 return zero, errors.New("shell exited during status read")
220 }
221 return DecodeStatus(bytes.TrimSpace(data), p.pid)
222 }
223
224 func processList() ([]windows.ProcessEntry32, error) {
225 h, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
226 if err != nil {
227 return nil, err
228 }
229 defer windows.CloseHandle(h)
230 var entries []windows.ProcessEntry32
231 var e windows.ProcessEntry32
232 e.Size = uint32(unsafe.Sizeof(e))
233 for err = windows.Process32First(h, &e); err == nil; err = windows.Process32Next(h, &e) {
234 entries = append(entries, e)
235 }
236 if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
237 return nil, err
238 }
239 return entries, nil
240 }
241
242 func messageProfile(pid uint32) string {
243 class, _ := windows.UTF16PtrFromString("Chrome_MessageWindow")
244 find := user32.NewProc("FindWindowExW")
245 var after uintptr
246 for {
247 hwnd, _, _ := find.Call(^uintptr(2), after, uintptr(unsafe.Pointer(class)), 0)
248 if hwnd == 0 {
249 return ""
250 }
251 after = hwnd
252 var owner uint32
253 user32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&owner)))
254 if owner != pid {
255 continue
256 }
257 text := make([]uint16, 32768)
258 n, _, _ := user32.NewProc("GetWindowTextW").Call(hwnd, uintptr(unsafe.Pointer(&text[0])), uintptr(len(text)))
259 if n > 0 {
260 return windows.UTF16ToString(text[:n])
261 }
262 }
263 }
264
265 func closeWindows(p *process) {
266 callback := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr {
267 var owner uint32
268 user32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&owner)))
269 if owner == p.pid && p.alive() {
270 user32.NewProc("PostMessageW").Call(hwnd, 0x0010, 0, 0)
271 }
272 return 1
273 })
274 user32.NewProc("EnumWindows").Call(callback, 0)
275 }
276
277 func focusLegacyWindow(p *process) bool {
278 shown := false
279 callback := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr {
280 var owner uint32
281 user32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&owner)))
282 if owner != p.pid || !p.alive() {
283 return 1
284 }
285 n, _, _ := user32.NewProc("GetWindowTextLengthW").Call(hwnd)
286 if n == 0 {
287 return 1
288 }
289 user32.NewProc("ShowWindow").Call(hwnd, 9) // SW_RESTORE also unhides tray windows.
290 user32.NewProc("SetForegroundWindow").Call(hwnd)
291 shown = true
292 return 0
293 })
294 user32.NewProc("EnumWindows").Call(callback, 0)
295 return shown
296 }
297
298 func lockInstall(root string) (func(), error) {
299 token, err := windows.GetCurrentProcessToken().GetTokenUser()
300 if err != nil {
301 return nil, err
302 }
303 key := ProfileKey(root + "|" + token.User.Sid.String())
304 name, _ := windows.UTF16PtrFromString(`Local\Reasonix-Recovery-` + key)
305 h, err := windows.CreateMutex(nil, false, name)
306 if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
307 return nil, err
308 }
309 runtime.LockOSThread()
310 result, err := windows.WaitForSingleObject(h, 120000)
311 if err != nil || (result != windows.WAIT_OBJECT_0 && result != windows.WAIT_ABANDONED) {
312 windows.CloseHandle(h)
313 runtime.UnlockOSThread()
314 return nil, outcome(ExitTimeout, "another install or recovery is still running")
315 }
316 return func() { _ = windows.ReleaseMutex(h); windows.CloseHandle(h); runtime.UnlockOSThread() }, nil
317 }
318
319 func Notify(err error) {
320 titleText, bodyText := notificationContent(err)
321 title, _ := windows.UTF16PtrFromString(titleText)
322 text, _ := windows.UTF16PtrFromString(bodyText)
323 user32.NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)), 0x30)
324 }
325
326 func notificationContent(err error) (string, string) {
327 var failure *Error
328 if errors.As(err, &failure) && failure.Code == UnsupportedPortableLocation {
329 return "Reasonix 无法从当前位置启动", unsupportedPortableLocationMessage
330 }
331 return "Reasonix 启动 / Startup", "Reasonix 未能完成启动或更新,请查看日志后重试。\nReasonix could not finish startup or update.\n\n" + err.Error()
332 }
333
334 func confirmProcesses(list []*process) bool {
335 var text strings.Builder
336 text.WriteString("旧版 Reasonix 尚未退出。结束进程可能丢失未保存内容。\n\nEnd these old Reasonix processes and continue? Unsaved work may be lost.\n")
337 for _, p := range list {
338 fmt.Fprintf(&text, "\nPID %d: %s", p.pid, p.image)
339 }
340 title, _ := windows.UTF16PtrFromString("Reasonix 恢复 / Recovery")
341 body, _ := windows.UTF16PtrFromString(text.String())
342 // Label the standard dialog's buttons explicitly; the negative action is
343 // still IDNO and remains the default even on non-Chinese Windows systems.
344 runtime.LockOSThread()
345 defer runtime.UnlockOSThread()
346 continueText, _ := windows.UTF16PtrFromString("结束旧进程并继续")
347 cancelText, _ := windows.UTF16PtrFromString("取消")
348 callback := syscall.NewCallback(func(code int32, hwnd, param uintptr) uintptr {
349 if code == 5 { // HCBT_ACTIVATE
350 user32.NewProc("SetDlgItemTextW").Call(hwnd, 6, uintptr(unsafe.Pointer(continueText)))
351 user32.NewProc("SetDlgItemTextW").Call(hwnd, 7, uintptr(unsafe.Pointer(cancelText)))
352 }
353 next, _, _ := user32.NewProc("CallNextHookEx").Call(0, uintptr(code), hwnd, param)
354 return next
355 })
356 hook, _, _ := user32.NewProc("SetWindowsHookExW").Call(5, callback, 0, uintptr(windows.GetCurrentThreadId()))
357 if hook == 0 {
358 return false
359 }
360 defer user32.NewProc("UnhookWindowsHookEx").Call(hook)
361 result, _, _ := user32.NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(body)), uintptr(unsafe.Pointer(title)), 0x4|0x30|0x100)
362 return result == 6
363 }
364
365 func inspect(root, profile string, all bool) ([]*process, error) {
366 entries, err := processList()
367 if err != nil {
368 return nil, err
369 }
370 var found []*process
371 fail := func(err error) ([]*process, error) {
372 for _, p := range found {
373 p.close()
374 }
375 return nil, err
376 }
377 for _, e := range entries {
378 name := strings.ToLower(windows.UTF16ToString(e.ExeFile[:]))
379 if name != "reasonix.exe" && name != "reasonix-desktop.exe" {
380 continue
381 }
382 p, err := openProcess(e.ProcessID, e.ParentProcessID)
383 if err != nil {
384 if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
385 if denied := deniedCandidate(e.ProcessID, processList); denied != nil {
386 return fail(denied)
387 }
388 }
389 continue
390 }
391 role := ImageRole(root, p.image)
392 if role != "" && !ordinaryProduct(p.image) {
393 p.close()
394 return fail(outcome(UnknownOwner, "product identity could not be verified for PID %d", e.ProcessID))
395 }
396 if name == "reasonix.exe" {
397 status, statusErr := readStatus(p)
398 if statusErr == nil {
399 p.status = &status
400 if status.HomeKey == ProfileKey(profile) && role == "" {
401 p.close()
402 return fail(outcome(OtherInstallation, "another Reasonix installation owns this data home"))
403 }
404 } else {
405 if role != "" && !errors.Is(statusErr, windows.ERROR_FILE_NOT_FOUND) {
406 p.close()
407 return fail(outcome(UnknownOwner, "shell status could not be verified for PID %d: %v", e.ProcessID, statusErr))
408 }
409 p.legacyProfile = messageProfile(p.pid)
410 if p.legacyProfile != "" {
411 if real, err := canonical(p.legacyProfile); err == nil && strings.EqualFold(real, profile) && role == "" {
412 p.close()
413 return fail(outcome(OtherInstallation, "another legacy Reasonix installation owns this data home"))
414 }
415 }
416 }
417 }
418 if role == "" {
419 p.close()
420 continue
421 }
422 if !all {
423 matches := p.status != nil && p.status.HomeKey == ProfileKey(profile)
424 if !matches && p.legacyProfile != "" {
425 if real, err := canonical(p.legacyProfile); err == nil {
426 matches = strings.EqualFold(real, profile)
427 }
428 }
429 if !matches {
430 p.close()
431 continue
432 }
433 }
434 found = append(found, p)
435 }
436 return found, nil
437 }
438
439 // Windows can deny opening a terminating process from an older snapshot.
440 // Only its confirmed disappearance permits skipping it; live unknown owners
441 // and failed snapshot refreshes must still stop launch or recovery.
442 func deniedCandidate(pid uint32, snapshot func() ([]windows.ProcessEntry32, error)) error {
443 entries, err := snapshot()
444 if err != nil {
445 return outcome(UnknownOwner, "cannot refresh candidate PID %d after access denial: %v", pid, err)
446 }
447 for _, entry := range entries {
448 if entry.ProcessID == pid {
449 return outcome(UnknownOwner, "access denied while identifying candidate PID %d", pid)
450 }
451 }
452 return nil
453 }
454
455 func preparePaths(root, home string) (string, string, error) {
456 root, err := canonical(root)
457 if err != nil {
458 return "", "", err
459 }
460 profile := filepath.Join(home, "desktop-shell")
461 if err := os.MkdirAll(profile, 0700); err != nil {
462 return "", "", err
463 }
464 profile, err = canonical(profile)
465 return root, profile, err
466 }
467
467 lines GO