mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-12 20:04:47 +00:00
Compare commits
5 Commits
sqlite2
...
brendan/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9f1a5d65 | ||
|
|
b5d8697c82 | ||
|
|
61c4d0a0d0 | ||
|
|
7c6d82b79a | ||
|
|
3d63f86d19 |
@@ -131,6 +131,13 @@ export function DialogSelectServer() {
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
|
||||
ssh: {
|
||||
command: "",
|
||||
connecting: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
},
|
||||
})
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
@@ -150,6 +157,7 @@ export function DialogSelectServer() {
|
||||
{ initialValue: null },
|
||||
)
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const canSsh = createMemo(() => !!platform.sshConnect)
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
@@ -189,6 +197,15 @@ export function DialogSelectServer() {
|
||||
})
|
||||
}
|
||||
|
||||
const resetSsh = () => {
|
||||
setStore("ssh", {
|
||||
command: "",
|
||||
connecting: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
})
|
||||
}
|
||||
|
||||
const replaceServer = (original: string, next: string) => {
|
||||
const active = server.url
|
||||
const nextActive = active === original ? next : active
|
||||
@@ -360,6 +377,35 @@ export function DialogSelectServer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSshConnect() {
|
||||
if (!platform.sshConnect) return
|
||||
if (store.ssh.connecting) return
|
||||
|
||||
const command = store.ssh.command.trim()
|
||||
if (!command) {
|
||||
resetSsh()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("ssh", { connecting: true, error: "" })
|
||||
try {
|
||||
const result = await platform.sshConnect(command)
|
||||
const url = normalizeServerUrl(result.url)
|
||||
if (!url) {
|
||||
setStore("ssh", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
resetSsh()
|
||||
await select(url, true)
|
||||
} catch (err) {
|
||||
setStore("ssh", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} finally {
|
||||
setStore("ssh", { connecting: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.server.title")}>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -517,18 +563,80 @@ export function DialogSelectServer() {
|
||||
</List>
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
||||
scrollListToBottom()
|
||||
}}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
||||
scrollListToBottom()
|
||||
}}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{store.addServer.adding
|
||||
? language.t("dialog.server.add.checking")
|
||||
: language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
|
||||
<Show when={canSsh()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="server"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setStore("ssh", { showForm: !store.ssh.showForm, error: "" })
|
||||
}}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
SSH
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={store.ssh.showForm && canSsh()}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={"ssh user@host"}
|
||||
value={store.ssh.command}
|
||||
validationState={store.ssh.error ? "invalid" : "valid"}
|
||||
error={store.ssh.error}
|
||||
disabled={store.ssh.connecting}
|
||||
onChange={(value) => {
|
||||
if (store.ssh.connecting) return
|
||||
setStore("ssh", { command: value, error: "" })
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
resetSsh()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
void handleSshConnect()
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="normal"
|
||||
variant="primary"
|
||||
disabled={store.ssh.connecting}
|
||||
onClick={() => void handleSshConnect()}
|
||||
>
|
||||
{store.ssh.connecting ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
<Button size="normal" variant="ghost" disabled={store.ssh.connecting} onClick={resetSsh}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -136,6 +136,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
if (!projectDirectory) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
const isNewSession = !params.id
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
@@ -194,7 +203,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
});
|
||||
|
||||
console.log({sessionDirectory})
|
||||
if (session) {
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
|
||||
@@ -170,7 +170,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
const auth = platform.wsAuth?.(sdk.url)
|
||||
if (auth) {
|
||||
url.username = auth.username
|
||||
url.password = auth.password
|
||||
} else if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
|
||||
@@ -57,6 +57,21 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
/** Override how the app groups server state (projects/history) for a URL */
|
||||
serverKey?(url: string): string
|
||||
|
||||
/** Override whether a server URL should be treated as local */
|
||||
isServerLocal?(url: string): boolean | undefined
|
||||
|
||||
/** Connect to a remote server over SSH (desktop only) */
|
||||
sshConnect?(command: string): Promise<{ url: string; key: string; password: string | null }>
|
||||
|
||||
/** Disconnect an SSH session (desktop only) */
|
||||
sshDisconnect?(key: string): Promise<void>
|
||||
|
||||
/** Credentials to embed in WebSocket URLs (desktop only) */
|
||||
wsAuth?(url: string): { username: string; password: string } | null
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
|
||||
@@ -148,9 +148,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
|
||||
const origin = createMemo(() => projectsKey(state.active))
|
||||
const origin = createMemo(() => {
|
||||
const url = state.active
|
||||
if (!url) return ""
|
||||
return platform.serverKey?.(url) ?? projectsKey(url)
|
||||
})
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const isLocal = createMemo(() => origin() === "local")
|
||||
const isLocal = createMemo(() => {
|
||||
const url = state.active
|
||||
if (!url) return false
|
||||
return platform.isServerLocal?.(url) ?? origin() === "local"
|
||||
})
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.dir) return
|
||||
if (params.dir === undefined) return
|
||||
if (directory()) return
|
||||
if (invalid === params.dir) return
|
||||
invalid = params.dir
|
||||
|
||||
7
packages/desktop/src-tauri/Cargo.lock
generated
7
packages/desktop/src-tauri/Cargo.lock
generated
@@ -3076,6 +3076,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shell-words",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tauri",
|
||||
@@ -4423,6 +4424,12 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
|
||||
@@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = "1.48.0"
|
||||
tokio = { version = "1.48.0", features = ["process", "net", "io-util", "time", "sync", "rt", "macros"] }
|
||||
listeners = "0.3"
|
||||
tauri-plugin-os = "2"
|
||||
futures = "0.3.31"
|
||||
@@ -47,6 +47,7 @@ specta = "=2.0.0-rc.22"
|
||||
specta-typescript = "0.0.9"
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
dirs = "6.0.0"
|
||||
shell-words = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -6,6 +6,7 @@ mod job_object;
|
||||
pub mod linux_display;
|
||||
mod markdown;
|
||||
mod server;
|
||||
mod ssh;
|
||||
mod window_customizer;
|
||||
mod windows;
|
||||
|
||||
@@ -402,7 +403,7 @@ fn check_linux_app(app_name: &str) -> bool {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
|
||||
if !cfg(windows) {
|
||||
if !cfg!(windows) {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
@@ -497,6 +498,7 @@ pub fn run() {
|
||||
println!("Received Exit");
|
||||
|
||||
kill_sidecar(app.clone());
|
||||
ssh::shutdown(app.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -517,7 +519,10 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
markdown::parse_markdown_command,
|
||||
check_app_exists,
|
||||
wsl_path,
|
||||
resolve_app_path
|
||||
resolve_app_path,
|
||||
ssh::ssh_connect,
|
||||
ssh::ssh_disconnect,
|
||||
ssh::ssh_prompt_reply
|
||||
])
|
||||
.events(tauri_specta::collect_events![LoadingWindowComplete])
|
||||
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
|
||||
@@ -685,6 +690,8 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
|
||||
// Initialize log state
|
||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||
|
||||
app.manage(ssh::SshState::default());
|
||||
|
||||
#[cfg(windows)]
|
||||
app.manage(JobObjectState::new());
|
||||
|
||||
|
||||
@@ -55,7 +55,84 @@ fn configure_display_backend() -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
trait AskpassStream: std::io::Read + std::io::Write {}
|
||||
|
||||
impl<T> AskpassStream for T where T: std::io::Read + std::io::Write {}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn askpass_stream(socket: &str) -> Result<Box<dyn AskpassStream>, String> {
|
||||
if let Some(addr) = socket.strip_prefix("tcp:") {
|
||||
let stream = std::net::TcpStream::connect(addr)
|
||||
.map_err(|e| format!("askpass connect failed: {e}"))?;
|
||||
let boxed: Box<dyn AskpassStream> = Box::new(stream);
|
||||
return Ok(boxed);
|
||||
}
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
let stream = UnixStream::connect(socket).map_err(|e| format!("askpass connect failed: {e}"))?;
|
||||
let boxed: Box<dyn AskpassStream> = Box::new(stream);
|
||||
Ok(boxed)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn askpass_stream(socket: &str) -> Result<Box<dyn AskpassStream>, String> {
|
||||
let addr = socket
|
||||
.strip_prefix("tcp:")
|
||||
.ok_or_else(|| "askpass socket is not tcp on this platform".to_string())?;
|
||||
let stream =
|
||||
std::net::TcpStream::connect(addr).map_err(|e| format!("askpass connect failed: {e}"))?;
|
||||
let boxed: Box<dyn AskpassStream> = Box::new(stream);
|
||||
Ok(boxed)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Ok(socket) = std::env::var("OPENCODE_SSH_ASKPASS_SOCKET") {
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::process::exit;
|
||||
|
||||
let args = std::env::args().collect::<Vec<_>>();
|
||||
let prompt = if let Some(pos) = args.iter().position(|a| a == "--ssh-askpass") {
|
||||
args.iter()
|
||||
.skip(pos + 2)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
args.iter().skip(1).cloned().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
|
||||
let mut stream = match askpass_stream(&socket) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = prompt.as_bytes();
|
||||
let len = u32::try_from(bytes.len()).unwrap_or(0);
|
||||
if stream.write_all(&len.to_be_bytes()).is_err() || stream.write_all(bytes).is_err() {
|
||||
eprintln!("askpass write failed");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
if stream.read_exact(&mut len_buf).is_err() {
|
||||
eprintln!("askpass read failed");
|
||||
exit(1);
|
||||
}
|
||||
let reply_len = u32::from_be_bytes(len_buf) as usize;
|
||||
let mut reply = vec![0u8; reply_len];
|
||||
if stream.read_exact(&mut reply).is_err() {
|
||||
eprintln!("askpass read failed");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let _ = std::io::stdout().write_all(&reply);
|
||||
let _ = std::io::stdout().write_all(b"\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure loopback connections are never sent through proxy settings.
|
||||
// Some VPNs/proxies set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY without excluding localhost.
|
||||
const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"];
|
||||
|
||||
863
packages/desktop/src-tauri/src/ssh.rs
Normal file
863
packages/desktop/src-tauri/src/ssh.rs
Normal file
@@ -0,0 +1,863 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::TcpListener,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Emitter as _, Manager};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::{Mutex, oneshot},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::server;
|
||||
|
||||
fn log(line: impl AsRef<str>) {
|
||||
eprintln!("[SSH] {}", line.as_ref());
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
|
||||
pub struct SshConnectData {
|
||||
pub key: String,
|
||||
pub url: String,
|
||||
pub password: String,
|
||||
pub destination: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
|
||||
pub struct SshPrompt {
|
||||
pub id: String,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SshState {
|
||||
session: Mutex<Option<SshSession>>,
|
||||
prompts: Mutex<HashMap<String, oneshot::Sender<String>>>,
|
||||
}
|
||||
|
||||
struct SshSession {
|
||||
key: String,
|
||||
destination: String,
|
||||
dir: PathBuf,
|
||||
askpass_task: tokio::task::JoinHandle<()>,
|
||||
socket_path: Option<PathBuf>,
|
||||
master: Option<Child>,
|
||||
forward: Child,
|
||||
server: Child,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Spec {
|
||||
destination: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Askpass {
|
||||
socket: String,
|
||||
exe: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ControlMode {
|
||||
Master,
|
||||
Client,
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
TcpListener::bind("127.0.0.1:0")
|
||||
.expect("Failed to bind to find free port")
|
||||
.local_addr()
|
||||
.expect("Failed to get local address")
|
||||
.port()
|
||||
}
|
||||
|
||||
fn parse_ssh_command(input: &str) -> Result<Spec, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("SSH command is empty".to_string());
|
||||
}
|
||||
|
||||
let without_prefix = trimmed.strip_prefix("ssh ").unwrap_or(trimmed);
|
||||
let tokens =
|
||||
shell_words::split(without_prefix).map_err(|e| format!("Invalid SSH command: {e}"))?;
|
||||
if tokens.is_empty() {
|
||||
return Err("SSH command is empty".to_string());
|
||||
}
|
||||
|
||||
const ALLOWED_OPTS: &[&str] = &[
|
||||
"-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
|
||||
];
|
||||
const ALLOWED_ARGS: &[&str] = &[
|
||||
"-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-w",
|
||||
];
|
||||
|
||||
// Disallowed: -E, -e, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W, -L, -R
|
||||
let mut args = Vec::<String>::new();
|
||||
let mut i = 0;
|
||||
let mut destination: Option<String> = None;
|
||||
|
||||
while i < tokens.len() {
|
||||
let tok = &tokens[i];
|
||||
|
||||
if destination.is_some() {
|
||||
return Err(
|
||||
"SSH command cannot include a remote command; only destination + options are supported"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if ALLOWED_OPTS.contains(&tok.as_str()) {
|
||||
args.push(tok.clone());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if tok == "-L" || tok.starts_with("-L") || tok == "-R" || tok.starts_with("-R") {
|
||||
return Err("SSH port forwarding flags (-L/-R) are not supported yet".to_string());
|
||||
}
|
||||
|
||||
if tok.starts_with('-') {
|
||||
let mut matched = false;
|
||||
for opt in ALLOWED_ARGS {
|
||||
if tok == opt {
|
||||
matched = true;
|
||||
args.push(tok.clone());
|
||||
i += 1;
|
||||
if i < tokens.len() {
|
||||
args.push(tokens[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if tok.starts_with(opt) {
|
||||
matched = true;
|
||||
args.push(tok.clone());
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
continue;
|
||||
}
|
||||
return Err(format!("Unsupported ssh argument: {tok}"));
|
||||
}
|
||||
|
||||
destination = Some(tok.clone());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let Some(destination) = destination else {
|
||||
return Err("Missing ssh destination (e.g. user@host)".to_string());
|
||||
};
|
||||
|
||||
Ok(Spec { destination, args })
|
||||
}
|
||||
|
||||
fn sh_quote(input: &str) -> String {
|
||||
let escaped = input.replace('\'', "'\\'''");
|
||||
format!("'{}'", escaped)
|
||||
}
|
||||
|
||||
fn exe_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
tauri::process::current_binary(&app.env())
|
||||
.map_err(|e| format!("Failed to locate current binary: {e}"))
|
||||
}
|
||||
|
||||
async fn ensure_ssh_available() -> Result<(), String> {
|
||||
let res = Command::new("ssh")
|
||||
.arg("-V")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if res.is_err() {
|
||||
if cfg!(windows) {
|
||||
return Err(
|
||||
"ssh.exe was not found on PATH. Install Windows OpenSSH or Git for Windows and ensure ssh.exe is on PATH."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err("ssh was not found on PATH".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ssh_command(askpass: &Askpass, args: Vec<String>) -> Command {
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.args(args);
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
cmd.env("SSH_ASKPASS_REQUIRE", "force");
|
||||
cmd.env("SSH_ASKPASS", &askpass.exe);
|
||||
cmd.env("OPENCODE_SSH_ASKPASS_SOCKET", &askpass.socket);
|
||||
|
||||
if std::env::var_os("DISPLAY").is_none() {
|
||||
cmd.env("DISPLAY", "1");
|
||||
}
|
||||
|
||||
// keep behavior consistent even if ssh wants a tty.
|
||||
cmd.env("TERM", "dumb");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn ssh_spawn_bg(askpass: &Askpass, args: Vec<String>) -> Command {
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.args(args);
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.stdout(std::process::Stdio::null());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
cmd.env("SSH_ASKPASS_REQUIRE", "force");
|
||||
cmd.env("SSH_ASKPASS", &askpass.exe);
|
||||
cmd.env("OPENCODE_SSH_ASKPASS_SOCKET", &askpass.socket);
|
||||
|
||||
if std::env::var_os("DISPLAY").is_none() {
|
||||
cmd.env("DISPLAY", "1");
|
||||
}
|
||||
|
||||
cmd.env("TERM", "dumb");
|
||||
cmd
|
||||
}
|
||||
|
||||
async fn ssh_output(askpass: &Askpass, args: Vec<String>) -> Result<String, String> {
|
||||
let out = ssh_command(askpass, args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run ssh: {e}"))?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let msg = stderr.trim();
|
||||
if msg.is_empty() {
|
||||
return Err("SSH command failed".to_string());
|
||||
}
|
||||
return Err(msg.to_string());
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
fn control_supported() -> bool {
|
||||
cfg!(unix)
|
||||
}
|
||||
|
||||
fn control_args(socket_path: Option<&Path>, mode: ControlMode) -> Vec<String> {
|
||||
if !control_supported() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Some(socket_path) = socket_path else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut args = Vec::new();
|
||||
match mode {
|
||||
ControlMode::Master => {
|
||||
args.push("-o".into());
|
||||
args.push("ControlMaster=yes".into());
|
||||
args.push("-o".into());
|
||||
args.push("ControlPersist=no".into());
|
||||
}
|
||||
ControlMode::Client => {
|
||||
args.push("-o".into());
|
||||
args.push("ControlMaster=no".into());
|
||||
}
|
||||
}
|
||||
|
||||
args.push("-o".into());
|
||||
args.push(format!("ControlPath={}", socket_path.display()));
|
||||
args
|
||||
}
|
||||
|
||||
async fn wait_master_ready(
|
||||
askpass: &Askpass,
|
||||
spec: &Spec,
|
||||
socket_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > Duration::from_secs(30) {
|
||||
return Err("Timed out waiting for SSH connection".to_string());
|
||||
}
|
||||
|
||||
let res = ssh_command(
|
||||
askpass,
|
||||
[
|
||||
control_args(Some(socket_path), ControlMode::Client),
|
||||
vec!["-O".into(), "check".into(), spec.destination.clone()],
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(out) = res {
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_remote_opencode(
|
||||
app: &AppHandle,
|
||||
askpass: &Askpass,
|
||||
spec: &Spec,
|
||||
socket_path: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let version = app.package_info().version.to_string();
|
||||
|
||||
let installed = ssh_output(
|
||||
askpass,
|
||||
[
|
||||
spec.args.clone(),
|
||||
control_args(socket_path, ControlMode::Client),
|
||||
vec![
|
||||
spec.destination.clone(),
|
||||
"cd; ~/.opencode/bin/opencode --version".into(),
|
||||
],
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string());
|
||||
|
||||
match installed.as_deref() {
|
||||
Some(version) => log(format!("Remote opencode detected: {version}")),
|
||||
None => log("Remote opencode not found"),
|
||||
}
|
||||
|
||||
if installed.as_deref() == Some(version.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log("Starting remote install");
|
||||
let cmd = format!(
|
||||
"cd; bash -lc {}",
|
||||
sh_quote(&format!(
|
||||
"curl -fsSL https://opencode.ai/install | bash -s -- --version {version} --no-modify-path"
|
||||
))
|
||||
);
|
||||
|
||||
ssh_output(
|
||||
askpass,
|
||||
[
|
||||
spec.args.clone(),
|
||||
control_args(socket_path, ControlMode::Client),
|
||||
vec![spec.destination.clone(), cmd],
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())?;
|
||||
|
||||
log("Remote install finished");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_master(askpass: &Askpass, spec: &Spec, socket_path: &Path) -> Result<Child, String> {
|
||||
let mut child = ssh_spawn_bg(
|
||||
askpass,
|
||||
[
|
||||
spec.args.clone(),
|
||||
vec!["-N".into()],
|
||||
control_args(Some(socket_path), ControlMode::Master),
|
||||
vec![spec.destination.clone()],
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start ssh: {e}"))?;
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
tokio::spawn(async move {
|
||||
let mut err = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = err.next_line().await {
|
||||
if !line.trim().is_empty() {
|
||||
log(format!("[master] {line}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn parse_listening_port(line: &str) -> Option<u16> {
|
||||
let needle = "opencode server listening on http://";
|
||||
let rest = line.trim();
|
||||
let rest = rest.strip_prefix(needle)?;
|
||||
let hostport = rest.split_whitespace().next().unwrap_or(rest);
|
||||
let port = hostport.rsplit(':').next()?;
|
||||
port.trim().parse().ok()
|
||||
}
|
||||
|
||||
async fn spawn_remote_server(
|
||||
askpass: &Askpass,
|
||||
spec: &Spec,
|
||||
socket_path: Option<&Path>,
|
||||
password: &str,
|
||||
) -> Result<(Child, u16), String> {
|
||||
let cmd = format!(
|
||||
"cd; env OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD={password} OPENCODE_CLIENT=desktop ~/.opencode/bin/opencode serve --hostname 127.0.0.1 --port 0"
|
||||
);
|
||||
|
||||
let mut child = ssh_command(
|
||||
askpass,
|
||||
[
|
||||
spec.args.clone(),
|
||||
control_args(socket_path, ControlMode::Client),
|
||||
vec![spec.destination.clone(), cmd],
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start remote server: {e}"))?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to capture remote server stdout".to_string())?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to capture remote server stderr".to_string())?;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<u16>(1);
|
||||
tokio::spawn(async move {
|
||||
let mut out = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = out.next_line().await {
|
||||
if !line.trim().is_empty() {
|
||||
log(format!("[server] {line}"));
|
||||
}
|
||||
if let Some(port) = parse_listening_port(&line) {
|
||||
let _ = tx.try_send(port);
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
let mut err = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(_line)) = err.next_line().await {
|
||||
if !_line.trim().is_empty() {
|
||||
log(format!("[server] {_line}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let port = tokio::time::timeout(Duration::from_secs(30), rx.recv())
|
||||
.await
|
||||
.map_err(|_| "Timed out waiting for remote server to start".to_string())?
|
||||
.ok_or_else(|| "Remote server exited before becoming ready".to_string())?;
|
||||
|
||||
Ok((child, port))
|
||||
}
|
||||
|
||||
async fn spawn_forward(
|
||||
_app: &AppHandle,
|
||||
askpass: &Askpass,
|
||||
spec: &Spec,
|
||||
socket_path: Option<&Path>,
|
||||
local_port: u16,
|
||||
remote_port: u16,
|
||||
) -> Result<Child, String> {
|
||||
let forward = format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}");
|
||||
let mut child = ssh_spawn_bg(
|
||||
askpass,
|
||||
[
|
||||
spec.args.clone(),
|
||||
vec![
|
||||
"-N".into(),
|
||||
"-L".into(),
|
||||
forward,
|
||||
"-o".into(),
|
||||
"ExitOnForwardFailure=yes".into(),
|
||||
],
|
||||
control_args(socket_path, ControlMode::Client),
|
||||
vec![spec.destination.clone()],
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start port forward: {e}"))?;
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
tokio::spawn(async move {
|
||||
let mut err = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = err.next_line().await {
|
||||
if !line.trim().is_empty() {
|
||||
log(format!("[forward] {line}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
async fn disconnect_session(mut session: SshSession) {
|
||||
let _ = session.forward.kill().await;
|
||||
let _ = session.server.kill().await;
|
||||
if let Some(mut master) = session.master {
|
||||
let _ = master.kill().await;
|
||||
}
|
||||
|
||||
session.askpass_task.abort();
|
||||
let _ = std::fs::remove_dir_all(session.dir);
|
||||
}
|
||||
|
||||
async fn read_prompt<S: AsyncReadExt + Unpin>(stream: &mut S) -> Result<String, String> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut len_buf)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read prompt length: {e}"))?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > 64 * 1024 {
|
||||
return Err("Askpass prompt too large".to_string());
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stream
|
||||
.read_exact(&mut buf)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read prompt: {e}"))?;
|
||||
let prompt = String::from_utf8(buf).map_err(|_| "Askpass prompt was not UTF-8".to_string())?;
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
async fn write_reply<S: AsyncWriteExt + Unpin>(stream: &mut S, value: &str) -> Result<(), String> {
|
||||
let bytes = value.as_bytes();
|
||||
let len = u32::try_from(bytes.len()).map_err(|_| "Askpass reply too large".to_string())?;
|
||||
stream
|
||||
.write_all(&len.to_be_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write reply length: {e}"))?;
|
||||
stream
|
||||
.write_all(bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write reply: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_askpass_server(
|
||||
app: AppHandle,
|
||||
dir: &Path,
|
||||
) -> Result<(tokio::task::JoinHandle<()>, String), String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket = dir.join("askpass.sock");
|
||||
let listener = UnixListener::bind(&socket)
|
||||
.map_err(|e| format!("Failed to bind askpass socket {}: {e}", socket.display()))?;
|
||||
let location = socket.to_string_lossy().to_string();
|
||||
|
||||
log(format!("Askpass listening on {}", socket.display()));
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
let prompt = match read_prompt(&mut stream).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
log(format!("Prompt received: {}", prompt.replace('\n', "\\n")));
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = oneshot::channel::<String>();
|
||||
|
||||
{
|
||||
let state = app.state::<SshState>();
|
||||
state.prompts.lock().await.insert(id.clone(), tx);
|
||||
}
|
||||
|
||||
match app.emit(
|
||||
"ssh_prompt",
|
||||
SshPrompt {
|
||||
id: id.clone(),
|
||||
prompt,
|
||||
},
|
||||
) {
|
||||
Ok(()) => log(format!("Prompt emitted: {id}")),
|
||||
Err(e) => log(format!("Prompt emit failed: {id}: {e}")),
|
||||
};
|
||||
|
||||
let value = tokio::time::timeout(Duration::from_secs(120), rx)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if value.is_empty() {
|
||||
log(format!("Prompt reply empty/timeout: {id}"));
|
||||
} else {
|
||||
log(format!("Prompt reply received: {id}"));
|
||||
}
|
||||
|
||||
{
|
||||
let state = app.state::<SshState>();
|
||||
state.prompts.lock().await.remove(&id);
|
||||
}
|
||||
|
||||
let _ = write_reply(&mut stream, &value).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Ok((task, location));
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind askpass listener: {e}"))?;
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to read askpass address: {e}"))?;
|
||||
let location = format!("tcp:{addr}");
|
||||
|
||||
log(format!("Askpass listening on {addr}"));
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
let prompt = match read_prompt(&mut stream).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
log(format!("Prompt received: {}", prompt.replace('\n', "\\n")));
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let (tx, rx) = oneshot::channel::<String>();
|
||||
|
||||
{
|
||||
let state = app.state::<SshState>();
|
||||
state.prompts.lock().await.insert(id.clone(), tx);
|
||||
}
|
||||
|
||||
match app.emit(
|
||||
"ssh_prompt",
|
||||
SshPrompt {
|
||||
id: id.clone(),
|
||||
prompt,
|
||||
},
|
||||
) {
|
||||
Ok(()) => log(format!("Prompt emitted: {id}")),
|
||||
Err(e) => log(format!("Prompt emit failed: {id}: {e}")),
|
||||
};
|
||||
|
||||
let value = tokio::time::timeout(Duration::from_secs(120), rx)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if value.is_empty() {
|
||||
log(format!("Prompt reply empty/timeout: {id}"));
|
||||
} else {
|
||||
log(format!("Prompt reply received: {id}"));
|
||||
}
|
||||
|
||||
{
|
||||
let state = app.state::<SshState>();
|
||||
state.prompts.lock().await.remove(&id);
|
||||
}
|
||||
|
||||
let _ = write_reply(&mut stream, &value).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Ok((task, location));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn ssh_prompt_reply(app: AppHandle, id: String, value: String) -> Result<(), String> {
|
||||
log(format!(
|
||||
"Prompt reply from UI: {id} ({} chars)",
|
||||
value.len()
|
||||
));
|
||||
let state = app.state::<SshState>();
|
||||
let tx = state.prompts.lock().await.remove(&id);
|
||||
let Some(tx) = tx else {
|
||||
return Ok(());
|
||||
};
|
||||
let _ = tx.send(value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn ssh_disconnect(app: AppHandle, key: String) -> Result<(), String> {
|
||||
let state = app.state::<SshState>();
|
||||
let session = {
|
||||
let mut lock = state.session.lock().await;
|
||||
if lock.as_ref().is_some_and(|s| s.key == key) {
|
||||
lock.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
tokio::spawn(async move {
|
||||
disconnect_session(session).await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn ssh_connect(app: AppHandle, command: String) -> Result<SshConnectData, String> {
|
||||
async {
|
||||
ensure_ssh_available().await?;
|
||||
let spec = parse_ssh_command(&command)?;
|
||||
|
||||
log(format!("Connect requested: {}", spec.destination));
|
||||
|
||||
// Disconnect any existing session.
|
||||
{
|
||||
let state = app.state::<SshState>();
|
||||
if let Some(session) = state.session.lock().await.take() {
|
||||
disconnect_session(session).await;
|
||||
}
|
||||
}
|
||||
|
||||
let key = uuid::Uuid::new_v4().to_string();
|
||||
let password = uuid::Uuid::new_v4().to_string();
|
||||
let local_port = free_port();
|
||||
let url = format!("http://127.0.0.1:{local_port}");
|
||||
|
||||
// Unix domain sockets (and OpenSSH ControlPath) have strict length limits on macOS.
|
||||
// Avoid long per-user temp dirs like /var/folders/... by using /tmp.
|
||||
let dir = if control_supported() {
|
||||
PathBuf::from("/tmp").join(format!("opencode-ssh-{key}"))
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("opencode-ssh-{key}"))
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create temp dir: {e}"))?;
|
||||
|
||||
let socket_path = control_supported().then(|| dir.join("ssh.sock"));
|
||||
let (askpass_task, askpass_socket) = spawn_askpass_server(app.clone(), &dir).await?;
|
||||
let askpass = Askpass {
|
||||
socket: askpass_socket,
|
||||
exe: exe_path(&app)?,
|
||||
};
|
||||
|
||||
log(format!("Session dir: {}", dir.display()));
|
||||
if let Some(path) = socket_path.as_ref() {
|
||||
log(format!("ControlPath: {}", path.display()));
|
||||
}
|
||||
log(format!("Askpass socket: {}", askpass.socket));
|
||||
|
||||
let master = if let Some(path) = socket_path.as_ref() {
|
||||
log("Starting SSH master");
|
||||
let master = spawn_master(&askpass, &spec, path).await?;
|
||||
log("Waiting for master ready");
|
||||
wait_master_ready(&askpass, &spec, path).await?;
|
||||
log("Master ready");
|
||||
Some(master)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
log("Ensuring remote opencode");
|
||||
ensure_remote_opencode(&app, &askpass, &spec, socket_path.as_deref()).await?;
|
||||
log("Remote opencode ready");
|
||||
|
||||
log("Starting remote opencode server");
|
||||
let (server_child, remote_port) =
|
||||
spawn_remote_server(&askpass, &spec, socket_path.as_deref(), &password).await?;
|
||||
|
||||
log(format!("Remote server port: {remote_port}"));
|
||||
log(format!("Starting port forward to {url}"));
|
||||
let forward_child = spawn_forward(
|
||||
&app,
|
||||
&askpass,
|
||||
&spec,
|
||||
socket_path.as_deref(),
|
||||
local_port,
|
||||
remote_port,
|
||||
)
|
||||
.await?;
|
||||
|
||||
log("Waiting for forwarded health");
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > Duration::from_secs(30) {
|
||||
return Err("Timed out waiting for forwarded server health".to_string());
|
||||
}
|
||||
if server::check_health(&url, Some(&password)).await {
|
||||
log("Forwarded health OK");
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let session = SshSession {
|
||||
key: key.clone(),
|
||||
destination: spec.destination.clone(),
|
||||
dir: dir.clone(),
|
||||
socket_path,
|
||||
askpass_task,
|
||||
master,
|
||||
forward: forward_child,
|
||||
server: server_child,
|
||||
};
|
||||
|
||||
app.state::<SshState>()
|
||||
.session
|
||||
.lock()
|
||||
.await
|
||||
.replace(session);
|
||||
|
||||
Ok(SshConnectData {
|
||||
key,
|
||||
url,
|
||||
password,
|
||||
destination: spec.destination,
|
||||
})
|
||||
}
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn shutdown(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<SshState>();
|
||||
if let Some(session) = state.session.lock().await.take() {
|
||||
disconnect_session(session).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const commands = {
|
||||
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
|
||||
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
|
||||
sshConnect: (command: string) => __TAURI_INVOKE<SshConnectData>("ssh_connect", { command }),
|
||||
sshDisconnect: (key: string) => __TAURI_INVOKE<null>("ssh_disconnect", { key }),
|
||||
sshPromptReply: (id: string, value: string) => __TAURI_INVOKE<null>("ssh_prompt_reply", { id, value }),
|
||||
};
|
||||
|
||||
/** Events */
|
||||
@@ -37,6 +40,13 @@ export type ServerReadyData = {
|
||||
password: string | null,
|
||||
};
|
||||
|
||||
export type SshConnectData = {
|
||||
key: string,
|
||||
url: string,
|
||||
password: string,
|
||||
destination: string,
|
||||
};
|
||||
|
||||
export type WslConfig = {
|
||||
enabled: boolean,
|
||||
};
|
||||
|
||||
@@ -22,15 +22,21 @@ import { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
|
||||
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import pkg from "../package.json"
|
||||
import "./styles.css"
|
||||
import { commands, InitStep, type WslConfig } from "./bindings"
|
||||
import { Channel } from "@tauri-apps/api/core"
|
||||
import { Channel, invoke } from "@tauri-apps/api/core"
|
||||
import { createMenu } from "./menu"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
@@ -40,6 +46,38 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
|
||||
void initI18n()
|
||||
|
||||
const ssh = new Map<string, string>()
|
||||
const auth = new Map<string, string>()
|
||||
|
||||
let base = null as string | null
|
||||
|
||||
type SshPrompt = { id: string; prompt: string }
|
||||
const sshPromptEvent = "opencode:ssh-prompt"
|
||||
const sshPrompts: SshPrompt[] = []
|
||||
|
||||
void listen<SshPrompt>("ssh_prompt", (event) => {
|
||||
sshPrompts.push(event.payload)
|
||||
window.dispatchEvent(new CustomEvent(sshPromptEvent))
|
||||
}).catch((err) => {
|
||||
console.error("Failed to listen for ssh_prompt", err)
|
||||
})
|
||||
|
||||
const isConfirmPrompt = (prompt: string) => {
|
||||
const text = prompt.toLowerCase()
|
||||
return text.includes("yes/no") || text.includes("continue connecting")
|
||||
}
|
||||
|
||||
const isMaskedPrompt = (prompt: string) => {
|
||||
const text = prompt.toLowerCase()
|
||||
return (
|
||||
text.includes("password") ||
|
||||
text.includes("passphrase") ||
|
||||
text.includes("verification code") ||
|
||||
text.includes("one-time") ||
|
||||
text.includes("otp")
|
||||
)
|
||||
}
|
||||
|
||||
let update: Update | null = null
|
||||
|
||||
const deepLinkEvent = "opencode:deep-link"
|
||||
@@ -58,7 +96,10 @@ const listenForDeepLinks = async () => {
|
||||
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
|
||||
}
|
||||
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
const createPlatform = (
|
||||
password: Accessor<string | null>,
|
||||
sshState: { get: Accessor<boolean>; set: (value: boolean) => void },
|
||||
): Platform => {
|
||||
const os = (() => {
|
||||
const type = ostype()
|
||||
if (type === "macos" || type === "windows" || type === "linux") return type
|
||||
@@ -230,8 +271,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
})().finally(() => {
|
||||
flushing = undefined
|
||||
})
|
||||
|
||||
return flushing
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
@@ -347,7 +386,20 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
const pw = password()
|
||||
if (typeof input === "string" && input.startsWith("/") && base) {
|
||||
input = base + input
|
||||
}
|
||||
|
||||
const origin = (() => {
|
||||
try {
|
||||
const url = input instanceof Request ? input.url : String(input)
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
const pw = origin ? (auth.get(origin) ?? null) : password()
|
||||
|
||||
const addHeader = (headers: Headers, password: string) => {
|
||||
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
@@ -381,6 +433,70 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
return result
|
||||
},
|
||||
|
||||
serverKey: (url) => {
|
||||
const origin = (() => {
|
||||
try {
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
})()
|
||||
const key = origin ? ssh.get(origin) : undefined
|
||||
if (key) return `ssh:${key}`
|
||||
if (origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]")) return "local"
|
||||
return url
|
||||
},
|
||||
|
||||
isServerLocal: (url) => {
|
||||
const origin = (() => {
|
||||
try {
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (origin && ssh.has(origin)) return false
|
||||
if (!origin) return false
|
||||
},
|
||||
|
||||
sshConnect: async (command) => {
|
||||
sshState.set(true)
|
||||
try {
|
||||
const result = await invoke<{ key: string; url: string; password: string; destination: string }>(
|
||||
"ssh_connect",
|
||||
{
|
||||
command,
|
||||
},
|
||||
)
|
||||
const origin = new URL(result.url).origin
|
||||
ssh.set(origin, result.key)
|
||||
auth.set(origin, result.password)
|
||||
return { url: result.url, key: result.key, password: result.password }
|
||||
} finally {
|
||||
sshState.set(false)
|
||||
}
|
||||
},
|
||||
|
||||
wsAuth: (url) => {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
const pw = auth.get(origin) ?? password()
|
||||
if (!pw) return null
|
||||
return { username: "opencode", password: pw }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
sshDisconnect: async (key) => {
|
||||
await invoke<void>("ssh_disconnect", { key })
|
||||
for (const [origin, k] of ssh.entries()) {
|
||||
if (k !== key) continue
|
||||
ssh.delete(origin)
|
||||
auth.delete(origin)
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
await commands.setDefaultServerUrl(url)
|
||||
},
|
||||
@@ -435,8 +551,143 @@ void listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const [sshConnecting, setSshConnecting] = createSignal(false)
|
||||
const platform = createPlatform(() => serverPassword(), { get: sshConnecting, set: setSshConnecting })
|
||||
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
function SshPromptDialog(props: {
|
||||
prompt: Accessor<string>
|
||||
pending: Accessor<boolean>
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const confirm = () => isConfirmPrompt(props.prompt())
|
||||
const masked = () => isMaskedPrompt(props.prompt())
|
||||
const [value, setValue] = createSignal("")
|
||||
|
||||
return (
|
||||
<Dialog title="SSH" fit>
|
||||
<div class="flex flex-col gap-3 px-3 pb-3">
|
||||
<div class="text-14-regular text-text-base whitespace-pre-wrap px-1">{props.prompt()}</div>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<TextField
|
||||
type={masked() ? "password" : "text"}
|
||||
hideLabel
|
||||
placeholder={masked() ? "Password" : "Response"}
|
||||
value={value()}
|
||||
autofocus
|
||||
disabled={props.pending()}
|
||||
onChange={(v) => setValue(v)}
|
||||
onKeyDown={(event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onCancel()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
props.onSubmit(value())
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Show
|
||||
when={confirm()}
|
||||
fallback={
|
||||
<>
|
||||
<Button variant="secondary" onClick={props.onCancel} disabled={props.pending()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => props.onSubmit(value())} disabled={props.pending()}>
|
||||
{props.pending() ? "Connecting..." : "Continue"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => props.onSubmit("no")} disabled={props.pending()}>
|
||||
No
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => props.onSubmit("yes")} disabled={props.pending()}>
|
||||
{props.pending() ? "Connecting..." : "Yes"}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SshPromptHandler(props: { connecting: Accessor<boolean> }) {
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({
|
||||
prompt: null as SshPrompt | null,
|
||||
pending: false,
|
||||
open: false,
|
||||
})
|
||||
|
||||
const open = () => {
|
||||
if (store.open) return
|
||||
setStore("open", true)
|
||||
dialog.show(
|
||||
() => (
|
||||
<SshPromptDialog
|
||||
prompt={() => store.prompt?.prompt ?? ""}
|
||||
pending={() => store.pending}
|
||||
onSubmit={async (value) => {
|
||||
const current = store.prompt
|
||||
if (!current) return
|
||||
setStore({ pending: true })
|
||||
await invoke<void>("ssh_prompt_reply", { id: current.id, value }).catch((err) => {
|
||||
console.error("Failed to send ssh_prompt_reply", err)
|
||||
})
|
||||
}}
|
||||
onCancel={async () => {
|
||||
const current = store.prompt
|
||||
setStore({ pending: true })
|
||||
if (current) {
|
||||
await invoke<void>("ssh_prompt_reply", { id: current.id, value: "" }).catch((err) => {
|
||||
console.error("Failed to send ssh_prompt_reply", err)
|
||||
})
|
||||
}
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => close(),
|
||||
)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (!store.open) return
|
||||
dialog.close()
|
||||
setStore({ open: false, pending: false, prompt: null })
|
||||
}
|
||||
|
||||
const showNext = () => {
|
||||
const next = sshPrompts.shift()
|
||||
if (!next) return
|
||||
setStore({ prompt: next, pending: false })
|
||||
open()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const onPrompt = () => showNext()
|
||||
window.addEventListener(sshPromptEvent, onPrompt)
|
||||
showNext()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener(sshPromptEvent, onPrompt)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.connecting()) return
|
||||
close()
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
@@ -459,6 +710,15 @@ render(() => {
|
||||
<ServerGate>
|
||||
{(data) => {
|
||||
setServerPassword(data().password)
|
||||
try {
|
||||
const origin = new URL(data().url).origin
|
||||
base = origin
|
||||
const pw = data().password
|
||||
if (pw) auth.set(origin, pw)
|
||||
if (!pw) auth.delete(origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
window.__OPENCODE__ ??= {}
|
||||
window.__OPENCODE__.serverPassword = data().password ?? undefined
|
||||
|
||||
@@ -471,9 +731,12 @@ render(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppInterface defaultUrl={data().url} isSidecar>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
<>
|
||||
<AppInterface defaultUrl={data().url} isSidecar>
|
||||
<Inner />
|
||||
<SshPromptHandler connecting={() => sshConnecting()} />
|
||||
</AppInterface>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</ServerGate>
|
||||
|
||||
Reference in New Issue
Block a user