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