返回 DeepSeek-Reasonix
shell_support.go
根目录 / desktop / shell_support.go
1 package main
2
3 import (
4 "fmt"
5 "runtime"
6 "strings"
7
8 "reasonix/internal/config"
9 "reasonix/internal/control"
10 "reasonix/internal/sandbox"
11 )
12
13 // Shell support discovery and repair guidance. The Git.Git winget manifest may
14 // elevate even with user scope, so Reasonix never launches that installer and
15 // Windows exposes only the official manual link.
16
17 // shellInstallActionGitForWindows is the single install action id hosts may
18 // request today; unknown ids are rejected as errors rather than no-ops so a
19 // frontend typo cannot silently do nothing.
20 const shellInstallActionGitForWindows = "git-for-windows"
21
22 // GitForWindowsManualURL is the official download page handed to Windows users.
23 const GitForWindowsManualURL = "https://git-scm.com/download/win"
24
25 // Structured outcomes retained by the desktop bridge contract. Invalid action ids remain
26 // errors; supported Windows requests always return manual_required.
27 const (
28 shellInstallStatusManualRequired = "manual_required"
29 shellInstallStatusUnsupported = "unsupported_platform"
30 )
31
32 // ShellInstallResult is the structured outcome of InstallShellSupport.
33 type ShellInstallResult struct {
34 Status string `json:"status"`
35 Path string `json:"path,omitempty"`
36 Reason string `json:"reason,omitempty"`
37 ManualURL string `json:"manualUrl,omitempty"`
38 }
39
40 // ShellCapabilityView is one discovered interpreter for the settings surface:
41 // whether it is usable, where it lives, how it was found, and why not when
42 // unavailable. Purely informational — resolution goes through ResolveShell.
43 type ShellCapabilityView struct {
44 ID string `json:"id"`
45 Variant string `json:"variant,omitempty"`
46 Available bool `json:"available"`
47 Path string `json:"path,omitempty"`
48 Source string `json:"source,omitempty"`
49 Reason string `json:"reason,omitempty"`
50 }
51
52 // ShellInstallActionView is retained in the bridge shape for older desktop
53 // clients. Current settings views leave it nil because Git Bash is no longer a
54 // Windows Agent runtime or a Shell settings repair target.
55 type ShellInstallActionView struct {
56 ID string `json:"id"`
57 Mode string `json:"mode"`
58 Available bool `json:"available"`
59 ManualURL string `json:"manualUrl,omitempty"`
60 }
61
62 // SandboxView is the Settings panel's sandbox surface. The shell fields
63 // separate three states: the configured preference (Shell), what the live
64 // controller bound (EffectiveShell), and what a reload would pick now
65 // (ResolvedShell) — ShellReloadRequired marks the divergence.
66 type SandboxView struct {
67 Bash string `json:"bash"`
68 Network bool `json:"network"`
69 WorkspaceRoot string `json:"workspaceRoot"`
70 AllowWrite []string `json:"allowWrite"`
71 EffectiveWorkspaceRoot string `json:"effectiveWorkspaceRoot"`
72 EffectiveWriteRoots []string `json:"effectiveWriteRoots"`
73 Shell string `json:"shell"` // [tools.shell] prefer: auto|bash|powershell|pwsh
74 EffectiveShell string `json:"effectiveShell,omitempty"`
75 ResolvedShell string `json:"resolvedShell,omitempty"`
76 ShellReloadRequired bool `json:"shellReloadRequired"`
77 ShellCapabilities []ShellCapabilityView `json:"shellCapabilities"`
78 GitCapability *ShellCapabilityView `json:"gitCapability,omitempty"`
79 ShellInstallAction *ShellInstallActionView `json:"shellInstallAction,omitempty"`
80 ShellRepairGuidance *RepairGuidanceView `json:"shellRepairGuidance,omitempty"`
81 GitRepairGuidance *RepairGuidanceView `json:"gitRepairGuidance,omitempty"`
82 }
83
84 // InstallShellSupport retains the existing bridge method while enforcing the
85 // manual-only policy. It never probes for or launches a package manager.
86 func (a *App) InstallShellSupport(id string) (ShellInstallResult, error) {
87 return installShellSupportForGOOS(runtime.GOOS, id)
88 }
89
90 func installShellSupportForGOOS(goos, id string) (ShellInstallResult, error) {
91 if strings.TrimSpace(id) != shellInstallActionGitForWindows {
92 return ShellInstallResult{}, fmt.Errorf("unknown shell support action %q", id)
93 }
94 if goos != "windows" {
95 return ShellInstallResult{
96 Status: shellInstallStatusUnsupported,
97 Reason: "shell helper install is only available on Windows",
98 }, nil
99 }
100 return ShellInstallResult{
101 Status: shellInstallStatusManualRequired,
102 Reason: "automatic installation is disabled because Git for Windows cannot reliably honor user scope",
103 ManualURL: GitForWindowsManualURL,
104 }, nil
105 }
106
107 // CancelShellInstall is retained as an idempotent compatibility no-op. No
108 // installer can be running under the manual-only policy.
109 func (a *App) CancelShellInstall() {}
110
111 // SetShellPreference updates only [tools.shell] prefer, preserving the
112 // configured shell path and every other sandbox field, so switching the
113 // interpreter never rewrites unrelated settings.
114 func (a *App) SetShellPreference(prefer string) error {
115 prefer = strings.TrimSpace(prefer)
116 switch strings.ToLower(prefer) {
117 case "", "auto", "bash", "powershell", "pwsh":
118 default:
119 return fmt.Errorf("invalid shell preference %q (use auto, bash, powershell, or pwsh)", prefer)
120 }
121 return a.applyConfigChange(func(c *config.Config) error {
122 c.Tools.Shell.Prefer = prefer
123 return nil
124 })
125 }
126
127 // shellInstallActionViewForGOOS exposes the official manual link on Windows.
128 // Other platforms use their native detect-and-guide policy.
129 func shellInstallActionViewForGOOS(goos string) *ShellInstallActionView {
130 if goos != "windows" {
131 return nil
132 }
133 return &ShellInstallActionView{
134 ID: shellInstallActionGitForWindows,
135 Mode: "manual",
136 Available: false,
137 ManualURL: GitForWindowsManualURL,
138 }
139 }
140
141 // sandboxViewFor builds the Settings panel's SandboxView shell surface.
142 // effectiveShell is what the live controller actually bound at build time;
143 // resolvedShell is what a reload would pick from the current config and
144 // machine state. They can diverge after a manual repair or an unreloaded config
145 // edit, so the surface shows both plus an explicit reload button.
146 func (a *App) sandboxViewFor(cfg *config.Config, ctrl control.SessionAPI, writeRoots []string, effectiveWorkspaceRoot string) SandboxView {
147 shell := cfg.Tools.Shell.Prefer
148 if shell == "" {
149 shell = "auto"
150 }
151 resolved := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, nil)
152 bound := resolved
153 if ctrl != nil {
154 if sh := ctrl.BoundShell(); sh.Path != "" {
155 bound = sh
156 }
157 }
158 return SandboxView{
159 Bash: cfg.BashMode(), Network: cfg.Sandbox.Network,
160 WorkspaceRoot: cfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(cfg.Sandbox.AllowWrite),
161 EffectiveWorkspaceRoot: effectiveWorkspaceRoot, EffectiveWriteRoots: nonNil(writeRoots),
162 Shell: shell, EffectiveShell: sandboxEffectiveShellView(bound),
163 ResolvedShell: sandboxEffectiveShellView(resolved),
164 ShellReloadRequired: bound != resolved,
165 ShellCapabilities: sandboxCapabilityViews(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path),
166 GitCapability: gitCapabilityView(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path),
167 ShellRepairGuidance: shellRepairGuidanceForGOOS(runtime.GOOS),
168 GitRepairGuidance: gitRepairGuidanceForGOOS(runtime.GOOS),
169 }
170 }
171
172 func sandboxEffectiveShellView(sh sandbox.Shell) string {
173 if sh.Kind == sandbox.ShellZsh {
174 return "zsh"
175 }
176 if sh.Kind == sandbox.ShellSh {
177 return "sh"
178 }
179 if sh.Kind == sandbox.ShellPowerShell {
180 if sh.SupportsChaining() {
181 return "pwsh"
182 }
183 return "powershell"
184 }
185 path := strings.ToLower(strings.ReplaceAll(sh.Path, "\\", "/"))
186 if strings.Contains(path, "/git/") && strings.HasSuffix(path, "bash.exe") {
187 return "git-bash"
188 }
189 return "bash"
190 }
191
192 func gitCapabilityView(prefer, configPath string) *ShellCapabilityView {
193 capability := sandbox.GitCapabilityForConfig(prefer, configPath)
194 return &ShellCapabilityView{
195 ID: capability.ID, Available: capability.Available, Path: capability.Path,
196 Source: capability.Source, Reason: capability.Reason,
197 }
198 }
199
200 // sandboxCapabilityViews projects the discovered shell inventory for the
201 // settings surface. The slice is never nil so the desktop binding always
202 // encodes a JSON array, not null.
203 func sandboxCapabilityViews(prefer, configPath string) []ShellCapabilityView {
204 caps := sandbox.ShellCapabilitiesForConfig(prefer, configPath)
205 out := make([]ShellCapabilityView, 0, len(caps))
206 for _, cap := range caps {
207 out = append(out, ShellCapabilityView{
208 ID: cap.ID,
209 Variant: cap.Variant,
210 Available: cap.Available,
211 Path: cap.Path,
212 Source: cap.Source,
213 Reason: cap.Reason,
214 })
215 }
216 return out
217 }
218
218 lines GO