fix(desktop): remove obsolete safe mode paths

Problem: v1.20 no longer enters product Safe Mode, but the desktop still carried unreachable startup recovery, theme gating, renderer fallback, and platform helper paths that kept lint red.

Root cause: the initial Guard removal disconnected the entry points without deleting their dormant implementations or moving Linux-only helpers behind the Linux build tag.

Fix: delete the obsolete recovery and renderer policy, simplify theme operations to the normal path, retain only ignored legacy launch tokens and always-false wire fields for upgrade compatibility, and clear the remaining Desktop lint findings.

Verification: go test ./...; go vet ./...; golangci-lint run ./...; pnpm typecheck; pnpm exec tsx src/__tests__/theme-pack.test.ts; Windows amd64 cross-compile.
This commit is contained in:
SivanCola committed 2026-08-02 19:45:37 +08:00
1 parent 5664dc8ba7
commit d8a4288862
23 files changed
+41 -600

No files matched your search

+1 -2
View File
@@ -693,9 +693,8 @@ func (a *App) restoreOrBuildTabs() {
// Run legacy config migration before the first config load so the
// freshly written config (including the user's default_model) is
// picked up by Load instead of falling back to built-in defaults.
f := desktopTabsFile{}
_, _ = config.MigrateLegacyIfNeeded()
f = loadTabsFile()
f := loadTabsFile()
_, _ = recoverLegacyProjectSidebarRoots(f)
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
-1
View File
@@ -2683,7 +2683,6 @@ export const en = {
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
"guard.safeMode": "Reasonix started in Safe Mode. Plugins, MCP servers, hooks, bots, automations, and saved tabs are disabled for this run.",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
-1
View File
@@ -1837,7 +1837,6 @@ export const zhTW: Record<DictKey, string> = {
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
"guard.safeMode": "Reasonix 已進入安全模式。本次執行已停用外掛、MCP、Hooks、機器人、自動化和上次儲存的分頁。",
"questionNav.label": "問題導航",
"questionNav.progress": "問題 {current} / {total}",
"questionNav.jump": "跳轉到問題 {n}",
-1
View File
@@ -2686,7 +2686,6 @@ export const zh: Record<DictKey, string> = {
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
"guard.safeMode": "Reasonix 已进入安全模式。本次运行已禁用插件、MCP、Hooks、机器人、自动化和上次保存的标签页。",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package main
func telemetryInstallProfile() string {
return metricBucket(detectInstallProfile().Mode)
}
-35
View File
@@ -1,35 +0,0 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"strings"
)
// telemetryInstallProfile returns only a bounded distribution label. Executable
// paths are never included in telemetry or reports.
func telemetryInstallProfile() string {
executable, err := os.Executable()
if err != nil {
return "unknown"
}
normalized := strings.ToLower(filepath.Clean(executable))
if strings.Contains(normalized, `\scoop\apps\reasonix-desktop\`) ||
strings.Contains(normalized, `\scoop\apps\reasonix\`) {
return "scoop"
}
// The NSIS package always places its per-user uninstaller beside the
// executable, including when the user selected a custom install directory.
if info, statErr := os.Stat(filepath.Join(filepath.Dir(executable), "uninstall.exe")); statErr == nil && !info.IsDir() {
return "installer"
}
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
installed := strings.ToLower(filepath.Join(localAppData, "Programs", "Reasonix"))
if normalized == installed || strings.HasPrefix(normalized, installed+string(os.PathSeparator)) {
return "installer"
}
}
return "portable"
}
-2
View File
@@ -115,8 +115,6 @@ func main() {
// Observe previous run for crash diagnostics only. Startup tracking must
// never force Safe Mode, disable plugins, or select a previous binary.
previousRun := repair.NewStartupTracker("").ObservePreviousRun()
// Always boot the normal WebKit path.
configureWebKitRendererRecovery(false)
app := NewApp()
app.previousRun = previousRun
-134
View File
@@ -1,134 +0,0 @@
package main
import (
"errors"
"fmt"
"reasonix/internal/repair"
)
type desktopRecoveryChoice int
const (
desktopRecoveryQuit desktopRecoveryChoice = iota
desktopRecoverySafeMode
desktopRecoveryRepair
)
type desktopStartupRecoveryDeps struct {
recoverFailedInstall func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error)
reconcilePendingUpdate func() (repair.PendingUpdateReconcileResult, error)
rollbackPendingUpdate func() (repair.UpdateRollbackResult, error)
repairGlobalConfig func() error
choose func() desktopRecoveryChoice
markClean func() error
relaunch func(string) error
report func(string)
}
// runDesktopStartupRecovery mirrors Guard's preflight for the macOS bundle,
// whose native executable must be the Wails process so LaunchServices, the Dock,
// and the window all share one process identity. Windows and Linux still enter
// through Guard and do not use this path.
func runDesktopStartupRecovery(recommended, explicitSafeMode bool, deps desktopStartupRecoveryDeps) (safeMode, continueLaunch bool) {
safeMode = explicitSafeMode
report := func(message string) {
if deps.report != nil {
deps.report(message)
}
}
relaunchRestored := func(result repair.UpdateRollbackResult) bool {
if deps.markClean != nil {
if err := deps.markClean(); err != nil {
report("could not reset startup recovery state: " + err.Error())
}
}
if deps.relaunch == nil {
report("the previous Reasonix version was restored, but it could not be relaunched")
return false
}
if err := deps.relaunch(result.TargetPath); err != nil {
report("the previous Reasonix version was restored, but relaunch failed: " + err.Error())
}
return false
}
if deps.recoverFailedInstall != nil {
result, _, err := deps.recoverFailedInstall()
if err != nil {
report("update rollback after a failed install failed: " + err.Error())
if !result.RolledBack {
return safeMode, false
}
}
if result.RolledBack {
return safeMode, relaunchRestored(result)
}
}
// Reconcile abandoned pre-publish transactions on every launch instead of
// waiting for a crash loop. A target release still in its health-confirmation
// window remains probationary and is intentionally left untouched.
if deps.reconcilePendingUpdate != nil {
result, err := deps.reconcilePendingUpdate()
if err != nil && !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
report("previous update recovery failed: " + err.Error())
if result.MixedInstall {
return safeMode, false
}
return true, true
}
if result.RolledBack {
return safeMode, relaunchRestored(repair.UpdateRollbackResult{
RolledBack: true,
FromVersion: result.ToVersion,
ToVersion: result.FromVersion,
TargetPath: result.TargetPath,
})
}
}
// An explicit Safe Mode request is an operator override. Preserve Guard's
// behavior by skipping crash-loop rollback and the recovery prompt.
if explicitSafeMode || !recommended {
return safeMode, true
}
if deps.rollbackPendingUpdate != nil {
result, err := deps.rollbackPendingUpdate()
if err != nil {
report("update rollback failed: " + err.Error())
if result.MixedInstall {
return safeMode, false
}
// Staging/verification failures leave the current release coherent.
// Safe Mode is therefore the conservative fallback while the pending
// transaction remains available for a later retry.
return true, true
}
if result.RolledBack {
return safeMode, relaunchRestored(result)
}
}
choice := desktopRecoverySafeMode
if deps.choose != nil {
choice = deps.choose()
}
switch choice {
case desktopRecoveryRepair:
if deps.repairGlobalConfig != nil {
if err := deps.repairGlobalConfig(); err != nil {
report("configuration repair failed: " + err.Error())
}
}
return true, true
case desktopRecoverySafeMode:
return true, true
case desktopRecoveryQuit:
return safeMode, false
default:
report(fmt.Sprintf("unknown recovery choice %d; starting in Safe Mode", choice))
return true, true
}
}
-63
View File
@@ -1,63 +0,0 @@
//go:build darwin
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"reasonix/internal/repair"
)
func preparePackagedStartupRecovery(tracker *repair.StartupTracker, recommended, explicitSafeMode bool) (bool, bool) {
return runDesktopStartupRecovery(recommended, explicitSafeMode, desktopStartupRecoveryDeps{
recoverFailedInstall: repair.RecoverFailedInstall,
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.ReconcilePendingUpdate(version)
},
rollbackPendingUpdate: repair.RollbackPendingUpdate,
repairGlobalConfig: func() error {
_, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Apply: true, OnlyScope: "global"})
return err
},
choose: nativeDesktopRecoveryChoice,
markClean: tracker.MarkClean,
relaunch: func(appBundle string) error {
if strings.TrimSpace(appBundle) == "" {
return fmt.Errorf("restored application bundle path is empty")
}
return exec.Command("/usr/bin/open", "-na", appBundle).Run()
},
report: func(message string) {
fmt.Fprintln(os.Stderr, "Reasonix recovery:", message)
showDesktopRecoveryError(message)
},
})
}
func nativeDesktopRecoveryChoice() desktopRecoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged global configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return desktopRecoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return desktopRecoveryRepair
case "Safe Mode":
return desktopRecoverySafeMode
default:
return desktopRecoveryQuit
}
}
func showDesktopRecoveryError(message string) {
// Pass the message as argv rather than interpolating AppleScript so file
// paths and command errors containing quotes cannot alter the script.
script := `on run argv
display dialog (item 1 of argv) with title "Reasonix Recovery" buttons {"OK"} default button "OK" with icon stop
end run`
_ = exec.Command("osascript", "-e", script, message).Run()
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !darwin
package main
import "reasonix/internal/repair"
func preparePackagedStartupRecovery(_ *repair.StartupTracker, recommended, explicitSafeMode bool) (bool, bool) {
return explicitSafeMode || recommended, true
}
-145
View File
@@ -1,145 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"reasonix/internal/repair"
)
func TestDesktopStartupRecoveryNormalLaunch(t *testing.T) {
recovered := false
reconciled := false
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
recovered = true
return repair.UpdateRollbackResult{}, nil, nil
},
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
reconciled = true
return repair.PendingUpdateReconcileResult{}, nil
},
})
if !recovered || !reconciled || safeMode || !proceed {
t.Fatalf("recovered=%v reconciled=%v safeMode=%v proceed=%v", recovered, reconciled, safeMode, proceed)
}
}
func TestDesktopStartupRecoveryReconcilesAbandonedUpdateBeforeCrashThreshold(t *testing.T) {
var relaunched string
markedClean := false
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{
Pending: true,
RolledBack: true,
FromVersion: "v1",
ToVersion: "v2",
TargetPath: "/Applications/Reasonix.app",
}, nil
},
markClean: func() error { markedClean = true; return nil },
relaunch: func(path string) error { relaunched = path; return nil },
})
if safeMode || proceed || !markedClean || relaunched != "/Applications/Reasonix.app" {
t.Fatalf("safeMode=%v proceed=%v markedClean=%v relaunched=%q", safeMode, proceed, markedClean, relaunched)
}
}
func TestDesktopStartupRecoveryLeavesProbationaryUpdate(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
reconcilePendingUpdate: func() (repair.PendingUpdateReconcileResult, error) {
return repair.PendingUpdateReconcileResult{Pending: true, AwaitingHealth: true}, repair.ErrPendingUpdateAwaitingHealth
},
})
if safeMode || !proceed {
t.Fatalf("safeMode=%v proceed=%v", safeMode, proceed)
}
}
func TestDesktopStartupRecoveryRelaunchesRestoredBundle(t *testing.T) {
var relaunched string
markedClean := false
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
return repair.UpdateRollbackResult{}, nil, nil
},
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{RolledBack: true, TargetPath: "/Applications/Reasonix.app"}, nil
},
markClean: func() error { markedClean = true; return nil },
relaunch: func(path string) error { relaunched = path; return nil },
})
if safeMode || proceed || !markedClean || relaunched != "/Applications/Reasonix.app" {
t.Fatalf("safeMode=%v proceed=%v markedClean=%v relaunched=%q", safeMode, proceed, markedClean, relaunched)
}
}
func TestDesktopStartupRecoveryFailedInstallFailsClosed(t *testing.T) {
var reports []string
_, proceed := runDesktopStartupRecovery(false, false, desktopStartupRecoveryDeps{
recoverFailedInstall: func() (repair.UpdateRollbackResult, *repair.UpdateApplyFailure, error) {
return repair.UpdateRollbackResult{}, &repair.UpdateApplyFailure{}, errors.New("restore failed")
},
report: func(message string) { reports = append(reports, message) },
})
if proceed {
t.Fatal("desktop proceeded after an incomplete failed-install rollback")
}
if len(reports) != 1 || !strings.Contains(reports[0], "restore failed") {
t.Fatalf("reports = %#v", reports)
}
}
func TestDesktopStartupRecoveryCoherentRollbackFailureUsesSafeMode(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{}, errors.New("backup unavailable")
},
})
if !safeMode || !proceed {
t.Fatalf("safeMode=%v proceed=%v, want true true", safeMode, proceed)
}
}
func TestDesktopStartupRecoveryMixedInstallFailsClosed(t *testing.T) {
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{MixedInstall: true}, errors.New("compensation failed")
},
})
if safeMode || proceed {
t.Fatalf("safeMode=%v proceed=%v, want false false", safeMode, proceed)
}
}
func TestDesktopStartupRecoveryRepairStartsSafeMode(t *testing.T) {
repaired := false
safeMode, proceed := runDesktopStartupRecovery(true, false, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
return repair.UpdateRollbackResult{}, nil
},
choose: func() desktopRecoveryChoice { return desktopRecoveryRepair },
repairGlobalConfig: func() error {
repaired = true
return nil
},
})
if !repaired || !safeMode || !proceed {
t.Fatalf("repaired=%v safeMode=%v proceed=%v, want true true true", repaired, safeMode, proceed)
}
}
func TestDesktopStartupRecoveryExplicitSafeModeSkipsCrashRollback(t *testing.T) {
rollbackCalled := false
safeMode, proceed := runDesktopStartupRecovery(true, true, desktopStartupRecoveryDeps{
rollbackPendingUpdate: func() (repair.UpdateRollbackResult, error) {
rollbackCalled = true
return repair.UpdateRollbackResult{}, nil
},
})
if rollbackCalled || !safeMode || !proceed {
t.Fatalf("rollbackCalled=%v safeMode=%v proceed=%v", rollbackCalled, safeMode, proceed)
}
}
+3 -30
View File
@@ -68,17 +68,13 @@ func (a *App) ListThemePacks() ([]ThemePackView, error) {
activeID := resolveActiveThemeID(st)
baseStyle := a.desktopBaseStyleLocked()
safe := a.themeSafeMode()
var out []ThemePackView
for _, m := range builtinThemePacks() {
cp := m
// Base "active" = no pack applied and this is the configured base style.
baseActive := !safe && activeID == "" && baseStyle == m.ID
baseActive := activeID == "" && baseStyle == m.ID
out = append(out, manifestToView(&cp, themeKindBase, baseActive, "", ""))
}
if safe {
return out, nil
}
for _, ot := range officialThemes() {
m := ot.manifest
bgURL := officialAssetURL(m.ID, m.Background.Image)
@@ -107,18 +103,13 @@ func (a *App) ListThemePacks() ([]ThemePackView, error) {
return out, nil
}
// GetActiveThemePack returns the currently enabled pack (nil pack when none / safe mode).
// Safe mode suppresses pack application but does not delete the stored id.
// GetActiveThemePack returns the currently enabled pack (nil pack when none).
func (a *App) GetActiveThemePack() (ThemeActiveView, error) {
themeMu.Lock()
defer themeMu.Unlock()
view := ThemeActiveView{SafeMode: a.themeSafeMode()}
view := ThemeActiveView{}
st := a.migrateThemeDesktopStateLocked()
if view.SafeMode {
// Do not clear ActiveThemeID — safe mode only blocks loading.
return view, nil
}
activeID := resolveActiveThemeID(st)
if st.ActiveThemeID != "" && activeID == "" {
// Broken or migrated-away pointer: clear so the next launch is clean.
@@ -146,7 +137,6 @@ func (a *App) GetThemeExperience() (ThemeExperienceView, error) {
themeMu.Lock()
defer themeMu.Unlock()
safe := a.themeSafeMode()
// Migrate first so a v1 base-style activeThemeId lands in desktop.theme_style
// before we read appearance.
st := a.migrateThemeDesktopStateLocked()
@@ -155,10 +145,6 @@ func (a *App) GetThemeExperience() (ThemeExperienceView, error) {
ThemeMode: themeMode,
BaseStyle: baseStyle,
EffectiveStyle: baseStyle,
SafeMode: safe,
}
if safe {
return view, nil
}
activeID := resolveActiveThemeID(st)
if st.ActiveThemeID != "" && activeID == "" {
@@ -219,9 +205,6 @@ func (a *App) ActivateThemePack(id string) error {
defer themeMu.Unlock()
id = strings.TrimSpace(id)
if a.themeSafeMode() && id != "" {
return fmt.Errorf("safe mode does not load external themes")
}
st := a.migrateThemeDesktopStateLocked()
if id == "" {
st.ActiveThemeID = ""
@@ -357,9 +340,6 @@ func (a *App) SaveThemePack(input ThemeSaveInput) (ThemePackView, error) {
themeMu.Lock()
defer themeMu.Unlock()
if a.themeSafeMode() {
return ThemePackView{}, fmt.Errorf("safe mode cannot save themes")
}
m := &ThemePackManifest{
SchemaVersion: themePackSchemaVersion,
ID: strings.TrimSpace(input.ID),
@@ -520,9 +500,6 @@ func (a *App) CopyThemePack(sourceID, newID, newName string) (ThemePackView, err
themeMu.Lock()
defer themeMu.Unlock()
if a.themeSafeMode() {
return ThemePackView{}, fmt.Errorf("safe mode cannot copy themes")
}
sourceID = strings.TrimSpace(sourceID)
newID = strings.TrimSpace(newID)
if !themePackIDRe.MatchString(newID) || isReservedThemeID(newID) {
@@ -607,10 +584,6 @@ func (a *App) ImportThemePack(sourcePath string, replace bool) (ThemeImportResul
themeMu.Lock()
defer themeMu.Unlock()
if a.themeSafeMode() {
return ThemeImportResult{}, fmt.Errorf("safe mode cannot import themes")
}
// Confirm a previously staged conflict without re-picking a file.
path := strings.TrimSpace(sourcePath)
if path == "" && replace {
-13
View File
@@ -8,8 +8,6 @@ import (
"path/filepath"
"strings"
"time"
"reasonix/internal/config"
)
const themeAssetURLPrefix = "/__reasonix_theme_asset/"
@@ -31,11 +29,6 @@ func (a *App) themeAssetMiddleware() func(http.Handler) http.Handler {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Safe mode never serves external theme assets.
if a.themeSafeMode() {
http.NotFound(w, r)
return
}
rest := strings.TrimPrefix(r.URL.Path, themeAssetURLPrefix)
parts := strings.Split(rest, "/")
if len(parts) != 3 {
@@ -145,12 +138,6 @@ func serveOfficialThemeAsset(w http.ResponseWriter, r *http.Request, themeID, di
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
}
func (a *App) themeSafeMode() bool {
// DesktopStartupSettings exposes SafeMode; keep a local helper so asset
// middleware and list/load paths share the same gate.
return config.SafeModeRequested()
}
func themeBackgroundURL(themeID, imageName string) string {
if imageName == "" || isBuiltinThemeID(themeID) {
return ""
+4 -2
View File
@@ -166,7 +166,8 @@ type ThemeContrastWarning struct {
type ThemeActiveView struct {
ActiveThemeID string `json:"activeThemeId,omitempty"`
Pack *ThemePackView `json:"pack,omitempty"`
SafeMode bool `json:"safeMode"`
// SafeMode is retained as an always-false compatibility field for older frontends.
SafeMode bool `json:"safeMode"`
}
// ThemeExperienceView is the unified appearance state for the redesigned
@@ -178,7 +179,8 @@ type ThemeExperienceView struct {
EffectiveStyle string `json:"effectiveStyle"` // pack.baseStyle when pack active, else baseStyle
ActiveThemeID string `json:"activeThemeId,omitempty"` // official/user only; never a base id
ActivePack *ThemePackView `json:"activePack,omitempty"`
SafeMode bool `json:"safeMode"`
// SafeMode is retained as an always-false compatibility field for older frontends.
SafeMode bool `json:"safeMode"`
}
// ThemeSaveInput is the editor payload for creating/updating a user theme.
-4
View File
@@ -5,11 +5,7 @@ package main
import "fmt"
var (
errUpdateAuthCancelled = fmt.Errorf("update: authorization cancelled")
errUpdateAuthFailed = fmt.Errorf("update: authorization failed")
errUpdatePkgBusy = fmt.Errorf("update: package manager busy")
errUpdatePkgInstall = fmt.Errorf("update: package install failed")
errUpdatePkgVerify = fmt.Errorf("update: package verify failed")
errUpdateCacheMismatch = fmt.Errorf("update: cached artifact does not match current install mode")
)
-32
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
@@ -126,25 +125,6 @@ func selectUpdateAsset(m *update.Manifest, profile installProfile) (update.Asset
return update.Asset{}, "", false
}
// dirIsWritable reports whether the process can create a temporary file in dir.
func dirIsWritable(dir string) bool {
if dir == "" {
return false
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return false
}
f, err := os.CreateTemp(dir, ".reasonix-write-test-*")
if err != nil {
return false
}
name := f.Name()
_ = f.Close()
_ = os.Remove(name)
return true
}
func linuxDebHelperReady() bool {
if runtime.GOOS != "linux" {
return false
@@ -158,18 +138,6 @@ func linuxDebHelperReady() bool {
return true
}
// resolveExecutablePath returns the real path of the running binary.
func resolveExecutablePath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
func manualDebInstallHint() string {
return "Install manually with: sudo apt install ./Reasonix-linux-amd64.deb"
}
+31
View File
@@ -10,6 +10,37 @@ import (
"strings"
)
// dirIsWritable reports whether the process can create a temporary file in dir.
func dirIsWritable(dir string) bool {
if dir == "" {
return false
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return false
}
f, err := os.CreateTemp(dir, ".reasonix-write-test-*")
if err != nil {
return false
}
name := f.Name()
_ = f.Close()
_ = os.Remove(name)
return true
}
// resolveExecutablePath returns the real path of the running binary.
func resolveExecutablePath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
// detectLinuxInstallProfile classifies the running Linux install.
//
// deb — dpkg owns the absolute executable path as reasonix-desktop, and the
-12
View File
@@ -108,18 +108,6 @@ import "C"
// the verified JavaScriptCore signal-handler race, but is not intended to cover
// failures that occur earlier while WebKit constructs the window.
// configureWebKitRendererRecovery applies WebKit's costly DMA-BUF fallback only
// during Safe Mode on NVIDIA systems. Normal launches keep accelerated backing
// store enabled; NVIDIA Wayland sessions continue to use the narrower explicit-
// sync workaround in nvidia_wayland_linux.go. Call this before wails.Run so the
// WebKit process inherits the environment.
func configureWebKitRendererRecovery(safeMode bool) {
if !safeMode {
return
}
configureWebKitRendererRecoveryForGPU(safeMode, hasNVIDIAGPU())
}
// scheduleWebKitSignalHandlerRepair starts the bounded GLib timer immediately
// before wails.Run. The callback begins executing when Wails enters GTK's main
// loop, which anchors the five-second repair window to WebKit startup instead
-2
View File
@@ -2,8 +2,6 @@
package main
func configureWebKitRendererRecovery(bool) {}
func scheduleWebKitSignalHandlerRepair() {}
func repairWebKitSignalHandlers() {}
-28
View File
@@ -1,28 +0,0 @@
//go:build !linux
package main
import (
"os"
"testing"
)
func TestConfigureWebKitRendererRecoveryIsNoop(t *testing.T) {
const key = "WEBKIT_DISABLE_DMABUF_RENDERER"
previous, set := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if set {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
configureWebKitRendererRecovery(true)
if value, exists := os.LookupEnv(key); exists {
t.Fatalf("non-Linux recovery unexpectedly set %s=%q", key, value)
}
}
-16
View File
@@ -1,16 +0,0 @@
package main
import "os"
// configureWebKitRendererRecoveryForGPU keeps the environment policy separate
// from Linux GPU detection so every platform can regression-test the decision.
// Only the Linux wrapper invokes it in production.
func configureWebKitRendererRecoveryForGPU(safeMode, nvidiaGPU bool) {
if !safeMode || !nvidiaGPU {
return
}
if _, explicitlySet := os.LookupEnv("WEBKIT_DISABLE_DMABUF_RENDERER"); explicitlySet {
return
}
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"os"
"testing"
)
func TestConfigureWebKitRendererRecoveryForGPU(t *testing.T) {
const key = "WEBKIT_DISABLE_DMABUF_RENDERER"
tests := []struct {
name string
safeMode bool
nvidiaGPU bool
explicit *string
wantSet bool
wantValue string
}{
{name: "normal NVIDIA launch keeps acceleration", nvidiaGPU: true},
{name: "safe mode without NVIDIA keeps acceleration", safeMode: true},
{name: "safe mode NVIDIA launch disables DMA-BUF", safeMode: true, nvidiaGPU: true, wantSet: true, wantValue: "1"},
{name: "preconfigured zero value is preserved", safeMode: true, nvidiaGPU: true, explicit: stringPointer("0"), wantSet: true, wantValue: "0"},
{name: "preconfigured enabled value is preserved", safeMode: true, nvidiaGPU: true, explicit: stringPointer("1"), wantSet: true, wantValue: "1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
restoreEnv(t, key)
if tt.explicit != nil {
if err := os.Setenv(key, *tt.explicit); err != nil {
t.Fatal(err)
}
}
configureWebKitRendererRecoveryForGPU(tt.safeMode, tt.nvidiaGPU)
got, set := os.LookupEnv(key)
if set != tt.wantSet || got != tt.wantValue {
t.Fatalf("%s = %q, set=%v; want %q, set=%v", key, got, set, tt.wantValue, tt.wantSet)
}
})
}
}
func stringPointer(value string) *string { return &value }
func restoreEnv(t *testing.T, key string) {
t.Helper()
previous, set := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if set {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
+2 -2
View File
@@ -12,9 +12,9 @@ const windowsWebView2StartupFallbackDelay = 15 * time.Second
const windowsWebView2StartupFallbackMessage = "The desktop interface did not become ready within 15 seconds. " +
"An unavailable Windows system proxy or a WebView2 failure may be blocking startup. " +
"Check the system proxy, then restart Reasonix. If the problem continues, start Reasonix with --safe-mode.\n\n" +
"Check the system proxy, then restart Reasonix. If the problem continues, repair Microsoft Edge WebView2 Runtime or reinstall Reasonix.\n\n" +
"桌面界面在 15 秒内未能就绪。不可用的 Windows 系统代理或 WebView2 故障可能阻塞了启动。" +
"请检查系统代理后重启 Reasonix;如果问题仍然存在,请使用 --safe-mode 启动。"
"请检查系统代理后重启 Reasonix;如果问题仍然存在,请修复 Microsoft Edge WebView2 Runtime 或重新安装 Reasonix。"
// startWindowsWebView2StartupFallback prevents StartHidden from turning a slow
// or failed WebView2 navigation into an apparently missing application. Proxy