| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "sync" |
| 9 | "sync/atomic" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/config" |
| 13 | ) |
| 14 | |
| 15 | const ( |
| 16 | windowRestoreStateVersion = 1 |
| 17 | windowRestoreTimeout = 12 * time.Second |
| 18 | windowRestorePollInterval = 100 * time.Millisecond |
| 19 | ) |
| 20 | |
| 21 | type windowRestoreState struct { |
| 22 | SchemaVersion int `json:"schemaVersion"` |
| 23 | PID int `json:"pid"` |
| 24 | AttemptID uint64 `json:"attemptId,omitempty"` |
| 25 | Source string `json:"source"` |
| 26 | StartedAt string `json:"startedAt"` |
| 27 | TimeoutReported bool `json:"timeoutReported,omitempty"` |
| 28 | } |
| 29 | |
| 30 | var ( |
| 31 | windowRestoreMu sync.Mutex |
| 32 | windowRestoreSequence atomic.Uint64 |
| 33 | ) |
| 34 | |
| 35 | func windowRestoreStatePath() string { |
| 36 | return filepath.Join(config.MemoryUserDir(), "repair", "window-restore-state.json") |
| 37 | } |
| 38 | |
| 39 | func writeWindowRestoreState(state windowRestoreState) bool { |
| 40 | body, err := json.Marshal(state) |
| 41 | if err != nil { |
| 42 | return false |
| 43 | } |
| 44 | path := windowRestoreStatePath() |
| 45 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| 46 | return false |
| 47 | } |
| 48 | // This journal is best-effort diagnostics, not user data. Avoid the |
| 49 | // fsync-and-retry path here because it runs immediately before WindowShow |
| 50 | // and Windows antivirus locks can otherwise delay restoration noticeably. |
| 51 | return os.WriteFile(path, body, 0o600) == nil |
| 52 | } |
| 53 | |
| 54 | func readWindowRestoreState() (windowRestoreState, error) { |
| 55 | body, err := os.ReadFile(windowRestoreStatePath()) |
| 56 | if err != nil { |
| 57 | return windowRestoreState{}, err |
| 58 | } |
| 59 | var state windowRestoreState |
| 60 | if err := json.Unmarshal(body, &state); err != nil { |
| 61 | return windowRestoreState{}, err |
| 62 | } |
| 63 | return state, nil |
| 64 | } |
| 65 | |
| 66 | func (a *App) observeIncompleteWindowRestore() { |
| 67 | if !windowRestoreDiagnosticsSupported() { |
| 68 | return |
| 69 | } |
| 70 | windowRestoreMu.Lock() |
| 71 | state, err := readWindowRestoreState() |
| 72 | if err != nil { |
| 73 | windowRestoreMu.Unlock() |
| 74 | return |
| 75 | } |
| 76 | if state.PID > 0 && windowRestoreOwnerAlive(state.PID) { |
| 77 | windowRestoreMu.Unlock() |
| 78 | return |
| 79 | } |
| 80 | _ = os.Remove(windowRestoreStatePath()) |
| 81 | windowRestoreMu.Unlock() |
| 82 | if state.TimeoutReported { |
| 83 | return |
| 84 | } |
| 85 | _ = writePendingReport(windowRestoreFailureReport("incomplete", state.Source, state.StartedAt), true) |
| 86 | a.recordDiagnosticMetric("desktop_restore", "incomplete") |
| 87 | } |
| 88 | |
| 89 | func (a *App) showMainWindowFrom(source string) { |
| 90 | if a.ctx == nil { |
| 91 | return |
| 92 | } |
| 93 | if !windowRestoreDiagnosticsSupported() { |
| 94 | showFromBackground(a.ctx, a.backgroundMaximised.Swap(false)) |
| 95 | a.kickDeferredRebuildRetry() |
| 96 | return |
| 97 | } |
| 98 | |
| 99 | windowRestoreMu.Lock() |
| 100 | attemptID := windowRestoreSequence.Add(1) |
| 101 | state := windowRestoreState{ |
| 102 | SchemaVersion: windowRestoreStateVersion, |
| 103 | PID: os.Getpid(), |
| 104 | AttemptID: attemptID, |
| 105 | Source: metricBucket(source), |
| 106 | StartedAt: time.Now().UTC().Format(time.RFC3339Nano), |
| 107 | } |
| 108 | _ = writeWindowRestoreState(state) |
| 109 | windowRestoreMu.Unlock() |
| 110 | |
| 111 | showFromBackground(a.ctx, a.backgroundMaximised.Swap(false)) |
| 112 | a.goSafe("windowRestoreMonitor", func() { |
| 113 | restored := waitForWindowRestoreConfirmation() |
| 114 | a.completeWindowRestoreAttempt(attemptID, state, restored) |
| 115 | }) |
| 116 | a.kickDeferredRebuildRetry() |
| 117 | } |
| 118 | |
| 119 | func waitForWindowRestoreConfirmation() bool { |
| 120 | ticker := time.NewTicker(windowRestorePollInterval) |
| 121 | defer ticker.Stop() |
| 122 | timer := time.NewTimer(windowRestoreTimeout) |
| 123 | defer timer.Stop() |
| 124 | return awaitWindowRestoreConfirmation(windowRestoreConfirmed, ticker.C, timer.C) |
| 125 | } |
| 126 | |
| 127 | func awaitWindowRestoreConfirmation(confirmed func() bool, ticks, deadline <-chan time.Time) bool { |
| 128 | if confirmed() { |
| 129 | return true |
| 130 | } |
| 131 | for { |
| 132 | select { |
| 133 | case <-ticks: |
| 134 | if confirmed() { |
| 135 | return true |
| 136 | } |
| 137 | case <-deadline: |
| 138 | return confirmed() |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func (a *App) completeWindowRestoreAttempt(attemptID uint64, state windowRestoreState, restored bool) { |
| 144 | metric := "success" |
| 145 | windowRestoreMu.Lock() |
| 146 | if windowRestoreSequence.Load() != attemptID { |
| 147 | windowRestoreMu.Unlock() |
| 148 | return |
| 149 | } |
| 150 | if restored { |
| 151 | _ = os.Remove(windowRestoreStatePath()) |
| 152 | } else { |
| 153 | metric = "timeout" |
| 154 | if writePendingReport(windowRestoreFailureReport("timeout", state.Source, state.StartedAt), true) { |
| 155 | state.TimeoutReported = true |
| 156 | _ = writeWindowRestoreState(state) |
| 157 | } |
| 158 | } |
| 159 | windowRestoreMu.Unlock() |
| 160 | a.recordDiagnosticMetric("desktop_restore", metric) |
| 161 | } |
| 162 | |
| 163 | func windowRestoreFailureReport(kind, source, startedAt string) crashReport { |
| 164 | kind = metricBucket(kind) |
| 165 | source = metricBucket(source) |
| 166 | report := baseCrashReport("performance") |
| 167 | report.SchemaVersion = 2 |
| 168 | report.Source = "native.window" |
| 169 | report.Label = "windows.window_restore." + kind |
| 170 | report.ErrorType = "WindowsWindowRestoreFailure" |
| 171 | report.ErrorMessage = sanitizeCrashText("Windows window restoration did not complete normally.", maxCrashFieldBytes) |
| 172 | report.TopFrame = "windows.window_restore." + source |
| 173 | report.FingerprintHint = "windows.window_restore." + kind + "." + source |
| 174 | report.OccurredAt = time.Now().UTC().Format(time.RFC3339) |
| 175 | report.Message = sanitizeCrashText(fmt.Sprintf(`[windows.window_restore.%s] |
| 176 | |
| 177 | Reasonix could not confirm that the hidden window was restored. |
| 178 | |
| 179 | source: %s |
| 180 | attempt started at: %s |
| 181 | timeout: %s`, kind, source, sanitizeCrashField(startedAt, 64), windowRestoreTimeout), maxCrashDetailBytes) |
| 182 | return report |
| 183 | } |
| 184 |