From 4c41f20677d09d6c253b8fea8c04bb7d517c892a Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 15 Feb 2026 18:30:39 +0100 Subject: [PATCH 1/4] feat(settings): add multi-remote mobile management and Orbit token targeting --- src-tauri/src/bin/codex_monitor_daemon.rs | 13 +- .../bin/codex_monitor_daemon/rpc/workspace.rs | 9 +- src-tauri/src/orbit/mod.rs | 7 +- src-tauri/src/shared/orbit_core.rs | 63 ++- src-tauri/src/shared/settings_core.rs | 54 ++- src-tauri/src/types.rs | 32 +- .../settings/components/SettingsView.test.tsx | 188 +++++++- .../sections/SettingsServerSection.tsx | 212 ++++++++- .../settings/components/settingsTypes.ts | 7 +- src/features/settings/hooks/useAppSettings.ts | 120 +++++- .../hooks/useSettingsServerSection.ts | 405 ++++++++++++++---- src/services/tauri.test.ts | 9 + src/services/tauri.ts | 16 +- src/styles/settings.css | 117 +++++ src/types.ts | 11 + 15 files changed, 1158 insertions(+), 105 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index fa1378641..9275ed671 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -551,6 +551,7 @@ impl DaemonState { async fn orbit_sign_in_poll( &self, device_code: String, + remote_backend_id: Option, ) -> Result { let auth_url = { let settings = self.app_settings.lock().await.clone(); @@ -564,6 +565,7 @@ impl DaemonState { &self.app_settings, &self.settings_path, Some(token), + remote_backend_id.as_deref(), ) .await?; } @@ -572,10 +574,16 @@ impl DaemonState { Ok(result) } - async fn orbit_sign_out(&self) -> Result { + async fn orbit_sign_out( + &self, + remote_backend_id: Option, + ) -> Result { let settings = self.app_settings.lock().await.clone(); let auth_url = shared::orbit_core::orbit_auth_url_optional(&settings); - let token = shared::orbit_core::remote_backend_token_optional(&settings); + let token = shared::orbit_core::remote_backend_token_for_id_optional( + &settings, + remote_backend_id.as_deref(), + ); let mut logout_error: Option = None; if let (Some(auth_url), Some(token)) = (auth_url.as_ref(), token.as_ref()) { @@ -588,6 +596,7 @@ impl DaemonState { &self.app_settings, &self.settings_path, None, + remote_backend_id.as_deref(), ) .await?; diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index d26b8c36d..8796adeb2 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -312,14 +312,19 @@ pub(super) async fn try_handle( Ok(value) => value, Err(err) => return Some(Err(err)), }; - let result = match state.orbit_sign_in_poll(device_code).await { + let remote_backend_id = parse_optional_string(params, "remoteBackendId"); + let result = match state + .orbit_sign_in_poll(device_code, remote_backend_id) + .await + { Ok(value) => value, Err(err) => return Some(Err(err)), }; Some(serde_json::to_value(result).map_err(|err| err.to_string())) } "orbit_sign_out" => { - let result = match state.orbit_sign_out().await { + let remote_backend_id = parse_optional_string(params, "remoteBackendId"); + let result = match state.orbit_sign_out(remote_backend_id).await { Ok(value) => value, Err(err) => return Some(Err(err)), }; diff --git a/src-tauri/src/orbit/mod.rs b/src-tauri/src/orbit/mod.rs index 5e29be26b..c0e2a4d72 100644 --- a/src-tauri/src/orbit/mod.rs +++ b/src-tauri/src/orbit/mod.rs @@ -168,6 +168,7 @@ pub(crate) async fn orbit_sign_in_start( #[tauri::command] pub(crate) async fn orbit_sign_in_poll( device_code: String, + remote_backend_id: Option, state: State<'_, AppState>, ) -> Result { let auth_url = { @@ -182,6 +183,7 @@ pub(crate) async fn orbit_sign_in_poll( &state.app_settings, &state.settings_path, Some(token), + remote_backend_id.as_deref(), ) .await?; } @@ -192,11 +194,13 @@ pub(crate) async fn orbit_sign_in_poll( #[tauri::command] pub(crate) async fn orbit_sign_out( + remote_backend_id: Option, state: State<'_, AppState>, ) -> Result { let settings = state.app_settings.lock().await.clone(); let auth_url = orbit_core::orbit_auth_url_optional(&settings); - let token = orbit_core::remote_backend_token_optional(&settings); + let token = + orbit_core::remote_backend_token_for_id_optional(&settings, remote_backend_id.as_deref()); let mut logout_error: Option = None; if let (Some(auth_url), Some(token)) = (auth_url.as_ref(), token.as_ref()) { @@ -209,6 +213,7 @@ pub(crate) async fn orbit_sign_out( &state.app_settings, &state.settings_path, None, + remote_backend_id.as_deref(), ) .await?; diff --git a/src-tauri/src/shared/orbit_core.rs b/src-tauri/src/shared/orbit_core.rs index d4f668005..6d13cd08e 100644 --- a/src-tauri/src/shared/orbit_core.rs +++ b/src-tauri/src/shared/orbit_core.rs @@ -153,6 +153,31 @@ pub(crate) fn remote_backend_token_optional(settings: &AppSettings) -> Option, +) -> Option { + let normalized_remote_backend_id = remote_backend_id + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(remote_backend_id) = normalized_remote_backend_id { + if let Some(entry) = settings + .remote_backends + .iter() + .find(|entry| entry.id == remote_backend_id) + { + return entry + .token + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + } + + remote_backend_token_optional(settings) +} + pub(crate) fn build_orbit_ws_url(ws_url: &str, auth_token: Option<&str>) -> Result { let raw_url = ws_url.trim(); if raw_url.is_empty() { @@ -400,7 +425,11 @@ pub(crate) async fn orbit_sign_out_core(auth_url: &str, token: &str) -> Result<( #[cfg(test)] mod tests { - use super::{build_orbit_ws_url, response_body_excerpt, MAX_ERROR_BODY_BYTES}; + use super::{ + build_orbit_ws_url, remote_backend_token_for_id_optional, response_body_excerpt, + MAX_ERROR_BODY_BYTES, + }; + use crate::types::{AppSettings, RemoteBackendProvider, RemoteBackendTarget}; #[test] fn build_orbit_ws_url_converts_http_scheme() { @@ -454,4 +483,36 @@ mod tests { format!("{}...", "a".repeat(MAX_ERROR_BODY_BYTES - 1)) ); } + + #[test] + fn remote_backend_token_for_id_falls_back_to_legacy_token_when_remote_missing() { + let settings = AppSettings { + remote_backend_token: Some("legacy-token".to_string()), + remote_backends: Vec::new(), + ..AppSettings::default() + }; + + let token = remote_backend_token_for_id_optional(&settings, Some("remote-a")); + assert_eq!(token.as_deref(), Some("legacy-token")); + } + + #[test] + fn remote_backend_token_for_id_prefers_matching_remote_token() { + let settings = AppSettings { + remote_backend_token: Some("legacy-token".to_string()), + remote_backends: vec![RemoteBackendTarget { + id: "remote-a".to_string(), + name: "Remote A".to_string(), + provider: RemoteBackendProvider::Orbit, + host: "127.0.0.1:4732".to_string(), + token: Some("remote-token".to_string()), + orbit_ws_url: None, + last_connected_at_ms: None, + }], + ..AppSettings::default() + }; + + let token = remote_backend_token_for_id_optional(&settings, Some("remote-a")); + assert_eq!(token.as_deref(), Some("remote-token")); + } } diff --git a/src-tauri/src/shared/settings_core.rs b/src-tauri/src/shared/settings_core.rs index 1e42e26a2..625484f30 100644 --- a/src-tauri/src/shared/settings_core.rs +++ b/src-tauri/src/shared/settings_core.rs @@ -63,16 +63,66 @@ pub(crate) async fn update_remote_backend_token_core( app_settings: &Mutex, settings_path: &PathBuf, token: Option<&str>, + remote_backend_id: Option<&str>, ) -> Result { let normalized_token = token .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + let normalized_remote_backend_id = remote_backend_id + .map(str::trim) + .filter(|value| !value.is_empty()); let mut next_settings = app_settings.lock().await.clone(); - if next_settings.remote_backend_token == normalized_token { + let mut changed = false; + + if next_settings.remote_backends.is_empty() { + if next_settings.remote_backend_token != normalized_token { + next_settings.remote_backend_token = normalized_token.clone(); + changed = true; + } + } else { + let active_index = next_settings + .active_remote_backend_id + .as_ref() + .and_then(|id| { + next_settings + .remote_backends + .iter() + .position(|entry| &entry.id == id) + }) + .unwrap_or(0); + let active_remote_id = next_settings.remote_backends[active_index].id.clone(); + if next_settings.active_remote_backend_id.as_deref() != Some(active_remote_id.as_str()) { + next_settings.active_remote_backend_id = Some(active_remote_id.clone()); + changed = true; + } + + let target_index = if let Some(target_id) = normalized_remote_backend_id { + next_settings + .remote_backends + .iter() + .position(|entry| entry.id == target_id) + } else { + Some(active_index) + }; + + if let Some(target_index) = target_index { + if next_settings.remote_backends[target_index].token != normalized_token { + next_settings.remote_backends[target_index].token = normalized_token.clone(); + changed = true; + } + + if target_index == active_index + && next_settings.remote_backend_token != normalized_token + { + next_settings.remote_backend_token = normalized_token.clone(); + changed = true; + } + } + } + if !changed { return Ok(next_settings); } - next_settings.remote_backend_token = normalized_token; update_app_settings_core(next_settings, app_settings, settings_path).await } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index a593efece..0ebb1961f 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -439,6 +439,21 @@ pub(crate) struct OpenAppTarget { pub(crate) args: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct RemoteBackendTarget { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) provider: RemoteBackendProvider, + #[serde(default = "default_remote_backend_host")] + pub(crate) host: String, + #[serde(default)] + pub(crate) token: Option, + #[serde(default, rename = "orbitWsUrl")] + pub(crate) orbit_ws_url: Option, + #[serde(default, rename = "lastConnectedAtMs")] + pub(crate) last_connected_at_ms: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct AppSettings { #[serde(default, rename = "codexBin")] @@ -453,6 +468,10 @@ pub(crate) struct AppSettings { pub(crate) remote_backend_host: String, #[serde(default, rename = "remoteBackendToken")] pub(crate) remote_backend_token: Option, + #[serde(default = "default_remote_backends", rename = "remoteBackends")] + pub(crate) remote_backends: Vec, + #[serde(default, rename = "activeRemoteBackendId")] + pub(crate) active_remote_backend_id: Option, #[serde(default, rename = "orbitWsUrl")] pub(crate) orbit_ws_url: Option, #[serde(default, rename = "orbitAuthUrl")] @@ -591,10 +610,7 @@ pub(crate) struct AppSettings { rename = "notificationSoundsEnabled" )] pub(crate) notification_sounds_enabled: bool, - #[serde( - default = "default_split_chat_diff_view", - rename = "splitChatDiffView" - )] + #[serde(default = "default_split_chat_diff_view", rename = "splitChatDiffView")] pub(crate) split_chat_diff_view: bool, #[serde(default = "default_preload_git_diffs", rename = "preloadGitDiffs")] pub(crate) preload_git_diffs: bool, @@ -759,6 +775,10 @@ fn default_remote_backend_host() -> String { "127.0.0.1:4732".to_string() } +fn default_remote_backends() -> Vec { + Vec::new() +} + fn default_ui_scale() -> f64 { 1.0 } @@ -1181,6 +1201,8 @@ impl Default for AppSettings { remote_backend_provider: RemoteBackendProvider::Tcp, remote_backend_host: default_remote_backend_host(), remote_backend_token: None, + remote_backends: default_remote_backends(), + active_remote_backend_id: None, orbit_ws_url: None, orbit_auth_url: None, orbit_runner_name: None, @@ -1282,6 +1304,8 @@ mod tests { )); assert_eq!(settings.remote_backend_host, "127.0.0.1:4732"); assert!(settings.remote_backend_token.is_none()); + assert!(settings.remote_backends.is_empty()); + assert!(settings.active_remote_backend_id.is_none()); assert!(settings.orbit_ws_url.is_none()); assert!(settings.orbit_auth_url.is_none()); assert!(settings.orbit_runner_name.is_none()); diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 191d678bf..72ebf6d78 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -41,6 +41,17 @@ const baseSettings: AppSettings = { remoteBackendProvider: "tcp", remoteBackendHost: "127.0.0.1:4732", remoteBackendToken: null, + remoteBackends: [ + { + id: "remote-default", + name: "Primary remote", + provider: "tcp", + host: "127.0.0.1:4732", + token: null, + orbitWsUrl: null, + }, + ], + activeRemoteBackendId: "remote-default", orbitWsUrl: null, orbitAuthUrl: null, orbitRunnerName: null, @@ -986,6 +997,177 @@ describe("SettingsView Codex overrides", () => { } }); + it("supports multiple saved remotes on iOS runtime", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor( + window.navigator, + "platform", + ); + const originalUserAgentDescriptor = Object.getOwnPropertyDescriptor( + window.navigator, + "userAgent", + ); + const originalTouchPointsDescriptor = Object.getOwnPropertyDescriptor( + window.navigator, + "maxTouchPoints", + ); + + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: "iPhone", + }); + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + }); + Object.defineProperty(window.navigator, "maxTouchPoints", { + configurable: true, + value: 5, + }); + + try { + render( + , + ); + + await waitFor(() => { + expect(screen.getByRole("list", { name: "Saved remotes" })).toBeTruthy(); + expect(screen.getByLabelText("Remote name")).toBeTruthy(); + }); + expect(screen.getAllByText(/Last connected: Never/i).length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: "Use Office Mac remote" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + activeRemoteBackendId: "remote-b", + remoteBackendProvider: "tcp", + remoteBackendHost: "office-mac.tailnet.ts.net:4732", + remoteBackendToken: "token-b", + }), + ); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.change(screen.getByLabelText("Remote name"), { + target: { value: "Home Mac" }, + }); + fireEvent.blur(screen.getByLabelText("Remote name")); + + await waitFor(() => { + expect( + screen.getAllByText('A remote named "Home Mac" already exists.').length, + ).toBeGreaterThan(0); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Add remote" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + const nextSettings = onUpdateAppSettings.mock.calls[0]?.[0] as AppSettings; + expect(nextSettings.remoteBackends).toHaveLength(3); + expect(nextSettings.activeRemoteBackendId).toBeTruthy(); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Move Home Mac down" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + const nextSettings = onUpdateAppSettings.mock.calls[0]?.[0] as AppSettings; + expect(nextSettings.remoteBackends[0]?.id).toBe("remote-b"); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Delete Office Mac" })); + fireEvent.click(screen.getByRole("button", { name: "Delete remote" })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); + const nextSettings = onUpdateAppSettings.mock.calls[0]?.[0] as AppSettings; + expect(nextSettings.remoteBackends.length).toBeGreaterThanOrEqual(1); + }); + } finally { + if (originalPlatformDescriptor) { + Object.defineProperty(window.navigator, "platform", originalPlatformDescriptor); + } else { + Reflect.deleteProperty(window.navigator, "platform"); + } + if (originalUserAgentDescriptor) { + Object.defineProperty(window.navigator, "userAgent", originalUserAgentDescriptor); + } else { + Reflect.deleteProperty(window.navigator, "userAgent"); + } + if (originalTouchPointsDescriptor) { + Object.defineProperty( + window.navigator, + "maxTouchPoints", + originalTouchPointsDescriptor, + ); + } else { + Reflect.deleteProperty(window.navigator, "maxTouchPoints"); + } + } + }); + it("polls Orbit sign-in using deviceCode until authorized", async () => { cleanup(); const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); @@ -1131,7 +1313,7 @@ describe("SettingsView Codex overrides", () => { await waitFor(() => { expect(startSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledTimes(2); - expect(pollSpy).toHaveBeenCalledWith("device-code-123"); + expect(pollSpy).toHaveBeenCalledWith("device-code-123", "remote-default"); expect(onUpdateAppSettings).toHaveBeenCalledWith( expect.objectContaining({ remoteBackendToken: "orbit-token-1", theme: "dark" }), ); @@ -1143,6 +1325,7 @@ describe("SettingsView Codex overrides", () => { it("syncs token state after Orbit sign-out", async () => { cleanup(); const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const signOutSpy = vi.fn().mockResolvedValue({ success: true, message: null }); const orbitServiceClient: NonNullable< ComponentProps["orbitServiceClient"] > = { @@ -1153,7 +1336,7 @@ describe("SettingsView Codex overrides", () => { }), orbitSignInStart: vi.fn(), orbitSignInPoll: vi.fn(), - orbitSignOut: vi.fn().mockResolvedValue({ success: true, message: null }), + orbitSignOut: signOutSpy, orbitRunnerStart: vi.fn().mockResolvedValue({ state: "running", pid: 123, @@ -1221,6 +1404,7 @@ describe("SettingsView Codex overrides", () => { }); await waitFor(() => { + expect(signOutSpy).toHaveBeenCalledWith("remote-default"); expect(onUpdateAppSettings).toHaveBeenCalledWith( expect.objectContaining({ remoteBackendToken: null }), ); diff --git a/src/features/settings/components/sections/SettingsServerSection.tsx b/src/features/settings/components/sections/SettingsServerSection.tsx index d52e35c8d..d8a3dfa0f 100644 --- a/src/features/settings/components/sections/SettingsServerSection.tsx +++ b/src/features/settings/components/sections/SettingsServerSection.tsx @@ -1,3 +1,4 @@ +import { useMemo, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import type { AppSettings, @@ -5,6 +6,7 @@ import type { TailscaleStatus, TcpDaemonStatus, } from "@/types"; +import { ModalShell } from "@/features/design-system/components/modal/ModalShell"; type SettingsServerSectionProps = { appSettings: AppSettings; @@ -13,6 +15,13 @@ type SettingsServerSectionProps = { mobileConnectBusy: boolean; mobileConnectStatusText: string | null; mobileConnectStatusError: boolean; + remoteBackends: AppSettings["remoteBackends"]; + activeRemoteBackendId: string | null; + remoteStatusText: string | null; + remoteStatusError: boolean; + remoteNameError: string | null; + remoteHostError: string | null; + remoteNameDraft: string; remoteHostDraft: string; remoteTokenDraft: string; orbitWsUrlDraft: string; @@ -32,6 +41,7 @@ type SettingsServerSectionProps = { tailscaleCommandError: string | null; tcpDaemonStatus: TcpDaemonStatus | null; tcpDaemonBusyAction: "start" | "stop" | "status" | null; + onSetRemoteNameDraft: Dispatch>; onSetRemoteHostDraft: Dispatch>; onSetRemoteTokenDraft: Dispatch>; onSetOrbitWsUrlDraft: Dispatch>; @@ -39,8 +49,13 @@ type SettingsServerSectionProps = { onSetOrbitRunnerNameDraft: Dispatch>; onSetOrbitAccessClientIdDraft: Dispatch>; onSetOrbitAccessClientSecretRefDraft: Dispatch>; + onCommitRemoteName: () => Promise; onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; + onSelectRemoteBackend: (id: string) => Promise; + onAddRemoteBackend: () => Promise; + onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; + onDeleteRemoteBackend: (id: string) => Promise; onChangeRemoteProvider: (provider: AppSettings["remoteBackendProvider"]) => Promise; onRefreshTailscaleStatus: () => void; onRefreshTailscaleCommandPreview: () => void; @@ -69,6 +84,13 @@ export function SettingsServerSection({ mobileConnectBusy, mobileConnectStatusText, mobileConnectStatusError, + remoteBackends, + activeRemoteBackendId, + remoteStatusText, + remoteStatusError, + remoteNameError, + remoteHostError, + remoteNameDraft, remoteHostDraft, remoteTokenDraft, orbitWsUrlDraft, @@ -88,6 +110,7 @@ export function SettingsServerSection({ tailscaleCommandError, tcpDaemonStatus, tcpDaemonBusyAction, + onSetRemoteNameDraft, onSetRemoteHostDraft, onSetRemoteTokenDraft, onSetOrbitWsUrlDraft, @@ -95,8 +118,13 @@ export function SettingsServerSection({ onSetOrbitRunnerNameDraft, onSetOrbitAccessClientIdDraft, onSetOrbitAccessClientSecretRefDraft, + onCommitRemoteName, onCommitRemoteHost, onCommitRemoteToken, + onSelectRemoteBackend, + onAddRemoteBackend, + onMoveRemoteBackend, + onDeleteRemoteBackend, onChangeRemoteProvider, onRefreshTailscaleStatus, onRefreshTailscaleCommandPreview, @@ -117,7 +145,17 @@ export function SettingsServerSection({ onOrbitRunnerStatus, onMobileConnectTest, }: SettingsServerSectionProps) { + const [pendingDeleteRemoteId, setPendingDeleteRemoteId] = useState( + null, + ); const isMobileSimplified = isMobilePlatform; + const pendingDeleteRemote = useMemo( + () => + pendingDeleteRemoteId == null + ? null + : remoteBackends.find((entry) => entry.id === pendingDeleteRemoteId) ?? null, + [pendingDeleteRemoteId, remoteBackends], + ); const tcpRunnerStatusText = (() => { if (!tcpDaemonStatus) { return null; @@ -169,6 +207,129 @@ export function SettingsServerSection({ )} <> + {isMobileSimplified && ( + <> +
+
Saved remotes
+
+ {remoteBackends.map((entry, index) => { + const isActive = entry.id === activeRemoteBackendId; + return ( +
+
+
+
{entry.name}
+ {isActive && Active} +
+
+ {entry.provider.toUpperCase()} · {entry.provider === "tcp" ? entry.host : (entry.orbitWsUrl ?? "Orbit endpoint")} +
+
+ Last connected:{" "} + {typeof entry.lastConnectedAtMs === "number" + ? new Date(entry.lastConnectedAtMs).toLocaleString() + : "Never"} +
+
+
+ + + + +
+
+ ); + })} +
+
+ +
+ {remoteStatusText && ( +
+ {remoteStatusText} +
+ )} +
+ Switch the active remote here. The fields below edit the active entry. +
+
+ +
+ + onSetRemoteNameDraft(event.target.value)} + onBlur={() => { + void onCommitRemoteName(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void onCommitRemoteName(); + } + }} + /> + {remoteNameError &&
{remoteNameError}
} +
+ + )} +
+ {remoteHostError &&
{remoteHostError}
}
{isMobileSimplified ? "Use the Tailscale host from your desktop CodexMonitor app (Server section), for example `macbook.your-tailnet.ts.net:4732`." @@ -278,9 +438,7 @@ export function SettingsServerSection({
{mobileConnectStatusText && ( -
+
{mobileConnectStatusText}
)} @@ -482,9 +640,7 @@ export function SettingsServerSection({
{mobileConnectStatusText && ( -
+
{mobileConnectStatusText}
)} @@ -697,10 +853,7 @@ export function SettingsServerSection({ )} {orbitVerificationUrl && (
- Verification URL:{" "} - - {orbitVerificationUrl} - + Verification URL: {orbitVerificationUrl}
)}
@@ -717,6 +870,39 @@ export function SettingsServerSection({ : "Use your own infrastructure only. On iOS, use the Orbit websocket URL and token configured on your desktop CodexMonitor setup." : "Mobile access should stay scoped to your own infrastructure (tailnet or self-hosted Orbit). CodexMonitor does not provide hosted backend services."} + {pendingDeleteRemote && ( + setPendingDeleteRemoteId(null)} + ariaLabel="Delete remote confirmation" + > +
Delete remote?
+
+ Remove {pendingDeleteRemote.name} from saved remotes? This only + removes the profile from this device. +
+
+ + +
+
+ )} ); } diff --git a/src/features/settings/components/settingsTypes.ts b/src/features/settings/components/settingsTypes.ts index 3c17f8f81..6c884d557 100644 --- a/src/features/settings/components/settingsTypes.ts +++ b/src/features/settings/components/settingsTypes.ts @@ -67,8 +67,11 @@ export type OpenAppDraft = OpenAppTarget & { argsText: string }; export type OrbitServiceClient = { orbitConnectTest: () => Promise; orbitSignInStart: () => Promise; - orbitSignInPoll: (deviceCode: string) => Promise; - orbitSignOut: () => Promise; + orbitSignInPoll: ( + deviceCode: string, + remoteBackendId?: string, + ) => Promise; + orbitSignOut: (remoteBackendId?: string) => Promise; orbitRunnerStart: () => Promise; orbitRunnerStop: () => Promise; orbitRunnerStatus: () => Promise; diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index b5177c2d9..48c2f74d2 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -22,18 +22,132 @@ import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; const allowedThemes = new Set(["system", "light", "dark", "dim"]); const allowedPersonality = new Set(["friendly", "pragmatic"]); +const DEFAULT_REMOTE_BACKEND_HOST = "127.0.0.1:4732"; +const DEFAULT_REMOTE_BACKEND_ID = "remote-default"; +const DEFAULT_REMOTE_BACKEND_NAME = "Primary remote"; +const DEFAULT_REMOTE_PROVIDER: AppSettings["remoteBackendProvider"] = "tcp"; +type RemoteBackendTarget = AppSettings["remoteBackends"][number]; + +function normalizeRemoteProvider(value: unknown): AppSettings["remoteBackendProvider"] { + return value === "orbit" ? "orbit" : "tcp"; +} + +function normalizeRemoteToken(value: string | null | undefined): string | null { + return value?.trim() ? value.trim() : null; +} + +function normalizeRemoteHost(value: string | null | undefined): string { + return value?.trim() ? value.trim() : DEFAULT_REMOTE_BACKEND_HOST; +} + +function normalizeRemoteName(value: string | null | undefined, fallback: string): string { + return value?.trim() ? value.trim() : fallback; +} + +function normalizeRemoteBackends(settings: AppSettings): { + remoteBackends: RemoteBackendTarget[]; + activeRemoteBackendId: string | null; + remoteBackendProvider: AppSettings["remoteBackendProvider"]; + remoteBackendHost: string; + remoteBackendToken: string | null; + orbitWsUrl: string | null; +} { + const legacyProvider = normalizeRemoteProvider(settings.remoteBackendProvider); + const legacyHost = normalizeRemoteHost(settings.remoteBackendHost); + const legacyToken = normalizeRemoteToken(settings.remoteBackendToken); + const legacyOrbitWsUrl = settings.orbitWsUrl?.trim() ? settings.orbitWsUrl.trim() : null; + const usedIds = new Set(); + + const normalized = (settings.remoteBackends ?? []).map((entry, index) => { + const baseId = entry.id?.trim() || `remote-${index + 1}`; + let id = baseId; + let suffix = 2; + while (usedIds.has(id)) { + id = `${baseId}-${suffix}`; + suffix += 1; + } + usedIds.add(id); + return { + id, + name: normalizeRemoteName(entry.name, `Remote ${index + 1}`), + provider: normalizeRemoteProvider(entry.provider), + host: normalizeRemoteHost(entry.host), + token: normalizeRemoteToken(entry.token), + orbitWsUrl: entry.orbitWsUrl?.trim() ? entry.orbitWsUrl.trim() : null, + lastConnectedAtMs: + typeof entry.lastConnectedAtMs === "number" && Number.isFinite(entry.lastConnectedAtMs) + ? entry.lastConnectedAtMs + : null, + }; + }); + + if (normalized.length === 0) { + const fallback: RemoteBackendTarget = { + id: DEFAULT_REMOTE_BACKEND_ID, + name: DEFAULT_REMOTE_BACKEND_NAME, + provider: legacyProvider, + host: legacyHost, + token: legacyToken, + orbitWsUrl: legacyOrbitWsUrl, + lastConnectedAtMs: null, + }; + return { + remoteBackends: [fallback], + activeRemoteBackendId: fallback.id, + remoteBackendProvider: fallback.provider, + remoteBackendHost: fallback.host, + remoteBackendToken: fallback.token, + orbitWsUrl: fallback.orbitWsUrl, + }; + } + + const activeIndexById = + settings.activeRemoteBackendId == null + ? -1 + : normalized.findIndex((entry) => entry.id === settings.activeRemoteBackendId); + const activeIndex = activeIndexById >= 0 ? activeIndexById : 0; + const active = normalized[activeIndex]; + const syncedActive = { + ...active, + provider: legacyProvider, + host: legacyHost, + token: legacyToken, + orbitWsUrl: legacyOrbitWsUrl, + }; + const remoteBackends = [...normalized]; + remoteBackends[activeIndex] = syncedActive; + return { + remoteBackends, + activeRemoteBackendId: syncedActive.id, + remoteBackendProvider: syncedActive.provider, + remoteBackendHost: syncedActive.host, + remoteBackendToken: syncedActive.token, + orbitWsUrl: syncedActive.orbitWsUrl, + }; +} function buildDefaultSettings(): AppSettings { const isMac = isMacPlatform(); const isMobile = isMobilePlatform(); + const defaultRemote: RemoteBackendTarget = { + id: DEFAULT_REMOTE_BACKEND_ID, + name: DEFAULT_REMOTE_BACKEND_NAME, + provider: DEFAULT_REMOTE_PROVIDER, + host: DEFAULT_REMOTE_BACKEND_HOST, + token: null, + orbitWsUrl: null, + lastConnectedAtMs: null, + }; return { codexBin: null, codexArgs: null, backendMode: isMobile ? "remote" : "local", - remoteBackendProvider: "tcp", - remoteBackendHost: "127.0.0.1:4732", + remoteBackendProvider: defaultRemote.provider, + remoteBackendHost: defaultRemote.host, remoteBackendToken: null, + remoteBackends: [defaultRemote], + activeRemoteBackendId: defaultRemote.id, orbitWsUrl: null, orbitAuthUrl: null, orbitRunnerName: null, @@ -107,6 +221,7 @@ function buildDefaultSettings(): AppSettings { } function normalizeAppSettings(settings: AppSettings): AppSettings { + const remoteBackendSettings = normalizeRemoteBackends(settings); const normalizedTargets = settings.openAppTargets && settings.openAppTargets.length ? normalizeOpenAppTargets(settings.openAppTargets) @@ -136,6 +251,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { ); return { ...settings, + ...remoteBackendSettings, codexBin: settings.codexBin?.trim() ? settings.codexBin.trim() : null, codexArgs: settings.codexArgs?.trim() ? settings.codexArgs.trim() : null, uiScale: clampUiScale(settings.uiScale), diff --git a/src/features/settings/hooks/useSettingsServerSection.ts b/src/features/settings/hooks/useSettingsServerSection.ts index 100e17d22..6bc8f8f8f 100644 --- a/src/features/settings/hooks/useSettingsServerSection.ts +++ b/src/features/settings/hooks/useSettingsServerSection.ts @@ -42,6 +42,13 @@ export type SettingsServerSectionProps = { mobileConnectBusy: boolean; mobileConnectStatusText: string | null; mobileConnectStatusError: boolean; + remoteBackends: AppSettings["remoteBackends"]; + activeRemoteBackendId: string | null; + remoteStatusText: string | null; + remoteStatusError: boolean; + remoteNameError: string | null; + remoteHostError: string | null; + remoteNameDraft: string; remoteHostDraft: string; remoteTokenDraft: string; orbitWsUrlDraft: string; @@ -61,6 +68,7 @@ export type SettingsServerSectionProps = { tailscaleCommandError: string | null; tcpDaemonStatus: TcpDaemonStatus | null; tcpDaemonBusyAction: "start" | "stop" | "status" | null; + onSetRemoteNameDraft: Dispatch>; onSetRemoteHostDraft: Dispatch>; onSetRemoteTokenDraft: Dispatch>; onSetOrbitWsUrlDraft: Dispatch>; @@ -68,8 +76,13 @@ export type SettingsServerSectionProps = { onSetOrbitRunnerNameDraft: Dispatch>; onSetOrbitAccessClientIdDraft: Dispatch>; onSetOrbitAccessClientSecretRefDraft: Dispatch>; + onCommitRemoteName: () => Promise; onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; + onSelectRemoteBackend: (id: string) => Promise; + onAddRemoteBackend: () => Promise; + onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; + onDeleteRemoteBackend: (id: string) => Promise; onChangeRemoteProvider: (provider: AppSettings["remoteBackendProvider"]) => Promise; onRefreshTailscaleStatus: () => void; onRefreshTailscaleCommandPreview: () => void; @@ -107,19 +120,77 @@ const formatErrorMessage = (error: unknown, fallback: string) => { return fallback; }; +type RemoteBackendTarget = AppSettings["remoteBackends"][number]; + +const createRemoteBackendId = () => + `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const buildFallbackRemoteBackend = (settings: AppSettings): RemoteBackendTarget => ({ + id: settings.activeRemoteBackendId ?? "remote-default", + name: "Primary remote", + provider: settings.remoteBackendProvider, + host: settings.remoteBackendHost, + token: settings.remoteBackendToken, + orbitWsUrl: settings.orbitWsUrl, + lastConnectedAtMs: null, +}); + +const getConfiguredRemoteBackends = (settings: AppSettings): RemoteBackendTarget[] => { + if (settings.remoteBackends.length > 0) { + return settings.remoteBackends; + } + return [buildFallbackRemoteBackend(settings)]; +}; + +const getActiveRemoteBackend = (settings: AppSettings): RemoteBackendTarget => { + const configured = getConfiguredRemoteBackends(settings); + return configured.find((entry) => entry.id === settings.activeRemoteBackendId) ?? configured[0]; +}; + +const validateRemoteHost = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return "Host is required."; + } + const match = trimmed.match(/^([^:\s]+|\[[^\]]+\]):([0-9]{1,5})$/); + if (!match) { + return "Use host:port (for example `macbook.tailnet.ts.net:4732`)."; + } + const port = Number(match[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return "Port must be between 1 and 65535."; + } + return null; +}; + +const buildNextRemoteName = (remoteBackends: RemoteBackendTarget[]) => { + const normalized = new Set(remoteBackends.map((entry) => entry.name.trim().toLowerCase())); + let index = remoteBackends.length + 1; + let candidate = `Remote ${index}`; + while (normalized.has(candidate.toLowerCase())) { + index += 1; + candidate = `Remote ${index}`; + } + return candidate; +}; + export const useSettingsServerSection = ({ appSettings, onUpdateAppSettings, onMobileConnectSuccess, orbitServiceClient, }: UseSettingsServerSectionArgs): SettingsServerSectionProps => { - const [remoteHostDraft, setRemoteHostDraft] = useState(appSettings.remoteBackendHost); - const [remoteTokenDraft, setRemoteTokenDraft] = useState(appSettings.remoteBackendToken ?? ""); - const [orbitWsUrlDraft, setOrbitWsUrlDraft] = useState(appSettings.orbitWsUrl ?? ""); + const initialActiveRemoteBackend = getActiveRemoteBackend(appSettings); + const [remoteNameDraft, setRemoteNameDraft] = useState(initialActiveRemoteBackend.name); + const [remoteHostDraft, setRemoteHostDraft] = useState(initialActiveRemoteBackend.host); + const [remoteTokenDraft, setRemoteTokenDraft] = useState(initialActiveRemoteBackend.token ?? ""); + const [orbitWsUrlDraft, setOrbitWsUrlDraft] = useState(initialActiveRemoteBackend.orbitWsUrl ?? ""); + const [remoteStatusText, setRemoteStatusText] = useState(null); + const [remoteStatusError, setRemoteStatusError] = useState(false); + const [remoteNameError, setRemoteNameError] = useState(null); + const [remoteHostError, setRemoteHostError] = useState(null); const [orbitAuthUrlDraft, setOrbitAuthUrlDraft] = useState(appSettings.orbitAuthUrl ?? ""); - const [orbitRunnerNameDraft, setOrbitRunnerNameDraft] = useState( - appSettings.orbitRunnerName ?? "", - ); + const [orbitRunnerNameDraft, setOrbitRunnerNameDraft] = useState(appSettings.orbitRunnerName ?? ""); const [orbitAccessClientIdDraft, setOrbitAccessClientIdDraft] = useState( appSettings.orbitAccessClientId ?? "", ); @@ -127,9 +198,7 @@ export const useSettingsServerSection = ({ useState(appSettings.orbitAccessClientSecretRef ?? ""); const [orbitStatusText, setOrbitStatusText] = useState(null); const [orbitAuthCode, setOrbitAuthCode] = useState(null); - const [orbitVerificationUrl, setOrbitVerificationUrl] = useState( - null, - ); + const [orbitVerificationUrl, setOrbitVerificationUrl] = useState(null); const [orbitBusyAction, setOrbitBusyAction] = useState(null); const [tailscaleStatus, setTailscaleStatus] = useState(null); const [tailscaleStatusBusy, setTailscaleStatusBusy] = useState(false); @@ -143,29 +212,30 @@ export const useSettingsServerSection = ({ "start" | "stop" | "status" | null >(null); const [mobileConnectBusy, setMobileConnectBusy] = useState(false); - const [mobileConnectStatusText, setMobileConnectStatusText] = useState( - null, - ); + const [mobileConnectStatusText, setMobileConnectStatusText] = useState(null); const [mobileConnectStatusError, setMobileConnectStatusError] = useState(false); const mobilePlatform = useMemo(() => isMobilePlatform(), []); const latestSettingsRef = useRef(appSettings); + const activeRemoteBackend = useMemo(() => getActiveRemoteBackend(appSettings), [appSettings]); + + const setRemoteStatus = useCallback((message: string | null, isError = false) => { + setRemoteStatusText(message); + setRemoteStatusError(isError); + }, []); useEffect(() => { latestSettingsRef.current = appSettings; }, [appSettings]); useEffect(() => { - setRemoteHostDraft(appSettings.remoteBackendHost); - }, [appSettings.remoteBackendHost]); - - useEffect(() => { - setRemoteTokenDraft(appSettings.remoteBackendToken ?? ""); - }, [appSettings.remoteBackendToken]); - - useEffect(() => { - setOrbitWsUrlDraft(appSettings.orbitWsUrl ?? ""); - }, [appSettings.orbitWsUrl]); + setRemoteNameDraft(activeRemoteBackend.name); + setRemoteHostDraft(activeRemoteBackend.host); + setRemoteTokenDraft(activeRemoteBackend.token ?? ""); + setOrbitWsUrlDraft(activeRemoteBackend.orbitWsUrl ?? ""); + setRemoteNameError(null); + setRemoteHostError(null); + }, [activeRemoteBackend]); useEffect(() => { setOrbitAuthUrlDraft(appSettings.orbitAuthUrl ?? ""); @@ -183,56 +253,136 @@ export const useSettingsServerSection = ({ setOrbitAccessClientSecretRefDraft(appSettings.orbitAccessClientSecretRef ?? ""); }, [appSettings.orbitAccessClientSecretRef]); - const updateRemoteBackendSettings = useCallback( - async ({ - host, - token, - provider, - orbitWsUrl, - }: { - host?: string; - token?: string | null; - provider?: AppSettings["remoteBackendProvider"]; - orbitWsUrl?: string | null; - }) => { - const latestSettings = latestSettingsRef.current; - const nextHost = host ?? latestSettings.remoteBackendHost; - const nextToken = - token === undefined ? latestSettings.remoteBackendToken : token; - const nextProvider = provider ?? latestSettings.remoteBackendProvider; - const nextOrbitWsUrl = - orbitWsUrl === undefined ? latestSettings.orbitWsUrl : orbitWsUrl; - const nextSettings: AppSettings = { + const normalizeRemoteBackendEntry = ( + entry: RemoteBackendTarget, + index: number, + ): RemoteBackendTarget => ({ + id: entry.id?.trim() || `remote-${index + 1}`, + name: entry.name?.trim() || `Remote ${index + 1}`, + provider: entry.provider === "orbit" ? "orbit" : "tcp", + host: entry.host?.trim() || DEFAULT_REMOTE_HOST, + token: entry.token?.trim() ? entry.token.trim() : null, + orbitWsUrl: entry.orbitWsUrl?.trim() ? entry.orbitWsUrl.trim() : null, + lastConnectedAtMs: + typeof entry.lastConnectedAtMs === "number" && Number.isFinite(entry.lastConnectedAtMs) + ? entry.lastConnectedAtMs + : null, + }); + + const buildSettingsFromRemoteBackends = useCallback( + ( + latestSettings: AppSettings, + remoteBackends: RemoteBackendTarget[], + preferredActiveId?: string | null, + ): AppSettings => { + const normalizedBackends = remoteBackends.length + ? remoteBackends.map(normalizeRemoteBackendEntry) + : [normalizeRemoteBackendEntry(buildFallbackRemoteBackend(latestSettings), 0)]; + const active = + normalizedBackends.find((entry) => entry.id === preferredActiveId) ?? + normalizedBackends.find((entry) => entry.id === latestSettings.activeRemoteBackendId) ?? + normalizedBackends[0]; + return { ...latestSettings, - remoteBackendHost: nextHost, - remoteBackendToken: nextToken, - remoteBackendProvider: nextProvider, - orbitWsUrl: nextOrbitWsUrl, + remoteBackends: normalizedBackends, + activeRemoteBackendId: active.id, + remoteBackendProvider: active.provider, + remoteBackendHost: active.host, + remoteBackendToken: active.token, + orbitWsUrl: active.orbitWsUrl, ...(mobilePlatform ? { backendMode: "remote", } : {}), }; + }, + [mobilePlatform], + ); + + const persistRemoteBackends = useCallback( + async (remoteBackends: RemoteBackendTarget[], preferredActiveId?: string | null) => { + const latestSettings = latestSettingsRef.current; + const nextSettings = buildSettingsFromRemoteBackends( + latestSettings, + remoteBackends, + preferredActiveId, + ); const unchanged = nextSettings.remoteBackendHost === latestSettings.remoteBackendHost && nextSettings.remoteBackendToken === latestSettings.remoteBackendToken && nextSettings.orbitWsUrl === latestSettings.orbitWsUrl && nextSettings.backendMode === latestSettings.backendMode && - nextSettings.remoteBackendProvider === latestSettings.remoteBackendProvider; + nextSettings.remoteBackendProvider === latestSettings.remoteBackendProvider && + nextSettings.activeRemoteBackendId === latestSettings.activeRemoteBackendId && + JSON.stringify(nextSettings.remoteBackends) === JSON.stringify(latestSettings.remoteBackends); if (unchanged) { return; } await onUpdateAppSettings(nextSettings); latestSettingsRef.current = nextSettings; }, - [mobilePlatform, onUpdateAppSettings], + [buildSettingsFromRemoteBackends, onUpdateAppSettings], + ); + + const updateActiveRemoteBackend = useCallback( + async (patch: Partial) => { + const latestSettings = latestSettingsRef.current; + const active = getActiveRemoteBackend(latestSettings); + const nextBackends = [...getConfiguredRemoteBackends(latestSettings)]; + const activeIndex = nextBackends.findIndex((entry) => entry.id === active.id); + const safeIndex = activeIndex >= 0 ? activeIndex : 0; + nextBackends[safeIndex] = { + ...nextBackends[safeIndex], + ...patch, + }; + await persistRemoteBackends(nextBackends, nextBackends[safeIndex].id); + }, + [persistRemoteBackends], ); const applyRemoteHost = async (rawValue: string) => { - const nextHost = rawValue.trim() || DEFAULT_REMOTE_HOST; - setRemoteHostDraft(nextHost); - await updateRemoteBackendSettings({ host: nextHost }); + const active = getActiveRemoteBackend(latestSettingsRef.current); + const nextHost = rawValue.trim(); + if (active.provider === "tcp") { + const validationError = validateRemoteHost(nextHost); + if (validationError) { + setRemoteHostError(validationError); + setRemoteStatus(validationError, true); + return false; + } + } + const normalizedHost = nextHost || DEFAULT_REMOTE_HOST; + setRemoteHostError(null); + setRemoteHostDraft(normalizedHost); + await updateActiveRemoteBackend({ host: normalizedHost }); + setRemoteStatus("Remote host saved."); + return true; + }; + + const handleCommitRemoteName = async () => { + const latestSettings = latestSettingsRef.current; + const active = getActiveRemoteBackend(latestSettings); + const nextName = remoteNameDraft.trim(); + if (!nextName) { + const message = "Name is required."; + setRemoteNameError(message); + setRemoteStatus(message, true); + return; + } + const duplicate = getConfiguredRemoteBackends(latestSettings).some( + (entry) => entry.id !== active.id && entry.name.trim().toLowerCase() === nextName.toLowerCase(), + ); + if (duplicate) { + const message = `A remote named \"${nextName}\" already exists.`; + setRemoteNameError(message); + setRemoteStatus(message, true); + return; + } + setRemoteNameError(null); + setRemoteNameDraft(nextName); + await updateActiveRemoteBackend({ name: nextName }); + setRemoteStatus(`Saved remote name \"${nextName}\".`); }; const handleCommitRemoteHost = async () => { @@ -242,14 +392,112 @@ export const useSettingsServerSection = ({ const handleCommitRemoteToken = async () => { const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; setRemoteTokenDraft(nextToken ?? ""); - await updateRemoteBackendSettings({ token: nextToken }); + await updateActiveRemoteBackend({ token: nextToken }); + setRemoteStatus("Remote token saved."); + }; + + const handleSelectRemoteBackend = async (id: string) => { + const latestSettings = latestSettingsRef.current; + const candidates = getConfiguredRemoteBackends(latestSettings); + const selected = candidates.find((entry) => entry.id === id); + if (!selected) { + return; + } + await persistRemoteBackends(candidates, id); + setRemoteStatus(`Active remote set to \"${selected.name}\".`); + }; + + const handleAddRemoteBackend = async () => { + const latestSettings = latestSettingsRef.current; + const existingBackends = getConfiguredRemoteBackends(latestSettings); + const nextId = createRemoteBackendId(); + const nextRemote: RemoteBackendTarget = { + id: nextId, + name: buildNextRemoteName(existingBackends), + provider: latestSettings.remoteBackendProvider, + host: DEFAULT_REMOTE_HOST, + token: null, + orbitWsUrl: null, + lastConnectedAtMs: null, + }; + await persistRemoteBackends([...existingBackends, nextRemote], nextId); + setRemoteStatus(`Added \"${nextRemote.name}\".`); + }; + + const handleSetRemoteNameDraft: Dispatch> = (value) => { + setRemoteNameError(null); + setRemoteStatus(null); + setRemoteNameDraft((previous) => (typeof value === "function" ? value(previous) : value)); + }; + + const handleSetRemoteHostDraft: Dispatch> = (value) => { + setRemoteHostError(null); + setRemoteStatus(null); + setRemoteHostDraft((previous) => (typeof value === "function" ? value(previous) : value)); + }; + + const handleMoveRemoteBackend = async (id: string, direction: "up" | "down") => { + const latestSettings = latestSettingsRef.current; + const nextBackends = [...getConfiguredRemoteBackends(latestSettings)]; + const index = nextBackends.findIndex((entry) => entry.id === id); + if (index < 0) { + return; + } + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= nextBackends.length) { + return; + } + const entry = nextBackends[index]; + nextBackends[index] = nextBackends[targetIndex]; + nextBackends[targetIndex] = entry; + await persistRemoteBackends(nextBackends); + setRemoteStatus(`Moved \"${entry.name}\" ${direction}.`); + }; + + const handleDeleteRemoteBackend = async (id: string) => { + const latestSettings = latestSettingsRef.current; + const existingBackends = getConfiguredRemoteBackends(latestSettings); + if (existingBackends.length <= 1) { + setRemoteStatus("You need at least one remote.", true); + return; + } + const index = existingBackends.findIndex((entry) => entry.id === id); + if (index < 0) { + return; + } + const removed = existingBackends[index]; + const remaining = existingBackends.filter((entry) => entry.id !== id); + const nextActiveId = + latestSettings.activeRemoteBackendId === id + ? remaining[Math.min(index, remaining.length - 1)]?.id ?? remaining[0]?.id ?? null + : latestSettings.activeRemoteBackendId; + await persistRemoteBackends(remaining, nextActiveId); + setRemoteStatus(`Deleted \"${removed.name}\".`); }; const handleMobileConnectTest = () => { void (async () => { - const provider = latestSettingsRef.current.remoteBackendProvider; + const active = getActiveRemoteBackend(latestSettingsRef.current); + const provider = active.provider; const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; setRemoteTokenDraft(nextToken ?? ""); + + if (!nextToken) { + setMobileConnectStatusError(true); + setMobileConnectStatusText("Remote backend token is required."); + return; + } + + if (provider === "tcp") { + const hostError = validateRemoteHost(remoteHostDraft); + if (hostError) { + setRemoteHostError(hostError); + setMobileConnectStatusError(true); + setMobileConnectStatusText(hostError); + return; + } + } + setMobileConnectBusy(true); setMobileConnectStatusText(null); setMobileConnectStatusError(false); @@ -257,7 +505,7 @@ export const useSettingsServerSection = ({ if (provider === "tcp") { const nextHost = remoteHostDraft.trim() || DEFAULT_REMOTE_HOST; setRemoteHostDraft(nextHost); - await updateRemoteBackendSettings({ + await updateActiveRemoteBackend({ host: nextHost, token: nextToken, }); @@ -267,7 +515,7 @@ export const useSettingsServerSection = ({ if (!nextOrbitWsUrl) { throw new Error("Orbit websocket URL is required."); } - await updateRemoteBackendSettings({ + await updateActiveRemoteBackend({ token: nextToken, orbitWsUrl: nextOrbitWsUrl, }); @@ -275,6 +523,11 @@ export const useSettingsServerSection = ({ const workspaces = await listWorkspaces(); const workspaceCount = workspaces.length; const workspaceWord = workspaceCount === 1 ? "workspace" : "workspaces"; + try { + await updateActiveRemoteBackend({ lastConnectedAtMs: Date.now() }); + } catch { + // Keep successful connectivity outcome even if timestamp persistence fails. + } setMobileConnectStatusText( `Connected. ${workspaceCount} ${workspaceWord} reachable on the remote backend.`, ); @@ -307,12 +560,13 @@ export const useSettingsServerSection = ({ const handleChangeRemoteProvider = async ( provider: AppSettings["remoteBackendProvider"], ) => { - if (provider === latestSettingsRef.current.remoteBackendProvider) { + if (provider === getActiveRemoteBackend(latestSettingsRef.current).provider) { return; } - await updateRemoteBackendSettings({ + await updateActiveRemoteBackend({ provider, }); + setRemoteStatus(`Connection type set to ${provider.toUpperCase()}.`); }; const handleRefreshTailscaleStatus = useCallback(() => { @@ -402,9 +656,10 @@ export const useSettingsServerSection = ({ const handleCommitOrbitWsUrl = async () => { const nextValue = normalizeOverrideValue(orbitWsUrlDraft); setOrbitWsUrlDraft(nextValue ?? ""); - await updateRemoteBackendSettings({ + await updateActiveRemoteBackend({ orbitWsUrl: nextValue, }); + setRemoteStatus("Orbit websocket URL saved."); }; const handleCommitOrbitAuthUrl = async () => { @@ -479,18 +734,7 @@ export const useSettingsServerSection = ({ const syncRemoteBackendToken = async (nextToken: string | null) => { const normalizedToken = nextToken?.trim() ? nextToken.trim() : null; setRemoteTokenDraft(normalizedToken ?? ""); - const latestSettings = latestSettingsRef.current; - if (normalizedToken === latestSettings.remoteBackendToken) { - return; - } - const nextSettings = { - ...latestSettings, - remoteBackendToken: normalizedToken, - }; - await onUpdateAppSettings({ - ...nextSettings, - }); - latestSettingsRef.current = nextSettings; + await updateActiveRemoteBackend({ token: normalizedToken }); }; const handleOrbitConnectTest = () => { @@ -509,6 +753,7 @@ export const useSettingsServerSection = ({ setOrbitAuthCode(null); setOrbitVerificationUrl(null); try { + const tokenTargetRemoteId = latestSettingsRef.current.activeRemoteBackendId ?? undefined; const startResult = await orbitServiceClient.orbitSignInStart(); setOrbitAuthCode(startResult.userCode ?? startResult.deviceCode); setOrbitVerificationUrl( @@ -532,6 +777,7 @@ export const useSettingsServerSection = ({ await delay(pollIntervalSeconds * 1000); const pollResult = await orbitServiceClient.orbitSignInPoll( startResult.deviceCode, + tokenTargetRemoteId, ); setOrbitStatusText( getOrbitStatusText(pollResult, "Orbit sign in status refreshed."), @@ -566,10 +812,11 @@ export const useSettingsServerSection = ({ const handleOrbitSignOut = () => { void (async () => { + const tokenTargetRemoteId = latestSettingsRef.current.activeRemoteBackendId ?? undefined; const result = await runOrbitAction( "sign-out", "Sign Out", - orbitServiceClient.orbitSignOut, + () => orbitServiceClient.orbitSignOut(tokenTargetRemoteId), "Signed out from Orbit.", ); if (result !== null) { @@ -638,6 +885,14 @@ export const useSettingsServerSection = ({ return { appSettings, onUpdateAppSettings, + remoteBackends: getConfiguredRemoteBackends(appSettings), + activeRemoteBackendId: + appSettings.activeRemoteBackendId ?? getConfiguredRemoteBackends(appSettings)[0]?.id ?? null, + remoteStatusText, + remoteStatusError, + remoteNameError, + remoteHostError, + remoteNameDraft, remoteHostDraft, remoteTokenDraft, orbitWsUrlDraft, @@ -657,15 +912,21 @@ export const useSettingsServerSection = ({ tailscaleCommandError, tcpDaemonStatus, tcpDaemonBusyAction, - onSetRemoteHostDraft: setRemoteHostDraft, + onSetRemoteNameDraft: handleSetRemoteNameDraft, + onSetRemoteHostDraft: handleSetRemoteHostDraft, onSetRemoteTokenDraft: setRemoteTokenDraft, onSetOrbitWsUrlDraft: setOrbitWsUrlDraft, onSetOrbitAuthUrlDraft: setOrbitAuthUrlDraft, onSetOrbitRunnerNameDraft: setOrbitRunnerNameDraft, onSetOrbitAccessClientIdDraft: setOrbitAccessClientIdDraft, onSetOrbitAccessClientSecretRefDraft: setOrbitAccessClientSecretRefDraft, + onCommitRemoteName: handleCommitRemoteName, onCommitRemoteHost: handleCommitRemoteHost, onCommitRemoteToken: handleCommitRemoteToken, + onSelectRemoteBackend: handleSelectRemoteBackend, + onAddRemoteBackend: handleAddRemoteBackend, + onMoveRemoteBackend: handleMoveRemoteBackend, + onDeleteRemoteBackend: handleDeleteRemoteBackend, onChangeRemoteProvider: handleChangeRemoteProvider, onRefreshTailscaleStatus: handleRefreshTailscaleStatus, onRefreshTailscaleCommandPreview: handleRefreshTailscaleCommandPreview, diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index e07a17e37..cb7d4e9a4 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -325,7 +325,9 @@ describe("tauri invoke wrappers", () => { await orbitConnectTest(); await orbitSignInStart(); await orbitSignInPoll("device-code"); + await orbitSignInPoll("device-code-2", "remote-a"); await orbitSignOut(); + await orbitSignOut("remote-b"); await orbitRunnerStart(); await orbitRunnerStop(); await orbitRunnerStatus(); @@ -335,7 +337,14 @@ describe("tauri invoke wrappers", () => { expect(invokeMock).toHaveBeenCalledWith("orbit_sign_in_poll", { deviceCode: "device-code", }); + expect(invokeMock).toHaveBeenCalledWith("orbit_sign_in_poll", { + deviceCode: "device-code-2", + remoteBackendId: "remote-a", + }); expect(invokeMock).toHaveBeenCalledWith("orbit_sign_out"); + expect(invokeMock).toHaveBeenCalledWith("orbit_sign_out", { + remoteBackendId: "remote-b", + }); expect(invokeMock).toHaveBeenCalledWith("orbit_runner_start"); expect(invokeMock).toHaveBeenCalledWith("orbit_runner_stop"); expect(invokeMock).toHaveBeenCalledWith("orbit_runner_status"); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 605e0b4ec..cd852ff7d 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -702,11 +702,23 @@ export async function orbitSignInStart(): Promise { return invoke("orbit_sign_in_start"); } -export async function orbitSignInPoll(deviceCode: string): Promise { +export async function orbitSignInPoll( + deviceCode: string, + remoteBackendId?: string, +): Promise { + if (remoteBackendId) { + return invoke("orbit_sign_in_poll", { + deviceCode, + remoteBackendId, + }); + } return invoke("orbit_sign_in_poll", { deviceCode }); } -export async function orbitSignOut(): Promise { +export async function orbitSignOut(remoteBackendId?: string): Promise { + if (remoteBackendId) { + return invoke("orbit_sign_out", { remoteBackendId }); + } return invoke("orbit_sign_out"); } diff --git a/src/styles/settings.css b/src/styles/settings.css index 74a4af928..9837442ee 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -177,6 +177,123 @@ margin-right: 4px; } +.settings-mobile-remotes { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-mobile-remote { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border-muted); + background: var(--surface-card); +} + +.settings-mobile-remote.is-active { + border-color: color-mix(in srgb, var(--border-accent) 70%, var(--border-muted)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--border-accent) 35%, transparent); +} + +.settings-mobile-remote-main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.settings-mobile-remote-name-row { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.settings-mobile-remote-name { + font-size: 12px; + font-weight: 600; + color: var(--text-strong); +} + +.settings-mobile-remote-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--text-strong); + background: color-mix(in srgb, var(--border-accent) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--border-accent) 45%, transparent); +} + +.settings-mobile-remote-meta { + font-size: 11px; + color: var(--text-subtle); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-mobile-remote-last { + font-size: 10px; + color: var(--text-faint); +} + +.settings-mobile-remote-actions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.settings-mobile-remote-action { + min-width: 28px; + padding: 5px 8px; + font-size: 11px; +} + +.settings-mobile-remote-action-danger { + color: var(--text-danger); +} + +.settings-delete-remote-overlay { + z-index: 40; +} + +.settings-delete-remote-card { + width: min(380px, calc(100vw - 40px)); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-delete-remote-title { + font-size: 14px; + font-weight: 700; + color: var(--text-strong); +} + +.settings-delete-remote-message { + font-size: 12px; + color: var(--text-subtle); + line-height: 1.45; +} + +.settings-delete-remote-actions { + display: inline-flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + .settings-shortcuts-search { margin-bottom: 20px; } diff --git a/src/types.ts b/src/types.ts index c54b6147c..d6252905a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,6 +144,15 @@ export type PullRequestSelectionRange = { export type AccessMode = "read-only" | "current" | "full-access"; export type BackendMode = "local" | "remote"; export type RemoteBackendProvider = "tcp" | "orbit"; +export type RemoteBackendTarget = { + id: string; + name: string; + provider: RemoteBackendProvider; + host: string; + token: string | null; + orbitWsUrl: string | null; + lastConnectedAtMs?: number | null; +}; export type ThemePreference = "system" | "light" | "dark" | "dim"; export type PersonalityPreference = "friendly" | "pragmatic"; @@ -177,6 +186,8 @@ export type AppSettings = { remoteBackendProvider: RemoteBackendProvider; remoteBackendHost: string; remoteBackendToken: string | null; + remoteBackends: RemoteBackendTarget[]; + activeRemoteBackendId: string | null; orbitWsUrl: string | null; orbitAuthUrl: string | null; orbitRunnerName: string | null; From 56ab917af56c3eff563640dd2a55bc9928d080db Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 15 Feb 2026 18:48:49 +0100 Subject: [PATCH 2/4] fix(settings): remove escaped quotes in remote status strings --- .../settings/hooks/useSettingsServerSection.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/settings/hooks/useSettingsServerSection.ts b/src/features/settings/hooks/useSettingsServerSection.ts index 6bc8f8f8f..910be038a 100644 --- a/src/features/settings/hooks/useSettingsServerSection.ts +++ b/src/features/settings/hooks/useSettingsServerSection.ts @@ -374,7 +374,7 @@ export const useSettingsServerSection = ({ (entry) => entry.id !== active.id && entry.name.trim().toLowerCase() === nextName.toLowerCase(), ); if (duplicate) { - const message = `A remote named \"${nextName}\" already exists.`; + const message = `A remote named "${nextName}" already exists.`; setRemoteNameError(message); setRemoteStatus(message, true); return; @@ -382,7 +382,7 @@ export const useSettingsServerSection = ({ setRemoteNameError(null); setRemoteNameDraft(nextName); await updateActiveRemoteBackend({ name: nextName }); - setRemoteStatus(`Saved remote name \"${nextName}\".`); + setRemoteStatus(`Saved remote name "${nextName}".`); }; const handleCommitRemoteHost = async () => { @@ -404,7 +404,7 @@ export const useSettingsServerSection = ({ return; } await persistRemoteBackends(candidates, id); - setRemoteStatus(`Active remote set to \"${selected.name}\".`); + setRemoteStatus(`Active remote set to "${selected.name}".`); }; const handleAddRemoteBackend = async () => { @@ -421,7 +421,7 @@ export const useSettingsServerSection = ({ lastConnectedAtMs: null, }; await persistRemoteBackends([...existingBackends, nextRemote], nextId); - setRemoteStatus(`Added \"${nextRemote.name}\".`); + setRemoteStatus(`Added "${nextRemote.name}".`); }; const handleSetRemoteNameDraft: Dispatch> = (value) => { @@ -451,7 +451,7 @@ export const useSettingsServerSection = ({ nextBackends[index] = nextBackends[targetIndex]; nextBackends[targetIndex] = entry; await persistRemoteBackends(nextBackends); - setRemoteStatus(`Moved \"${entry.name}\" ${direction}.`); + setRemoteStatus(`Moved "${entry.name}" ${direction}.`); }; const handleDeleteRemoteBackend = async (id: string) => { @@ -472,7 +472,7 @@ export const useSettingsServerSection = ({ ? remaining[Math.min(index, remaining.length - 1)]?.id ?? remaining[0]?.id ?? null : latestSettings.activeRemoteBackendId; await persistRemoteBackends(remaining, nextActiveId); - setRemoteStatus(`Deleted \"${removed.name}\".`); + setRemoteStatus(`Deleted "${removed.name}".`); }; const handleMobileConnectTest = () => { From e30a232c741dc08f45caee4dfd7e4acc92ec32e3 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 15 Feb 2026 20:36:14 +0100 Subject: [PATCH 3/4] refactor(remote): remove orbit support and standardize tcp tailscale flow --- README.md | 20 +- docs/codebase-map.md | 6 +- docs/mobile-ios-cloudflare-blueprint.md | 537 ------------- docs/mobile-ios-tailscale-blueprint.md | 77 ++ .../codex-monitor.xcodeproj/project.pbxproj | 13 - src-tauri/src/bin/codex_monitor_daemon.rs | 169 +--- .../bin/codex_monitor_daemon/rpc/workspace.rs | 37 - .../src/bin/codex_monitor_daemon/transport.rs | 169 ---- src-tauri/src/lib.rs | 52 -- src-tauri/src/orbit/mod.rs | 426 ----------- src-tauri/src/remote_backend/mod.rs | 52 +- .../src/remote_backend/orbit_ws_transport.rs | 116 --- src-tauri/src/remote_backend/tcp_transport.rs | 4 +- src-tauri/src/remote_backend/transport.rs | 7 - src-tauri/src/settings/mod.rs | 19 +- src-tauri/src/shared/mod.rs | 1 - src-tauri/src/shared/orbit_core.rs | 518 ------------- src-tauri/src/shared/settings_core.rs | 67 -- src-tauri/src/state.rs | 29 +- src-tauri/src/storage.rs | 77 +- src-tauri/src/types.rs | 106 --- .../components/MobileServerSetupWizard.tsx | 65 +- .../mobile/hooks/useMobileServerSetup.ts | 40 +- .../settings/components/SettingsView.test.tsx | 422 +--------- .../settings/components/SettingsView.tsx | 7 +- .../sections/SettingsServerSection.tsx | 723 +++++------------- .../settings/components/settingsTypes.ts | 22 +- .../components/settingsViewConstants.ts | 28 +- .../components/settingsViewHelpers.ts | 70 -- src/features/settings/hooks/useAppSettings.ts | 18 +- .../hooks/useSettingsServerSection.ts | 394 +--------- .../hooks/useSettingsViewOrchestration.ts | 4 - src/services/tauri.test.ts | 39 - src/services/tauri.ts | 45 -- src/types.ts | 55 +- 35 files changed, 423 insertions(+), 4011 deletions(-) delete mode 100644 docs/mobile-ios-cloudflare-blueprint.md create mode 100644 docs/mobile-ios-tailscale-blueprint.md delete mode 100644 src-tauri/src/orbit/mod.rs delete mode 100644 src-tauri/src/remote_backend/orbit_ws_transport.rs delete mode 100644 src-tauri/src/shared/orbit_core.rs diff --git a/README.md b/README.md index 4eca4c1ab..099ee21ef 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local - Worktree and clone agents for isolated work; worktrees live under the app data directory (legacy `.codex-worktrees` supported). - Thread management: pin/rename/archive/copy, per-thread drafts, and stop/interrupt in-flight turns. - Optional remote backend (daemon) mode for running Codex on another machine. -- Remote setup helpers for self-hosted connectivity (Orbit actions + Tailscale detection/host bootstrap for TCP mode). +- Remote setup helpers for self-hosted connectivity (Tailscale detection/host bootstrap for TCP mode). ### Composer & Agent Controls @@ -84,23 +84,21 @@ iOS support is currently in progress. ### iOS + Tailscale Setup (TCP) Use this when connecting the iOS app to a desktop-hosted daemon over your Tailscale tailnet. +Canonical runbook: `docs/mobile-ios-tailscale-blueprint.md`. 1. Install and sign in to Tailscale on both desktop and iPhone (same tailnet). 2. On desktop CodexMonitor, open `Settings > Server`. -3. Keep `Remote provider` set to `TCP (wip)`. -4. Set a `Remote backend token`. -5. Start the desktop daemon with `Start daemon` (in `Mobile access daemon`). -6. In `Tailscale helper`, use `Detect Tailscale` and note the suggested host (for example `your-mac.your-tailnet.ts.net:4732`). -7. On iOS CodexMonitor, open `Settings > Server`. -8. Set `Connection type` to `TCP`. -9. Enter the desktop Tailscale host and the same token. -10. Tap `Connect & test` and confirm it succeeds. +3. Set a `Remote backend token`. +4. Start the desktop daemon with `Start daemon` (in `Mobile access daemon`). +5. In `Tailscale helper`, use `Detect Tailscale` and note the suggested host (for example `your-mac.your-tailnet.ts.net:4732`). +6. On iOS CodexMonitor, open `Settings > Server`. +7. Enter the desktop Tailscale host and the same token. +8. Tap `Connect & test` and confirm it succeeds. Notes: - The desktop daemon must stay running while iOS is connected. - If the test fails, confirm both devices are online in Tailscale and that host/token match desktop settings. -- If you want to use Orbit instead of Tailscale TCP, switch `Connection type` to `Orbit` on iOS and use your desktop Orbit websocket URL/token. ### iOS Prerequisites @@ -277,4 +275,4 @@ Frontend calls live in `src/services/tauri.ts` and map to commands in `src-tauri - Git/GitHub: `get_git_status`, `list_git_roots`, `get_git_diffs`, `get_git_log`, `get_git_commit_diff`, `get_git_remote`, `stage_git_file`, `stage_git_all`, `unstage_git_file`, `revert_git_file`, `revert_git_all`, `commit_git`, `push_git`, `pull_git`, `fetch_git`, `sync_git`, `list_git_branches`, `checkout_git_branch`, `create_git_branch`, `get_github_issues`, `get_github_pull_requests`, `get_github_pull_request_diff`, `get_github_pull_request_comments`. - Prompts: `prompts_list`, `prompts_create`, `prompts_update`, `prompts_delete`, `prompts_move`, `prompts_workspace_dir`, `prompts_global_dir`. - Terminal/dictation/notifications/usage: `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `dictation_model_status`, `dictation_download_model`, `dictation_cancel_download`, `dictation_remove_model`, `dictation_request_permission`, `dictation_start`, `dictation_stop`, `dictation_cancel`, `send_notification_fallback`, `is_macos_debug_build`, `local_usage_snapshot`. -- Remote backend helpers: `orbit_connect_test`, `orbit_sign_in_start`, `orbit_sign_in_poll`, `orbit_sign_out`, `orbit_runner_start`, `orbit_runner_stop`, `orbit_runner_status`, `tailscale_status`, `tailscale_daemon_command_preview`, `tailscale_daemon_start`, `tailscale_daemon_stop`, `tailscale_daemon_status`. +- Remote backend helpers: `tailscale_status`, `tailscale_daemon_command_preview`, `tailscale_daemon_start`, `tailscale_daemon_stop`, `tailscale_daemon_status`. diff --git a/docs/codebase-map.md b/docs/codebase-map.md index c3a92a03e..7986e2fb3 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -2,6 +2,11 @@ Canonical navigation guide for CodexMonitor. Use this as: "if you need X, edit Y". +Related docs: + +- Setup/build/release: `README.md` +- iOS remote over Tailscale (TCP): `docs/mobile-ios-tailscale-blueprint.md` + ## Start Here: How Changes Flow For backend behavior, follow this path in order: @@ -118,7 +123,6 @@ All cross-runtime domain behavior belongs in `src-tauri/src/shared/*`: - Git and GitHub logic: `src-tauri/src/shared/git_core.rs`, `src-tauri/src/shared/git_ui_core.rs`, `src-tauri/src/shared/git_ui_core/*` - Prompts CRUD/listing: `src-tauri/src/shared/prompts_core.rs` - Usage snapshot and aggregation: `src-tauri/src/shared/local_usage_core.rs` -- Orbit connectivity/auth helpers: `src-tauri/src/shared/orbit_core.rs` - Process helpers: `src-tauri/src/shared/process_core.rs` ## Events Map (Backend -> Frontend) diff --git a/docs/mobile-ios-cloudflare-blueprint.md b/docs/mobile-ios-cloudflare-blueprint.md deleted file mode 100644 index fc5ab6443..000000000 --- a/docs/mobile-ios-cloudflare-blueprint.md +++ /dev/null @@ -1,537 +0,0 @@ -# CodexMonitor iOS Remote Blueprint (Orbit + Tailscale Bootstrap) - -This document is the canonical implementation plan for shipping CodexMonitor on iOS with a Tailscale-first bootstrap path and Orbit on Cloudflare as the production relay/control plane to a macOS runner. - -## Scope - -- Build and ship a real iOS app (Tauri mobile target). -- Keep macOS as the execution host (Codex binary, repos, git, terminals, files). -- Provide a low-friction self-host bootstrap path via Tailscale + TCP daemon for early end-to-end mobile testing. -- Use Orbit on Cloudflare as secure relay/realtime bridge between iOS and macOS. -- Make macOS setup manageable from CodexMonitor Settings: authenticate, pair device, launch/stop runner, inspect status/logs. -- Keep one backend logic path (shared core + daemon). Do not duplicate backend behavior in iOS UI. - -## Non-Goals (This Plan) - -- Building a custom Cloudflare Worker/DO protocol for relay. -- Defining a custom bridge envelope (`seq`/`ack`) as a required transport contract. -- Reintroducing CloudKit/PR31-based remote architecture. - -## Current State (Important) - -- Tauri app is desktop-first with `#[cfg_attr(mobile, tauri::mobile_entry_point)]` already present in `src-tauri/src/lib.rs`. -- `remote_backend` has been refactored into pluggable transport modules: - - `src-tauri/src/remote_backend/mod.rs` - - `src-tauri/src/remote_backend/protocol.rs` - - `src-tauri/src/remote_backend/transport.rs` - - `src-tauri/src/remote_backend/tcp_transport.rs` - - `src-tauri/src/remote_backend/orbit_ws_transport.rs` -- Current transport behavior: - - TCP transport remains intact (existing remote path preserved). - - Orbit WS transport is implemented for connect/read/write + request/response routing, including line-delimited frame splitting. - - App transport is currently single-connection (no client-side reconnect loop yet). - - Daemon Orbit runner mode includes reconnect/backoff for outbound Orbit WS. -- Remote provider settings baseline is implemented: - - `remoteBackendProvider`: `"tcp" | "orbit"` - - `remoteBackendHost`, `remoteBackendToken` - - `orbitWsUrl`, `orbitAuthUrl` - - `orbitRunnerName`, `orbitAutoStartRunner` - - `orbitUseAccess`, `orbitAccessClientId`, `orbitAccessClientSecretRef` -- Orbit remote operations are implemented in app and daemon wiring via shared core: - - `orbit_connect_test` - - `orbit_sign_in_start` - - `orbit_sign_in_poll` (stores token to app settings on authorization) - - `orbit_sign_out` (best-effort logout + token clear) - - `orbit_runner_start` - - `orbit_runner_stop` - - `orbit_runner_status` -- Settings UI now includes Orbit provider setup/actions in `SettingsView`: - - URLs, runner name, access fields, connect/sign-in/sign-out, runner start/stop/status - - inline device-code polling flow wired to `orbit_sign_in_poll` -- Remote notification forwarding currently handles only: - - `app-server-event` - - `terminal-output` - - `terminal-exit` -- Mobile UI scope is the existing app layout in mobile form-factor (no separate mobile-only feature surface). -- Shared-core parity refactor is in place for prompts, local usage, codex utility helpers, git/github UI helpers, and workspace actions. -- Tailscale setup helper is implemented for TCP remote mode: - - Desktop command: `tailscale_status` - - Desktop command: `tailscale_daemon_command_preview` - - Settings UI helpers: detect Tailscale, use suggested tailnet host, show daemon launch command template -- Daemon RPC parity for the current mobile scope is complete. -- `terminal_*` and `dictation_*` command parity are intentionally out of scope for this mobile phase. - -## Target Architecture - -## Components - -1. iOS App (Tauri) -- UI + local state + IPC wrappers. -- Uses remote mode only (no local codex execution). -- Connects to Orbit WebSocket endpoint and consumes Codex JSON-RPC stream. - -2. macOS App + Daemon Runner -- Runs all backend operations (shared cores, codex process, files/git/terminal). -- Maintains outbound connection to Orbit. -- Receives JSON-RPC from Orbit and returns results/events. - -3. Tailscale Tailnet (Bootstrap Path) -- iOS and macOS join the same user-managed tailnet. -- iOS connects directly to daemon TCP endpoint over tailnet (`remoteBackendProvider=tcp`). -- No hosted CodexMonitor service required. - -4. Orbit Cloud Services -- Auth service (passkey + JWT/session). -- Orbit relay (Worker + Durable Object routing + event persistence endpoint). -- User-owned self-host deployment path only. - -## Canonical Protocol Choice - -- Use Orbit JSON-RPC relay model plus Orbit control messages (`orbit.subscribe`, `orbit.unsubscribe`, `orbit.list-anchors`, keepalive ping/pong). -- For Tailscale bootstrap mode, continue using existing TCP JSON-RPC over tailnet (`remoteBackendProvider=tcp`) with token auth. -- Do not introduce a second custom transport protocol for this phase. -- Reconnection/resync should use Orbit thread event history endpoint and thread resume flows. - -## Data Flow - -1. macOS runner authenticates and opens persistent WS to Orbit. -2. iOS app authenticates and opens WS to Orbit. -3. iOS subscribes to thread channels via `orbit.subscribe`. -4. iOS sends JSON-RPC `invoke` messages (for example `thread/start`, `turn/start`) through Orbit. -5. Orbit relays to runner. -6. Runner executes daemon RPC / app-server operations. -7. Orbit relays results and notifications back to subscribed iOS clients. -8. On reconnect, iOS reloads state from thread resume + stored events endpoint. - -## Orbit Deployment Model - -## Self-Hosted Orbit Only - -- User deploys Orbit/Auth workers and D1 with Wrangler. -- User provides Orbit/Auth endpoints in Settings. -- Pair/auth flows remain the same once endpoints are configured. - -## Required Backend Refactor in CodexMonitor - -## 1) Refactor `remote_backend` to pluggable transport - -Target: keep existing `call_remote(...)` callsites while replacing transport internals. - -Implemented structure: - -- `src-tauri/src/remote_backend/mod.rs` -- `src-tauri/src/remote_backend/protocol.rs` -- `src-tauri/src/remote_backend/transport.rs` (trait) -- `src-tauri/src/remote_backend/tcp_transport.rs` (legacy/dev) -- `src-tauri/src/remote_backend/orbit_ws_transport.rs` (new) - -`RemoteTransport` trait: - -- `connect(config) -> Client` -- `send(request) -> pending result` -- `subscribe_events() -> stream` -- `close()` -- `status()` - -Current status: - -- Done: transport split + provider routing + Orbit WS connect/read/write path. -- Done: WebSocket payload parsing split to protocol lines before JSON-RPC dispatch. -- Pending: app-side reconnect strategy and replay/resync contract integration. - -## 2) Add bridge configuration to settings model - -Extend `AppSettings` in `src-tauri/src/types.rs` and UI types in `src/types.ts`. - -Implemented baseline fields: - -- `remoteBackendProvider`: `"tcp" | "orbit"` -- `remoteBackendHost` -- `remoteBackendToken` -- `orbitWsUrl` -- `orbitAuthUrl` -- `orbitRunnerName` -- `orbitAutoStartRunner` -- `orbitUseAccess` -- `orbitAccessClientId` -- `orbitAccessClientSecretRef` - -Planned next (not yet implemented in settings model): - -- deployment/auth/pairing metadata required for full self-host Orbit UX -- secure-storage integration for secret material lifecycle (set/reset/rotation) - -Keep secrets out of plain `settings.json` where possible. - -## 3) Secret storage - -Implement secure secret storage adapter: - -- macOS: Keychain via Rust crate (`keyring`) or dedicated secure-storage layer. -- iOS: Keychain-backed storage for mobile credentials. - -Store only secret references/aliases in app settings JSON. - -## 4) Runner service manager (macOS) - -Add backend service manager module: - -- `src-tauri/src/bridge_runner/mod.rs` - -Responsibilities: - -- Start runner process/task. -- Stop runner. -- Report health (`connecting|online|offline|error`). -- Persist last logs ring buffer. -- Auto-start on app launch if enabled. - -Current implementation: - -- Basic runner lifecycle controls are implemented via Tauri commands in `src-tauri/src/orbit/mod.rs` and daemon Orbit mode args in `src-tauri/src/bin/codex_monitor_daemon.rs`. -- Full background service management (LaunchAgent install/remove, log viewer, lifecycle recovery after app restart) remains pending. - -Potential implementations: - -- Embedded task in app process (faster iteration). -- Optional LaunchAgent installation for background persistence across app restarts. - -## 5) Daemon Orbit mode - -Extend daemon binary (`src-tauri/src/bin/codex_monitor_daemon.rs`) with optional Orbit connector mode. - -Representative options: - -- `--orbit-url` -- `--orbit-auth-url` -- `--orbit-device-login` -- `--orbit-token-ref` - -Behavior: - -- Outbound WS to Orbit relay. -- Translate Orbit-relayed JSON-RPC to existing RPC handler + event bus. -- Support runner reconnect and re-subscription behavior. - -Current implementation status: - -- Orbit mode args are implemented: `--orbit-url`, `--orbit-token`, `--orbit-auth-url`, `--orbit-runner-name`. -- Orbit mode loop is implemented with reconnect/backoff, event forwarding, ping/pong handling, and `anchor.hello` metadata send. -- Further Orbit-specific subscription/replay semantics remain pending until mobile Orbit client wiring is added. - -## 6) Command parity scope (mobile phase) - -Remote mode must support all commands exercised by the current mobile UI surface. - -Implemented in shared core + daemon/app adapters: - -- Git + GitHub UI commands: - - `list_git_roots`, `get_git_status`, `get_git_diffs`, `get_git_log`, `get_git_commit_diff`, `get_git_remote` - - `list_git_branches`, `checkout_git_branch`, `create_git_branch` - - `stage_git_file`, `stage_git_all`, `unstage_git_file` - - `revert_git_file`, `revert_git_all` - - `commit_git`, `push_git`, `pull_git`, `fetch_git`, `sync_git` - - GitHub issues/PRs/comments/diff commands -- Prompts commands: - - `prompts_list`, `prompts_create`, `prompts_update`, `prompts_delete`, `prompts_move`, `prompts_workspace_dir`, `prompts_global_dir` -- Workspace/app extras: - - `add_clone`, `apply_worktree_changes`, `open_workspace_in`, `get_open_app_icon` -- Utility commands: - - `codex_doctor`, `generate_commit_message`, `generate_run_metadata`, `local_usage_snapshot`, `send_notification_fallback`, `is_macos_debug_build`, `menu_set_accelerators` - -Out of scope for this mobile phase: - -- Terminal commands: - - `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close` -- Dictation commands: - - `dictation_model_status`, `dictation_download_model`, `dictation_cancel_download`, `dictation_remove_model`, `dictation_start`, `dictation_request_permission`, `dictation_stop`, `dictation_cancel` - -Validation policy: - -- No CI parity guard is required for this phase. -- Validate parity locally before merge (build/tests + remote-mode smoke checks). - -## Frontend Plan - -## Settings UX (required for easy setup) - -Update `src/features/settings/components/SettingsView.tsx` to add an Orbit section when `backendMode=remote` and provider is orbit. - -Required controls: - -- Provider selector (`TCP daemon` / `Orbit`) -- TCP + Tailscale helpers: - - `Detect Tailscale` - - `Use suggested host` - - daemon launch command template -- Orbit WS URL input -- Orbit Auth URL input -- Runner name input -- Access auth toggle + client id input + secret set/reset (optional) -- `Connect test` button -- `Sign In` / `Sign Out` actions -- `Start Runner` / `Stop Runner` buttons -- `Install LaunchAgent` / `Remove LaunchAgent` (optional) -- Status badge + last heartbeat + error message -- `Copy Pair Code` / `Show QR` -- `View Logs` drawer - -Current implementation status: - -- Implemented now: - - Provider selector - - TCP Tailscale helper controls (`Detect Tailscale`, suggested host, daemon command template) - - Orbit WS/Auth URL inputs - - Runner name input - - Access toggle + client id/secret ref fields - - `Connect test`, `Sign In`, `Sign Out`, `Start Runner`, `Stop Runner`, `Refresh Status` - - inline status/auth-code/verification URL display -- Pending: - - LaunchAgent install/remove controls - - status badge with heartbeat metadata - - `Copy Pair Code` / `Show QR` - - logs drawer UI - -UX behavior: - -- Disable invalid combinations. -- Show clear actionable errors (auth failed, runner offline, endpoint invalid, token expired). -- Persist non-secret fields immediately. -- Save secrets via secure backend command only. - -## iOS client UX - -- First launch setup: - - endpoint-aware sign-in (self-host) - - `Scan QR` / `Enter pair code` - - Recent sessions -- Runtime status: - - `Connected to ` - - Latency indicator - - Reconnecting state -- Conflict handling: - - Runner offline banner - - Rehydration state after reconnect - -## User Setup Flows - -## Tailscale Bootstrap (Implemented) - -Desktop setup: - -1. Install Tailscale and sign into the same tailnet on desktop and iPhone. -2. In CodexMonitor Settings, set `Backend Mode = Remote`, `Provider = TCP`. -3. Click `Detect Tailscale` and then `Use suggested host`. -4. Set a `Remote backend token`. -5. Copy the generated daemon command template and run it on desktop. -6. Use the same host/token in mobile app remote settings. - -Mobile setup: - -1. Install and sign into Tailscale on iOS. -2. Open CodexMonitor iOS app. -3. Set remote provider to TCP and enter tailnet host + token from desktop setup. -4. Connect and validate thread list + messaging. - -## Self-Hosted Orbit - -Desktop setup: - -1. Deploy Orbit/Auth services to Cloudflare. -2. Open CodexMonitor Settings. -3. Set `Backend Mode = Remote`, `Provider = Orbit`. -4. Enter `Orbit WS URL` and `Orbit Auth URL`. -5. Configure optional Access credentials. -6. Sign in and start runner. -7. Pair mobile via QR/code. - -Mobile setup: - -1. Launch iOS app. -2. Sign in against configured self-host auth. -3. Scan QR or enter pair code. -4. Store credentials in Keychain and auto-connect. - -User-provided information: - -- Orbit WS URL. -- Orbit Auth URL. -- Optional Access client credentials (if enabled). - -## Mobile-safe UI readiness - -Current responsive layouts exist (`phone`, `tablet`, `desktop`), but ensure: - -- touch target sizes are >= 44pt -- no hover-only actions for critical controls -- keyboard-safe composer on iOS (safe area + bottom inset) -- panel resizing gestures disabled on touch layouts - -## iOS Build + Install Runbook - -## Prerequisites (macOS) - -1. Xcode (full app, not only CLT). -2. Rust iOS targets: - -```bash -rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim -``` - -3. CocoaPods: - -```bash -brew install cocoapods -``` - -4. JS dependencies from repo root: - -```bash -npm install -``` - -## Initialize iOS project files - -From repo root: - -```bash -npm run tauri ios init -``` - -Expected output: -- `src-tauri/gen/apple/*` generated. -- Xcode project/workspace for iOS target available. - -## Run on iOS Simulator (dev) - -```bash -npm run tauri ios dev -``` - -Notes: -- Uses `build.devUrl` and `beforeDevCommand`. -- Rust + frontend hot-reload loop in dev. - -## Run on Physical Device (dev) - -1. Open generated Xcode workspace. -2. Set Apple Team + signing profile for iOS target. -3. Ensure frontend dev server reachable from device network. -4. Run: - -```bash -npm run tauri ios dev -- -``` - -If network issues appear, ensure dev server listens on host interface and uses `TAURI_DEV_HOST` when set. - -## Build production iOS app - -```bash -npm run tauri ios build -``` - -Output: -- Release build artifacts/IPA via Tauri iOS build flow. - -## Install build - -Development install options: - -1. Xcode run to connected device. -2. Xcode Organizer distribute to internal testers. -3. TestFlight (recommended for team validation). - -For direct IPA sideload in controlled environments, use Apple Configurator or MDM as appropriate. - -## Tauri and Cargo Changes Required for iOS Compatibility - -## Cargo dependency gating - -In `src-tauri/Cargo.toml`, gate non-mobile dependencies behind desktop cfg where needed (for example terminal/generic git native deps if unsupported on iOS runtime path). - -## Tauri config split - -Create and maintain iOS-specific config (`src-tauri/tauri.ios.conf.json`) for: - -- iOS bundle identifiers -- iOS icons/assets -- iOS permissions usage strings -- iOS-specific plugin toggles - -Keep desktop-only settings out of iOS config (titlebar/private APIs/updater artifacts). - -## Backend module gating - -Use `cfg` for mobile-safe stubs where functionality is desktop-only, while preserving command signatures used by frontend. - -## Testing and Validation Matrix - -## Unit/Type/Lint - -From repo root: - -```bash -npm run lint -npm run typecheck -npm run test -``` - -If Rust touched: - -```bash -cd src-tauri -cargo check -cargo test -``` - -## Orbit integration tests - -- Simulate iOS disconnect/reconnect. -- Verify thread rehydration via resume/events endpoint. -- Verify idempotent handling of duplicate RPC responses. -- Verify unauthorized client rejection. -- Verify runner failover from offline -> online. -- Verify thread subscription behavior (`orbit.subscribe`/`orbit.unsubscribe`). - -## Manual scenario checklist - -1. Pair iOS with macOS runner. -2. List workspaces. -3. Connect workspace. -4. Start thread, send messages, interrupt turn. -5. Git diff panel operations. -6. Prompts CRUD. -7. Verify terminal UI is not exposed in mobile mode. -8. Verify dictation UI is not exposed in mobile mode. -9. Background iOS app, resume, ensure state resync. -10. macOS runner restart, iOS auto-reconnect. - -## Implementation Milestones - -1. Milestone A: iOS compile baseline + mobile-safe stubs. -2. Milestone B: Orbit integration baseline (self-host config path). -3. Milestone C: `remote_backend` transport refactor + Orbit WS transport + runner Orbit mode. -4. Milestone D: daemon parity closure for mobile scope (excluding terminal/dictation). -5. Milestone E: Settings UX/service manager + pairing UX. -6. Milestone F: full E2E validation and TestFlight beta. - -## Definition of Done - -- iOS app can fully control a macOS runner via Orbit bridge. -- Remote feature parity with desktop local mode for supported workflows. -- macOS users can configure Orbit from Settings using self-hosted Orbit endpoints. -- Runner can be started/stopped/auto-started from app. -- Reconnect/resync is robust and observable. -- Build/install flow is documented and reproducible. - -## Fresh-Agent Execution Checklist - -1. Read this document completely. -2. Implement Milestone A first and ensure local iOS dev build works. -3. Integrate Orbit transport/auth in isolation with mock runner/client tests. -4. Refactor `remote_backend` to transport abstraction. -5. Complete daemon parity for mobile scope and validate locally. -6. Build settings UX and runner service controls. -7. Validate full manual checklist on simulator and physical device. -8. Ship behind feature flag, then remove flag after beta validation. diff --git a/docs/mobile-ios-tailscale-blueprint.md b/docs/mobile-ios-tailscale-blueprint.md new file mode 100644 index 000000000..f3dd8b3ac --- /dev/null +++ b/docs/mobile-ios-tailscale-blueprint.md @@ -0,0 +1,77 @@ +# CodexMonitor iOS Remote Blueprint (Tailscale + TCP) + +This document is the canonical runbook for iOS remote usage with a desktop-hosted CodexMonitor backend over Tailscale. + +## Scope + +- iOS app runs in remote backend mode. +- Desktop app runs the TCP mobile access daemon. +- Connectivity is provided by the user-managed Tailscale tailnet. +- Hosted relay providers are out of scope. + +## Current Architecture + +1. Desktop CodexMonitor hosts the daemon and executes Codex workflows. +2. iOS CodexMonitor connects to the desktop daemon using `remoteBackendHost` + token. +3. Transport is TCP only (`remoteBackendProvider = "tcp"`). +4. Tailscale is used as the network path between iOS and desktop. + +## Prerequisites + +- Desktop and iPhone are signed into the same Tailscale tailnet. +- Desktop CodexMonitor is installed and able to run local workspaces. +- iOS build/runtime is available (simulator or device). +- A non-empty remote backend token is configured. + +## Desktop Setup (Source of Truth) + +In desktop CodexMonitor: + +1. Open `Settings > Server`. +2. Set `Remote backend token`. +3. In `Mobile access daemon`, click `Start daemon`. +4. In `Tailscale helper`, click `Detect Tailscale`. +5. Use `Use suggested host` or copy the suggested host manually (example: `macbook.your-tailnet.ts.net:4732`). + +Optional fallback: + +- Use `Refresh daemon command` to get a manual launch command template. + +## iOS Setup + +In iOS CodexMonitor: + +1. Open `Settings > Server` (or the mobile setup wizard). +2. Enter the desktop Tailscale host (including port). +3. Enter the same remote backend token used on desktop. +4. Tap `Connect & test`. + +Success criteria: + +- Connectivity check passes. +- Workspace list loads from desktop backend. + +## Operational Notes + +- Desktop daemon must remain running while iOS is connected. +- Mobile flow is remote-only and uses user infrastructure. +- Desktop remains local-first unless switched to remote mode explicitly. + +## Known Mobile Limits + +- Terminal tooling is unavailable on mobile builds. +- Dictation is unavailable on mobile builds. + +## Troubleshooting + +- `Unable to reach remote backend`: + - Verify desktop daemon is running. + - Verify host/token match desktop settings. + - Verify both devices are online in the same tailnet. +- `Token (required)` / auth failures: + - Set a non-empty token in desktop Server settings. + - Re-enter the same token on iOS. +- No suggested host shown: + - Confirm Tailscale is installed and connected on desktop. + - Retry `Detect Tailscale`. + diff --git a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj index aa1500bfc..b612cdd58 100644 --- a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj +++ b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj @@ -65,7 +65,6 @@ 9D954D38686C4132856A59E4 /* tcp_transport.rs */ = {isa = PBXFileReference; path = tcp_transport.rs; sourceTree = ""; }; 9E58DB47DA7B47D628EADE5D /* args.rs */ = {isa = PBXFileReference; path = args.rs; sourceTree = ""; }; 9E5C2A555AC05BDC3564838B /* local_usage_core.rs */ = {isa = PBXFileReference; path = local_usage_core.rs; sourceTree = ""; }; - A07E819E1F8FC4710E1E5DAC /* orbit_core.rs */ = {isa = PBXFileReference; path = orbit_core.rs; sourceTree = ""; }; A35E6BC600DBB2FF9C6EABCD /* codex_monitor_daemon.rs */ = {isa = PBXFileReference; path = codex_monitor_daemon.rs; sourceTree = ""; }; A677124A2706E5BF201E0FEB /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; AA0D64CE30289CCE13E59F0F /* worktree_core.rs */ = {isa = PBXFileReference; path = worktree_core.rs; sourceTree = ""; }; @@ -83,7 +82,6 @@ D05DC45BD60F3A9C1A3B0663 /* policy.rs */ = {isa = PBXFileReference; path = policy.rs; sourceTree = ""; }; D3469462193DAA7437CF3971 /* state.rs */ = {isa = PBXFileReference; path = state.rs; sourceTree = ""; }; D5066E0D8C3EBB3BF6522255 /* codex_core.rs */ = {isa = PBXFileReference; path = codex_core.rs; sourceTree = ""; }; - D94AE32B1D64069E94AFEDC9 /* orbit_ws_transport.rs */ = {isa = PBXFileReference; path = orbit_ws_transport.rs; sourceTree = ""; }; DA6F8CA66C86156DDE024DBD /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; DA7D0B551D09725FFEF6A5F7 /* git.rs */ = {isa = PBXFileReference; path = git.rs; sourceTree = ""; }; DBB890CC58633E8FD68707DF /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; @@ -183,7 +181,6 @@ isa = PBXGroup; children = ( 08DA73A3B0441143BDAB249C /* mod.rs */, - D94AE32B1D64069E94AFEDC9 /* orbit_ws_transport.rs */, 90D7D2BB5D2B32D2D5E7FC74 /* protocol.rs */, 9D954D38686C4132856A59E4 /* tcp_transport.rs */, 6946AB1AF1A3D2A8B15D1851 /* transport.rs */, @@ -211,7 +208,6 @@ 80C8B0127080EFDC77615869 /* git_ui_core.rs */, 9E5C2A555AC05BDC3564838B /* local_usage_core.rs */, 94AD322F1E988800DBA78639 /* mod.rs */, - A07E819E1F8FC4710E1E5DAC /* orbit_core.rs */, 3694556D879AB22D60B3C7BE /* process_core.rs */, 86C83692DBFF6EB1642C8EDC /* prompts_core.rs */, 94D1E1436404E5CF32E1EC97 /* settings_core.rs */, @@ -279,7 +275,6 @@ 8A851D3110FA91F307B978D2 /* dictation */, 00C678587D406CBBBC7DB473 /* files */, 41BDAF45DFACDE58DB7F6CF9 /* git */, - EBFD460F3E3882D288DB1857 /* orbit */, 2D83F316741CCD9EEFB040D4 /* remote_backend */, BBC110A6B11308FA59CC6023 /* settings */, 3F64BF5418976E7EC8C3D35E /* shared */, @@ -358,14 +353,6 @@ name = Products; sourceTree = ""; }; - EBFD460F3E3882D288DB1857 /* orbit */ = { - isa = PBXGroup; - children = ( - B8230BF8F554EC5F090CD80F /* mod.rs */, - ); - path = orbit; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 9275ed671..acffebf02 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -69,16 +69,11 @@ use std::io::Read; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; -use futures_util::{SinkExt, StreamExt}; use ignore::WalkBuilder; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, mpsc, Mutex, Semaphore}; -use tokio::time::sleep; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; use backend::app_server::{spawn_workspace_session, WorkspaceSession}; use backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput}; @@ -92,8 +87,7 @@ use storage::{read_settings, read_workspaces}; use types::{ AppSettings, GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, LocalUsageSnapshot, - OrbitConnectTestResult, OrbitDeviceCodeStart, OrbitSignInPollResult, OrbitSignInStatus, - OrbitSignOutResult, WorkspaceEntry, WorkspaceInfo, WorkspaceSettings, WorktreeSetupStatus, + WorkspaceEntry, WorkspaceInfo, WorkspaceSettings, WorktreeSetupStatus, }; use workspace_settings::apply_workspace_settings_update; @@ -151,10 +145,6 @@ struct DaemonConfig { listen: SocketAddr, token: Option, data_dir: PathBuf, - orbit_url: Option, - orbit_token: Option, - orbit_auth_url: Option, - orbit_runner_name: Option, } struct DaemonState { @@ -166,7 +156,6 @@ struct DaemonState { app_settings: Mutex, event_sink: DaemonEventSink, codex_login_cancels: Mutex>, - daemon_mode: String, daemon_binary_path: Option, } @@ -182,11 +171,6 @@ impl DaemonState { let settings_path = config.data_dir.join("settings.json"); let workspaces = read_workspaces(&storage_path).unwrap_or_default(); let app_settings = read_settings(&settings_path).unwrap_or_default(); - let daemon_mode = if config.orbit_url.is_some() { - "orbit".to_string() - } else { - "tcp".to_string() - }; let daemon_binary_path = std::env::current_exe() .ok() .and_then(|path| path.to_str().map(str::to_string)); @@ -199,7 +183,6 @@ impl DaemonState { app_settings: Mutex::new(app_settings), event_sink, codex_login_cancels: Mutex::new(HashMap::new()), - daemon_mode, daemon_binary_path, } } @@ -209,7 +192,7 @@ impl DaemonState { "name": DAEMON_NAME, "version": env!("CARGO_PKG_VERSION"), "pid": std::process::id(), - "mode": self.daemon_mode, + "mode": "tcp", "binaryPath": self.daemon_binary_path, }) } @@ -528,84 +511,6 @@ impl DaemonState { codex_config::write_feature_enabled(feature_key.as_str(), enabled) } - async fn orbit_connect_test(&self) -> Result { - let settings = self.app_settings.lock().await.clone(); - let ws_url = shared::orbit_core::orbit_ws_url_from_settings(&settings)?; - shared::orbit_core::orbit_connect_test_core( - &ws_url, - settings.remote_backend_token.as_deref(), - ) - .await - } - - async fn orbit_sign_in_start(&self) -> Result { - let settings = self.app_settings.lock().await.clone(); - let auth_url = shared::orbit_core::orbit_auth_url_from_settings(&settings)?; - shared::orbit_core::orbit_sign_in_start_core( - &auth_url, - settings.orbit_runner_name.as_deref(), - ) - .await - } - - async fn orbit_sign_in_poll( - &self, - device_code: String, - remote_backend_id: Option, - ) -> Result { - let auth_url = { - let settings = self.app_settings.lock().await.clone(); - shared::orbit_core::orbit_auth_url_from_settings(&settings)? - }; - let result = shared::orbit_core::orbit_sign_in_poll_core(&auth_url, &device_code).await?; - - if matches!(result.status, OrbitSignInStatus::Authorized) { - if let Some(token) = result.token.as_ref() { - let _ = settings_core::update_remote_backend_token_core( - &self.app_settings, - &self.settings_path, - Some(token), - remote_backend_id.as_deref(), - ) - .await?; - } - } - - Ok(result) - } - - async fn orbit_sign_out( - &self, - remote_backend_id: Option, - ) -> Result { - let settings = self.app_settings.lock().await.clone(); - let auth_url = shared::orbit_core::orbit_auth_url_optional(&settings); - let token = shared::orbit_core::remote_backend_token_for_id_optional( - &settings, - remote_backend_id.as_deref(), - ); - - let mut logout_error: Option = None; - if let (Some(auth_url), Some(token)) = (auth_url.as_ref(), token.as_ref()) { - if let Err(err) = shared::orbit_core::orbit_sign_out_core(auth_url, token).await { - logout_error = Some(err); - } - } - - let _ = settings_core::update_remote_backend_token_core( - &self.app_settings, - &self.settings_path, - None, - remote_backend_id.as_deref(), - ) - .await?; - - Ok(OrbitSignOutResult { - success: logout_error.is_none(), - message: logout_error, - }) - } - async fn list_workspace_files(&self, workspace_id: String) -> Result, String> { workspaces_core::list_workspace_files_core(&self.workspaces, &workspace_id, |root| { list_workspace_files_inner(root, 20000) @@ -1373,8 +1278,8 @@ fn default_data_dir() -> PathBuf { fn usage() -> String { format!( "\ -USAGE:\n codex-monitor-daemon [--listen ] [--data-dir ] [--token | --insecure-no-auth]\n codex-monitor-daemon --orbit-url [--orbit-token ] [--orbit-auth-url ] [--orbit-runner-name ] [--data-dir ]\n\n\ -OPTIONS:\n --listen Bind address (default: {DEFAULT_LISTEN_ADDR})\n --data-dir Data dir holding workspaces.json/settings.json\n --token Shared token required by TCP clients\n --insecure-no-auth Disable TCP auth (dev only)\n --orbit-url Run in Orbit runner mode and connect outbound to this WS URL\n --orbit-token Orbit auth token (optional if URL already includes token)\n --orbit-auth-url Orbit auth base URL (metadata only, optional)\n --orbit-runner-name Runner display name (metadata only, optional)\n -h, --help Show this help\n" +USAGE:\n codex-monitor-daemon [--listen ] [--data-dir ] [--token | --insecure-no-auth]\n\n\ +OPTIONS:\n --listen Bind address (default: {DEFAULT_LISTEN_ADDR})\n --data-dir Data dir holding workspaces.json/settings.json\n --token Shared token required by TCP clients\n --insecure-no-auth Disable TCP auth (dev only)\n -h, --help Show this help\n" ) } @@ -1388,19 +1293,6 @@ fn parse_args() -> Result { .filter(|value| !value.is_empty()); let mut insecure_no_auth = false; let mut data_dir: Option = None; - let mut orbit_url: Option = None; - let mut orbit_token: Option = env::var("CODEX_MONITOR_ORBIT_TOKEN") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let mut orbit_auth_url: Option = env::var("CODEX_MONITOR_ORBIT_AUTH_URL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let mut orbit_runner_name: Option = env::var("CODEX_MONITOR_ORBIT_RUNNER_NAME") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); let mut args = env::args().skip(1); while let Some(arg) = args.next() { @@ -1433,44 +1325,11 @@ fn parse_args() -> Result { insecure_no_auth = true; token = None; } - "--orbit-url" => { - let value = args.next().ok_or("--orbit-url requires a value")?; - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("--orbit-url requires a non-empty value".to_string()); - } - orbit_url = Some(trimmed.to_string()); - } - "--orbit-token" => { - let value = args.next().ok_or("--orbit-token requires a value")?; - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("--orbit-token requires a non-empty value".to_string()); - } - orbit_token = Some(trimmed.to_string()); - } - "--orbit-auth-url" => { - let value = args.next().ok_or("--orbit-auth-url requires a value")?; - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("--orbit-auth-url requires a non-empty value".to_string()); - } - orbit_auth_url = Some(trimmed.to_string()); - } - "--orbit-runner-name" => { - let value = args.next().ok_or("--orbit-runner-name requires a value")?; - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("--orbit-runner-name requires a non-empty value".to_string()); - } - orbit_runner_name = Some(trimmed.to_string()); - } _ => return Err(format!("Unknown argument: {arg}")), } } - let is_orbit_mode = orbit_url.is_some(); - if !is_orbit_mode && token.is_none() && !insecure_no_auth { + if token.is_none() && !insecure_no_auth { return Err( "Missing --token (or set CODEX_MONITOR_DAEMON_TOKEN). Use --insecure-no-auth for local dev only." .to_string(), @@ -1481,10 +1340,6 @@ fn parse_args() -> Result { listen, token, data_dir: data_dir.unwrap_or_else(default_data_dir), - orbit_url, - orbit_token, - orbit_auth_url, - orbit_runner_name, }) } @@ -1532,7 +1387,6 @@ mod tests { app_settings: Mutex::new(AppSettings::default()), event_sink: DaemonEventSink { tx }, codex_login_cancels: Mutex::new(HashMap::new()), - daemon_mode: "tcp".to_string(), daemon_binary_path: Some("/tmp/codex-monitor-daemon".to_string()), } } @@ -1692,19 +1546,6 @@ fn main() { let state = Arc::new(DaemonState::load(&config, event_sink)); let config = Arc::new(config); - if config.orbit_url.is_some() { - eprintln!( - "codex-monitor-daemon orbit mode (data dir: {})", - state - .storage_path - .parent() - .unwrap_or(&state.storage_path) - .display() - ); - transport::run_orbit_mode(config, state, events_tx).await; - return; - } - let listener = match TcpListener::bind(config.listen).await { Ok(listener) => listener, Err(err) => { diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index 8796adeb2..f519ce10b 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -293,43 +293,6 @@ pub(super) async fn try_handle( }; Some(serde_json::to_value(updated).map_err(|err| err.to_string())) } - "orbit_connect_test" => { - let result = match state.orbit_connect_test().await { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - Some(serde_json::to_value(result).map_err(|err| err.to_string())) - } - "orbit_sign_in_start" => { - let result = match state.orbit_sign_in_start().await { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - Some(serde_json::to_value(result).map_err(|err| err.to_string())) - } - "orbit_sign_in_poll" => { - let device_code = match parse_string(params, "deviceCode") { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - let remote_backend_id = parse_optional_string(params, "remoteBackendId"); - let result = match state - .orbit_sign_in_poll(device_code, remote_backend_id) - .await - { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - Some(serde_json::to_value(result).map_err(|err| err.to_string())) - } - "orbit_sign_out" => { - let remote_backend_id = parse_optional_string(params, "remoteBackendId"); - let result = match state.orbit_sign_out(remote_backend_id).await { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - Some(serde_json::to_value(result).map_err(|err| err.to_string())) - } "add_clone" => { let source_workspace_id = match parse_string(params, "sourceWorkspaceId") { Ok(value) => value, diff --git a/src-tauri/src/bin/codex_monitor_daemon/transport.rs b/src-tauri/src/bin/codex_monitor_daemon/transport.rs index 9d8db9b8e..ca4cb2769 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/transport.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/transport.rs @@ -101,172 +101,3 @@ pub(super) async fn handle_client( } write_task.abort(); } - -fn handle_orbit_line( - line: &str, - state: Arc, - out_tx: mpsc::UnboundedSender, - client_version: String, - request_limiter: Arc, -) { - let message: Value = match serde_json::from_str(line) { - Ok(value) => value, - Err(_) => return, - }; - - if let Some(message_type) = message.get("type").and_then(Value::as_str) { - if message_type.eq_ignore_ascii_case("ping") { - let _ = out_tx.send(json!({ "type": "pong" }).to_string()); - } - return; - } - - let id = message.get("id").and_then(|value| value.as_u64()); - let method = message - .get("method") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - let params = message.get("params").cloned().unwrap_or(Value::Null); - if method.is_empty() { - return; - } - - if method == "auth" { - if let Some(response) = build_result_response(id, json!({ "ok": true })) { - let _ = out_tx.send(response); - } - return; - } - - spawn_rpc_response_task( - state, - out_tx, - id, - method, - params, - client_version, - request_limiter, - ); -} - -pub(super) async fn run_orbit_mode( - config: Arc, - state: Arc, - events_tx: broadcast::Sender, -) { - let orbit_url = config.orbit_url.clone().unwrap_or_default(); - let runner_name = config - .orbit_runner_name - .clone() - .unwrap_or_else(|| "codex-monitor-daemon".to_string()); - - let mut reconnect_delay = Duration::from_secs(1); - loop { - let ws_url = - match shared::orbit_core::build_orbit_ws_url(&orbit_url, config.orbit_token.as_deref()) - { - Ok(value) => value, - Err(err) => { - eprintln!("invalid orbit url: {err}"); - sleep(reconnect_delay).await; - reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(20)); - continue; - } - }; - - let stream = match connect_async(&ws_url).await { - Ok((stream, _response)) => stream, - Err(err) => { - eprintln!( - "orbit runner failed to connect to {}: {}. retrying in {}s", - ws_url, - err, - reconnect_delay.as_secs() - ); - sleep(reconnect_delay).await; - reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(20)); - continue; - } - }; - - reconnect_delay = Duration::from_secs(1); - eprintln!("orbit runner connected to {}", ws_url); - - let (mut writer, mut reader) = stream.split(); - let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - - let write_task = tokio::spawn(async move { - while let Some(message) = out_rx.recv().await { - if writer.send(Message::Text(message.into())).await.is_err() { - break; - } - } - }); - - let events_task = { - let rx = events_tx.subscribe(); - let out_tx_events = out_tx.clone(); - tokio::spawn(forward_events(rx, out_tx_events)) - }; - - let _ = out_tx.send( - json!({ - "type": "anchor.hello", - "name": runner_name.clone(), - "platform": std::env::consts::OS, - "authUrl": config.orbit_auth_url.clone(), - }) - .to_string(), - ); - - let client_version = format!("daemon-{}", env!("CARGO_PKG_VERSION")); - let request_limiter = Arc::new(Semaphore::new(MAX_IN_FLIGHT_RPC_PER_CONNECTION)); - while let Some(frame) = reader.next().await { - match frame { - Ok(Message::Text(text)) => { - for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { - handle_orbit_line( - line, - Arc::clone(&state), - out_tx.clone(), - client_version.clone(), - Arc::clone(&request_limiter), - ); - } - } - Ok(Message::Binary(bytes)) => { - if let Ok(text) = String::from_utf8(bytes.to_vec()) { - for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { - handle_orbit_line( - line, - Arc::clone(&state), - out_tx.clone(), - client_version.clone(), - Arc::clone(&request_limiter), - ); - } - } - } - Ok(Message::Close(_)) => break, - Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {} - Ok(Message::Frame(_)) => {} - Err(err) => { - eprintln!("orbit runner connection error: {err}"); - break; - } - } - } - - drop(out_tx); - events_task.abort(); - write_task.abort(); - - eprintln!( - "orbit runner disconnected. reconnecting in {}s", - reconnect_delay.as_secs() - ); - sleep(reconnect_delay).await; - reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(20)); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 52eead7dc..c02e84c38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,7 +21,6 @@ mod menu; #[path = "menu_mobile.rs"] mod menu; mod notifications; -mod orbit; mod prompts; mod remote_backend; mod rules; @@ -57,8 +56,6 @@ fn keep_daemon_running_after_close(app_handle: &tauri::AppHandle) -> bool { #[cfg(desktop)] async fn stop_managed_daemons_for_exit(app_handle: tauri::AppHandle) { - let state = app_handle.state::(); - let _ = orbit::orbit_runner_stop(state).await; let state = app_handle.state::(); let _ = tailscale::tailscale_daemon_stop(state).await; } @@ -142,48 +139,6 @@ pub fn run() { } } } - - if matches!(settings.backend_mode, crate::types::BackendMode::Remote) - && matches!( - settings.remote_backend_provider, - crate::types::RemoteBackendProvider::Orbit - ) - { - if settings.orbit_auto_start_runner { - if settings.keep_daemon_running_after_app_close { - // Avoid duplicate detached Orbit runners across relaunches. - // orbit_runner_start can still be called manually from Settings. - let state = app_handle.state::(); - let _ = orbit::orbit_runner_status(state).await; - } else { - let state = app_handle.state::(); - let _ = orbit::orbit_runner_start(state).await; - } - } else { - let state = app_handle.state::(); - if let Ok(status) = orbit::orbit_runner_status(state).await { - if matches!(status.state, crate::types::OrbitRunnerState::Running) { - // Enforce version for a currently running managed runner. - let state = app_handle.state::(); - let _ = orbit::orbit_runner_start(state).await; - } - } - } - } else if matches!( - settings.remote_backend_provider, - crate::types::RemoteBackendProvider::Orbit - ) { - // Local mode with Orbit selected: only enforce version if runner is already running. - let state = app_handle.state::(); - if let Ok(status) = orbit::orbit_runner_status(state).await { - if matches!(status.state, crate::types::OrbitRunnerState::Running) - && !settings.keep_daemon_running_after_app_close - { - let state = app_handle.state::(); - let _ = orbit::orbit_runner_start(state).await; - } - } - } }); } #[cfg(target_os = "ios")] @@ -312,13 +267,6 @@ pub fn run() { local_usage::local_usage_snapshot, notifications::is_macos_debug_build, notifications::send_notification_fallback, - orbit::orbit_connect_test, - orbit::orbit_sign_in_start, - orbit::orbit_sign_in_poll, - orbit::orbit_sign_out, - orbit::orbit_runner_start, - orbit::orbit_runner_stop, - orbit::orbit_runner_status, tailscale::tailscale_status, tailscale::tailscale_daemon_command_preview, tailscale::tailscale_daemon_start, diff --git a/src-tauri/src/orbit/mod.rs b/src-tauri/src/orbit/mod.rs deleted file mode 100644 index c0e2a4d72..000000000 --- a/src-tauri/src/orbit/mod.rs +++ /dev/null @@ -1,426 +0,0 @@ -use std::process::Stdio; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde::{Deserialize, Serialize}; -use tauri::State; -use tokio::fs; - -use crate::daemon_binary::resolve_daemon_binary_path; -use crate::shared::orbit_core; -use crate::shared::process_core::{kill_child_process_tree, tokio_command}; -use crate::shared::settings_core; -use crate::state::{AppState, OrbitRunnerRuntime}; -use crate::types::{ - OrbitConnectTestResult, OrbitRunnerState, OrbitRunnerStatus, OrbitSignInPollResult, - OrbitSignInStatus, OrbitSignOutResult, -}; - -const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -const ORBIT_RUNNER_RECORD_FILE: &str = "orbit_runner.json"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct OrbitRunnerRecord { - pid: u32, - version: String, - orbit_url: Option, - started_at_ms: Option, -} - -fn orbit_runner_record_path(state: &AppState) -> Option { - state - .settings_path - .parent() - .map(|parent| parent.join(ORBIT_RUNNER_RECORD_FILE)) -} - -async fn load_orbit_runner_record(state: &AppState) -> Option { - let path = orbit_runner_record_path(state)?; - let payload = fs::read(path).await.ok()?; - serde_json::from_slice(&payload).ok() -} - -async fn save_orbit_runner_record(state: &AppState, record: &OrbitRunnerRecord) { - let Some(path) = orbit_runner_record_path(state) else { - return; - }; - let Ok(payload) = serde_json::to_vec(record) else { - return; - }; - let _ = fs::write(path, payload).await; -} - -async fn clear_orbit_runner_record(state: &AppState) { - let Some(path) = orbit_runner_record_path(state) else { - return; - }; - let _ = fs::remove_file(path).await; -} - -#[cfg(unix)] -async fn is_pid_running(pid: u32) -> bool { - let result = unsafe { libc::kill(pid as i32, 0) }; - if result == 0 { - return true; - } - match std::io::Error::last_os_error().raw_os_error() { - Some(code) => code != libc::ESRCH, - None => false, - } -} - -#[cfg(windows)] -async fn is_pid_running(pid: u32) -> bool { - let output = match tokio_command("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) - .output() - .await - { - Ok(output) => output, - Err(_) => return false, - }; - if !output.status.success() { - return false; - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.lines().any(|line| line.contains(&format!("\"{pid}\""))) -} - -#[cfg(not(any(unix, windows)))] -async fn is_pid_running(_pid: u32) -> bool { - false -} - -fn now_unix_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as i64) - .unwrap_or(0) -} - -async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { - let Some(child) = runtime.child.as_mut() else { - runtime.status.state = OrbitRunnerState::Stopped; - runtime.status.pid = None; - runtime.managed_version = None; - return; - }; - - match child.try_wait() { - Ok(Some(status)) => { - let pid = child.id(); - runtime.child = None; - if status.success() { - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Stopped, - pid, - started_at_ms: None, - last_error: None, - orbit_url: runtime.status.orbit_url.clone(), - }; - } else { - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Error, - pid, - started_at_ms: runtime.status.started_at_ms, - last_error: Some(format!("Runner exited with status: {status}")), - orbit_url: runtime.status.orbit_url.clone(), - }; - } - runtime.managed_version = None; - } - Ok(None) => { - runtime.status.state = OrbitRunnerState::Running; - runtime.status.pid = child.id(); - runtime.status.last_error = None; - } - Err(err) => { - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Error, - pid: child.id(), - started_at_ms: runtime.status.started_at_ms, - last_error: Some(format!("Failed to inspect runner process: {err}")), - orbit_url: runtime.status.orbit_url.clone(), - }; - runtime.managed_version = None; - } - } -} - -#[tauri::command] -pub(crate) async fn orbit_connect_test( - state: State<'_, AppState>, -) -> Result { - let settings = state.app_settings.lock().await.clone(); - let ws_url = orbit_core::orbit_ws_url_from_settings(&settings)?; - orbit_core::orbit_connect_test_core(&ws_url, settings.remote_backend_token.as_deref()).await -} - -#[tauri::command] -pub(crate) async fn orbit_sign_in_start( - state: State<'_, AppState>, -) -> Result { - let settings = state.app_settings.lock().await.clone(); - let auth_url = orbit_core::orbit_auth_url_from_settings(&settings)?; - orbit_core::orbit_sign_in_start_core(&auth_url, settings.orbit_runner_name.as_deref()).await -} - -#[tauri::command] -pub(crate) async fn orbit_sign_in_poll( - device_code: String, - remote_backend_id: Option, - state: State<'_, AppState>, -) -> Result { - let auth_url = { - let settings = state.app_settings.lock().await.clone(); - orbit_core::orbit_auth_url_from_settings(&settings)? - }; - let result = orbit_core::orbit_sign_in_poll_core(&auth_url, &device_code).await?; - - if matches!(result.status, OrbitSignInStatus::Authorized) { - if let Some(token) = result.token.as_ref() { - let _ = settings_core::update_remote_backend_token_core( - &state.app_settings, - &state.settings_path, - Some(token), - remote_backend_id.as_deref(), - ) - .await?; - } - } - - Ok(result) -} - -#[tauri::command] -pub(crate) async fn orbit_sign_out( - remote_backend_id: Option, - state: State<'_, AppState>, -) -> Result { - let settings = state.app_settings.lock().await.clone(); - let auth_url = orbit_core::orbit_auth_url_optional(&settings); - let token = - orbit_core::remote_backend_token_for_id_optional(&settings, remote_backend_id.as_deref()); - - let mut logout_error: Option = None; - if let (Some(auth_url), Some(token)) = (auth_url.as_ref(), token.as_ref()) { - if let Err(err) = orbit_core::orbit_sign_out_core(auth_url, token).await { - logout_error = Some(err); - } - } - - let _ = settings_core::update_remote_backend_token_core( - &state.app_settings, - &state.settings_path, - None, - remote_backend_id.as_deref(), - ) - .await?; - - Ok(OrbitSignOutResult { - success: logout_error.is_none(), - message: logout_error, - }) -} - -#[tauri::command] -pub(crate) async fn orbit_runner_start( - state: State<'_, AppState>, -) -> Result { - if cfg!(any(target_os = "android", target_os = "ios")) { - return Err("Orbit runner start is only supported on desktop.".to_string()); - } - - let settings = state.app_settings.lock().await.clone(); - let ws_url = orbit_core::orbit_ws_url_from_settings(&settings)?; - let daemon_binary = resolve_daemon_binary_path()?; - - let data_dir = state - .settings_path - .parent() - .map(|path| path.to_path_buf()) - .ok_or_else(|| "Unable to resolve app data directory".to_string())?; - - let persisted_runner = load_orbit_runner_record(&state).await; - - let mut runtime = state.orbit_runner.lock().await; - refresh_runner_runtime(&mut runtime).await; - if matches!(runtime.status.state, OrbitRunnerState::Running) { - if runtime.managed_version.as_deref() == Some(CURRENT_APP_VERSION) { - return Ok(runtime.status.clone()); - } - - if runtime.child.is_none() { - let pid_display = runtime - .status - .pid - .map(|pid| pid.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let message = format!( - "Orbit runner (pid {pid_display}) is already running outside this app process. Stop it first to avoid duplicate runners." - ); - runtime.status.last_error = Some(message.clone()); - return Err(message); - } - - if let Some(mut child) = runtime.child.take() { - kill_child_process_tree(&mut child).await; - let _ = child.wait().await; - } - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Stopped, - pid: None, - started_at_ms: None, - last_error: None, - orbit_url: runtime.status.orbit_url.clone(), - }; - runtime.managed_version = None; - } - - if let Some(record) = persisted_runner { - if is_pid_running(record.pid).await { - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Running, - pid: Some(record.pid), - started_at_ms: record.started_at_ms, - last_error: None, - orbit_url: record.orbit_url.or_else(|| Some(ws_url.clone())), - }; - runtime.managed_version = Some(record.version.clone()); - if record.version == CURRENT_APP_VERSION { - return Ok(runtime.status.clone()); - } - let message = format!( - "Orbit runner version {} does not match app version {}. Stop the existing runner before starting a new one.", - record.version, CURRENT_APP_VERSION - ); - runtime.status.last_error = Some(message.clone()); - return Err(message); - } - clear_orbit_runner_record(&state).await; - } - - let mut command = tokio_command(&daemon_binary); - command - .arg("--data-dir") - .arg(data_dir) - .arg("--orbit-url") - .arg(ws_url.clone()) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - if let Some(token) = settings - .remote_backend_token - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - command.arg("--orbit-token").arg(token); - } - - if let Some(auth_url) = settings - .orbit_auth_url - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - command.arg("--orbit-auth-url").arg(auth_url); - } - - if let Some(runner_name) = settings - .orbit_runner_name - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - command.arg("--orbit-runner-name").arg(runner_name); - } - - let child = command - .spawn() - .map_err(|err| format!("Failed to start Orbit runner daemon: {err}"))?; - - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Running, - pid: child.id(), - started_at_ms: Some(now_unix_ms()), - last_error: None, - orbit_url: Some(ws_url), - }; - runtime.child = Some(child); - runtime.managed_version = Some(CURRENT_APP_VERSION.to_string()); - if let Some(pid) = runtime.status.pid { - save_orbit_runner_record( - &state, - &OrbitRunnerRecord { - pid, - version: CURRENT_APP_VERSION.to_string(), - orbit_url: runtime.status.orbit_url.clone(), - started_at_ms: runtime.status.started_at_ms, - }, - ) - .await; - } - - Ok(runtime.status.clone()) -} - -#[tauri::command] -pub(crate) async fn orbit_runner_stop( - state: State<'_, AppState>, -) -> Result { - let mut runtime = state.orbit_runner.lock().await; - if let Some(mut child) = runtime.child.take() { - kill_child_process_tree(&mut child).await; - let _ = child.wait().await; - clear_orbit_runner_record(&state).await; - } - - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Stopped, - pid: None, - started_at_ms: None, - last_error: None, - orbit_url: runtime.status.orbit_url.clone(), - }; - runtime.managed_version = None; - - Ok(runtime.status.clone()) -} - -#[tauri::command] -pub(crate) async fn orbit_runner_status( - state: State<'_, AppState>, -) -> Result { - let settings = state.app_settings.lock().await.clone(); - let configured_orbit_url = settings - .orbit_ws_url - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - - let mut runtime = state.orbit_runner.lock().await; - refresh_runner_runtime(&mut runtime).await; - if !matches!(runtime.status.state, OrbitRunnerState::Running) { - if let Some(record) = load_orbit_runner_record(&state).await { - if is_pid_running(record.pid).await { - runtime.status = OrbitRunnerStatus { - state: OrbitRunnerState::Running, - pid: Some(record.pid), - started_at_ms: record.started_at_ms, - last_error: None, - orbit_url: record.orbit_url.clone(), - }; - runtime.managed_version = Some(record.version); - } else { - clear_orbit_runner_record(&state).await; - } - } - } - if runtime.status.orbit_url.is_none() { - runtime.status.orbit_url = configured_orbit_url; - } - - Ok(runtime.status.clone()) -} diff --git a/src-tauri/src/remote_backend/mod.rs b/src-tauri/src/remote_backend/mod.rs index b4434e566..646977716 100644 --- a/src-tauri/src/remote_backend/mod.rs +++ b/src-tauri/src/remote_backend/mod.rs @@ -1,4 +1,3 @@ -mod orbit_ws_transport; mod protocol; mod tcp_transport; mod transport; @@ -13,9 +12,8 @@ use tokio::sync::Mutex; use tokio::time::timeout; use crate::state::AppState; -use crate::types::{BackendMode, RemoteBackendProvider}; +use crate::types::BackendMode; -use self::orbit_ws_transport::OrbitWsTransport; use self::protocol::{build_request_line, DEFAULT_REMOTE_HOST, DISCONNECTED_MESSAGE}; use self::tcp_transport::TcpTransport; use self::transport::{PendingMap, RemoteTransport, RemoteTransportConfig, RemoteTransportKind}; @@ -197,7 +195,6 @@ async fn ensure_remote_backend(state: &AppState, app: AppHandle) -> Result = match transport_config.kind() { RemoteTransportKind::Tcp => Box::new(TcpTransport), - RemoteTransportKind::OrbitWs => Box::new(OrbitWsTransport), }; let connection = transport.connect(app, transport_config).await?; @@ -230,50 +227,33 @@ async fn ensure_remote_backend(state: &AppState, app: AppHandle) -> Result Result { - match settings.remote_backend_provider { - RemoteBackendProvider::Tcp => { - let host = if settings.remote_backend_host.trim().is_empty() { - DEFAULT_REMOTE_HOST.to_string() - } else { - settings.remote_backend_host.clone() - }; - Ok(RemoteTransportConfig::Tcp { - host, - auth_token: settings.remote_backend_token.clone(), - }) - } - RemoteBackendProvider::Orbit => { - let ws_url = settings - .orbit_ws_url - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Orbit provider requires orbitWsUrl in app settings.".to_string())?; - Ok(RemoteTransportConfig::OrbitWs { - ws_url, - auth_token: settings.remote_backend_token.clone(), - }) - } - } + let host = if settings.remote_backend_host.trim().is_empty() { + DEFAULT_REMOTE_HOST.to_string() + } else { + settings.remote_backend_host.clone() + }; + Ok(RemoteTransportConfig::Tcp { + host, + auth_token: settings.remote_backend_token.clone(), + }) } #[cfg(test)] mod tests { use super::{can_retry_after_disconnect, resolve_transport_config}; use crate::remote_backend::transport::RemoteTransportConfig; - use crate::types::{AppSettings, RemoteBackendProvider}; + use crate::types::AppSettings; #[test] - fn resolve_orbit_transport_uses_orbit_ws_url() { + fn resolve_tcp_transport_uses_remote_host() { let mut settings = AppSettings::default(); - settings.remote_backend_provider = RemoteBackendProvider::Orbit; - settings.orbit_ws_url = Some("https://orbit.example/ws/live".to_string()); + settings.remote_backend_host = "tcp.example:4732".to_string(); let config = resolve_transport_config(&settings).expect("transport config"); - let RemoteTransportConfig::OrbitWs { ws_url, .. } = config else { - panic!("expected orbit transport config"); + let RemoteTransportConfig::Tcp { host, .. } = config else { + panic!("expected tcp transport config"); }; - assert_eq!(ws_url, "https://orbit.example/ws/live"); + assert_eq!(host, "tcp.example:4732"); } #[test] diff --git a/src-tauri/src/remote_backend/orbit_ws_transport.rs b/src-tauri/src/remote_backend/orbit_ws_transport.rs deleted file mode 100644 index d1c52b6c0..000000000 --- a/src-tauri/src/remote_backend/orbit_ws_transport.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::sync::atomic::AtomicBool; -use std::sync::Arc; - -use futures_util::{SinkExt, StreamExt}; -use tauri::AppHandle; -use tokio::sync::{mpsc, Mutex}; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; - -use crate::shared::orbit_core; - -use super::transport::{ - dispatch_incoming_line, mark_disconnected, PendingMap, RemoteTransport, RemoteTransportConfig, - TransportConnection, TransportFuture, -}; - -pub(crate) struct OrbitWsTransport; -const OUTBOUND_QUEUE_CAPACITY: usize = 512; - -impl RemoteTransport for OrbitWsTransport { - fn connect(&self, app: AppHandle, config: RemoteTransportConfig) -> TransportFuture { - Box::pin(async move { - let RemoteTransportConfig::OrbitWs { ws_url, auth_token } = config else { - return Err("invalid transport config for orbit websocket transport".to_string()); - }; - - let ws_url = orbit_core::build_orbit_ws_url(&ws_url, auth_token.as_deref())?; - let (stream, _response) = connect_async(&ws_url) - .await - .map_err(|err| format!("Failed to connect to Orbit relay at {ws_url}: {err}"))?; - let (mut writer, mut reader) = stream.split(); - - let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); - let pending = Arc::new(Mutex::new(PendingMap::new())); - let pending_for_writer = Arc::clone(&pending); - let pending_for_reader = Arc::clone(&pending); - - let connected = Arc::new(AtomicBool::new(true)); - let connected_for_writer = Arc::clone(&connected); - let connected_for_reader = Arc::clone(&connected); - - tokio::spawn(async move { - while let Some(message) = out_rx.recv().await { - if writer.send(Message::Text(message.into())).await.is_err() { - mark_disconnected(&pending_for_writer, &connected_for_writer).await; - break; - } - } - }); - - tokio::spawn(async move { - while let Some(frame) = reader.next().await { - match frame { - Ok(Message::Text(text)) => { - dispatch_incoming_payload(&app, &pending_for_reader, text.as_ref()) - .await; - } - Ok(Message::Binary(bytes)) => { - if let Ok(text) = String::from_utf8(bytes.to_vec()) { - dispatch_incoming_payload(&app, &pending_for_reader, &text).await; - } - } - Ok(Message::Close(_)) => break, - Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {} - Ok(Message::Frame(_)) => {} - Err(_) => break, - } - } - - mark_disconnected(&pending_for_reader, &connected_for_reader).await; - }); - - Ok(TransportConnection { - out_tx, - pending, - connected, - }) - }) - } -} - -#[cfg(test)] -mod tests { - use super::protocol_lines; - - #[test] - fn protocol_lines_splits_multiline_payload() { - let payload = "{\"id\":1}\n{\"id\":2}\n"; - let lines: Vec<&str> = protocol_lines(payload).collect(); - assert_eq!(lines, vec!["{\"id\":1}", "{\"id\":2}"]); - } - - #[test] - fn protocol_lines_trims_and_skips_empty_lines() { - let payload = " {\"id\":1} \n\n\t{\"id\":2}\r\n"; - let lines: Vec<&str> = protocol_lines(payload).collect(); - assert_eq!(lines, vec!["{\"id\":1}", "{\"id\":2}"]); - } -} - -async fn dispatch_incoming_payload( - app: &AppHandle, - pending: &Arc>, - payload: &str, -) { - for line in protocol_lines(payload) { - dispatch_incoming_line(app, pending, line).await; - } -} - -fn protocol_lines(payload: &str) -> impl Iterator { - payload - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) -} diff --git a/src-tauri/src/remote_backend/tcp_transport.rs b/src-tauri/src/remote_backend/tcp_transport.rs index 9cc288f90..cbddf4e7b 100644 --- a/src-tauri/src/remote_backend/tcp_transport.rs +++ b/src-tauri/src/remote_backend/tcp_transport.rs @@ -10,9 +10,7 @@ pub(crate) struct TcpTransport; impl RemoteTransport for TcpTransport { fn connect(&self, app: AppHandle, config: RemoteTransportConfig) -> TransportFuture { Box::pin(async move { - let RemoteTransportConfig::Tcp { host, .. } = config else { - return Err("invalid transport config for tcp transport".to_string()); - }; + let RemoteTransportConfig::Tcp { host, .. } = config; let stream = TcpStream::connect(host.clone()) .await diff --git a/src-tauri/src/remote_backend/transport.rs b/src-tauri/src/remote_backend/transport.rs index 7ffbeee80..57e7a4cbf 100644 --- a/src-tauri/src/remote_backend/transport.rs +++ b/src-tauri/src/remote_backend/transport.rs @@ -20,30 +20,23 @@ pub(crate) enum RemoteTransportConfig { host: String, auth_token: Option, }, - OrbitWs { - ws_url: String, - auth_token: Option, - }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub(crate) enum RemoteTransportKind { Tcp, - OrbitWs, } impl RemoteTransportConfig { pub(crate) fn kind(&self) -> RemoteTransportKind { match self { RemoteTransportConfig::Tcp { .. } => RemoteTransportKind::Tcp, - RemoteTransportConfig::OrbitWs { .. } => RemoteTransportKind::OrbitWs, } } pub(crate) fn auth_token(&self) -> Option<&str> { match self { RemoteTransportConfig::Tcp { auth_token, .. } => auth_token.as_deref(), - RemoteTransportConfig::OrbitWs { auth_token, .. } => auth_token.as_deref(), } } } diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 6b1da28a7..0277feb87 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -4,7 +4,7 @@ use crate::shared::settings_core::{ get_app_settings_core, get_codex_config_path_core, update_app_settings_core, }; use crate::state::AppState; -use crate::types::{AppSettings, BackendMode, RemoteBackendProvider}; +use crate::types::{AppSettings, BackendMode}; use crate::window; #[tauri::command] @@ -54,7 +54,6 @@ fn should_reset_remote_backend(previous: &AppSettings, updated: &AppSettings) -> || previous.remote_backend_provider != updated.remote_backend_provider || previous.remote_backend_host != updated.remote_backend_host || previous.remote_backend_token != updated.remote_backend_token - || previous.orbit_ws_url != updated.orbit_ws_url } async fn ensure_remote_runtime_for_settings(settings: &AppSettings, state: State<'_, AppState>) { @@ -65,28 +64,20 @@ async fn ensure_remote_runtime_for_settings(settings: &AppSettings, state: State return; } - match settings.remote_backend_provider { - RemoteBackendProvider::Tcp => { - let _ = crate::tailscale::tailscale_daemon_start(state).await; - } - RemoteBackendProvider::Orbit => { - if settings.orbit_auto_start_runner { - let _ = crate::orbit::orbit_runner_start(state).await; - } - } - } + let _ = crate::tailscale::tailscale_daemon_start(state).await; } #[cfg(test)] mod tests { use super::should_reset_remote_backend; - use crate::types::{AppSettings, BackendMode, RemoteBackendProvider}; + use crate::types::{AppSettings, BackendMode}; #[test] fn should_reset_remote_backend_when_provider_changes() { let previous = AppSettings::default(); let mut updated = previous.clone(); - updated.remote_backend_provider = RemoteBackendProvider::Orbit; + updated.remote_backend_provider = crate::types::RemoteBackendProvider::Tcp; + updated.remote_backend_host = "remote.example:4732".to_string(); assert!(should_reset_remote_backend(&previous, &updated)); } diff --git a/src-tauri/src/shared/mod.rs b/src-tauri/src/shared/mod.rs index 485fb76fd..6f8446313 100644 --- a/src-tauri/src/shared/mod.rs +++ b/src-tauri/src/shared/mod.rs @@ -6,7 +6,6 @@ pub(crate) mod files_core; pub(crate) mod git_core; pub(crate) mod git_ui_core; pub(crate) mod local_usage_core; -pub(crate) mod orbit_core; pub(crate) mod process_core; pub(crate) mod prompts_core; pub(crate) mod settings_core; diff --git a/src-tauri/src/shared/orbit_core.rs b/src-tauri/src/shared/orbit_core.rs deleted file mode 100644 index 6d13cd08e..000000000 --- a/src-tauri/src/shared/orbit_core.rs +++ /dev/null @@ -1,518 +0,0 @@ -use std::fmt::Write as _; -use std::time::{Duration, Instant}; - -use serde_json::{json, Value}; -use tokio_tungstenite::connect_async; - -use crate::types::{ - AppSettings, OrbitConnectTestResult, OrbitDeviceCodeStart, OrbitSignInPollResult, - OrbitSignInStatus, -}; - -const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS: u32 = 5; -const DEFAULT_DEVICE_EXPIRES_SECONDS: u32 = 600; -const MAX_ERROR_BODY_BYTES: usize = 400; - -fn reqwest_client() -> Result { - reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .map_err(|err| format!("Failed to create HTTP client: {err}")) -} - -fn normalize_auth_base_url(auth_url: &str) -> Result { - let trimmed = auth_url.trim(); - if trimmed.is_empty() { - return Err("Orbit auth URL is required.".to_string()); - } - if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { - return Err("orbitAuthUrl must start with https:// or http://".to_string()); - } - Ok(trimmed.trim_end_matches('/').to_string()) -} - -fn auth_endpoint(base_url: &str, path: &str) -> String { - let suffix = path.trim_start_matches('/'); - format!("{base_url}/{suffix}") -} - -fn value_string<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> { - for key in keys { - if let Some(found) = value.get(*key).and_then(Value::as_str) { - if !found.trim().is_empty() { - return Some(found); - } - } - } - None -} - -fn value_u32(value: &Value, keys: &[&str]) -> Option { - for key in keys { - if let Some(found) = value.get(*key).and_then(Value::as_u64) { - if found > 0 && found <= u32::MAX as u64 { - return Some(found as u32); - } - } - } - None -} - -fn response_body_excerpt(text: &str) -> String { - let trimmed = text.trim(); - if trimmed.len() <= MAX_ERROR_BODY_BYTES { - return trimmed.to_string(); - } - let mut boundary = MAX_ERROR_BODY_BYTES; - while boundary > 0 && !trimmed.is_char_boundary(boundary) { - boundary -= 1; - } - let mut output = trimmed[..boundary].to_string(); - output.push_str("..."); - output -} - -fn encode_query_component(value: &str) -> String { - let mut output = String::with_capacity(value.len()); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { - output.push(char::from(byte)); - } else { - let _ = write!(&mut output, "%{byte:02X}"); - } - } - output -} - -fn append_query(url: &str, key: &str, value: &str) -> String { - let (base, fragment) = match url.split_once('#') { - Some((before_fragment, after_fragment)) => (before_fragment, Some(after_fragment)), - None => (url, None), - }; - - let has_key = base - .split_once('?') - .map(|(_, query)| { - query - .split('&') - .filter(|entry| !entry.is_empty()) - .any(|entry| { - entry - .split('=') - .next() - .map(|candidate| candidate == key) - .unwrap_or(false) - }) - }) - .unwrap_or(false); - if has_key { - return url.to_string(); - } - let separator = if base.contains('?') { '&' } else { '?' }; - let encoded_key = encode_query_component(key); - let encoded_value = encode_query_component(value); - let mut output = format!("{base}{separator}{encoded_key}={encoded_value}"); - if let Some(fragment) = fragment { - output.push('#'); - output.push_str(fragment); - } - output -} - -pub(crate) fn orbit_auth_url_from_settings(settings: &AppSettings) -> Result { - settings - .orbit_auth_url - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Orbit auth URL is required in settings (orbitAuthUrl).".to_string()) -} - -pub(crate) fn orbit_ws_url_from_settings(settings: &AppSettings) -> Result { - settings - .orbit_ws_url - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Orbit WS URL is required in settings (orbitWsUrl).".to_string()) -} - -pub(crate) fn orbit_auth_url_optional(settings: &AppSettings) -> Option { - settings - .orbit_auth_url - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -pub(crate) fn remote_backend_token_optional(settings: &AppSettings) -> Option { - settings - .remote_backend_token - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -pub(crate) fn remote_backend_token_for_id_optional( - settings: &AppSettings, - remote_backend_id: Option<&str>, -) -> Option { - let normalized_remote_backend_id = remote_backend_id - .map(str::trim) - .filter(|value| !value.is_empty()); - - if let Some(remote_backend_id) = normalized_remote_backend_id { - if let Some(entry) = settings - .remote_backends - .iter() - .find(|entry| entry.id == remote_backend_id) - { - return entry - .token - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - } - } - - remote_backend_token_optional(settings) -} - -pub(crate) fn build_orbit_ws_url(ws_url: &str, auth_token: Option<&str>) -> Result { - let raw_url = ws_url.trim(); - if raw_url.is_empty() { - return Err("Orbit provider requires orbitWsUrl in app settings.".to_string()); - } - - let normalized = if let Some(rest) = raw_url.strip_prefix("https://") { - format!("wss://{rest}") - } else if let Some(rest) = raw_url.strip_prefix("http://") { - format!("ws://{rest}") - } else if raw_url.starts_with("wss://") || raw_url.starts_with("ws://") { - raw_url.to_string() - } else { - return Err("orbitWsUrl must start with https://, http://, wss://, or ws://".to_string()); - }; - - let Some(token) = auth_token.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(normalized); - }; - - Ok(append_query(&normalized, "token", token)) -} - -pub(crate) async fn orbit_connect_test_core( - ws_url: &str, - auth_token: Option<&str>, -) -> Result { - let ws_url = build_orbit_ws_url(ws_url, auth_token)?; - let started = Instant::now(); - - let _socket = connect_async(&ws_url) - .await - .map_err(|err| format!("Failed to connect to Orbit relay: {err}"))?; - - Ok(OrbitConnectTestResult { - ok: true, - latency_ms: Some(started.elapsed().as_millis() as u64), - message: "Connected to Orbit relay.".to_string(), - details: Some(ws_url), - }) -} - -pub(crate) async fn orbit_sign_in_start_core( - auth_url: &str, - runner_name: Option<&str>, -) -> Result { - let auth_base = normalize_auth_base_url(auth_url)?; - let endpoint = auth_endpoint(&auth_base, "/auth/device/code"); - - let client = reqwest_client()?; - let requested_name = runner_name - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("CodexMonitor"); - - let response = client - .post(&endpoint) - .json(&json!({ - "client": "codex-monitor", - "deviceName": requested_name, - })) - .send() - .await - .map_err(|err| format!("Failed to start Orbit device sign-in: {err}"))?; - - let status = response.status(); - let body_text = response - .text() - .await - .map_err(|err| format!("Failed to read Orbit auth response: {err}"))?; - - if !status.is_success() { - let excerpt = response_body_excerpt(&body_text); - return Err(format!( - "Orbit auth sign-in start failed ({}): {}", - status.as_u16(), - excerpt - )); - } - - let payload: Value = serde_json::from_str(&body_text) - .map_err(|err| format!("Invalid Orbit auth response JSON: {err}"))?; - - let device_code = value_string(&payload, &["deviceCode", "device_code"]) - .ok_or_else(|| "Orbit auth response missing `deviceCode`.".to_string())? - .to_string(); - let verification_uri = value_string( - &payload, - &[ - "verificationUri", - "verification_uri", - "verificationUrl", - "verification_url", - ], - ) - .ok_or_else(|| "Orbit auth response missing `verificationUri`.".to_string())? - .to_string(); - - Ok(OrbitDeviceCodeStart { - device_code, - user_code: value_string(&payload, &["userCode", "user_code"]).map(str::to_string), - verification_uri, - verification_uri_complete: value_string( - &payload, - &["verificationUriComplete", "verification_uri_complete"], - ) - .map(str::to_string), - interval_seconds: value_u32(&payload, &["interval", "pollInterval", "poll_interval"]) - .unwrap_or(DEFAULT_DEVICE_POLL_INTERVAL_SECONDS), - expires_in_seconds: value_u32(&payload, &["expiresIn", "expires_in"]) - .unwrap_or(DEFAULT_DEVICE_EXPIRES_SECONDS), - }) -} - -pub(crate) async fn orbit_sign_in_poll_core( - auth_url: &str, - device_code: &str, -) -> Result { - let auth_base = normalize_auth_base_url(auth_url)?; - let endpoint = auth_endpoint(&auth_base, "/auth/device/token"); - - let trimmed_device_code = device_code.trim(); - if trimmed_device_code.is_empty() { - return Err("Device code is required.".to_string()); - } - - let client = reqwest_client()?; - let response = client - .post(&endpoint) - .json(&json!({ - "deviceCode": trimmed_device_code, - "device_code": trimmed_device_code, - })) - .send() - .await - .map_err(|err| format!("Failed to poll Orbit auth token: {err}"))?; - - let status = response.status(); - let body_text = response - .text() - .await - .map_err(|err| format!("Failed to read Orbit auth poll response: {err}"))?; - - let payload: Value = serde_json::from_str(&body_text).unwrap_or_else(|_| json!({})); - - if let Some(token) = value_string(&payload, &["token", "accessToken", "access_token", "jwt"]) { - return Ok(OrbitSignInPollResult { - status: OrbitSignInStatus::Authorized, - token: Some(token.to_string()), - message: Some("Orbit sign-in complete.".to_string()), - interval_seconds: value_u32(&payload, &["interval", "pollInterval", "poll_interval"]), - }); - } - - let status_label = value_string(&payload, &["status", "state"]) - .unwrap_or_default() - .to_ascii_lowercase(); - - if status == reqwest::StatusCode::ACCEPTED - || status == reqwest::StatusCode::TOO_EARLY - || status_label == "pending" - || status_label == "authorization_pending" - { - return Ok(OrbitSignInPollResult { - status: OrbitSignInStatus::Pending, - token: None, - message: Some("Waiting for Orbit device authorization.".to_string()), - interval_seconds: value_u32(&payload, &["interval", "pollInterval", "poll_interval"]) - .or(Some(DEFAULT_DEVICE_POLL_INTERVAL_SECONDS)), - }); - } - - if status == reqwest::StatusCode::GONE - || status_label == "expired" - || status_label == "expired_token" - { - return Ok(OrbitSignInPollResult { - status: OrbitSignInStatus::Expired, - token: None, - message: Some("Orbit device code expired.".to_string()), - interval_seconds: None, - }); - } - - if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - || status_label == "denied" - || status_label == "access_denied" - { - return Ok(OrbitSignInPollResult { - status: OrbitSignInStatus::Denied, - token: None, - message: Some("Orbit device authorization denied.".to_string()), - interval_seconds: None, - }); - } - - let excerpt = response_body_excerpt(&body_text); - Ok(OrbitSignInPollResult { - status: OrbitSignInStatus::Error, - token: None, - message: Some(format!( - "Orbit token polling failed ({}): {}", - status.as_u16(), - excerpt - )), - interval_seconds: None, - }) -} - -pub(crate) async fn orbit_sign_out_core(auth_url: &str, token: &str) -> Result<(), String> { - let auth_base = normalize_auth_base_url(auth_url)?; - let endpoint = auth_endpoint(&auth_base, "/auth/logout"); - - let token = token.trim(); - if token.is_empty() { - return Ok(()); - } - - let client = reqwest_client()?; - let response = client - .post(&endpoint) - .bearer_auth(token) - .send() - .await - .map_err(|err| format!("Failed to call Orbit auth logout: {err}"))?; - - if response.status().is_success() { - return Ok(()); - } - - let status = response.status(); - let body = response - .text() - .await - .map_err(|err| format!("Failed to read Orbit logout response: {err}"))?; - let excerpt = response_body_excerpt(&body); - - Err(format!( - "Orbit sign-out failed ({}): {}", - status.as_u16(), - excerpt - )) -} - -#[cfg(test)] -mod tests { - use super::{ - build_orbit_ws_url, remote_backend_token_for_id_optional, response_body_excerpt, - MAX_ERROR_BODY_BYTES, - }; - use crate::types::{AppSettings, RemoteBackendProvider, RemoteBackendTarget}; - - #[test] - fn build_orbit_ws_url_converts_http_scheme() { - let value = build_orbit_ws_url("https://example.com/ws/client", None).expect("ws url"); - assert_eq!(value, "wss://example.com/ws/client"); - } - - #[test] - fn build_orbit_ws_url_appends_token_query() { - let value = build_orbit_ws_url("wss://example.com/ws/client", Some("abc")).expect("ws url"); - assert_eq!(value, "wss://example.com/ws/client?token=abc"); - } - - #[test] - fn build_orbit_ws_url_appends_token_when_id_token_present() { - let value = build_orbit_ws_url("wss://example.com/ws/client?id_token=abc", Some("def")) - .expect("ws url"); - assert_eq!(value, "wss://example.com/ws/client?id_token=abc&token=def"); - } - - #[test] - fn build_orbit_ws_url_does_not_append_duplicate_token_query() { - let value = build_orbit_ws_url( - "wss://example.com/ws/client?token=abc&id_token=def", - Some("xyz"), - ) - .expect("ws url"); - assert_eq!(value, "wss://example.com/ws/client?token=abc&id_token=def"); - } - - #[test] - fn build_orbit_ws_url_appends_token_before_fragment() { - let value = - build_orbit_ws_url("wss://example.com/ws/client#frag", Some("abc")).expect("ws url"); - assert_eq!(value, "wss://example.com/ws/client?token=abc#frag"); - } - - #[test] - fn build_orbit_ws_url_encodes_query_token() { - let value = - build_orbit_ws_url("wss://example.com/ws/client", Some("a&b#c+d%e")).expect("ws url"); - assert_eq!(value, "wss://example.com/ws/client?token=a%26b%23c%2Bd%25e"); - } - - #[test] - fn response_body_excerpt_preserves_utf8_boundaries() { - let text = format!("{}étail", "a".repeat(MAX_ERROR_BODY_BYTES - 1)); - let excerpt = response_body_excerpt(&text); - assert_eq!( - excerpt, - format!("{}...", "a".repeat(MAX_ERROR_BODY_BYTES - 1)) - ); - } - - #[test] - fn remote_backend_token_for_id_falls_back_to_legacy_token_when_remote_missing() { - let settings = AppSettings { - remote_backend_token: Some("legacy-token".to_string()), - remote_backends: Vec::new(), - ..AppSettings::default() - }; - - let token = remote_backend_token_for_id_optional(&settings, Some("remote-a")); - assert_eq!(token.as_deref(), Some("legacy-token")); - } - - #[test] - fn remote_backend_token_for_id_prefers_matching_remote_token() { - let settings = AppSettings { - remote_backend_token: Some("legacy-token".to_string()), - remote_backends: vec![RemoteBackendTarget { - id: "remote-a".to_string(), - name: "Remote A".to_string(), - provider: RemoteBackendProvider::Orbit, - host: "127.0.0.1:4732".to_string(), - token: Some("remote-token".to_string()), - orbit_ws_url: None, - last_connected_at_ms: None, - }], - ..AppSettings::default() - }; - - let token = remote_backend_token_for_id_optional(&settings, Some("remote-a")); - assert_eq!(token.as_deref(), Some("remote-token")); - } -} diff --git a/src-tauri/src/shared/settings_core.rs b/src-tauri/src/shared/settings_core.rs index 625484f30..52cce84c2 100644 --- a/src-tauri/src/shared/settings_core.rs +++ b/src-tauri/src/shared/settings_core.rs @@ -59,73 +59,6 @@ pub(crate) async fn update_app_settings_core( Ok(settings) } -pub(crate) async fn update_remote_backend_token_core( - app_settings: &Mutex, - settings_path: &PathBuf, - token: Option<&str>, - remote_backend_id: Option<&str>, -) -> Result { - let normalized_token = token - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let normalized_remote_backend_id = remote_backend_id - .map(str::trim) - .filter(|value| !value.is_empty()); - let mut next_settings = app_settings.lock().await.clone(); - let mut changed = false; - - if next_settings.remote_backends.is_empty() { - if next_settings.remote_backend_token != normalized_token { - next_settings.remote_backend_token = normalized_token.clone(); - changed = true; - } - } else { - let active_index = next_settings - .active_remote_backend_id - .as_ref() - .and_then(|id| { - next_settings - .remote_backends - .iter() - .position(|entry| &entry.id == id) - }) - .unwrap_or(0); - let active_remote_id = next_settings.remote_backends[active_index].id.clone(); - if next_settings.active_remote_backend_id.as_deref() != Some(active_remote_id.as_str()) { - next_settings.active_remote_backend_id = Some(active_remote_id.clone()); - changed = true; - } - - let target_index = if let Some(target_id) = normalized_remote_backend_id { - next_settings - .remote_backends - .iter() - .position(|entry| entry.id == target_id) - } else { - Some(active_index) - }; - - if let Some(target_index) = target_index { - if next_settings.remote_backends[target_index].token != normalized_token { - next_settings.remote_backends[target_index].token = normalized_token.clone(); - changed = true; - } - - if target_index == active_index - && next_settings.remote_backend_token != normalized_token - { - next_settings.remote_backend_token = normalized_token.clone(); - changed = true; - } - } - } - if !changed { - return Ok(next_settings); - } - update_app_settings_core(next_settings, app_settings, settings_path).await -} - pub(crate) fn get_codex_config_path_core() -> Result { codex_config::config_toml_path() .ok_or_else(|| "Unable to resolve CODEX_HOME".to_string()) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index beee51050..e28cf4ed7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -8,32 +8,7 @@ use tokio::sync::Mutex; use crate::dictation::DictationState; use crate::shared::codex_core::CodexLoginCancelState; use crate::storage::{read_settings, read_workspaces}; -use crate::types::{ - AppSettings, OrbitRunnerState, OrbitRunnerStatus, TcpDaemonState, TcpDaemonStatus, - WorkspaceEntry, -}; - -pub(crate) struct OrbitRunnerRuntime { - pub(crate) child: Option, - pub(crate) status: OrbitRunnerStatus, - pub(crate) managed_version: Option, -} - -impl Default for OrbitRunnerRuntime { - fn default() -> Self { - Self { - child: None, - status: OrbitRunnerStatus { - state: OrbitRunnerState::Stopped, - pid: None, - started_at_ms: None, - last_error: None, - orbit_url: None, - }, - managed_version: None, - } - } -} +use crate::types::{AppSettings, TcpDaemonState, TcpDaemonStatus, WorkspaceEntry}; pub(crate) struct TcpDaemonRuntime { pub(crate) child: Option, @@ -65,7 +40,6 @@ pub(crate) struct AppState { pub(crate) app_settings: Mutex, pub(crate) dictation: Mutex, pub(crate) codex_login_cancels: Mutex>, - pub(crate) orbit_runner: Mutex, pub(crate) tcp_daemon: Mutex, } @@ -89,7 +63,6 @@ impl AppState { app_settings: Mutex::new(app_settings), dictation: Mutex::new(DictationState::default()), codex_login_cancels: Mutex::new(HashMap::new()), - orbit_runner: Mutex::new(OrbitRunnerRuntime::default()), tcp_daemon: Mutex::new(TcpDaemonRuntime::default()), } } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 2a63aa54e..78192481f 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::types::{AppSettings, WorkspaceEntry}; +use serde_json::Value; pub(crate) fn read_workspaces(path: &PathBuf) -> Result, String> { if !path.exists() { @@ -28,7 +29,14 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result { return Ok(AppSettings::default()); } let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - serde_json::from_str(&data).map_err(|e| e.to_string()) + match serde_json::from_str(&data) { + Ok(settings) => Ok(settings), + Err(_) => { + let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + sanitize_remote_settings_for_tcp_only(&mut value); + serde_json::from_value(value).map_err(|e| e.to_string()) + } + } } pub(crate) fn write_settings(path: &PathBuf, settings: &AppSettings) -> Result<(), String> { @@ -39,9 +47,34 @@ pub(crate) fn write_settings(path: &PathBuf, settings: &AppSettings) -> Result<( std::fs::write(path, data).map_err(|e| e.to_string()) } +fn sanitize_remote_settings_for_tcp_only(value: &mut Value) { + let Value::Object(root) = value else { + return; + }; + root.insert( + "remoteBackendProvider".to_string(), + Value::String("tcp".to_string()), + ); + if let Some(Value::Array(remote_backends)) = root.get_mut("remoteBackends") { + for entry in remote_backends { + let Value::Object(entry_obj) = entry else { + continue; + }; + entry_obj.insert("provider".to_string(), Value::String("tcp".to_string())); + entry_obj.retain(|key, _| { + matches!( + key.as_str(), + "id" | "name" | "provider" | "host" | "token" | "lastConnectedAtMs" + ) + }); + } + } + root.retain(|key, _| !key.to_ascii_lowercase().starts_with("orb")); +} + #[cfg(test)] mod tests { - use super::{read_workspaces, write_workspaces}; + use super::{read_settings, read_workspaces, write_workspaces}; use crate::types::{WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; use uuid::Uuid; @@ -81,4 +114,44 @@ mod tests { Some("--profile personal") ); } + + #[test] + fn read_settings_sanitizes_non_tcp_remote_provider() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("settings.json"); + + std::fs::write( + &path, + r#"{ + "remoteBackendProvider": "legacy-provider", + "remoteBackendHost": "example:4732", + "remoteBackendToken": "token-1", + "remoteBackends": [ + { + "id": "remote-a", + "name": "Remote A", + "provider": "legacy-provider", + "host": "example:4732", + "token": "token-1", + "legacyWsUrl": "wss://example/ws" + } + ], + "theme": "dark" +}"#, + ) + .expect("write settings"); + + let settings = read_settings(&path).expect("read settings"); + assert!(matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Tcp + )); + assert_eq!(settings.remote_backends.len(), 1); + assert!(matches!( + settings.remote_backends[0].provider, + crate::types::RemoteBackendProvider::Tcp + )); + assert_eq!(settings.theme, "dark"); + } } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 0ebb1961f..e215efe23 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -187,81 +187,6 @@ pub(crate) struct LocalUsageSnapshot { pub(crate) top_models: Vec, } -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OrbitConnectTestResult { - pub(crate) ok: bool, - pub(crate) latency_ms: Option, - pub(crate) message: String, - #[serde(default)] - pub(crate) details: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OrbitDeviceCodeStart { - pub(crate) device_code: String, - #[serde(default)] - pub(crate) user_code: Option, - pub(crate) verification_uri: String, - #[serde(default)] - pub(crate) verification_uri_complete: Option, - pub(crate) interval_seconds: u32, - pub(crate) expires_in_seconds: u32, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum OrbitSignInStatus { - Pending, - Authorized, - Denied, - Expired, - Error, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OrbitSignInPollResult { - pub(crate) status: OrbitSignInStatus, - #[serde(default)] - pub(crate) token: Option, - #[serde(default)] - pub(crate) message: Option, - #[serde(default)] - pub(crate) interval_seconds: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OrbitSignOutResult { - pub(crate) success: bool, - #[serde(default)] - pub(crate) message: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum OrbitRunnerState { - Stopped, - Running, - Error, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub(crate) struct OrbitRunnerStatus { - pub(crate) state: OrbitRunnerState, - #[serde(default)] - pub(crate) pid: Option, - #[serde(default)] - pub(crate) started_at_ms: Option, - #[serde(default)] - pub(crate) last_error: Option, - #[serde(default)] - pub(crate) orbit_url: Option, -} - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub(crate) enum TcpDaemonState { @@ -448,8 +373,6 @@ pub(crate) struct RemoteBackendTarget { pub(crate) host: String, #[serde(default)] pub(crate) token: Option, - #[serde(default, rename = "orbitWsUrl")] - pub(crate) orbit_ws_url: Option, #[serde(default, rename = "lastConnectedAtMs")] pub(crate) last_connected_at_ms: Option, } @@ -472,22 +395,8 @@ pub(crate) struct AppSettings { pub(crate) remote_backends: Vec, #[serde(default, rename = "activeRemoteBackendId")] pub(crate) active_remote_backend_id: Option, - #[serde(default, rename = "orbitWsUrl")] - pub(crate) orbit_ws_url: Option, - #[serde(default, rename = "orbitAuthUrl")] - pub(crate) orbit_auth_url: Option, - #[serde(default, rename = "orbitRunnerName")] - pub(crate) orbit_runner_name: Option, - #[serde(default, rename = "orbitAutoStartRunner")] - pub(crate) orbit_auto_start_runner: bool, #[serde(default, rename = "keepDaemonRunningAfterAppClose")] pub(crate) keep_daemon_running_after_app_close: bool, - #[serde(default, rename = "orbitUseAccess")] - pub(crate) orbit_use_access: bool, - #[serde(default, rename = "orbitAccessClientId")] - pub(crate) orbit_access_client_id: Option, - #[serde(default, rename = "orbitAccessClientSecretRef")] - pub(crate) orbit_access_client_secret_ref: Option, #[serde(default = "default_access_mode", rename = "defaultAccessMode")] pub(crate) default_access_mode: String, #[serde( @@ -746,7 +655,6 @@ impl Default for BackendMode { #[serde(rename_all = "lowercase")] pub(crate) enum RemoteBackendProvider { Tcp, - Orbit, } impl Default for RemoteBackendProvider { @@ -1203,14 +1111,7 @@ impl Default for AppSettings { remote_backend_token: None, remote_backends: default_remote_backends(), active_remote_backend_id: None, - orbit_ws_url: None, - orbit_auth_url: None, - orbit_runner_name: None, - orbit_auto_start_runner: false, keep_daemon_running_after_app_close: false, - orbit_use_access: false, - orbit_access_client_id: None, - orbit_access_client_secret_ref: None, default_access_mode: "current".to_string(), review_delivery_mode: default_review_delivery_mode(), composer_model_shortcut: default_composer_model_shortcut(), @@ -1306,14 +1207,7 @@ mod tests { assert!(settings.remote_backend_token.is_none()); assert!(settings.remote_backends.is_empty()); assert!(settings.active_remote_backend_id.is_none()); - assert!(settings.orbit_ws_url.is_none()); - assert!(settings.orbit_auth_url.is_none()); - assert!(settings.orbit_runner_name.is_none()); - assert!(!settings.orbit_auto_start_runner); assert!(!settings.keep_daemon_running_after_app_close); - assert!(!settings.orbit_use_access); - assert!(settings.orbit_access_client_id.is_none()); - assert!(settings.orbit_access_client_secret_ref.is_none()); assert_eq!(settings.default_access_mode, "current"); assert_eq!(settings.review_delivery_mode, "inline"); let expected_primary = if cfg!(target_os = "macos") { diff --git a/src/features/mobile/components/MobileServerSetupWizard.tsx b/src/features/mobile/components/MobileServerSetupWizard.tsx index 0eaee5671..d3e017c82 100644 --- a/src/features/mobile/components/MobileServerSetupWizard.tsx +++ b/src/features/mobile/components/MobileServerSetupWizard.tsx @@ -1,35 +1,26 @@ import "../../../styles/mobile-setup-wizard.css"; import { ModalShell } from "../../design-system/components/modal/ModalShell"; -import type { AppSettings } from "../../../types"; export type MobileServerSetupWizardProps = { - provider: AppSettings["remoteBackendProvider"]; remoteHostDraft: string; - orbitWsUrlDraft: string; remoteTokenDraft: string; busy: boolean; checking: boolean; statusMessage: string | null; statusError: boolean; - onProviderChange: (provider: AppSettings["remoteBackendProvider"]) => void; onRemoteHostChange: (value: string) => void; - onOrbitWsUrlChange: (value: string) => void; onRemoteTokenChange: (value: string) => void; onConnectTest: () => void; }; export function MobileServerSetupWizard({ - provider, remoteHostDraft, - orbitWsUrlDraft, remoteTokenDraft, busy, checking, statusMessage, statusError, - onProviderChange, onRemoteHostChange, - onOrbitWsUrlChange, onRemoteTokenChange, onConnectTest, }: MobileServerSetupWizardProps) { @@ -49,53 +40,17 @@ export function MobileServerSetupWizard({
-
diff --git a/src/features/mobile/hooks/useMobileServerSetup.ts b/src/features/mobile/hooks/useMobileServerSetup.ts index e1aa947bf..e72541fc7 100644 --- a/src/features/mobile/hooks/useMobileServerSetup.ts +++ b/src/features/mobile/hooks/useMobileServerSetup.ts @@ -19,20 +19,10 @@ type UseMobileServerSetupResult = { }; function isRemoteServerConfigured(settings: AppSettings): boolean { - const tokenConfigured = Boolean(settings.remoteBackendToken?.trim()); - if (!tokenConfigured) { - return false; - } - if (settings.remoteBackendProvider === "orbit") { - return Boolean(settings.orbitWsUrl?.trim()); - } - return Boolean(settings.remoteBackendHost.trim()); + return Boolean(settings.remoteBackendToken?.trim()) && Boolean(settings.remoteBackendHost.trim()); } -function defaultMobileSetupMessage(provider: AppSettings["remoteBackendProvider"]): string { - if (provider === "orbit") { - return "Enter your Orbit websocket URL and token, then run Connect & test."; - } +function defaultMobileSetupMessage(): string { return "Enter your desktop Tailscale host and token, then run Connect & test."; } @@ -44,11 +34,7 @@ export function useMobileServerSetup({ }: UseMobileServerSetupParams): UseMobileServerSetupResult { const isMobileRuntime = useMemo(() => isMobilePlatform(), []); - const [providerDraft, setProviderDraft] = useState( - appSettings.remoteBackendProvider, - ); const [remoteHostDraft, setRemoteHostDraft] = useState(appSettings.remoteBackendHost); - const [orbitWsUrlDraft, setOrbitWsUrlDraft] = useState(appSettings.orbitWsUrl ?? ""); const [remoteTokenDraft, setRemoteTokenDraft] = useState(appSettings.remoteBackendToken ?? ""); const [busy, setBusy] = useState(false); const [checking, setChecking] = useState(false); @@ -60,14 +46,10 @@ export function useMobileServerSetup({ if (!isMobileRuntime) { return; } - setProviderDraft(appSettings.remoteBackendProvider); setRemoteHostDraft(appSettings.remoteBackendHost); - setOrbitWsUrlDraft(appSettings.orbitWsUrl ?? ""); setRemoteTokenDraft(appSettings.remoteBackendToken ?? ""); }, [ - appSettings.orbitWsUrl, appSettings.remoteBackendHost, - appSettings.remoteBackendProvider, appSettings.remoteBackendToken, isMobileRuntime, ]); @@ -112,16 +94,13 @@ export function useMobileServerSetup({ return; } - const nextProvider = providerDraft; const nextHost = remoteHostDraft.trim(); const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; - const nextOrbitWsUrl = orbitWsUrlDraft.trim() ? orbitWsUrlDraft.trim() : null; - const missingEndpoint = nextProvider === "orbit" ? !nextOrbitWsUrl : !nextHost.trim(); - if (missingEndpoint || !nextToken) { + if (!nextHost || !nextToken) { setMobileServerReady(false); setStatusError(true); - setStatusMessage(defaultMobileSetupMessage(nextProvider)); + setStatusMessage(defaultMobileSetupMessage()); return; } @@ -132,10 +111,9 @@ export function useMobileServerSetup({ await queueSaveSettings({ ...appSettings, backendMode: "remote", - remoteBackendProvider: nextProvider, + remoteBackendProvider: "tcp", remoteBackendHost: nextHost, remoteBackendToken: nextToken, - orbitWsUrl: nextOrbitWsUrl, }); await runConnectivityCheck({ announceSuccess: true }); } catch (error) { @@ -152,8 +130,6 @@ export function useMobileServerSetup({ appSettings, busy, isMobileRuntime, - orbitWsUrlDraft, - providerDraft, queueSaveSettings, remoteHostDraft, remoteTokenDraft, @@ -168,7 +144,7 @@ export function useMobileServerSetup({ setMobileServerReady(false); setChecking(false); setStatusError(true); - setStatusMessage(defaultMobileSetupMessage(appSettings.remoteBackendProvider)); + setStatusMessage(defaultMobileSetupMessage()); return; } @@ -214,17 +190,13 @@ export function useMobileServerSetup({ isMobileRuntime, showMobileSetupWizard: isMobileRuntime && !appSettingsLoading && !mobileServerReady, mobileSetupWizardProps: { - provider: providerDraft, remoteHostDraft, - orbitWsUrlDraft, remoteTokenDraft, busy, checking, statusMessage, statusError, - onProviderChange: setProviderDraft, onRemoteHostChange: setRemoteHostDraft, - onOrbitWsUrlChange: setOrbitWsUrlDraft, onRemoteTokenChange: setRemoteTokenDraft, onConnectTest, }, diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 72ebf6d78..b064bb635 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -48,18 +48,10 @@ const baseSettings: AppSettings = { provider: "tcp", host: "127.0.0.1:4732", token: null, - orbitWsUrl: null, }, ], activeRemoteBackendId: "remote-default", - orbitWsUrl: null, - orbitAuthUrl: null, - orbitRunnerName: null, - orbitAutoStartRunner: false, keepDaemonRunningAfterAppClose: false, - orbitUseAccess: false, - orbitAccessClientId: null, - orbitAccessClientSecretRef: null, defaultAccessMode: "current", reviewDeliveryMode: "inline", composerModelShortcut: null, @@ -789,60 +781,6 @@ describe("SettingsView Codex overrides", () => { }); }); - it("renders Orbit controls for Orbit provider even in local backend mode", async () => { - cleanup(); - render( - , - ); - - await waitFor(() => { - expect(screen.getByLabelText("Orbit websocket URL")).toBeTruthy(); - expect(screen.getByLabelText("Orbit auth URL")).toBeTruthy(); - expect(screen.getByLabelText("Orbit runner name")).toBeTruthy(); - expect(screen.getByLabelText("Orbit access client ID")).toBeTruthy(); - expect(screen.getByLabelText("Orbit access client secret ref")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Connect test" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Sign In" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Sign Out" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Start Runner" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Stop Runner" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Refresh Status" })).toBeTruthy(); - }); - }); - it("renders mobile daemon controls in local backend mode for TCP provider", async () => { cleanup(); render( @@ -939,7 +877,7 @@ describe("SettingsView Codex overrides", () => { appSettings={{ ...baseSettings, backendMode: "local", - remoteBackendProvider: "orbit", + remoteBackendProvider: "tcp", }} openAppIconById={{}} onUpdateAppSettings={vi.fn().mockResolvedValue(undefined)} @@ -959,8 +897,7 @@ describe("SettingsView Codex overrides", () => { ); await waitFor(() => { - expect(screen.getByLabelText("Connection type")).toBeTruthy(); - expect(screen.getByLabelText("Orbit websocket URL")).toBeTruthy(); + expect(screen.getByLabelText("Remote backend host")).toBeTruthy(); expect(screen.getByLabelText("Remote backend token")).toBeTruthy(); expect(screen.getByRole("button", { name: "Connect & test" })).toBeTruthy(); }); @@ -968,11 +905,9 @@ describe("SettingsView Codex overrides", () => { expect(screen.queryByLabelText("Backend mode")).toBeNull(); expect(screen.queryByRole("button", { name: "Start daemon" })).toBeNull(); expect(screen.queryByRole("button", { name: "Detect Tailscale" })).toBeNull(); - expect(screen.queryByRole("button", { name: "Connect test" })).toBeNull(); - expect(screen.queryByLabelText("Remote backend host")).toBeNull(); - expect(screen.queryByRole("button", { name: "Sign In" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Start Runner" })).toBeNull(); expect( - screen.getByText(/use the orbit websocket url and token configured/i), + screen.getByText(/get the tailscale hostname and token from your desktop/i), ).toBeTruthy(); } finally { if (originalPlatformDescriptor) { @@ -1045,18 +980,16 @@ describe("SettingsView Codex overrides", () => { onToggleTransparency={vi.fn()} appSettings={{ ...baseSettings, - remoteBackendProvider: "orbit", + remoteBackendProvider: "tcp", remoteBackendHost: "127.0.0.1:4732", remoteBackendToken: "token-a", - orbitWsUrl: "wss://orbit-a.example/ws", remoteBackends: [ { id: "remote-a", name: "Home Mac", - provider: "orbit", + provider: "tcp", host: "127.0.0.1:4732", token: "token-a", - orbitWsUrl: "wss://orbit-a.example/ws", }, { id: "remote-b", @@ -1064,7 +997,6 @@ describe("SettingsView Codex overrides", () => { provider: "tcp", host: "office-mac.tailnet.ts.net:4732", token: "token-b", - orbitWsUrl: null, }, ], activeRemoteBackendId: "remote-a", @@ -1168,348 +1100,6 @@ describe("SettingsView Codex overrides", () => { } }); - it("polls Orbit sign-in using deviceCode until authorized", async () => { - cleanup(); - const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); - const startSpy = vi.fn().mockResolvedValueOnce({ - deviceCode: "device-code-123", - userCode: "ABCD-1234", - verificationUri: "https://orbit.example/verify", - verificationUriComplete: null, - intervalSeconds: 1, - expiresInSeconds: 30, - }); - const pollSpy = vi - .fn() - .mockResolvedValueOnce({ - status: "pending", - token: null, - message: "Waiting for authorization.", - intervalSeconds: 1, - }) - .mockResolvedValueOnce({ - status: "authorized", - token: "orbit-token-1", - message: "Orbit sign in complete.", - intervalSeconds: null, - }); - const orbitServiceClient: NonNullable< - ComponentProps["orbitServiceClient"] - > = { - orbitConnectTest: vi.fn().mockResolvedValue({ - ok: true, - latencyMs: 12, - message: "Connected to Orbit relay.", - }), - orbitSignInStart: startSpy, - orbitSignInPoll: pollSpy, - orbitSignOut: vi.fn().mockResolvedValue({ success: true, message: null }), - orbitRunnerStart: vi.fn().mockResolvedValue({ - state: "running", - pid: 123, - startedAtMs: Date.now(), - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - orbitRunnerStop: vi.fn().mockResolvedValue({ - state: "stopped", - pid: null, - startedAtMs: null, - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - orbitRunnerStatus: vi.fn().mockResolvedValue({ - state: "stopped", - pid: null, - startedAtMs: null, - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - }; - const rendered = render( - , - ); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Sign In" })); - }); - await waitFor(() => { - expect(pollSpy).toHaveBeenCalledTimes(1); - }, { timeout: 2500 }); - - rendered.rerender( - , - ); - - await waitFor(() => { - expect(startSpy).toHaveBeenCalledTimes(1); - expect(pollSpy).toHaveBeenCalledTimes(2); - expect(pollSpy).toHaveBeenCalledWith("device-code-123", "remote-default"); - expect(onUpdateAppSettings).toHaveBeenCalledWith( - expect.objectContaining({ remoteBackendToken: "orbit-token-1", theme: "dark" }), - ); - expect(screen.getByText(/Auth code:/).textContent ?? "").toContain("ABCD-1234"); - expect(screen.getByText("Orbit sign in complete.")).toBeTruthy(); - }, { timeout: 3500 }); - }); - - it("syncs token state after Orbit sign-out", async () => { - cleanup(); - const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); - const signOutSpy = vi.fn().mockResolvedValue({ success: true, message: null }); - const orbitServiceClient: NonNullable< - ComponentProps["orbitServiceClient"] - > = { - orbitConnectTest: vi.fn().mockResolvedValue({ - ok: true, - latencyMs: 12, - message: "Connected to Orbit relay.", - }), - orbitSignInStart: vi.fn(), - orbitSignInPoll: vi.fn(), - orbitSignOut: signOutSpy, - orbitRunnerStart: vi.fn().mockResolvedValue({ - state: "running", - pid: 123, - startedAtMs: Date.now(), - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - orbitRunnerStop: vi.fn().mockResolvedValue({ - state: "stopped", - pid: null, - startedAtMs: null, - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - orbitRunnerStatus: vi.fn().mockResolvedValue({ - state: "stopped", - pid: null, - startedAtMs: null, - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - }; - - render( - , - ); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Sign Out" })); - }); - - await waitFor(() => { - expect(signOutSpy).toHaveBeenCalledWith("remote-default"); - expect(onUpdateAppSettings).toHaveBeenCalledWith( - expect.objectContaining({ remoteBackendToken: null }), - ); - }); - }); - - it("retries Orbit token persistence after a failed save", async () => { - cleanup(); - const onUpdateAppSettings = vi - .fn() - .mockRejectedValueOnce(new Error("settings write failed")) - .mockResolvedValue(undefined); - const orbitServiceClient: NonNullable< - ComponentProps["orbitServiceClient"] - > = { - orbitConnectTest: vi.fn().mockResolvedValue({ - ok: true, - latencyMs: 12, - message: "Connected to Orbit relay.", - }), - orbitSignInStart: vi.fn(), - orbitSignInPoll: vi.fn(), - orbitSignOut: vi.fn().mockResolvedValue({ success: true, message: null }), - orbitRunnerStart: vi.fn().mockResolvedValue({ - state: "running", - pid: 123, - startedAtMs: Date.now(), - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - orbitRunnerStop: vi.fn().mockResolvedValue({ - state: "stopped", - pid: null, - startedAtMs: null, - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - orbitRunnerStatus: vi.fn().mockResolvedValue({ - state: "stopped", - pid: null, - startedAtMs: null, - lastError: null, - orbitUrl: "wss://orbit.example/ws", - }), - }; - - render( - , - ); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Sign Out" })); - }); - - await waitFor(() => { - expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); - expect(screen.getByText("Sign Out failed: settings write failed")).toBeTruthy(); - }); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Sign Out" })); - }); - - await waitFor(() => { - expect(onUpdateAppSettings).toHaveBeenCalledTimes(2); - expect(onUpdateAppSettings).toHaveBeenLastCalledWith( - expect.objectContaining({ remoteBackendToken: null }), - ); - }); - }); }); describe("SettingsView Codex defaults", () => { diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index d6ca73b72..2a387b5ce 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -14,8 +14,8 @@ import { useSettingsViewNavigation } from "@settings/hooks/useSettingsViewNaviga import { useSettingsViewOrchestration } from "@settings/hooks/useSettingsViewOrchestration"; import { ModalShell } from "@/features/design-system/components/modal/ModalShell"; import { SettingsNav } from "./SettingsNav"; -import type { CodexSection, OrbitServiceClient } from "./settingsTypes"; -import { ORBIT_SERVICES, SETTINGS_SECTION_LABELS } from "./settingsViewConstants"; +import type { CodexSection } from "./settingsTypes"; +import { SETTINGS_SECTION_LABELS } from "./settingsViewConstants"; import { SettingsSectionContainers } from "./sections/SettingsSectionContainers"; export type SettingsViewProps = { @@ -65,7 +65,6 @@ export type SettingsViewProps = { onCancelDictationDownload?: () => void; onRemoveDictationModel?: () => void; initialSection?: CodexSection; - orbitServiceClient?: OrbitServiceClient; }; export function SettingsView({ @@ -99,7 +98,6 @@ export function SettingsView({ onCancelDictationDownload, onRemoveDictationModel, initialSection, - orbitServiceClient = ORBIT_SERVICES, }: SettingsViewProps) { const { activeSection, @@ -138,7 +136,6 @@ export function SettingsView({ onDownloadDictationModel, onCancelDictationDownload, onRemoveDictationModel, - orbitServiceClient, }); useSettingsViewCloseShortcuts(onClose); diff --git a/src/features/settings/components/sections/SettingsServerSection.tsx b/src/features/settings/components/sections/SettingsServerSection.tsx index d8a3dfa0f..197b67c27 100644 --- a/src/features/settings/components/sections/SettingsServerSection.tsx +++ b/src/features/settings/components/sections/SettingsServerSection.tsx @@ -24,15 +24,6 @@ type SettingsServerSectionProps = { remoteNameDraft: string; remoteHostDraft: string; remoteTokenDraft: string; - orbitWsUrlDraft: string; - orbitAuthUrlDraft: string; - orbitRunnerNameDraft: string; - orbitAccessClientIdDraft: string; - orbitAccessClientSecretRefDraft: string; - orbitStatusText: string | null; - orbitAuthCode: string | null; - orbitVerificationUrl: string | null; - orbitBusyAction: string | null; tailscaleStatus: TailscaleStatus | null; tailscaleStatusBusy: boolean; tailscaleStatusError: string | null; @@ -44,11 +35,6 @@ type SettingsServerSectionProps = { onSetRemoteNameDraft: Dispatch>; onSetRemoteHostDraft: Dispatch>; onSetRemoteTokenDraft: Dispatch>; - onSetOrbitWsUrlDraft: Dispatch>; - onSetOrbitAuthUrlDraft: Dispatch>; - onSetOrbitRunnerNameDraft: Dispatch>; - onSetOrbitAccessClientIdDraft: Dispatch>; - onSetOrbitAccessClientSecretRefDraft: Dispatch>; onCommitRemoteName: () => Promise; onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; @@ -56,24 +42,12 @@ type SettingsServerSectionProps = { onAddRemoteBackend: () => Promise; onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; onDeleteRemoteBackend: (id: string) => Promise; - onChangeRemoteProvider: (provider: AppSettings["remoteBackendProvider"]) => Promise; onRefreshTailscaleStatus: () => void; onRefreshTailscaleCommandPreview: () => void; onUseSuggestedTailscaleHost: () => Promise; onTcpDaemonStart: () => Promise; onTcpDaemonStop: () => Promise; onTcpDaemonStatus: () => Promise; - onCommitOrbitWsUrl: () => Promise; - onCommitOrbitAuthUrl: () => Promise; - onCommitOrbitRunnerName: () => Promise; - onCommitOrbitAccessClientId: () => Promise; - onCommitOrbitAccessClientSecretRef: () => Promise; - onOrbitConnectTest: () => void; - onOrbitSignIn: () => void; - onOrbitSignOut: () => void; - onOrbitRunnerStart: () => void; - onOrbitRunnerStop: () => void; - onOrbitRunnerStatus: () => void; onMobileConnectTest: () => void; }; @@ -93,15 +67,6 @@ export function SettingsServerSection({ remoteNameDraft, remoteHostDraft, remoteTokenDraft, - orbitWsUrlDraft, - orbitAuthUrlDraft, - orbitRunnerNameDraft, - orbitAccessClientIdDraft, - orbitAccessClientSecretRefDraft, - orbitStatusText, - orbitAuthCode, - orbitVerificationUrl, - orbitBusyAction, tailscaleStatus, tailscaleStatusBusy, tailscaleStatusError, @@ -113,11 +78,6 @@ export function SettingsServerSection({ onSetRemoteNameDraft, onSetRemoteHostDraft, onSetRemoteTokenDraft, - onSetOrbitWsUrlDraft, - onSetOrbitAuthUrlDraft, - onSetOrbitRunnerNameDraft, - onSetOrbitAccessClientIdDraft, - onSetOrbitAccessClientSecretRefDraft, onCommitRemoteName, onCommitRemoteHost, onCommitRemoteToken, @@ -125,24 +85,12 @@ export function SettingsServerSection({ onAddRemoteBackend, onMoveRemoteBackend, onDeleteRemoteBackend, - onChangeRemoteProvider, onRefreshTailscaleStatus, onRefreshTailscaleCommandPreview, onUseSuggestedTailscaleHost, onTcpDaemonStart, onTcpDaemonStop, onTcpDaemonStatus, - onCommitOrbitWsUrl, - onCommitOrbitAuthUrl, - onCommitOrbitRunnerName, - onCommitOrbitAccessClientId, - onCommitOrbitAccessClientSecretRef, - onOrbitConnectTest, - onOrbitSignIn, - onOrbitSignOut, - onOrbitRunnerStart, - onOrbitRunnerStop, - onOrbitRunnerStatus, onMobileConnectTest, }: SettingsServerSectionProps) { const [pendingDeleteRemoteId, setPendingDeleteRemoteId] = useState( @@ -176,8 +124,8 @@ export function SettingsServerSection({
Server
{isMobileSimplified - ? "Choose TCP or Orbit, fill in the connection endpoint and token from your desktop setup, then run a connection test." - : "Configure how CodexMonitor exposes backend access for mobile and remote clients. Desktop usage remains local unless you explicitly connect through remote mode."} + ? "Configure TCP host/token from your desktop setup, then run a connection test." + : "Configure how CodexMonitor exposes TCP backend access for mobile and remote clients. Desktop usage remains local unless you explicitly connect through remote mode."}
{!isMobileSimplified && ( @@ -201,7 +149,7 @@ export function SettingsServerSection({
Local keeps desktop requests in-process. Remote routes desktop requests through the same - network transport path used by mobile clients. + TCP transport path used by mobile clients.
)} @@ -225,9 +173,7 @@ export function SettingsServerSection({
{entry.name}
{isActive && Active} -
- {entry.provider.toUpperCase()} · {entry.provider === "tcp" ? entry.host : (entry.orbitWsUrl ?? "Orbit endpoint")} -
+
TCP · {entry.host}
Last connected:{" "} {typeof entry.lastConnectedAtMs === "number" @@ -330,35 +276,12 @@ export function SettingsServerSection({ )} -
- - -
- {isMobileSimplified - ? "TCP uses your desktop daemon Tailscale address. Orbit uses your Orbit websocket endpoint." - : "Select which remote transport configuration to maintain for mobile access and optional desktop remote-mode testing."} -
-
- {!isMobileSimplified && (
Keep daemon running after app closes
- If disabled, CodexMonitor stops managed TCP and Orbit daemon processes before exit. + If disabled, CodexMonitor stops managed TCP daemon processes before exit.
-
- {mobileConnectStatusText && ( -
- {mobileConnectStatusText} -
- )} -
- Make sure your desktop app daemon is running and reachable on Tailscale, then - retry this test. -
-
- )} +
+
Remote backend
+
+ onSetRemoteHostDraft(event.target.value)} + onBlur={() => { + void onCommitRemoteHost(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void onCommitRemoteHost(); + } + }} + aria-label="Remote backend host" + /> + onSetRemoteTokenDraft(event.target.value)} + onBlur={() => { + void onCommitRemoteToken(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void onCommitRemoteToken(); + } + }} + aria-label="Remote backend token" + /> +
+ {remoteHostError &&
{remoteHostError}
} +
+ {isMobileSimplified + ? "Use the Tailscale host from your desktop CodexMonitor app (Server section), for example `macbook.your-tailnet.ts.net:4732`." + : "This host/token is used by mobile clients and desktop remote-mode testing."} +
+
- {!isMobileSimplified && ( -
-
Mobile access daemon
-
- - - -
- {tcpRunnerStatusText &&
{tcpRunnerStatusText}
} - {tcpDaemonStatus?.startedAtMs && ( -
- Started at: {new Date(tcpDaemonStatus.startedAtMs).toLocaleString()} -
- )} -
- Start this daemon before connecting from iOS. It uses your current token and - listens on 0.0.0.0:<port>, matching your configured host port. -
+ {isMobileSimplified && ( +
+
Connection test
+
+ +
+ {mobileConnectStatusText && ( +
+ {mobileConnectStatusText}
)} +
+ Make sure your desktop app daemon is running and reachable on Tailscale, then retry + this test. +
+
+ )} - {!isMobileSimplified && ( -
-
Tailscale helper
-
- - - -
- {tailscaleStatusError && ( -
{tailscaleStatusError}
- )} - {tailscaleStatus && ( - <> -
{tailscaleStatus.message}
-
- {tailscaleStatus.installed - ? `Version: ${tailscaleStatus.version ?? "unknown"}` - : "Install Tailscale on both desktop and iOS to continue."} -
- {tailscaleStatus.suggestedRemoteHost && ( -
- Suggested remote host: {tailscaleStatus.suggestedRemoteHost} -
- )} - {tailscaleStatus.tailnetName && ( -
- Tailnet: {tailscaleStatus.tailnetName} -
- )} - - )} - {tailscaleCommandError && ( -
{tailscaleCommandError}
- )} - {tailscaleCommandPreview && ( - <> -
- Command template (manual fallback) for starting the daemon: -
-
-                      {tailscaleCommandPreview.command}
-                    
- {!tailscaleCommandPreview.tokenConfigured && ( -
- Remote backend token is empty. Set one before exposing daemon access. -
- )} - - )} + {!isMobileSimplified && ( +
+
Mobile access daemon
+
+ + + +
+ {tcpRunnerStatusText &&
{tcpRunnerStatusText}
} + {tcpDaemonStatus?.startedAtMs && ( +
+ Started at: {new Date(tcpDaemonStatus.startedAtMs).toLocaleString()}
)} - +
+ Start this daemon before connecting from iOS. It uses your current token and listens + on 0.0.0.0:<port>, matching your configured host port. +
+
)} - {appSettings.remoteBackendProvider === "orbit" && ( - <> -
- - onSetOrbitWsUrlDraft(event.target.value)} - onBlur={() => { - void onCommitOrbitWsUrl(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void onCommitOrbitWsUrl(); - } + {!isMobileSimplified && ( +
+
Tailscale helper
+
+ + +
- - {isMobileSimplified && ( + {tailscaleStatusError && ( +
{tailscaleStatusError}
+ )} + {tailscaleStatus && ( <> -
- - onSetRemoteTokenDraft(event.target.value)} - onBlur={() => { - void onCommitRemoteToken(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void onCommitRemoteToken(); - } - }} - aria-label="Remote backend token" - /> -
- Use the same token configured on your desktop Orbit daemon setup. -
+
{tailscaleStatus.message}
+
+ {tailscaleStatus.installed + ? `Version: ${tailscaleStatus.version ?? "unknown"}` + : "Install Tailscale on both desktop and iOS to continue."}
-
-
Connection test
-
- + {tailscaleStatus.suggestedRemoteHost && ( +
+ Suggested remote host: {tailscaleStatus.suggestedRemoteHost}
- {mobileConnectStatusText && ( -
- {mobileConnectStatusText} -
- )} + )} + {tailscaleStatus.tailnetName && (
- Make sure the Orbit endpoint and token match your desktop setup, then retry. + Tailnet: {tailscaleStatus.tailnetName}
-
+ )} )} - - {!isMobileSimplified && ( + {tailscaleCommandError && ( +
{tailscaleCommandError}
+ )} + {tailscaleCommandPreview && ( <> -
- - onSetOrbitAuthUrlDraft(event.target.value)} - onBlur={() => { - void onCommitOrbitAuthUrl(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void onCommitOrbitAuthUrl(); - } - }} - aria-label="Orbit auth URL" - /> -
- -
- - onSetOrbitRunnerNameDraft(event.target.value)} - onBlur={() => { - void onCommitOrbitRunnerName(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void onCommitOrbitRunnerName(); - } - }} - aria-label="Orbit runner name" - /> -
- -
-
-
Auto start runner
-
- Start the Orbit runner automatically when remote mode activates. -
-
- -
- -
-
-
Use Orbit Access
-
- Enable OAuth client credentials for Orbit Access. -
-
- -
- -
- - onSetOrbitAccessClientIdDraft(event.target.value)} - onBlur={() => { - void onCommitOrbitAccessClientId(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void onCommitOrbitAccessClientId(); - } - }} - aria-label="Orbit access client ID" - /> -
- -
- - onSetOrbitAccessClientSecretRefDraft(event.target.value)} - onBlur={() => { - void onCommitOrbitAccessClientSecretRef(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void onCommitOrbitAccessClientSecretRef(); - } - }} - aria-label="Orbit access client secret ref" - /> +
+ Command template (manual fallback) for starting the daemon:
- -
-
Orbit actions
-
- - - +
+                  {tailscaleCommandPreview.command}
+                
+ {!tailscaleCommandPreview.tokenConfigured && ( +
+ Remote backend token is empty. Set one before exposing daemon access.
-
- - - -
- {orbitStatusText &&
{orbitStatusText}
} - {orbitAuthCode && ( -
- Auth code: {orbitAuthCode} -
- )} - {orbitVerificationUrl && ( -
- Verification URL: {orbitVerificationUrl} -
- )} -
+ )} )} - +
)}
{isMobileSimplified - ? appSettings.remoteBackendProvider === "tcp" - ? "Use your own infrastructure only. On iOS, get the Tailscale hostname and token from your desktop CodexMonitor setup." - : "Use your own infrastructure only. On iOS, use the Orbit websocket URL and token configured on your desktop CodexMonitor setup." - : "Mobile access should stay scoped to your own infrastructure (tailnet or self-hosted Orbit). CodexMonitor does not provide hosted backend services."} + ? "Use your own infrastructure only. On iOS, get the Tailscale hostname and token from your desktop CodexMonitor setup." + : "Mobile access should stay scoped to your own infrastructure (tailnet). CodexMonitor does not provide hosted backend services."}
{pendingDeleteRemote && ( ; export type OpenAppDraft = OpenAppTarget & { argsText: string }; - -export type OrbitServiceClient = { - orbitConnectTest: () => Promise; - orbitSignInStart: () => Promise; - orbitSignInPoll: ( - deviceCode: string, - remoteBackendId?: string, - ) => Promise; - orbitSignOut: (remoteBackendId?: string) => Promise; - orbitRunnerStart: () => Promise; - orbitRunnerStop: () => Promise; - orbitRunnerStatus: () => Promise; -}; diff --git a/src/features/settings/components/settingsViewConstants.ts b/src/features/settings/components/settingsViewConstants.ts index 9abb40dc7..4a8046310 100644 --- a/src/features/settings/components/settingsViewConstants.ts +++ b/src/features/settings/components/settingsViewConstants.ts @@ -1,19 +1,5 @@ import type { AppSettings } from "@/types"; -import { - orbitConnectTest, - orbitRunnerStart, - orbitRunnerStatus, - orbitRunnerStop, - orbitSignInPoll, - orbitSignInStart, - orbitSignOut, -} from "@services/tauri"; -import type { - CodexSection, - OrbitServiceClient, - ShortcutDraftKey, - ShortcutSettingKey, -} from "./settingsTypes"; +import type { CodexSection, ShortcutDraftKey, ShortcutSettingKey } from "./settingsTypes"; export const DICTATION_MODELS = [ { id: "tiny", label: "Tiny", size: "75 MB", note: "Fastest, least accurate." }, @@ -84,18 +70,6 @@ export const COMPOSER_PRESET_CONFIGS: Record< }, }; -export const ORBIT_SERVICES: OrbitServiceClient = { - orbitConnectTest, - orbitSignInStart, - orbitSignInPoll, - orbitSignOut, - orbitRunnerStart, - orbitRunnerStop, - orbitRunnerStatus, -}; - -export const ORBIT_DEFAULT_POLL_INTERVAL_SECONDS = 5; -export const ORBIT_MAX_INLINE_POLL_SECONDS = 180; export const SETTINGS_MOBILE_BREAKPOINT_PX = 720; export const DEFAULT_REMOTE_HOST = "127.0.0.1:4732"; diff --git a/src/features/settings/components/settingsViewHelpers.ts b/src/features/settings/components/settingsViewHelpers.ts index ffdc926ea..2772e1380 100644 --- a/src/features/settings/components/settingsViewHelpers.ts +++ b/src/features/settings/components/settingsViewHelpers.ts @@ -1,10 +1,6 @@ import type { AppSettings, OpenAppTarget, - OrbitConnectTestResult, - OrbitRunnerStatus, - OrbitSignInPollResult, - OrbitSignOutResult, WorkspaceInfo, } from "@/types"; import type { OpenAppDraft, ShortcutDrafts } from "./settingsTypes"; @@ -42,72 +38,6 @@ export const isNarrowSettingsViewport = (): boolean => { return window.matchMedia(`(max-width: ${SETTINGS_MOBILE_BREAKPOINT_PX}px)`).matches; }; -export const delay = (durationMs: number): Promise => - new Promise((resolve) => { - window.setTimeout(resolve, durationMs); - }); - -export type OrbitActionResult = - | OrbitConnectTestResult - | OrbitSignInPollResult - | OrbitSignOutResult - | OrbitRunnerStatus; - -export const getOrbitStatusText = ( - value: OrbitActionResult, - fallback: string, -): string => { - if ("ok" in value) { - if (!value.ok) { - return value.message || fallback; - } - if (value.message.trim()) { - return value.message; - } - if (typeof value.latencyMs === "number") { - return `Connected to Orbit relay in ${value.latencyMs}ms.`; - } - return fallback; - } - - if ("status" in value) { - if (value.message && value.message.trim()) { - return value.message; - } - switch (value.status) { - case "pending": - return "Waiting for Orbit sign-in authorization."; - case "authorized": - return "Orbit sign in complete."; - case "denied": - return "Orbit sign in denied."; - case "expired": - return "Orbit sign in code expired."; - case "error": - return "Orbit sign in failed."; - default: - return fallback; - } - } - - if ("success" in value) { - if (!value.success && value.message && value.message.trim()) { - return value.message; - } - return value.success ? "Signed out from Orbit." : fallback; - } - - if (value.state === "running") { - return value.pid - ? `Orbit runner is running (pid ${value.pid}).` - : "Orbit runner is running."; - } - if (value.state === "error") { - return value.lastError?.trim() || "Orbit runner is in error state."; - } - return "Orbit runner is stopped."; -}; - export const buildOpenAppDrafts = (targets: OpenAppTarget[]): OpenAppDraft[] => targets.map((target) => ({ ...target, diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 48c2f74d2..563c8f7f6 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -30,7 +30,8 @@ const DEFAULT_REMOTE_PROVIDER: AppSettings["remoteBackendProvider"] = "tcp"; type RemoteBackendTarget = AppSettings["remoteBackends"][number]; function normalizeRemoteProvider(value: unknown): AppSettings["remoteBackendProvider"] { - return value === "orbit" ? "orbit" : "tcp"; + void value; + return "tcp"; } function normalizeRemoteToken(value: string | null | undefined): string | null { @@ -51,12 +52,10 @@ function normalizeRemoteBackends(settings: AppSettings): { remoteBackendProvider: AppSettings["remoteBackendProvider"]; remoteBackendHost: string; remoteBackendToken: string | null; - orbitWsUrl: string | null; } { const legacyProvider = normalizeRemoteProvider(settings.remoteBackendProvider); const legacyHost = normalizeRemoteHost(settings.remoteBackendHost); const legacyToken = normalizeRemoteToken(settings.remoteBackendToken); - const legacyOrbitWsUrl = settings.orbitWsUrl?.trim() ? settings.orbitWsUrl.trim() : null; const usedIds = new Set(); const normalized = (settings.remoteBackends ?? []).map((entry, index) => { @@ -74,7 +73,6 @@ function normalizeRemoteBackends(settings: AppSettings): { provider: normalizeRemoteProvider(entry.provider), host: normalizeRemoteHost(entry.host), token: normalizeRemoteToken(entry.token), - orbitWsUrl: entry.orbitWsUrl?.trim() ? entry.orbitWsUrl.trim() : null, lastConnectedAtMs: typeof entry.lastConnectedAtMs === "number" && Number.isFinite(entry.lastConnectedAtMs) ? entry.lastConnectedAtMs @@ -89,7 +87,6 @@ function normalizeRemoteBackends(settings: AppSettings): { provider: legacyProvider, host: legacyHost, token: legacyToken, - orbitWsUrl: legacyOrbitWsUrl, lastConnectedAtMs: null, }; return { @@ -98,7 +95,6 @@ function normalizeRemoteBackends(settings: AppSettings): { remoteBackendProvider: fallback.provider, remoteBackendHost: fallback.host, remoteBackendToken: fallback.token, - orbitWsUrl: fallback.orbitWsUrl, }; } @@ -113,7 +109,6 @@ function normalizeRemoteBackends(settings: AppSettings): { provider: legacyProvider, host: legacyHost, token: legacyToken, - orbitWsUrl: legacyOrbitWsUrl, }; const remoteBackends = [...normalized]; remoteBackends[activeIndex] = syncedActive; @@ -123,7 +118,6 @@ function normalizeRemoteBackends(settings: AppSettings): { remoteBackendProvider: syncedActive.provider, remoteBackendHost: syncedActive.host, remoteBackendToken: syncedActive.token, - orbitWsUrl: syncedActive.orbitWsUrl, }; } @@ -136,7 +130,6 @@ function buildDefaultSettings(): AppSettings { provider: DEFAULT_REMOTE_PROVIDER, host: DEFAULT_REMOTE_BACKEND_HOST, token: null, - orbitWsUrl: null, lastConnectedAtMs: null, }; return { @@ -148,14 +141,7 @@ function buildDefaultSettings(): AppSettings { remoteBackendToken: null, remoteBackends: [defaultRemote], activeRemoteBackendId: defaultRemote.id, - orbitWsUrl: null, - orbitAuthUrl: null, - orbitRunnerName: null, - orbitAutoStartRunner: false, keepDaemonRunningAfterAppClose: false, - orbitUseAccess: false, - orbitAccessClientId: null, - orbitAccessClientSecretRef: null, defaultAccessMode: "current", reviewDeliveryMode: "inline", composerModelShortcut: isMac ? "cmd+shift+m" : "ctrl+shift+m", diff --git a/src/features/settings/hooks/useSettingsServerSection.ts b/src/features/settings/hooks/useSettingsServerSection.ts index 910be038a..9b61113b4 100644 --- a/src/features/settings/hooks/useSettingsServerSection.ts +++ b/src/features/settings/hooks/useSettingsServerSection.ts @@ -15,24 +15,12 @@ import { tailscaleStatus as fetchTailscaleStatus, } from "@services/tauri"; import { isMobilePlatform } from "@utils/platformPaths"; -import type { OrbitServiceClient } from "@settings/components/settingsTypes"; -import { - DEFAULT_REMOTE_HOST, - ORBIT_DEFAULT_POLL_INTERVAL_SECONDS, - ORBIT_MAX_INLINE_POLL_SECONDS, -} from "@settings/components/settingsViewConstants"; -import { - delay, - getOrbitStatusText, - normalizeOverrideValue, - type OrbitActionResult, -} from "@settings/components/settingsViewHelpers"; +import { DEFAULT_REMOTE_HOST } from "@settings/components/settingsViewConstants"; type UseSettingsServerSectionArgs = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; onMobileConnectSuccess?: () => Promise | void; - orbitServiceClient: OrbitServiceClient; }; export type SettingsServerSectionProps = { @@ -51,15 +39,6 @@ export type SettingsServerSectionProps = { remoteNameDraft: string; remoteHostDraft: string; remoteTokenDraft: string; - orbitWsUrlDraft: string; - orbitAuthUrlDraft: string; - orbitRunnerNameDraft: string; - orbitAccessClientIdDraft: string; - orbitAccessClientSecretRefDraft: string; - orbitStatusText: string | null; - orbitAuthCode: string | null; - orbitVerificationUrl: string | null; - orbitBusyAction: string | null; tailscaleStatus: TailscaleStatus | null; tailscaleStatusBusy: boolean; tailscaleStatusError: string | null; @@ -71,11 +50,6 @@ export type SettingsServerSectionProps = { onSetRemoteNameDraft: Dispatch>; onSetRemoteHostDraft: Dispatch>; onSetRemoteTokenDraft: Dispatch>; - onSetOrbitWsUrlDraft: Dispatch>; - onSetOrbitAuthUrlDraft: Dispatch>; - onSetOrbitRunnerNameDraft: Dispatch>; - onSetOrbitAccessClientIdDraft: Dispatch>; - onSetOrbitAccessClientSecretRefDraft: Dispatch>; onCommitRemoteName: () => Promise; onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; @@ -83,24 +57,12 @@ export type SettingsServerSectionProps = { onAddRemoteBackend: () => Promise; onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; onDeleteRemoteBackend: (id: string) => Promise; - onChangeRemoteProvider: (provider: AppSettings["remoteBackendProvider"]) => Promise; onRefreshTailscaleStatus: () => void; onRefreshTailscaleCommandPreview: () => void; onUseSuggestedTailscaleHost: () => Promise; onTcpDaemonStart: () => Promise; onTcpDaemonStop: () => Promise; onTcpDaemonStatus: () => Promise; - onCommitOrbitWsUrl: () => Promise; - onCommitOrbitAuthUrl: () => Promise; - onCommitOrbitRunnerName: () => Promise; - onCommitOrbitAccessClientId: () => Promise; - onCommitOrbitAccessClientSecretRef: () => Promise; - onOrbitConnectTest: () => void; - onOrbitSignIn: () => void; - onOrbitSignOut: () => void; - onOrbitRunnerStart: () => void; - onOrbitRunnerStop: () => void; - onOrbitRunnerStatus: () => void; onMobileConnectTest: () => void; }; @@ -128,10 +90,9 @@ const createRemoteBackendId = () => const buildFallbackRemoteBackend = (settings: AppSettings): RemoteBackendTarget => ({ id: settings.activeRemoteBackendId ?? "remote-default", name: "Primary remote", - provider: settings.remoteBackendProvider, + provider: "tcp", host: settings.remoteBackendHost, token: settings.remoteBackendToken, - orbitWsUrl: settings.orbitWsUrl, lastConnectedAtMs: null, }); @@ -178,28 +139,15 @@ export const useSettingsServerSection = ({ appSettings, onUpdateAppSettings, onMobileConnectSuccess, - orbitServiceClient, }: UseSettingsServerSectionArgs): SettingsServerSectionProps => { const initialActiveRemoteBackend = getActiveRemoteBackend(appSettings); const [remoteNameDraft, setRemoteNameDraft] = useState(initialActiveRemoteBackend.name); const [remoteHostDraft, setRemoteHostDraft] = useState(initialActiveRemoteBackend.host); const [remoteTokenDraft, setRemoteTokenDraft] = useState(initialActiveRemoteBackend.token ?? ""); - const [orbitWsUrlDraft, setOrbitWsUrlDraft] = useState(initialActiveRemoteBackend.orbitWsUrl ?? ""); const [remoteStatusText, setRemoteStatusText] = useState(null); const [remoteStatusError, setRemoteStatusError] = useState(false); const [remoteNameError, setRemoteNameError] = useState(null); const [remoteHostError, setRemoteHostError] = useState(null); - const [orbitAuthUrlDraft, setOrbitAuthUrlDraft] = useState(appSettings.orbitAuthUrl ?? ""); - const [orbitRunnerNameDraft, setOrbitRunnerNameDraft] = useState(appSettings.orbitRunnerName ?? ""); - const [orbitAccessClientIdDraft, setOrbitAccessClientIdDraft] = useState( - appSettings.orbitAccessClientId ?? "", - ); - const [orbitAccessClientSecretRefDraft, setOrbitAccessClientSecretRefDraft] = - useState(appSettings.orbitAccessClientSecretRef ?? ""); - const [orbitStatusText, setOrbitStatusText] = useState(null); - const [orbitAuthCode, setOrbitAuthCode] = useState(null); - const [orbitVerificationUrl, setOrbitVerificationUrl] = useState(null); - const [orbitBusyAction, setOrbitBusyAction] = useState(null); const [tailscaleStatus, setTailscaleStatus] = useState(null); const [tailscaleStatusBusy, setTailscaleStatusBusy] = useState(false); const [tailscaleStatusError, setTailscaleStatusError] = useState(null); @@ -232,37 +180,19 @@ export const useSettingsServerSection = ({ setRemoteNameDraft(activeRemoteBackend.name); setRemoteHostDraft(activeRemoteBackend.host); setRemoteTokenDraft(activeRemoteBackend.token ?? ""); - setOrbitWsUrlDraft(activeRemoteBackend.orbitWsUrl ?? ""); setRemoteNameError(null); setRemoteHostError(null); }, [activeRemoteBackend]); - useEffect(() => { - setOrbitAuthUrlDraft(appSettings.orbitAuthUrl ?? ""); - }, [appSettings.orbitAuthUrl]); - - useEffect(() => { - setOrbitRunnerNameDraft(appSettings.orbitRunnerName ?? ""); - }, [appSettings.orbitRunnerName]); - - useEffect(() => { - setOrbitAccessClientIdDraft(appSettings.orbitAccessClientId ?? ""); - }, [appSettings.orbitAccessClientId]); - - useEffect(() => { - setOrbitAccessClientSecretRefDraft(appSettings.orbitAccessClientSecretRef ?? ""); - }, [appSettings.orbitAccessClientSecretRef]); - const normalizeRemoteBackendEntry = ( entry: RemoteBackendTarget, index: number, ): RemoteBackendTarget => ({ id: entry.id?.trim() || `remote-${index + 1}`, name: entry.name?.trim() || `Remote ${index + 1}`, - provider: entry.provider === "orbit" ? "orbit" : "tcp", + provider: "tcp", host: entry.host?.trim() || DEFAULT_REMOTE_HOST, token: entry.token?.trim() ? entry.token.trim() : null, - orbitWsUrl: entry.orbitWsUrl?.trim() ? entry.orbitWsUrl.trim() : null, lastConnectedAtMs: typeof entry.lastConnectedAtMs === "number" && Number.isFinite(entry.lastConnectedAtMs) ? entry.lastConnectedAtMs @@ -286,10 +216,9 @@ export const useSettingsServerSection = ({ ...latestSettings, remoteBackends: normalizedBackends, activeRemoteBackendId: active.id, - remoteBackendProvider: active.provider, + remoteBackendProvider: "tcp", remoteBackendHost: active.host, remoteBackendToken: active.token, - orbitWsUrl: active.orbitWsUrl, ...(mobilePlatform ? { backendMode: "remote", @@ -311,7 +240,6 @@ export const useSettingsServerSection = ({ const unchanged = nextSettings.remoteBackendHost === latestSettings.remoteBackendHost && nextSettings.remoteBackendToken === latestSettings.remoteBackendToken && - nextSettings.orbitWsUrl === latestSettings.orbitWsUrl && nextSettings.backendMode === latestSettings.backendMode && nextSettings.remoteBackendProvider === latestSettings.remoteBackendProvider && nextSettings.activeRemoteBackendId === latestSettings.activeRemoteBackendId && @@ -335,6 +263,7 @@ export const useSettingsServerSection = ({ nextBackends[safeIndex] = { ...nextBackends[safeIndex], ...patch, + provider: "tcp", }; await persistRemoteBackends(nextBackends, nextBackends[safeIndex].id); }, @@ -342,15 +271,12 @@ export const useSettingsServerSection = ({ ); const applyRemoteHost = async (rawValue: string) => { - const active = getActiveRemoteBackend(latestSettingsRef.current); const nextHost = rawValue.trim(); - if (active.provider === "tcp") { - const validationError = validateRemoteHost(nextHost); - if (validationError) { - setRemoteHostError(validationError); - setRemoteStatus(validationError, true); - return false; - } + const validationError = validateRemoteHost(nextHost); + if (validationError) { + setRemoteHostError(validationError); + setRemoteStatus(validationError, true); + return false; } const normalizedHost = nextHost || DEFAULT_REMOTE_HOST; setRemoteHostError(null); @@ -414,10 +340,9 @@ export const useSettingsServerSection = ({ const nextRemote: RemoteBackendTarget = { id: nextId, name: buildNextRemoteName(existingBackends), - provider: latestSettings.remoteBackendProvider, + provider: "tcp", host: DEFAULT_REMOTE_HOST, token: null, - orbitWsUrl: null, lastConnectedAtMs: null, }; await persistRemoteBackends([...existingBackends, nextRemote], nextId); @@ -477,8 +402,6 @@ export const useSettingsServerSection = ({ const handleMobileConnectTest = () => { void (async () => { - const active = getActiveRemoteBackend(latestSettingsRef.current); - const provider = active.provider; const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; setRemoteTokenDraft(nextToken ?? ""); @@ -488,38 +411,25 @@ export const useSettingsServerSection = ({ return; } - if (provider === "tcp") { - const hostError = validateRemoteHost(remoteHostDraft); - if (hostError) { - setRemoteHostError(hostError); - setMobileConnectStatusError(true); - setMobileConnectStatusText(hostError); - return; - } + const hostError = validateRemoteHost(remoteHostDraft); + if (hostError) { + setRemoteHostError(hostError); + setMobileConnectStatusError(true); + setMobileConnectStatusText(hostError); + return; } setMobileConnectBusy(true); setMobileConnectStatusText(null); setMobileConnectStatusError(false); try { - if (provider === "tcp") { - const nextHost = remoteHostDraft.trim() || DEFAULT_REMOTE_HOST; - setRemoteHostDraft(nextHost); - await updateActiveRemoteBackend({ - host: nextHost, - token: nextToken, - }); - } else { - const nextOrbitWsUrl = normalizeOverrideValue(orbitWsUrlDraft); - setOrbitWsUrlDraft(nextOrbitWsUrl ?? ""); - if (!nextOrbitWsUrl) { - throw new Error("Orbit websocket URL is required."); - } - await updateActiveRemoteBackend({ - token: nextToken, - orbitWsUrl: nextOrbitWsUrl, - }); - } + const nextHost = remoteHostDraft.trim() || DEFAULT_REMOTE_HOST; + setRemoteHostDraft(nextHost); + await updateActiveRemoteBackend({ + host: nextHost, + token: nextToken, + }); + const workspaces = await listWorkspaces(); const workspaceCount = workspaces.length; const workspaceWord = workspaceCount === 1 ? "workspace" : "workspaces"; @@ -549,25 +459,7 @@ export const useSettingsServerSection = ({ } setMobileConnectStatusText(null); setMobileConnectStatusError(false); - }, [ - appSettings.remoteBackendProvider, - mobilePlatform, - orbitWsUrlDraft, - remoteHostDraft, - remoteTokenDraft, - ]); - - const handleChangeRemoteProvider = async ( - provider: AppSettings["remoteBackendProvider"], - ) => { - if (provider === getActiveRemoteBackend(latestSettingsRef.current).provider) { - return; - } - await updateActiveRemoteBackend({ - provider, - }); - setRemoteStatus(`Connection type set to ${provider.toUpperCase()}.`); - }; + }, [mobilePlatform, remoteHostDraft, remoteTokenDraft]); const handleRefreshTailscaleStatus = useCallback(() => { void (async () => { @@ -653,216 +545,7 @@ export const useSettingsServerSection = ({ await runTcpDaemonAction("status", tailscaleDaemonStatus); }, [runTcpDaemonAction]); - const handleCommitOrbitWsUrl = async () => { - const nextValue = normalizeOverrideValue(orbitWsUrlDraft); - setOrbitWsUrlDraft(nextValue ?? ""); - await updateActiveRemoteBackend({ - orbitWsUrl: nextValue, - }); - setRemoteStatus("Orbit websocket URL saved."); - }; - - const handleCommitOrbitAuthUrl = async () => { - const nextValue = normalizeOverrideValue(orbitAuthUrlDraft); - setOrbitAuthUrlDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitAuthUrl) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitAuthUrl: nextValue, - }); - }; - - const handleCommitOrbitRunnerName = async () => { - const nextValue = normalizeOverrideValue(orbitRunnerNameDraft); - setOrbitRunnerNameDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitRunnerName) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitRunnerName: nextValue, - }); - }; - - const handleCommitOrbitAccessClientId = async () => { - const nextValue = normalizeOverrideValue(orbitAccessClientIdDraft); - setOrbitAccessClientIdDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitAccessClientId) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitAccessClientId: nextValue, - }); - }; - - const handleCommitOrbitAccessClientSecretRef = async () => { - const nextValue = normalizeOverrideValue(orbitAccessClientSecretRefDraft); - setOrbitAccessClientSecretRefDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitAccessClientSecretRef) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitAccessClientSecretRef: nextValue, - }); - }; - - const runOrbitAction = async ( - actionKey: string, - actionLabel: string, - action: () => Promise, - successFallback: string, - ): Promise => { - setOrbitBusyAction(actionKey); - setOrbitStatusText(`${actionLabel}...`); - try { - const result = await action(); - setOrbitStatusText(getOrbitStatusText(result, successFallback)); - return result; - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown Orbit error"; - setOrbitStatusText(`${actionLabel} failed: ${message}`); - return null; - } finally { - setOrbitBusyAction(null); - } - }; - - const syncRemoteBackendToken = async (nextToken: string | null) => { - const normalizedToken = nextToken?.trim() ? nextToken.trim() : null; - setRemoteTokenDraft(normalizedToken ?? ""); - await updateActiveRemoteBackend({ token: normalizedToken }); - }; - - const handleOrbitConnectTest = () => { - void runOrbitAction( - "connect-test", - "Connect test", - orbitServiceClient.orbitConnectTest, - "Orbit connection test succeeded.", - ); - }; - - const handleOrbitSignIn = () => { - void (async () => { - setOrbitBusyAction("sign-in"); - setOrbitStatusText("Starting Orbit sign in..."); - setOrbitAuthCode(null); - setOrbitVerificationUrl(null); - try { - const tokenTargetRemoteId = latestSettingsRef.current.activeRemoteBackendId ?? undefined; - const startResult = await orbitServiceClient.orbitSignInStart(); - setOrbitAuthCode(startResult.userCode ?? startResult.deviceCode); - setOrbitVerificationUrl( - startResult.verificationUriComplete ?? startResult.verificationUri, - ); - setOrbitStatusText( - "Orbit sign in started. Finish authorization in the browser window, then keep this dialog open while we poll for completion.", - ); - - const maxPollWindowSeconds = Math.max( - 1, - Math.min(startResult.expiresInSeconds, ORBIT_MAX_INLINE_POLL_SECONDS), - ); - const deadlineMs = Date.now() + maxPollWindowSeconds * 1000; - let pollIntervalSeconds = Math.max( - 1, - startResult.intervalSeconds || ORBIT_DEFAULT_POLL_INTERVAL_SECONDS, - ); - - while (Date.now() < deadlineMs) { - await delay(pollIntervalSeconds * 1000); - const pollResult = await orbitServiceClient.orbitSignInPoll( - startResult.deviceCode, - tokenTargetRemoteId, - ); - setOrbitStatusText( - getOrbitStatusText(pollResult, "Orbit sign in status refreshed."), - ); - - if (pollResult.status === "pending") { - if (typeof pollResult.intervalSeconds === "number") { - pollIntervalSeconds = Math.max(1, pollResult.intervalSeconds); - } - continue; - } - - if (pollResult.status === "authorized") { - if (pollResult.token) { - await syncRemoteBackendToken(pollResult.token); - } - } - return; - } - - setOrbitStatusText( - "Orbit sign in is still pending. Leave this window open and try Sign In again if authorization just completed.", - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown Orbit error"; - setOrbitStatusText(`Sign In failed: ${message}`); - } finally { - setOrbitBusyAction(null); - } - })(); - }; - - const handleOrbitSignOut = () => { - void (async () => { - const tokenTargetRemoteId = latestSettingsRef.current.activeRemoteBackendId ?? undefined; - const result = await runOrbitAction( - "sign-out", - "Sign Out", - () => orbitServiceClient.orbitSignOut(tokenTargetRemoteId), - "Signed out from Orbit.", - ); - if (result !== null) { - try { - await syncRemoteBackendToken(null); - setOrbitAuthCode(null); - setOrbitVerificationUrl(null); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown Orbit error"; - setOrbitStatusText(`Sign Out failed: ${message}`); - } - } - })(); - }; - - const handleOrbitRunnerStart = () => { - void runOrbitAction( - "runner-start", - "Start Runner", - orbitServiceClient.orbitRunnerStart, - "Orbit runner started.", - ); - }; - - const handleOrbitRunnerStop = () => { - void runOrbitAction( - "runner-stop", - "Stop Runner", - orbitServiceClient.orbitRunnerStop, - "Orbit runner stopped.", - ); - }; - - const handleOrbitRunnerStatus = () => { - void runOrbitAction( - "runner-status", - "Refresh Status", - orbitServiceClient.orbitRunnerStatus, - "Orbit runner status refreshed.", - ); - }; - useEffect(() => { - if (appSettings.remoteBackendProvider !== "tcp") { - return; - } if (!mobilePlatform) { handleRefreshTailscaleCommandPreview(); void handleTcpDaemonStatus(); @@ -871,7 +554,6 @@ export const useSettingsServerSection = ({ handleRefreshTailscaleStatus(); } }, [ - appSettings.remoteBackendProvider, appSettings.remoteBackendToken, handleRefreshTailscaleCommandPreview, handleRefreshTailscaleStatus, @@ -895,15 +577,6 @@ export const useSettingsServerSection = ({ remoteNameDraft, remoteHostDraft, remoteTokenDraft, - orbitWsUrlDraft, - orbitAuthUrlDraft, - orbitRunnerNameDraft, - orbitAccessClientIdDraft, - orbitAccessClientSecretRefDraft, - orbitStatusText, - orbitAuthCode, - orbitVerificationUrl, - orbitBusyAction, tailscaleStatus, tailscaleStatusBusy, tailscaleStatusError, @@ -915,11 +588,6 @@ export const useSettingsServerSection = ({ onSetRemoteNameDraft: handleSetRemoteNameDraft, onSetRemoteHostDraft: handleSetRemoteHostDraft, onSetRemoteTokenDraft: setRemoteTokenDraft, - onSetOrbitWsUrlDraft: setOrbitWsUrlDraft, - onSetOrbitAuthUrlDraft: setOrbitAuthUrlDraft, - onSetOrbitRunnerNameDraft: setOrbitRunnerNameDraft, - onSetOrbitAccessClientIdDraft: setOrbitAccessClientIdDraft, - onSetOrbitAccessClientSecretRefDraft: setOrbitAccessClientSecretRefDraft, onCommitRemoteName: handleCommitRemoteName, onCommitRemoteHost: handleCommitRemoteHost, onCommitRemoteToken: handleCommitRemoteToken, @@ -927,24 +595,12 @@ export const useSettingsServerSection = ({ onAddRemoteBackend: handleAddRemoteBackend, onMoveRemoteBackend: handleMoveRemoteBackend, onDeleteRemoteBackend: handleDeleteRemoteBackend, - onChangeRemoteProvider: handleChangeRemoteProvider, onRefreshTailscaleStatus: handleRefreshTailscaleStatus, onRefreshTailscaleCommandPreview: handleRefreshTailscaleCommandPreview, onUseSuggestedTailscaleHost: handleUseSuggestedTailscaleHost, onTcpDaemonStart: handleTcpDaemonStart, onTcpDaemonStop: handleTcpDaemonStop, onTcpDaemonStatus: handleTcpDaemonStatus, - onCommitOrbitWsUrl: handleCommitOrbitWsUrl, - onCommitOrbitAuthUrl: handleCommitOrbitAuthUrl, - onCommitOrbitRunnerName: handleCommitOrbitRunnerName, - onCommitOrbitAccessClientId: handleCommitOrbitAccessClientId, - onCommitOrbitAccessClientSecretRef: handleCommitOrbitAccessClientSecretRef, - onOrbitConnectTest: handleOrbitConnectTest, - onOrbitSignIn: handleOrbitSignIn, - onOrbitSignOut: handleOrbitSignOut, - onOrbitRunnerStart: handleOrbitRunnerStart, - onOrbitRunnerStop: handleOrbitRunnerStop, - onOrbitRunnerStatus: handleOrbitRunnerStatus, isMobilePlatform: mobilePlatform, mobileConnectBusy, mobileConnectStatusText, diff --git a/src/features/settings/hooks/useSettingsViewOrchestration.ts b/src/features/settings/hooks/useSettingsViewOrchestration.ts index acafc366f..afa444e0b 100644 --- a/src/features/settings/hooks/useSettingsViewOrchestration.ts +++ b/src/features/settings/hooks/useSettingsViewOrchestration.ts @@ -18,7 +18,6 @@ import { useSettingsGitSection } from "./useSettingsGitSection"; import { useSettingsProjectsSection } from "./useSettingsProjectsSection"; import { useSettingsServerSection } from "./useSettingsServerSection"; import type { GroupedWorkspaces } from "./settingsSectionTypes"; -import type { OrbitServiceClient } from "@settings/components/settingsTypes"; import { COMPOSER_PRESET_CONFIGS, COMPOSER_PRESET_LABELS, @@ -66,7 +65,6 @@ type UseSettingsViewOrchestrationArgs = { onDownloadDictationModel?: () => void; onCancelDictationDownload?: () => void; onRemoveDictationModel?: () => void; - orbitServiceClient: OrbitServiceClient; }; export function useSettingsViewOrchestration({ @@ -98,7 +96,6 @@ export function useSettingsViewOrchestration({ onDownloadDictationModel, onCancelDictationDownload, onRemoveDictationModel, - orbitServiceClient, }: UseSettingsViewOrchestrationArgs) { const projects = useMemo( () => groupedWorkspaces.flatMap((group) => group.workspaces), @@ -196,7 +193,6 @@ export function useSettingsViewOrchestration({ appSettings, onUpdateAppSettings, onMobileConnectSuccess, - orbitServiceClient, }); const codexSectionProps = useSettingsCodexSection({ diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index cb7d4e9a4..37b829d34 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -18,13 +18,6 @@ import { readGlobalAgentsMd, readGlobalCodexConfigToml, listWorkspaces, - orbitConnectTest, - orbitRunnerStart, - orbitRunnerStatus, - orbitRunnerStop, - orbitSignInPoll, - orbitSignInStart, - orbitSignOut, openWorkspaceIn, readAgentMd, stageGitAll, @@ -318,38 +311,6 @@ describe("tauri invoke wrappers", () => { }); }); - it("invokes orbit remote auth/runner wrappers", async () => { - const invokeMock = vi.mocked(invoke); - invokeMock.mockResolvedValue(undefined); - - await orbitConnectTest(); - await orbitSignInStart(); - await orbitSignInPoll("device-code"); - await orbitSignInPoll("device-code-2", "remote-a"); - await orbitSignOut(); - await orbitSignOut("remote-b"); - await orbitRunnerStart(); - await orbitRunnerStop(); - await orbitRunnerStatus(); - - expect(invokeMock).toHaveBeenCalledWith("orbit_connect_test"); - expect(invokeMock).toHaveBeenCalledWith("orbit_sign_in_start"); - expect(invokeMock).toHaveBeenCalledWith("orbit_sign_in_poll", { - deviceCode: "device-code", - }); - expect(invokeMock).toHaveBeenCalledWith("orbit_sign_in_poll", { - deviceCode: "device-code-2", - remoteBackendId: "remote-a", - }); - expect(invokeMock).toHaveBeenCalledWith("orbit_sign_out"); - expect(invokeMock).toHaveBeenCalledWith("orbit_sign_out", { - remoteBackendId: "remote-b", - }); - expect(invokeMock).toHaveBeenCalledWith("orbit_runner_start"); - expect(invokeMock).toHaveBeenCalledWith("orbit_runner_stop"); - expect(invokeMock).toHaveBeenCalledWith("orbit_runner_status"); - }); - it("invokes tailscale wrappers", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValue(undefined); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index cd852ff7d..107400e7f 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -8,11 +8,6 @@ import type { DictationModelStatus, DictationSessionState, LocalUsageSnapshot, - OrbitConnectTestResult, - OrbitDeviceCodeStart, - OrbitRunnerStatus, - OrbitSignInPollResult, - OrbitSignOutResult, TcpDaemonStatus, TailscaleDaemonCommandPreview, TailscaleStatus, @@ -694,46 +689,6 @@ export async function updateAppSettings(settings: AppSettings): Promise("update_app_settings", { settings }); } -export async function orbitConnectTest(): Promise { - return invoke("orbit_connect_test"); -} - -export async function orbitSignInStart(): Promise { - return invoke("orbit_sign_in_start"); -} - -export async function orbitSignInPoll( - deviceCode: string, - remoteBackendId?: string, -): Promise { - if (remoteBackendId) { - return invoke("orbit_sign_in_poll", { - deviceCode, - remoteBackendId, - }); - } - return invoke("orbit_sign_in_poll", { deviceCode }); -} - -export async function orbitSignOut(remoteBackendId?: string): Promise { - if (remoteBackendId) { - return invoke("orbit_sign_out", { remoteBackendId }); - } - return invoke("orbit_sign_out"); -} - -export async function orbitRunnerStart(): Promise { - return invoke("orbit_runner_start"); -} - -export async function orbitRunnerStop(): Promise { - return invoke("orbit_runner_stop"); -} - -export async function orbitRunnerStatus(): Promise { - return invoke("orbit_runner_status"); -} - export async function tailscaleStatus(): Promise { return invoke("tailscale_status"); } diff --git a/src/types.ts b/src/types.ts index d6252905a..03e769ecd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,14 +143,13 @@ export type PullRequestSelectionRange = { export type AccessMode = "read-only" | "current" | "full-access"; export type BackendMode = "local" | "remote"; -export type RemoteBackendProvider = "tcp" | "orbit"; +export type RemoteBackendProvider = "tcp"; export type RemoteBackendTarget = { id: string; name: string; provider: RemoteBackendProvider; host: string; token: string | null; - orbitWsUrl: string | null; lastConnectedAtMs?: number | null; }; export type ThemePreference = "system" | "light" | "dark" | "dim"; @@ -188,14 +187,7 @@ export type AppSettings = { remoteBackendToken: string | null; remoteBackends: RemoteBackendTarget[]; activeRemoteBackendId: string | null; - orbitWsUrl: string | null; - orbitAuthUrl: string | null; - orbitRunnerName: string | null; - orbitAutoStartRunner: boolean; keepDaemonRunningAfterAppClose: boolean; - orbitUseAccess: boolean; - orbitAccessClientId: string | null; - orbitAccessClientSecretRef: string | null; defaultAccessMode: AccessMode; reviewDeliveryMode: "inline" | "detached"; composerModelShortcut: string | null; @@ -259,13 +251,6 @@ export type AppSettings = { selectedOpenAppId: string; }; -export type OrbitConnectTestResult = { - ok: boolean; - latencyMs: number | null; - message: string; - details?: string | null; -}; - export type CodexFeatureStage = | "under_development" | "beta" @@ -283,44 +268,6 @@ export type CodexFeature = { announcement: string | null; }; -export type OrbitDeviceCodeStart = { - deviceCode: string; - userCode: string | null; - verificationUri: string; - verificationUriComplete: string | null; - intervalSeconds: number; - expiresInSeconds: number; -}; - -export type OrbitSignInStatus = - | "pending" - | "authorized" - | "denied" - | "expired" - | "error"; - -export type OrbitSignInPollResult = { - status: OrbitSignInStatus; - token: string | null; - message: string | null; - intervalSeconds: number | null; -}; - -export type OrbitSignOutResult = { - success: boolean; - message: string | null; -}; - -export type OrbitRunnerState = "stopped" | "running" | "error"; - -export type OrbitRunnerStatus = { - state: OrbitRunnerState; - pid: number | null; - startedAtMs: number | null; - lastError: string | null; - orbitUrl: string | null; -}; - export type TcpDaemonState = "stopped" | "running" | "error"; export type TcpDaemonStatus = { From 38954528977dd1121f1a60ce14ce5d854b878932 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 16 Feb 2026 07:18:24 +0100 Subject: [PATCH 4/4] feat(mobile): add dismissible setup wizard and remote add modal --- .../components/MobileServerSetupWizard.tsx | 12 ++ .../mobile/hooks/useMobileServerSetup.ts | 50 ++++++- .../settings/components/SettingsView.test.tsx | 51 ++++++- .../sections/SettingsServerSection.tsx | 139 +++++++++++++++++- .../hooks/useSettingsServerSection.ts | 92 +++++++++++- src/styles/mobile-setup-wizard.css | 18 +++ src/styles/settings.css | 94 +++++++++++- 7 files changed, 435 insertions(+), 21 deletions(-) diff --git a/src/features/mobile/components/MobileServerSetupWizard.tsx b/src/features/mobile/components/MobileServerSetupWizard.tsx index d3e017c82..d1afa5e5b 100644 --- a/src/features/mobile/components/MobileServerSetupWizard.tsx +++ b/src/features/mobile/components/MobileServerSetupWizard.tsx @@ -1,4 +1,5 @@ import "../../../styles/mobile-setup-wizard.css"; +import X from "lucide-react/dist/esm/icons/x"; import { ModalShell } from "../../design-system/components/modal/ModalShell"; export type MobileServerSetupWizardProps = { @@ -8,6 +9,7 @@ export type MobileServerSetupWizardProps = { checking: boolean; statusMessage: string | null; statusError: boolean; + onClose: () => void; onRemoteHostChange: (value: string) => void; onRemoteTokenChange: (value: string) => void; onConnectTest: () => void; @@ -20,6 +22,7 @@ export function MobileServerSetupWizard({ checking, statusMessage, statusError, + onClose, onRemoteHostChange, onRemoteTokenChange, onConnectTest, @@ -28,9 +31,18 @@ export function MobileServerSetupWizard({
+
Mobile Setup Required

Connect to your desktop backend

diff --git a/src/features/mobile/hooks/useMobileServerSetup.ts b/src/features/mobile/hooks/useMobileServerSetup.ts index e72541fc7..729ea02ba 100644 --- a/src/features/mobile/hooks/useMobileServerSetup.ts +++ b/src/features/mobile/hooks/useMobileServerSetup.ts @@ -26,6 +26,40 @@ function defaultMobileSetupMessage(): string { return "Enter your desktop Tailscale host and token, then run Connect & test."; } +function markActiveRemoteBackendConnected(settings: AppSettings, connectedAtMs: number): AppSettings { + const existingBackends: AppSettings["remoteBackends"] = + settings.remoteBackends.length > 0 + ? [...settings.remoteBackends] + : [ + { + id: settings.activeRemoteBackendId ?? "remote-default", + name: "Primary remote", + provider: "tcp" as const, + host: settings.remoteBackendHost, + token: settings.remoteBackendToken, + lastConnectedAtMs: null, + }, + ]; + const activeIndexById = + settings.activeRemoteBackendId == null + ? -1 + : existingBackends.findIndex((entry) => entry.id === settings.activeRemoteBackendId); + const activeIndex = activeIndexById >= 0 ? activeIndexById : 0; + const active = existingBackends[activeIndex]; + existingBackends[activeIndex] = { + ...active, + provider: "tcp", + host: settings.remoteBackendHost, + token: settings.remoteBackendToken, + lastConnectedAtMs: connectedAtMs, + }; + return { + ...settings, + remoteBackends: existingBackends, + activeRemoteBackendId: existingBackends[activeIndex]?.id ?? settings.activeRemoteBackendId, + }; +} + export function useMobileServerSetup({ appSettings, appSettingsLoading, @@ -41,6 +75,7 @@ export function useMobileServerSetup({ const [statusMessage, setStatusMessage] = useState(null); const [statusError, setStatusError] = useState(false); const [mobileServerReady, setMobileServerReady] = useState(!isMobileRuntime); + const [setupWizardDismissed, setSetupWizardDismissed] = useState(false); useEffect(() => { if (!isMobileRuntime) { @@ -105,17 +140,21 @@ export function useMobileServerSetup({ } setBusy(true); + setSetupWizardDismissed(false); setStatusError(false); setStatusMessage(null); try { - await queueSaveSettings({ + const saved = await queueSaveSettings({ ...appSettings, backendMode: "remote", remoteBackendProvider: "tcp", remoteBackendHost: nextHost, remoteBackendToken: nextToken, }); - await runConnectivityCheck({ announceSuccess: true }); + const connected = await runConnectivityCheck({ announceSuccess: true }); + if (connected) { + await queueSaveSettings(markActiveRemoteBackendConnected(saved, Date.now())); + } } catch (error) { const message = error instanceof Error ? error.message : "Unable to save remote backend settings."; @@ -179,6 +218,7 @@ export function useMobileServerSetup({ setStatusError(false); setStatusMessage(null); setMobileServerReady(true); + setSetupWizardDismissed(false); try { await refreshWorkspaces(); } catch { @@ -188,7 +228,8 @@ export function useMobileServerSetup({ return { isMobileRuntime, - showMobileSetupWizard: isMobileRuntime && !appSettingsLoading && !mobileServerReady, + showMobileSetupWizard: + isMobileRuntime && !appSettingsLoading && !mobileServerReady && !setupWizardDismissed, mobileSetupWizardProps: { remoteHostDraft, remoteTokenDraft, @@ -196,6 +237,9 @@ export function useMobileServerSetup({ checking, statusMessage, statusError, + onClose: () => { + setSetupWizardDismissed(true); + }, onRemoteHostChange: setRemoteHostDraft, onRemoteTokenChange: setRemoteTokenDraft, onConnectTest, diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index b064bb635..37e4aace5 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -11,7 +11,7 @@ import { import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import type { AppSettings, WorkspaceInfo } from "@/types"; -import { getExperimentalFeatureList, getModelList } from "@services/tauri"; +import { getExperimentalFeatureList, getModelList, listWorkspaces } from "@services/tauri"; import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; import { SettingsView } from "./SettingsView"; @@ -28,11 +28,14 @@ vi.mock("@services/tauri", async () => { ...actual, getModelList: vi.fn(), getExperimentalFeatureList: vi.fn(), + listWorkspaces: vi.fn(), }; }); const getModelListMock = vi.mocked(getModelList); const getExperimentalFeatureListMock = vi.mocked(getExperimentalFeatureList); +const listWorkspacesMock = vi.mocked(listWorkspaces); +listWorkspacesMock.mockResolvedValue([]); const baseSettings: AppSettings = { codexBin: null, @@ -1051,12 +1054,50 @@ describe("SettingsView Codex overrides", () => { onUpdateAppSettings.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Add remote" })); + expect(screen.getByRole("dialog", { name: "Add remote" })).toBeTruthy(); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(0); + + fireEvent.click(screen.getByRole("button", { name: "Close add remote modal" })); + expect(screen.queryByRole("dialog", { name: "Add remote" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Add remote" })); + fireEvent.change(screen.getByLabelText("New remote name"), { + target: { value: "Travel Mac" }, + }); + fireEvent.change(screen.getByLabelText("New remote host"), { + target: { value: "travel-mac.tailnet.ts.net:4732" }, + }); + fireEvent.change(screen.getByLabelText("New remote token"), { + target: { value: "token-travel" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect & add" })); await waitFor(() => { - expect(onUpdateAppSettings).toHaveBeenCalledTimes(1); - const nextSettings = onUpdateAppSettings.mock.calls[0]?.[0] as AppSettings; - expect(nextSettings.remoteBackends).toHaveLength(3); - expect(nextSettings.activeRemoteBackendId).toBeTruthy(); + expect(onUpdateAppSettings).toHaveBeenCalledTimes(2); + }); + const trialSettings = onUpdateAppSettings.mock.calls[0]?.[0] as AppSettings; + const connectedSettings = onUpdateAppSettings.mock.calls[1]?.[0] as AppSettings; + expect(trialSettings.remoteBackends).toHaveLength(3); + expect(trialSettings.activeRemoteBackendId).toBeTruthy(); + expect(trialSettings.remoteBackendHost).toBe("travel-mac.tailnet.ts.net:4732"); + expect(trialSettings.remoteBackendToken).toBe("token-travel"); + expect(connectedSettings.remoteBackends).toHaveLength(3); + const connectedEntry = connectedSettings.remoteBackends.find( + (entry) => entry.id === connectedSettings.activeRemoteBackendId, + ); + expect(connectedEntry?.lastConnectedAtMs).toEqual(expect.any(Number)); + expect(screen.queryByRole("dialog", { name: "Add remote" })).toBeNull(); + expect(listWorkspacesMock).toHaveBeenCalled(); + + onUpdateAppSettings.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Add remote" })); + fireEvent.change(screen.getByLabelText("New remote token"), { + target: { value: "" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect & add" })); + + await waitFor(() => { + expect(screen.getByText("Remote backend token is required.")).toBeTruthy(); }); onUpdateAppSettings.mockClear(); diff --git a/src/features/settings/components/sections/SettingsServerSection.tsx b/src/features/settings/components/sections/SettingsServerSection.tsx index 197b67c27..f209314a5 100644 --- a/src/features/settings/components/sections/SettingsServerSection.tsx +++ b/src/features/settings/components/sections/SettingsServerSection.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; +import X from "lucide-react/dist/esm/icons/x"; import type { AppSettings, TailscaleDaemonCommandPreview, @@ -8,6 +9,12 @@ import type { } from "@/types"; import { ModalShell } from "@/features/design-system/components/modal/ModalShell"; +type AddRemoteBackendDraft = { + name: string; + host: string; + token: string; +}; + type SettingsServerSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; @@ -24,6 +31,7 @@ type SettingsServerSectionProps = { remoteNameDraft: string; remoteHostDraft: string; remoteTokenDraft: string; + nextRemoteNameSuggestion: string; tailscaleStatus: TailscaleStatus | null; tailscaleStatusBusy: boolean; tailscaleStatusError: string | null; @@ -39,7 +47,7 @@ type SettingsServerSectionProps = { onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; onSelectRemoteBackend: (id: string) => Promise; - onAddRemoteBackend: () => Promise; + onAddRemoteBackend: (draft: AddRemoteBackendDraft) => Promise; onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; onDeleteRemoteBackend: (id: string) => Promise; onRefreshTailscaleStatus: () => void; @@ -67,6 +75,7 @@ export function SettingsServerSection({ remoteNameDraft, remoteHostDraft, remoteTokenDraft, + nextRemoteNameSuggestion, tailscaleStatus, tailscaleStatusBusy, tailscaleStatusError, @@ -96,6 +105,12 @@ export function SettingsServerSection({ const [pendingDeleteRemoteId, setPendingDeleteRemoteId] = useState( null, ); + const [addRemoteOpen, setAddRemoteOpen] = useState(false); + const [addRemoteBusy, setAddRemoteBusy] = useState(false); + const [addRemoteError, setAddRemoteError] = useState(null); + const [addRemoteNameDraft, setAddRemoteNameDraft] = useState(""); + const [addRemoteHostDraft, setAddRemoteHostDraft] = useState(""); + const [addRemoteTokenDraft, setAddRemoteTokenDraft] = useState(""); const isMobileSimplified = isMobilePlatform; const pendingDeleteRemote = useMemo( () => @@ -119,6 +134,44 @@ export function SettingsServerSection({ return `Mobile daemon is stopped${tcpDaemonStatus.listenAddr ? ` (${tcpDaemonStatus.listenAddr})` : ""}.`; })(); + const openAddRemoteModal = () => { + setAddRemoteError(null); + setAddRemoteNameDraft(nextRemoteNameSuggestion); + setAddRemoteHostDraft(remoteHostDraft); + setAddRemoteTokenDraft(""); + setAddRemoteOpen(true); + }; + + const closeAddRemoteModal = () => { + if (addRemoteBusy) { + return; + } + setAddRemoteOpen(false); + setAddRemoteError(null); + }; + + const handleAddRemoteConfirm = () => { + void (async () => { + if (addRemoteBusy) { + return; + } + setAddRemoteBusy(true); + setAddRemoteError(null); + try { + await onAddRemoteBackend({ + name: addRemoteNameDraft, + host: addRemoteHostDraft, + token: addRemoteTokenDraft, + }); + setAddRemoteOpen(false); + } catch (error) { + setAddRemoteError(error instanceof Error ? error.message : "Unable to add remote."); + } finally { + setAddRemoteBusy(false); + } + })(); + }; + return (

Server
@@ -234,9 +287,7 @@ export function SettingsServerSection({ @@ -499,6 +550,86 @@ export function SettingsServerSection({ ? "Use your own infrastructure only. On iOS, get the Tailscale hostname and token from your desktop CodexMonitor setup." : "Mobile access should stay scoped to your own infrastructure (tailnet). CodexMonitor does not provide hosted backend services."}
+ {addRemoteOpen && ( + +
+
Add remote
+ +
+
+ + setAddRemoteNameDraft(event.target.value)} + disabled={addRemoteBusy} + /> +
+
+ + setAddRemoteHostDraft(event.target.value)} + disabled={addRemoteBusy} + /> +
+
+ + setAddRemoteTokenDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddRemoteConfirm(); + } + }} + disabled={addRemoteBusy} + /> +
+ {addRemoteError &&
{addRemoteError}
} +
+ + +
+
+ )} {pendingDeleteRemote && ( Promise | void; }; +export type AddRemoteBackendDraft = { + name: string; + host: string; + token: string; +}; + export type SettingsServerSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; @@ -39,6 +45,7 @@ export type SettingsServerSectionProps = { remoteNameDraft: string; remoteHostDraft: string; remoteTokenDraft: string; + nextRemoteNameSuggestion: string; tailscaleStatus: TailscaleStatus | null; tailscaleStatusBusy: boolean; tailscaleStatusError: string | null; @@ -54,7 +61,7 @@ export type SettingsServerSectionProps = { onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; onSelectRemoteBackend: (id: string) => Promise; - onAddRemoteBackend: () => Promise; + onAddRemoteBackend: (draft: AddRemoteBackendDraft) => Promise; onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; onDeleteRemoteBackend: (id: string) => Promise; onRefreshTailscaleStatus: () => void; @@ -333,20 +340,90 @@ export const useSettingsServerSection = ({ setRemoteStatus(`Active remote set to "${selected.name}".`); }; - const handleAddRemoteBackend = async () => { + const handleAddRemoteBackend = async (draft: AddRemoteBackendDraft) => { const latestSettings = latestSettingsRef.current; const existingBackends = getConfiguredRemoteBackends(latestSettings); + const nextName = draft.name.trim(); + if (!nextName) { + const message = "Name is required."; + setRemoteStatus(message, true); + throw new Error(message); + } + const duplicate = existingBackends.some( + (entry) => entry.name.trim().toLowerCase() === nextName.toLowerCase(), + ); + if (duplicate) { + const message = `A remote named "${nextName}" already exists.`; + setRemoteStatus(message, true); + throw new Error(message); + } + const nextHost = draft.host.trim(); + const hostError = validateRemoteHost(nextHost); + if (hostError) { + setRemoteStatus(hostError, true); + throw new Error(hostError); + } + const nextToken = draft.token.trim() ? draft.token.trim() : null; + if (!nextToken) { + const message = "Remote backend token is required."; + setRemoteStatus(message, true); + throw new Error(message); + } + const nextId = createRemoteBackendId(); const nextRemote: RemoteBackendTarget = { id: nextId, - name: buildNextRemoteName(existingBackends), + name: nextName, provider: "tcp", - host: DEFAULT_REMOTE_HOST, - token: null, + host: nextHost, + token: nextToken, lastConnectedAtMs: null, }; - await persistRemoteBackends([...existingBackends, nextRemote], nextId); - setRemoteStatus(`Added "${nextRemote.name}".`); + + const previousSettings = latestSettings; + const candidateBackends = [...existingBackends, nextRemote]; + const candidateSettings = buildSettingsFromRemoteBackends( + previousSettings, + candidateBackends, + nextId, + ); + + let candidatePersisted = false; + try { + await onUpdateAppSettings(candidateSettings); + latestSettingsRef.current = candidateSettings; + candidatePersisted = true; + + const workspaces = await listWorkspaces(); + const workspaceCount = workspaces.length; + const workspaceWord = workspaceCount === 1 ? "workspace" : "workspaces"; + const connectedBackends = candidateBackends.map((entry) => + entry.id === nextId ? { ...entry, lastConnectedAtMs: Date.now() } : entry, + ); + const connectedSettings = buildSettingsFromRemoteBackends( + candidateSettings, + connectedBackends, + nextId, + ); + await onUpdateAppSettings(connectedSettings); + latestSettingsRef.current = connectedSettings; + setRemoteStatus( + `Added "${nextName}" and connected. ${workspaceCount} ${workspaceWord} reachable on the remote backend.`, + ); + await onMobileConnectSuccess?.(); + } catch (error) { + if (candidatePersisted) { + try { + await onUpdateAppSettings(previousSettings); + latestSettingsRef.current = previousSettings; + } catch { + // Keep the original connection error surfaced below. + } + } + const message = formatErrorMessage(error, "Unable to connect to the new remote backend."); + setRemoteStatus(message, true); + throw new Error(message); + } }; const handleSetRemoteNameDraft: Dispatch> = (value) => { @@ -577,6 +654,7 @@ export const useSettingsServerSection = ({ remoteNameDraft, remoteHostDraft, remoteTokenDraft, + nextRemoteNameSuggestion: buildNextRemoteName(getConfiguredRemoteBackends(appSettings)), tailscaleStatus, tailscaleStatusBusy, tailscaleStatusError, diff --git a/src/styles/mobile-setup-wizard.css b/src/styles/mobile-setup-wizard.css index 9d7d9719c..67f88f095 100644 --- a/src/styles/mobile-setup-wizard.css +++ b/src/styles/mobile-setup-wizard.css @@ -25,9 +25,27 @@ } .mobile-setup-wizard-header { + position: relative; padding: 20px 22px 12px; } +.mobile-setup-wizard-close { + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.mobile-setup-wizard-close svg { + width: 14px; + height: 14px; +} + .mobile-setup-wizard-kicker { display: inline-flex; align-items: center; diff --git a/src/styles/settings.css b/src/styles/settings.css index 9837442ee..e582a87c1 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -267,23 +267,82 @@ z-index: 40; } +.settings-add-remote-overlay { + z-index: 40; +} + +.settings-add-remote-overlay .ds-modal-backdrop, +.settings-delete-remote-overlay .ds-modal-backdrop { + background: rgba(5, 8, 14, 0.8); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.settings-add-remote-card { + width: min(420px, calc(100vw - 40px)); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + background: #141b27; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); + color: #eef3ff; +} + +.settings-add-remote-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.settings-add-remote-title { + font-size: 14px; + font-weight: 700; + color: #f5f8ff; +} + +.settings-add-remote-close { + padding: 4px; + color: #dce6f7; +} + +.settings-add-remote-close svg { + width: 14px; + height: 14px; +} + +.settings-add-remote-actions { + display: inline-flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + .settings-delete-remote-card { width: min(380px, calc(100vw - 40px)); padding: 16px; display: flex; flex-direction: column; gap: 10px; + background: #141b27; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); + color: #eef3ff; } .settings-delete-remote-title { font-size: 14px; font-weight: 700; - color: var(--text-strong); + color: #f5f8ff; } .settings-delete-remote-message { font-size: 12px; - color: var(--text-subtle); + color: rgba(232, 240, 251, 0.86); line-height: 1.45; } @@ -294,6 +353,37 @@ margin-top: 4px; } +.settings-add-remote-card .settings-field-label, +.settings-add-remote-card .settings-help { + color: rgba(232, 240, 251, 0.86); +} + +.settings-add-remote-card .settings-help-error { + color: #ffb1b1; +} + +.settings-add-remote-card .settings-input { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + color: #f5f8ff; +} + +.settings-add-remote-card .settings-input::placeholder { + color: rgba(232, 240, 251, 0.52); +} + +.settings-add-remote-card .ghost, +.settings-delete-remote-card .ghost { + color: #dce6f7; + border-color: rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.06); +} + +.settings-add-remote-card .ghost:hover, +.settings-delete-remote-card .ghost:hover { + background: rgba(255, 255, 255, 0.11); +} + .settings-shortcuts-search { margin-bottom: 20px; }