fix(local-terminal): use platform default shell (#792)
This commit is contained in:
1 parent
d48a0f8ba4
commit
1d0f302783
7 files changed
+103
-30
No files matched your search
@@ -35,6 +35,11 @@ pub struct StartRecordingRequest {
|
||||
explicit_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_local_shell() -> String {
|
||||
core::default_local_shell_path()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_ssh_session(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) use session::{
|
||||
SessionCwdReplacement, now_session_started_at, replace_cwd_state, session_command_channel,
|
||||
update_cwd_if_changed,
|
||||
};
|
||||
pub(crate) use terminal_session::local::default_local_shell_path;
|
||||
pub use terminal_session::local::{LocalSessionConfig, create_local_session};
|
||||
pub use terminal_session::serial::{SerialConfig, create_serial_session, list_serial_ports};
|
||||
pub use terminal_session::telnet::{
|
||||
|
||||
@@ -23,23 +23,23 @@ fn default_local_shell_args(program: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_shell_spec() -> ShellCommandSpec {
|
||||
pub(crate) fn default_local_shell_path() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
ShellCommandSpec {
|
||||
program: resolve_program_for_spawn("powershell.exe"),
|
||||
args: Vec::new(),
|
||||
resolution_source: ShellResolutionSource::Direct,
|
||||
}
|
||||
"powershell.exe".to_string()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let program = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
ShellCommandSpec {
|
||||
args: default_local_shell_args(&program),
|
||||
program,
|
||||
resolution_source: ShellResolutionSource::Direct,
|
||||
}
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_shell_spec() -> ShellCommandSpec {
|
||||
let program = default_local_shell_path();
|
||||
ShellCommandSpec {
|
||||
args: default_local_shell_args(&program),
|
||||
program: resolve_program_for_spawn(&program),
|
||||
resolution_source: ShellResolutionSource::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ pub fn run() {
|
||||
cmd::session::create_ssh_session,
|
||||
cmd::session::create_temporary_ssh_session,
|
||||
cmd::session::create_multiplexed_ssh_session,
|
||||
cmd::session::get_default_local_shell,
|
||||
cmd::session::create_local_session,
|
||||
cmd::session::create_telnet_session,
|
||||
cmd::session::create_serial_session,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { isMacOS, isWindows } from "@/lib/platform";
|
||||
import type { RecordingMode } from "@/types/global";
|
||||
|
||||
interface LocalTerminalProps {
|
||||
@@ -37,7 +38,17 @@ interface LocalTerminalProps {
|
||||
setDynamicTabTitle: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const BUILTIN_SHELL_PATHS = ["powershell.exe", "cmd.exe", "bash", "wsl.exe", "wt.exe"] as const;
|
||||
const BUILTIN_SHELL_PATHS: readonly string[] = isWindows
|
||||
? ["powershell.exe", "cmd.exe", "bash", "wsl.exe", "wt.exe"]
|
||||
: ["zsh", "bash", "fish"];
|
||||
|
||||
function shellBasename(path: string) {
|
||||
return path.replace(/\\/g, "/").split("/").filter(Boolean).pop()?.toLowerCase() || path;
|
||||
}
|
||||
|
||||
function matchesShellPreset(shellPath: string, preset: string) {
|
||||
return shellPath === preset || shellBasename(shellPath) === shellBasename(preset);
|
||||
}
|
||||
|
||||
export function LocalTerminal({
|
||||
shellPath,
|
||||
@@ -90,9 +101,8 @@ export function LocalTerminal({
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Select
|
||||
value={
|
||||
BUILTIN_SHELL_PATHS.includes(shellPath as (typeof BUILTIN_SHELL_PATHS)[number])
|
||||
? shellPath
|
||||
: "custom"
|
||||
BUILTIN_SHELL_PATHS.find((preset) => matchesShellPreset(shellPath, preset)) ??
|
||||
"custom"
|
||||
}
|
||||
onValueChange={handleShellSelectChange}
|
||||
>
|
||||
@@ -100,15 +110,26 @@ export function LocalTerminal({
|
||||
<SelectValue placeholder={t("dialog.selectShell", "Select Shell")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="powershell.exe">
|
||||
{t("dialog.shellPowerShell", "PowerShell")}
|
||||
</SelectItem>
|
||||
<SelectItem value="cmd.exe">{t("dialog.shellCmd", "Command Prompt")}</SelectItem>
|
||||
<SelectItem value="bash">{t("dialog.shellBash", "Bash")}</SelectItem>
|
||||
<SelectItem value="wsl.exe">{t("dialog.shellWsl", "WSL")}</SelectItem>
|
||||
<SelectItem value="wt.exe">
|
||||
{t("dialog.shellWindowsTerminal", "Windows Terminal")}
|
||||
</SelectItem>
|
||||
{isWindows ? (
|
||||
<>
|
||||
<SelectItem value="powershell.exe">
|
||||
{t("dialog.shellPowerShell", "PowerShell")}
|
||||
</SelectItem>
|
||||
<SelectItem value="cmd.exe">{t("dialog.shellCmd", "Command Prompt")}</SelectItem>
|
||||
<SelectItem value="bash">{t("dialog.shellBash", "Bash")}</SelectItem>
|
||||
<SelectItem value="wsl.exe">{t("dialog.shellWsl", "WSL")}</SelectItem>
|
||||
<SelectItem value="wt.exe">
|
||||
{t("dialog.shellWindowsTerminal", "Windows Terminal")}
|
||||
</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isMacOS && <SelectItem value="zsh">Zsh</SelectItem>}
|
||||
<SelectItem value="bash">{t("dialog.shellBash", "Bash")}</SelectItem>
|
||||
<SelectItem value="fish">Fish</SelectItem>
|
||||
{!isMacOS && <SelectItem value="zsh">Zsh</SelectItem>}
|
||||
</>
|
||||
)}
|
||||
<SelectItem value="custom">{t("dialog.shellCustom", "Custom...")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
closeMock,
|
||||
emitMock,
|
||||
invokeMock,
|
||||
localTerminalMock,
|
||||
rdpFormMock,
|
||||
serialFormMock,
|
||||
sshFormMock,
|
||||
@@ -16,6 +17,7 @@ const {
|
||||
closeMock: vi.fn(),
|
||||
emitMock: vi.fn(),
|
||||
invokeMock: vi.fn(),
|
||||
localTerminalMock: vi.fn(),
|
||||
rdpFormMock: vi.fn(),
|
||||
serialFormMock: vi.fn(),
|
||||
sshFormMock: vi.fn(),
|
||||
@@ -45,7 +47,12 @@ vi.mock("@tauri-apps/api/window", () => ({
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
|
||||
|
||||
vi.mock("@/components/sessions/LocalTerminal", () => ({ LocalTerminal: () => null }));
|
||||
vi.mock("@/components/sessions/LocalTerminal", () => ({
|
||||
LocalTerminal: (props: Record<string, unknown>) => {
|
||||
localTerminalMock(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/sessions/SerialForm", () => ({
|
||||
SerialForm: (props: Record<string, unknown>) => {
|
||||
serialFormMock(props);
|
||||
@@ -252,18 +259,37 @@ describe("NewSessionPage", () => {
|
||||
]);
|
||||
case "list_serial_ports":
|
||||
return Promise.resolve(["COM3"]);
|
||||
case "get_default_local_shell":
|
||||
return Promise.resolve("/bin/zsh");
|
||||
case "save_connection":
|
||||
return Promise.resolve(rdpConnection.id);
|
||||
default:
|
||||
return Promise.reject(new Error(`Unexpected command: ${command}`));
|
||||
}
|
||||
});
|
||||
localTerminalMock.mockReset();
|
||||
rdpFormMock.mockReset();
|
||||
serialFormMock.mockReset();
|
||||
sshFormMock.mockReset();
|
||||
telnetFormMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses the backend-resolved default shell for a new local terminal", async () => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
render(<NewSessionPage />);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: "dialog.localTerminal" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localTerminalMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ shellPath: "/bin/zsh" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("restores an RDP jump host and keeps it when saving without changes", async () => {
|
||||
render(<NewSessionPage />);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import { isWindows } from "@/lib/platform";
|
||||
import { isValidSerialBaudRate, MAX_SERIAL_BAUD_RATE, MIN_SERIAL_BAUD_RATE } from "@/lib/serial";
|
||||
import { validateSshAgentForwardingEndpoints } from "@/lib/sshAgent";
|
||||
import type {
|
||||
@@ -64,6 +65,7 @@ const MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS = 100;
|
||||
const MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_RDP_USERNAME = "Administrator";
|
||||
const CUSTOM_ICON_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "bmp", "gif"];
|
||||
const FALLBACK_LOCAL_SHELL = isWindows ? "powershell.exe" : "/bin/bash";
|
||||
const DEFAULT_SSH_ALGORITHMS: SshAlgorithmPreferences = {
|
||||
mode: "compatible",
|
||||
kex: [],
|
||||
@@ -273,7 +275,8 @@ export default function NewSessionPage() {
|
||||
>("zmodem");
|
||||
|
||||
// Local Terminal States
|
||||
const [shellPath, setShellPath] = useState("powershell.exe");
|
||||
const [defaultLocalShell, setDefaultLocalShell] = useState(FALLBACK_LOCAL_SHELL);
|
||||
const [shellPath, setShellPath] = useState(FALLBACK_LOCAL_SHELL);
|
||||
const [shellArgs, setShellArgs] = useState("");
|
||||
const [workingDir, setWorkingDir] = useState("");
|
||||
const [dynamicTabTitle, setDynamicTabTitle] = useState(false);
|
||||
@@ -294,6 +297,16 @@ export default function NewSessionPage() {
|
||||
const [recordingMode, setRecordingMode] = useState<RecordingMode>("transcript");
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string>("get_default_local_shell")
|
||||
.then((value) => {
|
||||
const resolvedShell = value.trim();
|
||||
if (!resolvedShell) return;
|
||||
setDefaultLocalShell(resolvedShell);
|
||||
if (!editId) {
|
||||
setShellPath((current) => (current === FALLBACK_LOCAL_SHELL ? resolvedShell : current));
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
invoke<Group[]>("get_groups")
|
||||
.then(setGroups)
|
||||
.catch((e) => setError(getErrorMessage(e)));
|
||||
@@ -389,7 +402,7 @@ export default function NewSessionPage() {
|
||||
setTelnetSendNaws(found.send_naws ?? true);
|
||||
setTelnetSendSga(found.send_sga ?? true);
|
||||
} else if (found.type === "local_terminal") {
|
||||
setShellPath(found.shell_path || "powershell.exe");
|
||||
setShellPath(found.shell_path || FALLBACK_LOCAL_SHELL);
|
||||
setShellArgs(found.shell_args || "");
|
||||
setWorkingDir(found.working_dir || "");
|
||||
setDynamicTabTitle(found.dynamic_tab_title ?? false);
|
||||
@@ -440,6 +453,12 @@ export default function NewSessionPage() {
|
||||
.catch((e) => setError(getErrorMessage(e)));
|
||||
}, [appSettings.recording.auto_start, appSettings.recording.default_mode, editId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData?.type === "local_terminal" && !initialData.shell_path?.trim()) {
|
||||
setShellPath(defaultLocalShell);
|
||||
}
|
||||
}, [defaultLocalShell, initialData]);
|
||||
|
||||
const loadSerialPorts = useCallback(async () => {
|
||||
setSerialPortsLoading(true);
|
||||
setSerialPortsError("");
|
||||
@@ -506,7 +525,7 @@ export default function NewSessionPage() {
|
||||
setParity("none");
|
||||
setStopBits("1");
|
||||
setSerialModemUploadProtocol("zmodem");
|
||||
setShellPath("powershell.exe");
|
||||
setShellPath(defaultLocalShell);
|
||||
setShellArgs("");
|
||||
setWorkingDir("");
|
||||
setDynamicTabTitle(false);
|
||||
@@ -542,7 +561,7 @@ export default function NewSessionPage() {
|
||||
setShowIconPicker(false);
|
||||
setError("");
|
||||
setConnecting(false);
|
||||
}, [appSettings.recording.auto_start, appSettings.recording.default_mode, currentTab]);
|
||||
}, [appSettings.recording.auto_start, appSettings.recording.default_mode, currentTab, defaultLocalShell]);
|
||||
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setCurrentTab(value);
|
||||
|
||||
Reference in new issue
Block a user