diff --git a/package.json b/package.json index 34881a77..2d2de382 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "pnpm run build:web", "build:web": "pnpm run type-check:web && pnpm --dir src/web-ui build", "build:mobile-web": "pnpm --dir src/mobile-web build", + "prepare:mobile-web": "node scripts/mobile-web-build.cjs", "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", "desktop:dev:raw": "cd src/apps/desktop && npx tauri dev", diff --git a/scripts/dev.cjs b/scripts/dev.cjs index dee43282..8fce888e 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -16,6 +16,7 @@ const { printComplete, printBlank, } = require('./console-style.cjs'); +const { buildMobileWeb } = require('./mobile-web-build.cjs'); const ROOT_DIR = path.resolve(__dirname, '..'); @@ -105,32 +106,6 @@ function runCommand(command, cwd = ROOT_DIR) { }); } -/** - * Clean stale mobile-web resource copies in Tauri target directories. - * - * Tauri copies resources from src/mobile-web/dist/ into target/{profile}/mobile-web/dist/ - * on each dev/build run, but never removes old files. Since Vite generates content-hashed - * filenames, previous builds leave behind orphaned assets that accumulate over time. - * This causes the relay upload to send hundreds of stale files instead of just a few. - */ -function cleanStaleMobileWebResources() { - const fs = require('fs'); - const targetDir = path.join(ROOT_DIR, 'target'); - if (!fs.existsSync(targetDir)) return; - - let cleaned = 0; - for (const profile of fs.readdirSync(targetDir)) { - const mobileWebDir = path.join(targetDir, profile, 'mobile-web'); - if (fs.existsSync(mobileWebDir) && fs.statSync(mobileWebDir).isDirectory()) { - fs.rmSync(mobileWebDir, { recursive: true, force: true }); - cleaned++; - } - } - if (cleaned > 0) { - printInfo(`Cleaned stale mobile-web resources from ${cleaned} target profile(s)`); - } -} - /** * Main entry */ @@ -183,24 +158,15 @@ async function main() { // Step 3: Build mobile-web (desktop only) if (mode === 'desktop') { printStep(3, 4, 'Build mobile-web'); - const mobileWebDir = path.join(ROOT_DIR, 'src/mobile-web'); - const mobileWebResult = runSilent('pnpm install --silent', mobileWebDir); + const mobileWebResult = buildMobileWeb({ + install: true, + logInfo: printInfo, + logSuccess: printSuccess, + logError: printError, + }); if (!mobileWebResult.ok) { - printError('mobile-web pnpm install failed'); - const output = tailOutput(mobileWebResult.stderr || mobileWebResult.stdout); - if (output) printError(output); - process.exit(1); - } - const buildResult = runInherit('pnpm run build', mobileWebDir); - if (!buildResult.ok) { - printError('mobile-web build failed'); - if (buildResult.error && buildResult.error.message) { - printError(buildResult.error.message); - } process.exit(1); } - cleanStaleMobileWebResources(); - printSuccess('mobile-web build complete'); } // Final step: Start dev server diff --git a/scripts/mobile-web-build.cjs b/scripts/mobile-web-build.cjs new file mode 100644 index 00000000..1e6a5bd1 --- /dev/null +++ b/scripts/mobile-web-build.cjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * Shared mobile-web build helpers for desktop dev/build flows. + * + * We must clean copied resources in Tauri target directories before rebuilding, + * otherwise old Vite hashed assets remain in target profile mobile-web folders and get bundled + * or uploaded by remote connect along with the latest files. + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const { + printInfo, + printSuccess, + printError, +} = require('./console-style.cjs'); + +const ROOT_DIR = path.resolve(__dirname, '..'); + +function decodeOutput(output) { + if (!output) return ''; + if (typeof output === 'string') return output; + const buffer = Buffer.isBuffer(output) ? output : Buffer.from(output); + if (process.platform !== 'win32') return buffer.toString('utf-8'); + + const utf8 = buffer.toString('utf-8'); + if (!utf8.includes('�')) return utf8; + + try { + const { TextDecoder } = require('util'); + const decoder = new TextDecoder('gbk'); + const gbk = decoder.decode(buffer); + if (gbk && !gbk.includes('�')) return gbk; + return gbk || utf8; + } catch (error) { + return utf8; + } +} + +function tailOutput(output, maxLines = 12) { + if (!output) return ''; + const lines = output + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); + if (lines.length <= maxLines) return lines.join('\n'); + return lines.slice(-maxLines).join('\n'); +} + +function runSilent(command, cwd = ROOT_DIR) { + try { + const stdout = execSync(command, { + cwd, + stdio: 'pipe', + encoding: 'buffer', + }); + return { ok: true, stdout: decodeOutput(stdout), stderr: '' }; + } catch (error) { + const stdout = error.stdout ? decodeOutput(error.stdout) : ''; + const stderr = error.stderr ? decodeOutput(error.stderr) : ''; + return { ok: false, stdout, stderr, error }; + } +} + +function runInherit(command, cwd = ROOT_DIR) { + try { + execSync(command, { cwd, stdio: 'inherit' }); + return { ok: true, error: null }; + } catch (error) { + return { ok: false, error }; + } +} + +function cleanStaleMobileWebResources(logInfo = printInfo) { + const fs = require('fs'); + const targetDir = path.join(ROOT_DIR, 'target'); + if (!fs.existsSync(targetDir)) return 0; + + let cleaned = 0; + for (const profile of fs.readdirSync(targetDir)) { + const mobileWebDir = path.join(targetDir, profile, 'mobile-web'); + if (fs.existsSync(mobileWebDir) && fs.statSync(mobileWebDir).isDirectory()) { + fs.rmSync(mobileWebDir, { recursive: true, force: true }); + cleaned++; + } + } + + if (cleaned > 0) { + logInfo(`Cleaned stale mobile-web resources from ${cleaned} target profile(s)`); + } + + return cleaned; +} + +function buildMobileWeb(options = {}) { + const { + install = false, + logInfo = printInfo, + logSuccess = printSuccess, + logError = printError, + } = options; + + const mobileWebDir = path.join(ROOT_DIR, 'src/mobile-web'); + + cleanStaleMobileWebResources(logInfo); + + if (install) { + const installResult = runSilent('pnpm install --silent', mobileWebDir); + if (!installResult.ok) { + logError('mobile-web pnpm install failed'); + const output = tailOutput(installResult.stderr || installResult.stdout); + if (output) { + logError(output); + } else if (installResult.error?.message) { + logError(installResult.error.message); + } + return { ok: false }; + } + } + + const buildResult = runInherit('pnpm run build', mobileWebDir); + if (!buildResult.ok) { + logError('mobile-web build failed'); + if (buildResult.error?.message) { + logError(buildResult.error.message); + } + return { ok: false }; + } + + logSuccess('mobile-web build complete'); + return { ok: true }; +} + +if (require.main === module) { + const result = buildMobileWeb(); + process.exit(result.ok ? 0 : 1); +} + +module.exports = { + buildMobileWeb, + cleanStaleMobileWebResources, +}; diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index b6463dca..94ca6847 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1,8 +1,8 @@ //! Tauri commands for Remote Connect. use bitfun_core::service::remote_connect::{ - bot::BotConfig, lan, ConnectionMethod, ConnectionResult, PairingState, RemoteConnectConfig, - RemoteConnectService, + bot::{self, BotConfig}, lan, ConnectionMethod, ConnectionResult, PairingState, + RemoteConnectConfig, RemoteConnectService, }; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -201,6 +201,7 @@ pub struct RemoteConnectStatusResponse { pub pairing_state: PairingState, pub active_method: Option, pub peer_device_name: Option, + pub peer_user_id: Option, /// Independent bot connection info — e.g. "Telegram(7096812005)". /// Present when a bot is active, regardless of relay pairing state. pub bot_connected: Option, @@ -415,7 +416,6 @@ pub async fn remote_connect_stop_bot() -> Result<(), String> { service.stop_bots().await; } // Remove persistence so the bot is not auto-restored - use bitfun_core::service::remote_connect::bot; let mut data = bot::load_bot_persistence(); data.connections.clear(); bot::save_bot_persistence(&data); @@ -432,6 +432,7 @@ pub async fn remote_connect_status() -> Result Result Result { + Ok(bot::load_bot_persistence().form_state) +} + +#[tauri::command] +pub async fn remote_connect_set_form_state( + request: bot::RemoteConnectFormState, +) -> Result<(), String> { + let mut data = bot::load_bot_persistence(); + data.form_state = request; + bot::save_bot_persistence(&data); + Ok(()) +} + #[tauri::command] pub async fn remote_connect_configure_custom_server(url: String) -> Result<(), String> { let holder = get_service_holder(); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 62d8f504..9f25f4f7 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -596,6 +596,8 @@ pub async fn run() { api::remote_connect_api::remote_connect_stop, api::remote_connect_api::remote_connect_stop_bot, api::remote_connect_api::remote_connect_status, + api::remote_connect_api::remote_connect_get_form_state, + api::remote_connect_api::remote_connect_set_form_state, api::remote_connect_api::remote_connect_configure_custom_server, api::remote_connect_api::remote_connect_configure_bot, // MiniApp API diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 2e544abc..50e13b27 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -5,7 +5,7 @@ "build": { "beforeDevCommand": "pnpm run dev:web", "devUrl": "http://localhost:1422", - "beforeBuildCommand": "pnpm run build:web && pnpm run build:mobile-web", + "beforeBuildCommand": "pnpm run build:web && pnpm run prepare:mobile-web", "frontendDist": "../../../dist" }, "bundle": { diff --git a/src/apps/relay-server/test_incremental_upload.py b/src/apps/relay-server/test_incremental_upload.py index 7ac71e4d..d1fa3421 100644 --- a/src/apps/relay-server/test_incremental_upload.py +++ b/src/apps/relay-server/test_incremental_upload.py @@ -33,7 +33,7 @@ subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"]) import websockets -RELAY_URL = sys.argv[1] if len(sys.argv) > 1 else "http://remote.openbitfun.com/relay" +RELAY_URL = sys.argv[1] if len(sys.argv) > 1 else "https://remote.openbitfun.com/relay" WS_URL = RELAY_URL.replace("http://", "ws://").replace("https://", "wss://") + "/ws" PASS = 0 diff --git a/src/crates/core/src/miniapp/compiler.rs b/src/crates/core/src/miniapp/compiler.rs index e2090c10..a2446d72 100644 --- a/src/crates/core/src/miniapp/compiler.rs +++ b/src/crates/core/src/miniapp/compiler.rs @@ -162,7 +162,6 @@ fn inject_into_head(html: &str, content: &str) -> BitFunResult { #[cfg(test)] mod tests { use super::*; - use crate::miniapp::types::MiniAppSource; #[test] fn test_inject_into_head() { diff --git a/src/crates/core/src/service/lsp/plugin_loader.rs b/src/crates/core/src/service/lsp/plugin_loader.rs index 5c8eaefd..b4bd6fb3 100644 --- a/src/crates/core/src/service/lsp/plugin_loader.rs +++ b/src/crates/core/src/service/lsp/plugin_loader.rs @@ -201,11 +201,12 @@ impl PluginLoader { let command = command.replace('/', std::path::MAIN_SEPARATOR_STR); - let mut server_path = plugin_dir.join(&command); + let server_path = plugin_dir.join(&command); if !server_path.exists() { #[cfg(windows)] { + let mut server_path = server_path; let extensions = vec![".exe", ".bat", ".cmd"]; let mut found = false; diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index e8d076bc..7aebaca2 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -39,10 +39,22 @@ pub struct SavedBotConnection { pub connected_at: i64, } +/// Persisted remote-connect form values shown in the desktop dialog. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RemoteConnectFormState { + pub custom_server_url: String, + pub telegram_bot_token: String, + pub feishu_app_id: String, + pub feishu_app_secret: String, +} + /// All persisted bot connections (one per bot type at most). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BotPersistenceData { + #[serde(default)] pub connections: Vec, + #[serde(default)] + pub form_state: RemoteConnectFormState, } impl BotPersistenceData { @@ -508,10 +520,15 @@ fn generate_download_token(chat_id: &str) -> String { format!("{:08x}", ns ^ salt) } -const BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; +const REMOTE_CONNECT_PERSISTENCE_FILENAME: &str = "remote_connect_persistence.json"; +const LEGACY_BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; pub fn bot_persistence_path() -> Option { - dirs::home_dir().map(|home| home.join(".bitfun").join(BOT_PERSISTENCE_FILENAME)) + dirs::home_dir().map(|home| home.join(".bitfun").join(REMOTE_CONNECT_PERSISTENCE_FILENAME)) +} + +fn legacy_bot_persistence_path() -> Option { + dirs::home_dir().map(|home| home.join(".bitfun").join(LEGACY_BOT_PERSISTENCE_FILENAME)) } pub fn load_bot_persistence() -> BotPersistenceData { @@ -520,7 +537,15 @@ pub fn load_bot_persistence() -> BotPersistenceData { }; match std::fs::read_to_string(&path) { Ok(data) => serde_json::from_str(&data).unwrap_or_default(), - Err(_) => BotPersistenceData::default(), + Err(_) => { + let Some(legacy_path) = legacy_bot_persistence_path() else { + return BotPersistenceData::default(); + }; + match std::fs::read_to_string(&legacy_path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => BotPersistenceData::default(), + } + } } } diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index 6d81d1c4..315c0a74 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -60,8 +60,8 @@ impl Default for RemoteConnectConfig { fn default() -> Self { Self { lan_port: 9700, - bitfun_server_url: "http://remote.openbitfun.com/relay".to_string(), - web_app_url: "http://remote.openbitfun.com/relay".to_string(), + bitfun_server_url: "https://remote.openbitfun.com/relay".to_string(), + web_app_url: "https://remote.openbitfun.com/relay".to_string(), custom_server_url: None, bot_feishu: None, bot_telegram: None, @@ -93,6 +93,12 @@ impl BotHandle { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TrustedMobileIdentity { + mobile_install_id: String, + user_id: String, +} + /// Unified Remote Connect service that orchestrates all connection methods. pub struct RemoteConnectService { config: RemoteConnectConfig, @@ -112,6 +118,8 @@ pub struct RemoteConnectService { /// Independent bot connection state — not tied to PairingProtocol. /// Stores the peer description (e.g. "Telegram(7096812005)") when a bot is active. bot_connected_info: Arc>>, + /// Trusted mobile identity for the current relay lifecycle only. + trusted_mobile_identity: Arc>>, } impl RemoteConnectService { @@ -133,6 +141,7 @@ impl RemoteConnectService { telegram_bot: Arc::new(RwLock::new(None)), feishu_bot: Arc::new(RwLock::new(None)), bot_connected_info: Arc::new(RwLock::new(None)), + trusted_mobile_identity: Arc::new(RwLock::new(None)), }) } @@ -140,6 +149,70 @@ impl RemoteConnectService { &self.device_identity } + async fn validate_mobile_identity( + trusted_mobile_identity: &Arc>>, + response: &pairing::PairingResponse, + ) -> std::result::Result { + let mobile_install_id = response + .mobile_install_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing mobile installation ID".to_string())?; + let user_id = response + .user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing user ID".to_string())?; + + let submitted = TrustedMobileIdentity { + mobile_install_id: mobile_install_id.to_string(), + user_id: user_id.to_string(), + }; + + let trusted = trusted_mobile_identity.read().await.clone(); + match trusted { + Some(existing) if existing.mobile_install_id == submitted.mobile_install_id => { + if existing.user_id != submitted.user_id { + Err("This mobile device must continue using the previously confirmed user ID".to_string()) + } else { + Ok(submitted) + } + } + Some(existing) if existing.user_id != submitted.user_id => Err( + "This remote URL is already protected. Enter the previously confirmed user ID to continue.".to_string(), + ), + _ => Ok(submitted), + } + } + + async fn persist_mobile_identity( + trusted_mobile_identity: &Arc>>, + identity: TrustedMobileIdentity, + ) { + *trusted_mobile_identity.write().await = Some(identity); + } + + async fn send_pairing_error_response( + relay_arc: &Arc>>, + correlation_id: &str, + shared_secret: &[u8; 32], + message: String, + ) { + let server = RemoteServer::new(*shared_secret); + if let Ok((enc, nonce)) = server.encrypt_response( + &remote_server::RemoteResponse::Error { message }, + None, + ) { + if let Some(ref client) = *relay_arc.read().await { + let _ = client + .send_relay_response(correlation_id, &enc, &nonce) + .await; + } + } + } + pub fn update_bot_config(&mut self, bot_config: bot::BotConfig) { match bot_config { bot::BotConfig::Feishu { app_id, app_secret } => { @@ -312,6 +385,7 @@ impl RemoteConnectService { let pairing_arc = self.pairing.clone(); let relay_arc = self.relay_client.clone(); let server_arc = self.remote_server.clone(); + let trusted_mobile_identity_arc = self.trusted_mobile_identity.clone(); tokio::spawn(async move { while let Some(event) = event_rx.recv().await { match event { @@ -322,10 +396,6 @@ impl RemoteConnectService { device_name: _, } => { info!("PairRequest from {device_id}"); - // Allow re-pairing: clear existing server so the - // subsequent challenge-echo enters the pairing - // verification branch instead of the command branch. - *server_arc.write().await = None; let mut p = pairing_arc.write().await; match p.on_peer_joined(&public_key).await { Ok(challenge) => { @@ -355,13 +425,13 @@ impl RemoteConnectService { encrypted_data, nonce, } => { - let is_paired = server_arc.read().await.is_some(); - - if is_paired { + let mut handled_as_active_command = false; + { let server_guard = server_arc.read().await; if let Some(ref server) = *server_guard { match server.decrypt_command(&encrypted_data, &nonce) { Ok((cmd, request_id)) => { + handled_as_active_command = true; debug!("Remote command: {cmd:?}"); let response = server.dispatch(&cmd).await; match server @@ -385,56 +455,108 @@ impl RemoteConnectService { } } } - Err(e) => debug!("Ignoring undecryptable command (likely stale mobile session): {e}"), + Err(e) => { + debug!( + "Active session could not decrypt command, falling back to pairing verification: {e}" + ); + } } } - } else { - let p = pairing_arc.read().await; - if let Some(secret) = p.shared_secret() { - if let Ok(json) = encryption::decrypt_from_base64( - secret, - &encrypted_data, - &nonce, - ) { - if let Ok(response) = - serde_json::from_str::(&json) - { - drop(p); - let mut pw = pairing_arc.write().await; - match pw.verify_response(&response).await { - Ok(true) => { - info!("Pairing verified successfully"); - if let Some(s) = pw.shared_secret() { - let server = RemoteServer::new(*s); - - let initial_sync = - server.generate_initial_sync().await; - if let Ok((enc, resp_nonce)) = server - .encrypt_response(&initial_sync, None) + } + if handled_as_active_command { + continue; + } + + let p = pairing_arc.read().await; + if let Some(secret) = p.shared_secret() { + let shared_secret = *secret; + if let Ok(json) = encryption::decrypt_from_base64( + &shared_secret, + &encrypted_data, + &nonce, + ) { + if let Ok(response) = + serde_json::from_str::(&json) + { + let submitted_identity = + match RemoteConnectService::validate_mobile_identity( + &trusted_mobile_identity_arc, + &response, + ) + .await + { + Ok(identity) => identity, + Err(message) => { + drop(p); + RemoteConnectService::send_pairing_error_response( + &relay_arc, + &correlation_id, + &shared_secret, + message, + ) + .await; + continue; + } + }; + drop(p); + let mut pw = pairing_arc.write().await; + match pw.verify_response(&response).await { + Ok(true) => { + info!("Pairing verified successfully"); + RemoteConnectService::persist_mobile_identity( + &trusted_mobile_identity_arc, + submitted_identity.clone(), + ) + .await; + if let Some(s) = pw.shared_secret() { + let server = RemoteServer::new(*s); + + let initial_sync = server + .generate_initial_sync(Some( + submitted_identity.user_id.clone(), + )) + .await; + if let Ok((enc, resp_nonce)) = + server.encrypt_response(&initial_sync, None) + { + if let Some(ref client) = + *relay_arc.read().await { - if let Some(ref client) = - *relay_arc.read().await - { - info!("Sending initial sync to mobile after pairing"); - let _ = client - .send_relay_response( - &correlation_id, - &enc, - &resp_nonce, - ) - .await; - } + info!( + "Sending initial sync to mobile after pairing" + ); + let _ = client + .send_relay_response( + &correlation_id, + &enc, + &resp_nonce, + ) + .await; } - - *server_arc.write().await = Some(server); } + + *server_arc.write().await = Some(server); } - Ok(false) => { - error!("Pairing verification failed"); - } - Err(e) => { - error!("Pairing verification error: {e}"); - } + } + Ok(false) => { + error!("Pairing verification failed"); + RemoteConnectService::send_pairing_error_response( + &relay_arc, + &correlation_id, + &shared_secret, + "Pairing verification failed".to_string(), + ) + .await; + } + Err(e) => { + error!("Pairing verification error: {e}"); + RemoteConnectService::send_pairing_error_response( + &relay_arc, + &correlation_id, + &shared_secret, + format!("Pairing verification error: {e}"), + ) + .await; } } } @@ -710,6 +832,7 @@ impl RemoteConnectService { *self.embedded_relay.write().await = None; self.pairing.write().await.reset().await; + *self.trusted_mobile_identity.write().await = None; info!("Relay connections stopped (bots unaffected)"); } @@ -769,6 +892,14 @@ impl RemoteConnectService { pub async fn bot_connected_info(&self) -> Option { self.bot_connected_info.read().await.clone() } + + pub async fn trusted_mobile_user_id(&self) -> Option { + self.trusted_mobile_identity + .read() + .await + .as_ref() + .map(|identity| identity.user_id.clone()) + } } // ── Upload mobile-web to relay server ────────────────────────────── @@ -788,6 +919,8 @@ struct CollectedFile { hash: String, } +const MAX_UPLOAD_BATCH_BASE64_BYTES: usize = 256 * 1024; + async fn upload_mobile_web(relay_url: &str, room_id: &str, web_dir: &str) -> Result<()> { let base = std::path::Path::new(web_dir); if !base.join("index.html").exists() { @@ -843,7 +976,6 @@ async fn upload_mobile_web(relay_url: &str, room_id: &str, web_dir: &str) -> Res let existing = body["existing_count"].as_u64().unwrap_or(0); let total = body["total_count"].as_u64().unwrap_or(0); - if needed.is_empty() { info!( "All {total} files already exist on relay server, no upload needed" @@ -888,23 +1020,24 @@ async fn upload_needed_files( let needed_set: std::collections::HashSet<&str> = needed.iter().map(|s| s.as_str()).collect(); - let mut files_payload: HashMap = HashMap::new(); + let mut files_payload: Vec<(String, serde_json::Value, usize)> = Vec::new(); for f in all_files { if needed_set.contains(f.rel_path.as_str()) { - files_payload.insert( + let encoded = B64.encode(&f.content); + let encoded_len = encoded.len(); + files_payload.push(( f.rel_path.clone(), serde_json::json!({ - "content": B64.encode(&f.content), + "content": encoded, "hash": f.hash, }), - ); + encoded_len, + )); } } let url = format!("{relay_base}/api/rooms/{room_id}/upload-web-files"); - let total_b64_bytes: usize = files_payload.values().map(|v| { - v["content"].as_str().map_or(0, |s| s.len()) - }).sum(); + let total_b64_bytes: usize = files_payload.iter().map(|(_, _, len)| *len).sum(); info!( "Uploading {} needed files ({} bytes base64) to {url}", @@ -912,20 +1045,40 @@ async fn upload_needed_files( total_b64_bytes ); - let resp = client - .post(&url) - .json(&serde_json::json!({ "files": files_payload })) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| anyhow::anyhow!("upload-web-files: {e}"))?; + let mut current_batch: HashMap = HashMap::new(); + let mut current_batch_b64_bytes = 0usize; + let mut batch_index = 0usize; + for (path, entry, entry_len) in files_payload { + let should_flush = !current_batch.is_empty() + && current_batch_b64_bytes + entry_len > MAX_UPLOAD_BATCH_BASE64_BYTES; + if should_flush { + upload_web_files_batch( + client, + &url, + room_id, + batch_index, + ¤t_batch, + current_batch_b64_bytes, + ) + .await?; + batch_index += 1; + current_batch = HashMap::new(); + current_batch_b64_bytes = 0; + } + current_batch.insert(path, entry); + current_batch_b64_bytes += entry_len; + } - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!( - "upload-web-files failed: HTTP {status} — {body}" - )); + if !current_batch.is_empty() { + upload_web_files_batch( + client, + &url, + room_id, + batch_index, + ¤t_batch, + current_batch_b64_bytes, + ) + .await?; } Ok(()) @@ -941,9 +1094,11 @@ async fn upload_all_files( use base64::{engine::general_purpose::STANDARD as B64, Engine}; use std::collections::HashMap; - let mut files: HashMap = HashMap::new(); + let mut files: Vec<(String, String, usize)> = Vec::new(); for f in all_files { - files.insert(f.rel_path.clone(), B64.encode(&f.content)); + let encoded = B64.encode(&f.content); + let encoded_len = encoded.len(); + files.push((f.rel_path.clone(), encoded, encoded_len)); } let url = format!("{relay_base}/api/rooms/{room_id}/upload-web"); @@ -951,25 +1106,97 @@ async fn upload_all_files( info!( "Full upload: {} files ({} bytes base64) to {url}", files.len(), - files.values().map(|v| v.len()).sum::() + files.iter().map(|(_, _, len)| *len).sum::() ); + let mut current_batch: HashMap = HashMap::new(); + let mut current_batch_b64_bytes = 0usize; + let mut batch_index = 0usize; + for (path, encoded, encoded_len) in files { + let should_flush = !current_batch.is_empty() + && current_batch_b64_bytes + encoded_len > MAX_UPLOAD_BATCH_BASE64_BYTES; + if should_flush { + upload_web_legacy_batch( + client, + &url, + room_id, + batch_index, + ¤t_batch, + current_batch_b64_bytes, + ) + .await?; + batch_index += 1; + current_batch = HashMap::new(); + current_batch_b64_bytes = 0; + } + current_batch.insert(path, encoded); + current_batch_b64_bytes += encoded_len; + } + + if !current_batch.is_empty() { + upload_web_legacy_batch( + client, + &url, + room_id, + batch_index, + ¤t_batch, + current_batch_b64_bytes, + ) + .await?; + } + + Ok(()) +} + +async fn upload_web_files_batch( + client: &reqwest::Client, + url: &str, + _room_id: &str, + batch_index: usize, + files_payload: &std::collections::HashMap, + _total_b64_bytes: usize, +) -> Result<()> { let resp = client - .post(&url) - .json(&serde_json::json!({ "files": files })) + .post(url) + .json(&serde_json::json!({ "files": files_payload })) .timeout(std::time::Duration::from_secs(30)) .send() .await - .map_err(|e| anyhow::anyhow!("upload mobile-web: {e}"))?; + .map_err(|e| anyhow::anyhow!("upload-web-files batch {batch_index}: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow::anyhow!( - "upload mobile-web failed: HTTP {status} — {body}" + "upload-web-files batch {batch_index} failed: HTTP {status} — {body}" )); } + Ok(()) +} +async fn upload_web_legacy_batch( + client: &reqwest::Client, + url: &str, + _room_id: &str, + batch_index: usize, + files_payload: &std::collections::HashMap, + _total_b64_bytes: usize, +) -> Result<()> { + let resp = client + .post(url) + .json(&serde_json::json!({ "files": files_payload })) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| anyhow::anyhow!("upload mobile-web batch {batch_index}: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "upload mobile-web batch {batch_index} failed: HTTP {status} — {body}" + )); + } Ok(()) } diff --git a/src/crates/core/src/service/remote_connect/pairing.rs b/src/crates/core/src/service/remote_connect/pairing.rs index 35b24ad7..befd0948 100644 --- a/src/crates/core/src/service/remote_connect/pairing.rs +++ b/src/crates/core/src/service/remote_connect/pairing.rs @@ -50,6 +50,10 @@ pub struct PairingResponse { pub challenge_echo: String, pub device_id: String, pub device_name: String, + #[serde(default)] + pub mobile_install_id: Option, + #[serde(default)] + pub user_id: Option, } /// Manages the pairing state machine. @@ -162,11 +166,15 @@ impl PairingProtocol { pub fn answer_challenge( challenge: &PairingChallenge, device_identity: &DeviceIdentity, + mobile_install_id: Option, + user_id: Option, ) -> PairingResponse { PairingResponse { challenge_echo: challenge.challenge.clone(), device_id: device_identity.device_id.clone(), device_name: device_identity.device_name.clone(), + mobile_install_id, + user_id, } } @@ -249,7 +257,12 @@ mod tests { assert_eq!(protocol.state().await, PairingState::Verifying); // Mobile answers the challenge - let response = PairingProtocol::answer_challenge(&challenge, &mobile_device); + let response = PairingProtocol::answer_challenge( + &challenge, + &mobile_device, + Some("install-id-1".into()), + Some("alice".into()), + ); // Step 3: Desktop verifies let ok = protocol.verify_response(&response).await.unwrap(); diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 583a4c24..281de076 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -250,6 +250,8 @@ pub enum RemoteResponse { git_branch: Option, sessions: Vec, has_more_sessions: bool, + #[serde(skip_serializing_if = "Option::is_none")] + authenticated_user_id: Option, }, /// Incremental poll response. SessionPoll { @@ -1695,7 +1697,10 @@ impl RemoteServer { get_or_init_global_dispatcher().ensure_tracker(session_id) } - pub async fn generate_initial_sync(&self) -> RemoteResponse { + pub async fn generate_initial_sync( + &self, + authenticated_user_id: Option, + ) -> RemoteResponse { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; @@ -1757,6 +1762,7 @@ impl RemoteServer { git_branch, sessions, has_more_sessions: has_more, + authenticated_user_id, } } diff --git a/src/crates/core/src/service/terminal/src/pty/process.rs b/src/crates/core/src/service/terminal/src/pty/process.rs index 2f275241..8eaff0b9 100644 --- a/src/crates/core/src/service/terminal/src/pty/process.rs +++ b/src/crates/core/src/service/terminal/src/pty/process.rs @@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; -use log::{debug, error, warn}; +use log::{error, warn}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use tokio::sync::mpsc; diff --git a/src/crates/core/src/util/process_manager.rs b/src/crates/core/src/util/process_manager.rs index 4b9af3cc..839ef6e7 100644 --- a/src/crates/core/src/util/process_manager.rs +++ b/src/crates/core/src/util/process_manager.rs @@ -1,10 +1,15 @@ //! Unified process management to avoid Windows child process leaks -use log::warn; use std::process::Command; -use std::sync::{Arc, LazyLock, Mutex}; +use std::sync::LazyLock; use tokio::process::Command as TokioCommand; +#[cfg(windows)] +use log::warn; + +#[cfg(windows)] +use std::sync::{Arc, Mutex}; + #[cfg(windows)] use std::os::windows::process::CommandExt; @@ -79,25 +84,31 @@ impl ProcessManager { /// Create synchronous Command (Windows automatically adds CREATE_NO_WINDOW) pub fn create_command>(program: S) -> Command { - let mut cmd = Command::new(program.as_ref()); + let cmd = Command::new(program.as_ref()); #[cfg(windows)] { + let mut cmd = cmd; cmd.creation_flags(CREATE_NO_WINDOW); + return cmd; } + #[cfg(not(windows))] cmd } /// Create Tokio async Command (Windows automatically adds CREATE_NO_WINDOW) pub fn create_tokio_command>(program: S) -> TokioCommand { - let mut cmd = TokioCommand::new(program.as_ref()); + let cmd = TokioCommand::new(program.as_ref()); #[cfg(windows)] { + let mut cmd = cmd; cmd.creation_flags(CREATE_NO_WINDOW); + return cmd; } + #[cfg(not(windows))] cmd } diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index c1da0efe..290d95a9 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -947,15 +947,43 @@ const ToolCard: React.FC<{ const READ_LIKE_TOOLS = new Set(['Read', 'Grep', 'Glob', 'SemanticSearch']); +function getToolSummaryLabel(toolName: string): string { + const toolKey = toolName.toLowerCase().replace(/[\s-]/g, '_'); + return TOOL_TYPE_MAP[toolKey] || TOOL_TYPE_MAP[toolName] || toolName; +} + +function buildGroupedToolSummary(tools: RemoteToolStatus[]): string { + const counts = new Map(); + const order: string[] = []; + + for (const tool of tools) { + const label = getToolSummaryLabel(tool.name); + const key = label.toLowerCase(); + const existing = counts.get(key); + if (existing) { + existing.count += 1; + continue; + } + counts.set(key, { label, count: 1 }); + order.push(key); + } + + return order + .map((key) => { + const entry = counts.get(key)!; + return `${entry.label} ${entry.count}`; + }) + .join(', '); +} + const ReadFilesToggle: React.FC<{ tools: RemoteToolStatus[] }> = ({ tools }) => { const [open, setOpen] = useState(false); if (tools.length === 0) return null; const doneCount = tools.filter(t => t.status === 'completed').length; const allDone = doneCount === tools.length; - const label = allDone - ? `Read ${tools.length} file${tools.length === 1 ? '' : 's'}` - : `Reading ${tools.length} file${tools.length === 1 ? '' : 's'} (${doneCount} done)`; + const summary = buildGroupedToolSummary(tools); + const label = allDone ? summary : `${summary} (${doneCount} done)`; return (
diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index 339b133d..e2960980 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { RelayHttpClient } from '../services/RelayHttpClient'; import { RemoteSessionManager } from '../services/RemoteSessionManager'; import { useMobileStore } from '../services/store'; @@ -20,82 +20,255 @@ const CubeLogo: React.FC = () => (
); +const MOBILE_INSTALL_ID_KEY = 'bitfun.mobile.install_id'; +const MOBILE_USER_ID_KEY = 'bitfun.mobile.user_id'; +const MOBILE_LOCK_UNTIL_KEY = 'bitfun.mobile.user_id_lock_until'; +const MOBILE_FAILURE_COUNT_KEY = 'bitfun.mobile.user_id_failure_count'; +const MAX_FAILED_USER_ID_ATTEMPTS = 3; +const USER_ID_LOCKOUT_MS = 60_000; + +function isProtectedUserIdError(message: string): boolean { + return message.includes('This remote URL is already protected') + || message.includes('This mobile device must continue using the previously confirmed user ID'); +} + +function generateInstallId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `mobile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function getOrCreateInstallId(): string { + const existing = localStorage.getItem(MOBILE_INSTALL_ID_KEY)?.trim(); + if (existing) return existing; + const created = generateInstallId(); + localStorage.setItem(MOBILE_INSTALL_ID_KEY, created); + return created; +} + +function resolveRelayBaseUrl(): { room: string | null; pk: string | null; httpBaseUrl: string } { + const hash = window.location.hash; + const params = new URLSearchParams(hash.replace(/^#\/pair\?/, '')); + const room = params.get('room'); + const pk = params.get('pk'); + const relayParam = params.get('relay'); + + if (relayParam) { + return { + room, + pk, + httpBaseUrl: relayParam + .replace(/^wss:\/\//, 'https://') + .replace(/^ws:\/\//, 'http://') + .replace(/\/ws\/?$/, '') + .replace(/\/$/, ''), + }; + } + + const origin = window.location.origin; + const pathname = window.location.pathname + .replace(/\/[^/]*$/, '') + .replace(/\/r\/[^/]*$/, ''); + return { + room, + pk, + httpBaseUrl: origin + pathname, + }; +} + const PairingPage: React.FC = ({ onPaired }) => { - const { connectionStatus, setConnectionStatus, setError, error } = useMobileStore(); - const pairedRef = useRef(false); + const { + connectionStatus, + setConnectionStatus, + setError, + error, + setAuthenticatedUserId, + } = useMobileStore(); + const [userId, setUserId] = useState(''); + const [mobileInstallId, setMobileInstallId] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [failureCount, setFailureCount] = useState(0); + const [lockUntil, setLockUntil] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const autoReconnectAttemptedRef = useRef(false); + const failureCountRef = useRef(0); + const lockUntilRef = useRef(null); - useEffect(() => { - const hash = window.location.hash; - const params = new URLSearchParams(hash.replace(/^#\/pair\?/, '')); - const room = params.get('room'); - const pk = params.get('pk'); - const relayParam = params.get('relay'); + const pairingTarget = useMemo(() => resolveRelayBaseUrl(), []); + const isLocked = !!lockUntil && lockUntil > now; + const remainingLockSeconds = isLocked + ? Math.max(1, Math.ceil((lockUntil - now) / 1000)) + : 0; - if (!room || !pk) { + const attemptPair = useCallback(async ( + providedUserId: string, + options?: { autoReconnect?: boolean; installId?: string }, + ) => { + const userIdValue = providedUserId.trim(); + const autoReconnect = options?.autoReconnect === true; + const currentInstallId = options?.installId || mobileInstallId || getOrCreateInstallId(); + const activeLockUntil = lockUntilRef.current; + const lockActive = !!activeLockUntil && activeLockUntil > Date.now(); + const currentRemainingLockSeconds = lockActive + ? Math.max(1, Math.ceil((activeLockUntil - Date.now()) / 1000)) + : 0; + if (!pairingTarget.room || !pairingTarget.pk) { setError('Invalid QR code: missing room or public key'); setConnectionStatus('error'); return; } - - let httpBaseUrl: string; - if (relayParam) { - httpBaseUrl = relayParam - .replace(/^wss:\/\//, 'https://') - .replace(/^ws:\/\//, 'http://') - .replace(/\/ws\/?$/, '') - .replace(/\/$/, ''); - } else { - const origin = window.location.origin; - const pathname = window.location.pathname - .replace(/\/[^/]*$/, '') - .replace(/\/r\/[^/]*$/, ''); - httpBaseUrl = origin + pathname; + if (!userIdValue) { + setError('User ID is required'); + setConnectionStatus('error'); + return; + } + if (!autoReconnect && lockActive) { + // #region agent log + fetch('http://127.0.0.1:7682/ingest/8685ca77-c5bb-4ac6-aaa8-13e4fb36cf13',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'54c236'},body:JSON.stringify({sessionId:'54c236',runId:'post-fix',hypothesisId:'H14',location:'src/mobile-web/src/pages/PairingPage.tsx:108',message:'Manual pairing blocked by local lockout',data:{remainingLockSeconds:currentRemainingLockSeconds,failureCount:failureCountRef.current},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + setError(`Too many failed attempts. Try again in ${currentRemainingLockSeconds}s.`); + setConnectionStatus('error'); + return; } - const client = new RelayHttpClient(httpBaseUrl, room); - - (async () => { - try { - setConnectionStatus('pairing'); - const initialSync = await client.pair(pk); - pairedRef.current = true; - setConnectionStatus('paired'); - - const sessionMgr = new RemoteSessionManager(client); - - const store = useMobileStore.getState(); - if (initialSync.has_workspace) { - store.setCurrentWorkspace({ - has_workspace: true, - path: initialSync.path, - project_name: initialSync.project_name, - git_branch: initialSync.git_branch, - }); - } - if (initialSync.sessions) { - store.setSessions(initialSync.sessions); + setMobileInstallId(currentInstallId); + setSubmitting(true); + + const client = new RelayHttpClient(pairingTarget.httpBaseUrl, pairingTarget.room); + + try { + setError(null); + setConnectionStatus('pairing'); + const initialSync = await client.pair(pairingTarget.pk, { + userId: userIdValue, + mobileInstallId: currentInstallId, + }); + setConnectionStatus('paired'); + localStorage.setItem(MOBILE_USER_ID_KEY, userIdValue); + localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); + localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); + setFailureCount(0); + setLockUntil(null); + setAuthenticatedUserId(initialSync.authenticated_user_id ?? userIdValue); + + const sessionMgr = new RemoteSessionManager(client); + const store = useMobileStore.getState(); + if (initialSync.has_workspace) { + store.setCurrentWorkspace({ + has_workspace: true, + path: initialSync.path, + project_name: initialSync.project_name, + git_branch: initialSync.git_branch, + }); + } + if (initialSync.sessions) { + store.setSessions(initialSync.sessions); + } + onPaired(client, sessionMgr); + } catch (e: any) { + const errorMessage = e?.message || 'Pairing failed'; + // #region agent log + fetch('http://127.0.0.1:7682/ingest/8685ca77-c5bb-4ac6-aaa8-13e4fb36cf13',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'54c236'},body:JSON.stringify({sessionId:'54c236',runId:'pre-fix',hypothesisId:'H13',location:'src/mobile-web/src/pages/PairingPage.tsx:135',message:'Pairing attempt failed',data:{autoReconnect,errorMessage,connectionStatusBefore:'pairing'},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + if (!autoReconnect && isProtectedUserIdError(errorMessage)) { + const nextFailureCount = failureCountRef.current + 1; + const shouldLock = nextFailureCount >= MAX_FAILED_USER_ID_ATTEMPTS; + const nextLockUntil = shouldLock ? Date.now() + USER_ID_LOCKOUT_MS : null; + localStorage.setItem(MOBILE_FAILURE_COUNT_KEY, String(nextFailureCount)); + if (nextLockUntil) { + localStorage.setItem(MOBILE_LOCK_UNTIL_KEY, String(nextLockUntil)); + } else { + localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); } + setFailureCount(nextFailureCount); + setLockUntil(nextLockUntil); + // #region agent log + fetch('http://127.0.0.1:7682/ingest/8685ca77-c5bb-4ac6-aaa8-13e4fb36cf13',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'54c236'},body:JSON.stringify({sessionId:'54c236',runId:'post-fix',hypothesisId:'H14',location:'src/mobile-web/src/pages/PairingPage.tsx:154',message:'Counted protected user ID failure',data:{nextFailureCount,shouldLock,lockUntil:nextLockUntil},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + setError( + shouldLock + ? `Too many failed attempts. Try again in ${Math.ceil(USER_ID_LOCKOUT_MS / 1000)}s.` + : errorMessage, + ); + } else { + setError(errorMessage); + } + setConnectionStatus('error'); + } finally { + setSubmitting(false); + } + }, [mobileInstallId, pairingTarget.httpBaseUrl, pairingTarget.pk, pairingTarget.room, setAuthenticatedUserId, setConnectionStatus, setError]); + + useEffect(() => { + const savedUserId = localStorage.getItem(MOBILE_USER_ID_KEY)?.trim() ?? ''; + const currentInstallId = getOrCreateInstallId(); + const persistedFailureCount = Number(localStorage.getItem(MOBILE_FAILURE_COUNT_KEY) || '0'); + const persistedLockUntil = Number(localStorage.getItem(MOBILE_LOCK_UNTIL_KEY) || '0'); + const normalizedLockUntil = persistedLockUntil > Date.now() ? persistedLockUntil : null; + if (persistedLockUntil && !normalizedLockUntil) { + localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); + localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); + } + const shouldAutoReconnect = !!savedUserId && !!currentInstallId && !!pairingTarget.room && !!pairingTarget.pk; + // #region agent log + fetch('http://127.0.0.1:7682/ingest/8685ca77-c5bb-4ac6-aaa8-13e4fb36cf13',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'54c236'},body:JSON.stringify({sessionId:'54c236',runId:'pre-fix',hypothesisId:'H11',location:'src/mobile-web/src/pages/PairingPage.tsx:145',message:'Loaded pairing page local identity and lock state',data:{hasSavedUserId:!!savedUserId,hasInstallId:!!currentInstallId,shouldAutoReconnect,hasLockUntil:!!normalizedLockUntil,isLocked:!!normalizedLockUntil&&normalizedLockUntil>Date.now(),failureCount:persistedFailureCount},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + setUserId(savedUserId); + setMobileInstallId(currentInstallId); + setFailureCount(normalizedLockUntil ? persistedFailureCount : 0); + setLockUntil(normalizedLockUntil); + setConnectionStatus(shouldAutoReconnect ? 'pairing' : 'idle'); + setError(null); + if (shouldAutoReconnect && !autoReconnectAttemptedRef.current) { + autoReconnectAttemptedRef.current = true; + void attemptPair(savedUserId, { autoReconnect: true, installId: currentInstallId }); + } + }, [attemptPair, pairingTarget.pk, pairingTarget.room, setConnectionStatus, setError]); + + useEffect(() => { + failureCountRef.current = failureCount; + lockUntilRef.current = lockUntil; + }, [failureCount, lockUntil]); - onPaired(client, sessionMgr); - } catch (e: any) { - setError(e?.message || 'Pairing failed'); - setConnectionStatus('error'); + useEffect(() => { + if (!lockUntil) return; + if (lockUntil <= Date.now()) { + setLockUntil(null); + setFailureCount(0); + localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); + localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); + return; + } + const timer = window.setInterval(() => { + const currentNow = Date.now(); + setNow(currentNow); + if (lockUntil <= currentNow) { + setLockUntil(null); + setFailureCount(0); + localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); + localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); } - })(); - }, []); + }, 1000); + return () => window.clearInterval(timer); + }, [lockUntil]); + + const handleConnect = async () => { + autoReconnectAttemptedRef.current = true; + // #region agent log + fetch('http://127.0.0.1:7682/ingest/8685ca77-c5bb-4ac6-aaa8-13e4fb36cf13',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'54c236'},body:JSON.stringify({sessionId:'54c236',runId:'pre-fix',hypothesisId:'H12',location:'src/mobile-web/src/pages/PairingPage.tsx:160',message:'Manual pairing submit triggered',data:{userIdLength:userId.trim().length},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + await attemptPair(userId, { autoReconnect: false }); + }; const stateLabels: Record = { + idle: 'Enter your user ID to continue', pairing: 'Connecting and pairing...', paired: 'Paired! Loading sessions...', error: 'Connection error', }; - - const handleRetry = () => { - window.location.reload(); - }; - - const showRetry = connectionStatus === 'error'; const showSpinner = connectionStatus === 'pairing'; + const showForm = connectionStatus === 'idle' || connectionStatus === 'error'; return (
@@ -110,13 +283,36 @@ const PairingPage: React.FC = ({ onPaired }) => { {stateLabels[connectionStatus] || connectionStatus}
- {error &&
{error}
} - - {showRetry && ( - + {showForm && ( +
+ +

+ The first successful connection binds this URL to your user ID for the current remote session. +

+ +
)} + + {error &&
{error}
} ); }; diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 85e54590..7d4f8c0c 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -83,7 +83,15 @@ const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( ); const SessionListPage: React.FC = ({ sessionMgr, onSelectSession, onOpenWorkspace }) => { - const { sessions, setSessions, appendSessions, setError, currentWorkspace, setCurrentWorkspace } = useMobileStore(); + const { + sessions, + setSessions, + appendSessions, + setError, + currentWorkspace, + setCurrentWorkspace, + authenticatedUserId, + } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); const [creating, setCreating] = useState(false); const [loading, setLoading] = useState(false); @@ -255,6 +263,13 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS + {authenticatedUserId && ( +
+ User ID + {authenticatedUserId} +
+ )} +
{ + async pair( + desktopPubKeyB64: string, + identity: { + userId: string; + mobileInstallId: string; + }, + ): Promise { this.keyPair = await generateKeyPair(); const desktopPub = fromB64(desktopPubKeyB64); this.sharedKey = await deriveSharedKey(this.keyPair, desktopPub); - const deviceId = `mobile-${Date.now().toString(36)}`; + const deviceId = identity.mobileInstallId; const deviceName = this.getMobileDeviceName(); + const userId = identity.userId.trim(); + const mobileInstallId = identity.mobileInstallId.trim(); // Step 1: POST /pair → encrypted challenge const pairResp = await fetch( @@ -71,6 +79,8 @@ export class RelayHttpClient { challenge_echo: challenge.challenge, device_id: deviceId, device_name: deviceName, + mobile_install_id: mobileInstallId, + user_id: userId, }); const { data: encData, nonce: encNonce } = await encrypt( this.sharedKey, @@ -96,7 +106,11 @@ export class RelayHttpClient { cmdData.encrypted_data, cmdData.nonce, ); - return JSON.parse(initialSyncJson); + const parsed = JSON.parse(initialSyncJson); + if (parsed?.resp === 'error') { + throw new Error(parsed?.message || 'Pairing rejected'); + } + return parsed; } /** diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index f98fffca..18fef4fd 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -97,6 +97,7 @@ export interface InitialSyncData { git_branch?: string; sessions: SessionInfo[]; has_more_sessions: boolean; + authenticated_user_id?: string; } export class RemoteSessionManager { diff --git a/src/mobile-web/src/services/store.ts b/src/mobile-web/src/services/store.ts index 9e0610b7..e78b332b 100644 --- a/src/mobile-web/src/services/store.ts +++ b/src/mobile-web/src/services/store.ts @@ -6,7 +6,7 @@ import type { ActiveTurnSnapshot, } from './RemoteSessionManager'; -export type ConnectionStatus = 'pairing' | 'paired' | 'error'; +export type ConnectionStatus = 'idle' | 'pairing' | 'paired' | 'error'; interface MobileStore { connectionStatus: ConnectionStatus; @@ -15,6 +15,9 @@ interface MobileStore { currentWorkspace: WorkspaceInfo | null; setCurrentWorkspace: (w: WorkspaceInfo | null) => void; + authenticatedUserId: string | null; + setAuthenticatedUserId: (userId: string | null) => void; + sessions: SessionInfo[]; setSessions: (s: SessionInfo[]) => void; appendSessions: (s: SessionInfo[]) => void; @@ -36,12 +39,15 @@ interface MobileStore { } export const useMobileStore = create((set, get) => ({ - connectionStatus: 'pairing', + connectionStatus: 'idle', setConnectionStatus: (connectionStatus) => set({ connectionStatus }), currentWorkspace: null, setCurrentWorkspace: (currentWorkspace) => set({ currentWorkspace }), + authenticatedUserId: null, + setAuthenticatedUserId: (authenticatedUserId) => set({ authenticatedUserId }), + sessions: [], setSessions: (sessions) => set({ sessions }), appendSessions: (newSessions) => diff --git a/src/mobile-web/src/styles/components/pairing.scss b/src/mobile-web/src/styles/components/pairing.scss index 270c6fae..447844ef 100644 --- a/src/mobile-web/src/styles/components/pairing.scss +++ b/src/mobile-web/src/styles/components/pairing.scss @@ -54,6 +54,51 @@ text-align: center; } +.pairing-page__form { + width: min(100%, 320px); + display: flex; + flex-direction: column; + gap: var(--size-gap-3); +} + +.pairing-page__field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pairing-page__field-label { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.pairing-page__input { + width: 100%; + min-height: 44px; + padding: 0 14px; + border: 1px solid var(--border-base); + border-radius: var(--size-radius-base); + background: var(--color-bg-elevated); + color: var(--color-text-primary); + font-size: var(--font-size-base); + outline: none; + + &:focus { + border-color: var(--color-accent-400); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-400) 18%, transparent); + } +} + +.pairing-page__note { + margin: 0; + font-size: var(--font-size-xs); + line-height: 1.5; + color: var(--color-text-muted); + text-align: left; +} + .pairing-page__spinner-wrap { height: 32px; display: flex; @@ -86,4 +131,9 @@ &:active { background: var(--btn-hover-bg); } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } } diff --git a/src/mobile-web/src/styles/components/sessions.scss b/src/mobile-web/src/styles/components/sessions.scss index 6d3cfd81..9ecffd2e 100644 --- a/src/mobile-web/src/styles/components/sessions.scss +++ b/src/mobile-web/src/styles/components/sessions.scss @@ -111,6 +111,35 @@ } } +.session-list__identity-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: var(--size-gap-3) var(--size-gap-4) 0; + padding: 10px 14px; + border: 1px solid var(--border-subtle); + @include squircle(16px); + background: var(--color-bg-elevated); + box-shadow: var(--shadow-sm); +} + +.session-list__identity-label { + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.session-list__identity-value { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .session-list__workspace-icon { flex-shrink: 0; display: inline-flex; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 09673772..ea06196f 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -260,13 +260,51 @@ text-align: left; } -.bitfun-remote-connect__description-link { +.bitfun-remote-connect__info-card { + width: 100%; + max-width: 360px; + margin: 0 auto; + padding: 14px 16px; + border: 1px dashed var(--border-subtle); + border-radius: 4px; + background: color-mix(in srgb, var(--element-bg-subtle) 84%, transparent); + box-sizing: border-box; +} + +.bitfun-remote-connect__info-text, +.bitfun-remote-connect__info-meta { + font-size: 12px; color: var(--color-text-muted); + line-height: 1.7; + margin: 0; + text-align: left; +} + +.bitfun-remote-connect__info-meta-group { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-subtle); +} + +.bitfun-remote-connect__description-link, +.bitfun-remote-connect__step-link { + color: var(--color-text-secondary); text-decoration: underline; cursor: pointer; + text-underline-offset: 2px; + transition: color 0.15s ease, opacity 0.15s ease; &:hover { - color: var(--color-text-muted); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent-500, #3b82f6) 55%, transparent); + outline-offset: 2px; + border-radius: 4px; } } @@ -324,9 +362,10 @@ } .bitfun-remote-connect__field-prefix { - min-width: 72px; + min-width: 96px; color: var(--color-text-secondary); text-align: left; + flex-shrink: 0; } // ==================== Buttons ==================== @@ -402,11 +441,9 @@ .bitfun-remote-connect__steps { display: flex; flex-direction: column; - align-items: stretch; - gap: 6px; + align-items: flex-start; + gap: 8px; width: 100%; - max-width: 420px; - margin: 0 auto; } .bitfun-remote-connect__step { @@ -415,15 +452,6 @@ line-height: 1.6; margin: 0; text-align: left; -} - -.bitfun-remote-connect__step-link { - color: var(--color-text-muted); - text-decoration: underline; - cursor: pointer; - - &:hover { - color: var(--color-text-muted); - } + width: 100%; } diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 9e143e6e..c6ab34dc 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -25,12 +25,13 @@ import './RemoteConnectDialog.scss'; // ── Types ──────────────────────────────────────────────────────────── type ActiveGroup = 'network' | 'bot'; -type NetworkTab = 'lan' | 'ngrok' | 'custom_server'; +type NetworkTab = 'lan' | 'ngrok' | 'bitfun_server' | 'custom_server'; type BotTab = 'telegram' | 'feishu'; const NETWORK_TABS: { id: NetworkTab; labelKey: string }[] = [ { id: 'lan', labelKey: 'remoteConnect.tabLan' }, { id: 'ngrok', labelKey: 'remoteConnect.tabNgrok' }, + { id: 'bitfun_server', labelKey: 'remoteConnect.tabBitfunServer' }, { id: 'custom_server', labelKey: 'remoteConnect.tabCustomServer' }, ]; @@ -50,6 +51,7 @@ const methodToNetworkTab = (method: string | null | undefined): NetworkTab | nul if (!method) return null; if (method.startsWith('Lan')) return 'lan'; if (method.startsWith('Ngrok')) return 'ngrok'; + if (method.startsWith('BitfunServer')) return 'bitfun_server'; if (method.startsWith('CustomServer')) return 'custom_server'; return null; }; @@ -189,6 +191,27 @@ export const RemoteConnectDialog: React.FC = ({ }; }, [isOpen, activeGroup, networkTab]); + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + const loadFormState = async () => { + try { + const formState = await remoteConnectAPI.getFormState(); + if (cancelled) return; + setCustomUrl(formState.custom_server_url ?? ''); + setTgToken(formState.telegram_bot_token ?? ''); + setFeishuAppId(formState.feishu_app_id ?? ''); + setFeishuAppSecret(formState.feishu_app_secret ?? ''); + } catch { + // Ignore form-state restore failures and keep in-memory defaults. + } + }; + void loadFormState(); + return () => { + cancelled = true; + }; + }, [isOpen]); + // ── Connection handlers ────────────────────────────────────────── const handleConnect = useCallback(async () => { @@ -197,6 +220,13 @@ export const RemoteConnectDialog: React.FC = ({ setConnectionResult(null); try { + await remoteConnectAPI.setFormState({ + custom_server_url: customUrl, + telegram_bot_token: tgToken, + feishu_app_id: feishuAppId, + feishu_app_secret: feishuAppSecret, + }); + let method: string; let serverUrl: string | undefined; @@ -213,7 +243,6 @@ export const RemoteConnectDialog: React.FC = ({ method = networkTab; if (networkTab === 'custom_server') serverUrl = customUrl || undefined; } - const result = await remoteConnectAPI.startConnection(method, serverUrl); setConnectionResult(result); startPolling(activeGroup === 'bot' ? 'bot' : 'relay'); @@ -269,6 +298,12 @@ export const RemoteConnectDialog: React.FC = ({ void systemAPI.openExternal(FEISHU_SETUP_GUIDE_URLS[currentLanguage]); }, [currentLanguage]); + const renderInfoCard = (children: React.ReactNode) => ( +
+ {children} +
+ ); + // ── Sub-tab disabled logic ─────────────────────────────────────── const isNetworkSubDisabled = (tabId: NetworkTab): boolean => { @@ -298,12 +333,21 @@ export const RemoteConnectDialog: React.FC = ({ ); }; - const renderConnectedView = (peerName: string, onDisconnect: () => void) => ( + const renderConnectedView = ( + peerName: string, + onDisconnect: () => void, + userId?: string | null, + ) => (
{t('remoteConnect.stateConnected')} {peerName}
+ {userId && ( +

+ {t('remoteConnect.connectedUserId')}: {userId} +

+ )}

{t('remoteConnect.connectedHint')}