diff --git a/Cargo.lock b/Cargo.lock index a56cfba4..cccaffb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4802,6 +4802,10 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "peekoo-python-sdk" +version = "0.1.0" + [[package]] name = "peekoo-scheduler" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a133f2bf..b6a4e1e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/acp-registry-client", "crates/peekoo-mcp-server", "crates/peekoo-analytics", + "crates/peekoo-python-sdk", "apps/desktop-tauri/src-tauri", ] resolver = "2" @@ -42,4 +43,3 @@ strip = true - diff --git a/apps/desktop-tauri/src-tauri/gen/schemas/macOS-schema.json b/apps/desktop-tauri/src-tauri/gen/schemas/macOS-schema.json index e8c95528..8a206235 100644 --- a/apps/desktop-tauri/src-tauri/gen/schemas/macOS-schema.json +++ b/apps/desktop-tauri/src-tauri/gen/schemas/macOS-schema.json @@ -2636,6 +2636,60 @@ "const": "notification:deny-show", "markdownDescription": "Denies the show command without any pre-configured scope." }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-capture`\n- `allow-identify`\n- `allow-alias`\n- `allow-reset`\n- `allow-get-distinct-id`\n- `allow-get-config`", + "type": "string", + "const": "posthog:default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-capture`\n- `allow-identify`\n- `allow-alias`\n- `allow-reset`\n- `allow-get-distinct-id`\n- `allow-get-config`" + }, + { + "description": "Allows creating user aliases in PostHog", + "type": "string", + "const": "posthog:allow-alias", + "markdownDescription": "Allows creating user aliases in PostHog" + }, + { + "description": "Allows capturing PostHog events", + "type": "string", + "const": "posthog:allow-capture", + "markdownDescription": "Allows capturing PostHog events" + }, + { + "description": "Allows getting PostHog configuration for frontend initialization", + "type": "string", + "const": "posthog:allow-get-config", + "markdownDescription": "Allows getting PostHog configuration for frontend initialization" + }, + { + "description": "Allows getting the current distinct ID", + "type": "string", + "const": "posthog:allow-get-distinct-id", + "markdownDescription": "Allows getting the current distinct ID" + }, + { + "description": "Allows identifying users in PostHog", + "type": "string", + "const": "posthog:allow-identify", + "markdownDescription": "Allows identifying users in PostHog" + }, + { + "description": "Enables the ping command without any pre-configured scope.", + "type": "string", + "const": "posthog:allow-ping", + "markdownDescription": "Enables the ping command without any pre-configured scope." + }, + { + "description": "Allows resetting user data in PostHog", + "type": "string", + "const": "posthog:allow-reset", + "markdownDescription": "Allows resetting user data in PostHog" + }, + { + "description": "Denies the ping command without any pre-configured scope.", + "type": "string", + "const": "posthog:deny-ping", + "markdownDescription": "Denies the ping command without any pre-configured scope." + }, { "description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`", "type": "string", @@ -2666,6 +2720,48 @@ "const": "process:deny-restart", "markdownDescription": "Denies the restart command without any pre-configured scope." }, + { + "description": "Allows all required permissions (envelope, breadcrumb)\n#### This default permission set includes:\n\n- `allow-envelope`\n- `allow-breadcrumb`", + "type": "string", + "const": "sentry:default", + "markdownDescription": "Allows all required permissions (envelope, breadcrumb)\n#### This default permission set includes:\n\n- `allow-envelope`\n- `allow-breadcrumb`" + }, + { + "description": "Enables the breadcrumb command.", + "type": "string", + "const": "sentry:allow-breadcrumb", + "markdownDescription": "Enables the breadcrumb command." + }, + { + "description": "Enables the envelope command.", + "type": "string", + "const": "sentry:allow-envelope", + "markdownDescription": "Enables the envelope command." + }, + { + "description": "Enables the event command without any pre-configured scope.", + "type": "string", + "const": "sentry:allow-event", + "markdownDescription": "Enables the event command without any pre-configured scope." + }, + { + "description": "Denies the breadcrumb command.", + "type": "string", + "const": "sentry:deny-breadcrumb", + "markdownDescription": "Denies the breadcrumb command." + }, + { + "description": "Denies the envelope command.", + "type": "string", + "const": "sentry:deny-envelope", + "markdownDescription": "Denies the envelope command." + }, + { + "description": "Denies the event command without any pre-configured scope.", + "type": "string", + "const": "sentry:deny-event", + "markdownDescription": "Denies the event command without any pre-configured scope." + }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", diff --git a/crates/peekoo-plugin-host/src/host_functions.rs b/crates/peekoo-plugin-host/src/host_functions.rs index 7306fb3d..9ae156f1 100644 --- a/crates/peekoo-plugin-host/src/host_functions.rs +++ b/crates/peekoo-plugin-host/src/host_functions.rs @@ -884,13 +884,32 @@ fn host_process_exec( ) -> Result<(), Error> { let ctx = user_data.get().map_err(|e| Error::msg(format!("{e}")))?; let ctx = ctx.lock().map_err(|e| Error::msg(format!("{e}")))?; - require_declared_capability(&ctx, "process:exec")?; + if let Err(err) = require_declared_capability(&ctx, "process:exec") { + let response = serde_json::json!({ + "ok": false, + "statusCode": -1, + "stdout": "", + "stderr": err.to_string(), + }) + .to_string(); + write_output(plugin, outputs, &response)?; + return Ok(()); + } + let input_str = read_input(plugin, inputs)?; let req: serde_json::Value = serde_json::from_str(&input_str).unwrap_or_default(); let program = req["program"].as_str().unwrap_or_default().trim(); if program.is_empty() { - return Err(Error::msg("process program is required")); + let response = serde_json::json!({ + "ok": false, + "statusCode": -1, + "stdout": "", + "stderr": "process program is required", + }) + .to_string(); + write_output(plugin, outputs, &response)?; + return Ok(()); } let args = req["args"] @@ -903,13 +922,44 @@ fn host_process_exec( .unwrap_or_default(); let cwd = req["cwd"].as_str().unwrap_or("."); - let resolved_cwd = resolve_process_cwd(&ctx, cwd)?; + let resolved_cwd = match resolve_process_cwd(&ctx, cwd) { + Ok(path) => path, + Err(err) => { + let response = serde_json::json!({ + "ok": false, + "statusCode": -1, + "stdout": "", + "stderr": err.to_string(), + }) + .to_string(); + write_output(plugin, outputs, &response)?; + return Ok(()); + } + }; - let output = Command::new(program) + let resolved_program = resolve_process_program(program, &resolved_cwd); + let output = match Command::new(&resolved_program) .args(&args) .current_dir(&resolved_cwd) .output() - .map_err(|e| Error::msg(format!("Process spawn failed: {e}")))?; + { + Ok(output) => output, + Err(e) => { + let response = serde_json::json!({ + "ok": false, + "statusCode": -1, + "stdout": "", + "stderr": format!( + "Process spawn failed: program='{}' cwd='{}' ({e})", + resolved_program.display(), + resolved_cwd.display() + ), + }) + .to_string(); + write_output(plugin, outputs, &response)?; + return Ok(()); + } + }; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -930,6 +980,22 @@ fn host_process_exec( Ok(()) } +fn resolve_process_program(program: &str, cwd: &Path) -> PathBuf { + let expanded = expand_tilde_path(program); + let candidate = PathBuf::from(expanded); + if candidate.is_absolute() { + return candidate; + } + + // Keep PATH lookup behavior for plain command names (e.g. "python3"). + let has_path_separator = program.contains('/') || program.contains('\\'); + if !has_path_separator { + return candidate; + } + + cwd.join(candidate) +} + fn host_set_mood( plugin: &mut CurrentPlugin, inputs: &[Val], diff --git a/crates/peekoo-plugin-sdk/src/lib.rs b/crates/peekoo-plugin-sdk/src/lib.rs index 2b5f1f5b..f893baaf 100644 --- a/crates/peekoo-plugin-sdk/src/lib.rs +++ b/crates/peekoo-plugin-sdk/src/lib.rs @@ -96,6 +96,7 @@ pub mod log; pub mod mood; pub mod notify; pub mod oauth; +pub mod process; pub mod schedule; pub mod secrets; pub mod state; @@ -125,6 +126,7 @@ pub mod peekoo { pub use crate::mood; pub use crate::notify; pub use crate::oauth; + pub use crate::process; pub use crate::schedule; pub use crate::secrets; pub use crate::state; diff --git a/crates/peekoo-python-sdk/Cargo.toml b/crates/peekoo-python-sdk/Cargo.toml new file mode 100644 index 00000000..f71fcd99 --- /dev/null +++ b/crates/peekoo-python-sdk/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "peekoo-python-sdk" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[lib] +path = "src/lib.rs" diff --git a/crates/peekoo-python-sdk/scripts/build_runtime_package.sh b/crates/peekoo-python-sdk/scripts/build_runtime_package.sh new file mode 100755 index 00000000..023c191d --- /dev/null +++ b/crates/peekoo-python-sdk/scripts/build_runtime_package.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +REQ_FILE="${1:-}" +OUT_FILE="${2:-}" + +if [[ -z "$REQ_FILE" || -z "$OUT_FILE" ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +if [[ ! -f "$REQ_FILE" ]]; then + echo "requirements file not found: $REQ_FILE" >&2 + exit 1 +fi + +platform="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +case "$platform:$arch" in + darwin:arm64) target_triple="aarch64-apple-darwin" ;; + darwin:x86_64) target_triple="x86_64-apple-darwin" ;; + linux:x86_64) target_triple="x86_64-unknown-linux-gnu" ;; + linux:aarch64) target_triple="aarch64-unknown-linux-gnu" ;; + *) + echo "unsupported platform: $platform/$arch" >&2 + exit 1 + ;; +esac + +release_tag="${PEEKOO_PYTHON_STANDALONE_TAG:-20250317}" +archive_url="${PEEKOO_PYTHON_STANDALONE_URL:-https://github.com/indygreg/python-build-standalone/releases/download/${release_tag}/cpython-3.12.9+${release_tag}-${target_triple}-install_only.tar.gz}" + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +archive_path="$workdir/python-runtime.tar.gz" +curl -fL "$archive_url" -o "$archive_path" +tar -xzf "$archive_path" -C "$workdir" + +if [[ -x "$workdir/python/bin/python3" ]]; then + py_bin="$workdir/python/bin/python3" +elif [[ -x "$workdir/python/bin/python" ]]; then + py_bin="$workdir/python/bin/python" +else + echo "python binary not found in extracted runtime" >&2 + exit 1 +fi + +"$py_bin" -m ensurepip --upgrade || true +"$py_bin" -m pip install --upgrade pip setuptools wheel +"$py_bin" -m pip install --no-cache-dir -r "$REQ_FILE" + +mkdir -p "$(dirname "$OUT_FILE")" +tar -czf "$OUT_FILE" -C "$workdir" python + +echo "Python runtime package created: $OUT_FILE" diff --git a/crates/peekoo-python-sdk/scripts/install_runtime_package.sh b/crates/peekoo-python-sdk/scripts/install_runtime_package.sh new file mode 100755 index 00000000..f864603c --- /dev/null +++ b/crates/peekoo-python-sdk/scripts/install_runtime_package.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +PACKAGE_FILE="${1:-}" +TARGET_DIR="${2:-$HOME/.peekoo/python-sdk}" + +if [[ -z "$PACKAGE_FILE" ]]; then + echo "usage: $0 [target-dir]" >&2 + exit 1 +fi + +if [[ ! -f "$PACKAGE_FILE" ]]; then + echo "package file not found: $PACKAGE_FILE" >&2 + exit 1 +fi + +mkdir -p "$TARGET_DIR" +rm -rf "$TARGET_DIR/python" +tar -xzf "$PACKAGE_FILE" -C "$TARGET_DIR" + +echo "Python runtime installed to: $TARGET_DIR/python" diff --git a/crates/peekoo-python-sdk/src/lib.rs b/crates/peekoo-python-sdk/src/lib.rs new file mode 100644 index 00000000..df79f074 --- /dev/null +++ b/crates/peekoo-python-sdk/src/lib.rs @@ -0,0 +1,39 @@ +pub const SHARED_PYTHON_SDK_ROOT: &str = "~/.peekoo/python-sdk"; + +pub fn shared_python_candidates() -> Vec { + vec![ + format!("{SHARED_PYTHON_SDK_ROOT}/python/bin/python3"), + format!("{SHARED_PYTHON_SDK_ROOT}/python/bin/python"), + format!("{SHARED_PYTHON_SDK_ROOT}/python/python.exe"), + format!("{SHARED_PYTHON_SDK_ROOT}/python/bin/python.exe"), + ] +} + +pub fn plugin_local_python_candidates() -> Vec { + vec![ + "runtime/python/bin/python3".to_string(), + "runtime/python/bin/python".to_string(), + "runtime/python/python.exe".to_string(), + "runtime/python/bin/python.exe".to_string(), + ] +} + +pub fn system_python_candidates() -> Vec { + vec![ + "python3".to_string(), + "python".to_string(), + "py".to_string(), + ] +} + +pub fn all_python_candidates() -> Vec { + let mut out = Vec::new(); + out.extend(shared_python_candidates()); + out.extend(plugin_local_python_candidates()); + out.extend(system_python_candidates()); + out +} + +pub fn is_spawn_error_message(message: &str) -> bool { + message.contains("Process spawn failed") +} diff --git a/justfile b/justfile index 6e89f789..53b84418 100644 --- a/justfile +++ b/justfile @@ -82,6 +82,8 @@ plugin-install name: python -c "import pathlib, shutil, tomllib; src = pathlib.Path('plugins/{{name}}'); manifest = tomllib.loads((src / 'peekoo-plugin.toml').read_text()); wasm_rel = pathlib.Path(manifest['plugin']['wasm']); wasm_src = src / wasm_rel; wasm_dst = pathlib.Path.home() / '.peekoo' / 'plugins' / '{{name}}' / wasm_rel; wasm_dst.parent.mkdir(parents=True, exist_ok=True); shutil.copy2(wasm_src, wasm_dst)" if [ -d plugins/{{name}}/ui ]; then cp -r plugins/{{name}}/ui ~/.peekoo/plugins/{{name}}/; fi if [ -d plugins/{{name}}/companions ]; then cp -r plugins/{{name}}/companions ~/.peekoo/plugins/{{name}}/; fi + if [ -d plugins/{{name}}/runtime ]; then cp -r plugins/{{name}}/runtime ~/.peekoo/plugins/{{name}}/; fi + if [ -d plugins/{{name}}/vendor ]; then cp -r plugins/{{name}}/vendor ~/.peekoo/plugins/{{name}}/; fi # Install an AssemblyScript plugin into the local Peekoo plugin dir plugin-install-as name: @@ -92,6 +94,34 @@ plugin-install-as name: # Build and install a Rust plugin plugin name: (plugin-build name) (plugin-install name) +# Build a reusable Python SDK runtime package from requirements +python-sdk-package requirements output: + bash crates/peekoo-python-sdk/scripts/build_runtime_package.sh {{requirements}} {{output}} + +# Install a packaged Python SDK runtime into a target directory +python-sdk-install package target_dir: + bash crates/peekoo-python-sdk/scripts/install_runtime_package.sh {{package}} {{target_dir}} + +# Install a packaged Python SDK runtime into default shared Peekoo directory +python-sdk-install-default package: + bash crates/peekoo-python-sdk/scripts/install_runtime_package.sh {{package}} "$HOME/.peekoo/python-sdk" + +# Build and install shared Python SDK runtime for Mijia plugin +plugin-install-mijia-python-sdk: + #!/usr/bin/env bash + set -euo pipefail + pkg="$(mktemp -t peekoo-mijia-python-sdk.XXXXXX).tar.gz" + bash crates/peekoo-python-sdk/scripts/build_runtime_package.sh \ + plugins/mijia-smart-home/companions/requirements.txt \ + "$pkg" + bash crates/peekoo-python-sdk/scripts/install_runtime_package.sh \ + "$pkg" \ + "$HOME/.peekoo/python-sdk" + rm -f "$pkg" + +# Build runtime, compile WASM, then install Mijia plugin +plugin-mijia-smart-home: plugin-install-mijia-python-sdk (plugin-build "mijia-smart-home") (plugin-install "mijia-smart-home") + # Build all maintained first-party plugins plugin-build-all: just plugin-build health-reminders diff --git a/plugins/mijia-smart-home/Cargo.toml b/plugins/mijia-smart-home/Cargo.toml index c5f003e5..06225bdf 100644 --- a/plugins/mijia-smart-home/Cargo.toml +++ b/plugins/mijia-smart-home/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib"] [dependencies] peekoo-plugin-sdk = { path = "../../crates/peekoo-plugin-sdk" } +peekoo-python-sdk = { path = "../../crates/peekoo-python-sdk" } extism-pdk = "1.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/plugins/mijia-smart-home/README.md b/plugins/mijia-smart-home/README.md new file mode 100644 index 00000000..b5ef405c --- /dev/null +++ b/plugins/mijia-smart-home/README.md @@ -0,0 +1,43 @@ +# Mijia Smart Home Plugin + +This plugin runs on the shared Peekoo Python SDK runtime, so end users do not need system Python installed. + +## Build shared Python SDK runtime + +```bash +just plugin-install-mijia-python-sdk +``` + +This script will: +- build a standalone Python runtime package with Mijia dependencies +- install it to `~/.peekoo/python-sdk/python` +- install Python dependencies from `companions/requirements.txt` + +You can override the runtime archive URL: + +```bash +PEEKOO_PYTHON_STANDALONE_URL="" just plugin-install-mijia-python-sdk +``` + +## Build and install plugin + +```bash +just plugin-mijia-smart-home +``` + +This command packages runtime + builds WASM + installs the plugin to `~/.peekoo/plugins/mijia-smart-home`. + +## Runtime lookup order + +At runtime, the plugin tries Python interpreters in this order: +1. `~/.peekoo/python-sdk/python/bin/python3` +2. `~/.peekoo/python-sdk/python/bin/python` +3. `~/.peekoo/python-sdk/python/python.exe` +4. `~/.peekoo/python-sdk/python/bin/python.exe` +5. `runtime/python/bin/python3` (plugin-local fallback) +6. `runtime/python/bin/python` (plugin-local fallback) +7. `runtime/python/python.exe` (plugin-local fallback) +8. `runtime/python/bin/python.exe` (plugin-local fallback) +9. system `python3` +10. system `python` +11. system `py` diff --git a/plugins/mijia-smart-home/companions/mijia_bridge.py b/plugins/mijia-smart-home/companions/mijia_bridge.py index 0fba6700..acbd3889 100644 --- a/plugins/mijia-smart-home/companions/mijia_bridge.py +++ b/plugins/mijia-smart-home/companions/mijia_bridge.py @@ -698,7 +698,7 @@ def main(): emit( { "success": False, - "message": "Python package mijiaAPI not found. Install it with: pip install mijiaAPI", + "message": "Python package mijiaAPI not found in runtime. Repackage the plugin runtime or install with: pip install mijiaAPI", "code": "mijia_api_missing", }, 1, @@ -707,7 +707,7 @@ def main(): emit( { "success": False, - "message": "Python package requests not found. Install it with: pip install requests", + "message": "Python package requests not found in runtime. Repackage the plugin runtime or install with: pip install requests", "code": "requests_missing", }, 1, diff --git a/plugins/mijia-smart-home/companions/requirements.txt b/plugins/mijia-smart-home/companions/requirements.txt new file mode 100644 index 00000000..875d52ad --- /dev/null +++ b/plugins/mijia-smart-home/companions/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31,<3 +qrcode>=7.4,<9 +mijiaAPI @ git+https://github.com/Do1e/mijia-api.git diff --git a/plugins/mijia-smart-home/peekoo-plugin.toml b/plugins/mijia-smart-home/peekoo-plugin.toml index 0703ea45..704c8ffd 100644 --- a/plugins/mijia-smart-home/peekoo-plugin.toml +++ b/plugins/mijia-smart-home/peekoo-plugin.toml @@ -8,8 +8,8 @@ min_peekoo_version = "0.1.0" wasm = "target/wasm32-wasip1/release/mijia_smart_home.wasm" [permissions] -required = [] -optional = ["process:exec"] +required = ["process:exec"] +optional = [] [[tools.definitions]] name = "mijia_bridge" diff --git a/plugins/mijia-smart-home/src/lib.rs b/plugins/mijia-smart-home/src/lib.rs index 96783a77..6254beac 100644 --- a/plugins/mijia-smart-home/src/lib.rs +++ b/plugins/mijia-smart-home/src/lib.rs @@ -1,6 +1,7 @@ #![no_main] use peekoo_plugin_sdk::prelude::*; +use peekoo_python_sdk::{all_python_candidates, is_spawn_error_message}; use serde_json::{Value, json}; #[plugin_fn] @@ -20,8 +21,8 @@ struct MijiaBridgeInput { payload: Value, } -fn python_candidates() -> Vec<&'static str> { - vec!["python", "python3"] +fn python_candidates() -> Vec { + all_python_candidates() } fn run_bridge_once(program: &str, action: &str, payload_json: &str) -> FnResult { @@ -30,7 +31,16 @@ fn run_bridge_once(program: &str, action: &str, payload_json: &str) -> FnResult< action.to_string(), payload_json.to_string(), ]; - let result = peekoo::process::exec(program, &args, Some("."))?; + let result = match peekoo::process::exec(program, &args, Some(".")) { + Ok(result) => result, + Err(err) => { + return Ok(json!({ + "success": false, + "message": format!("process exec failed: {err}") + }) + .to_string()); + } + }; if result.ok { return Ok(result.stdout.trim().to_string()); } @@ -68,7 +78,7 @@ pub fn tool_mijia_bridge(Json(input): Json) -> FnResult(&out) { let success = parsed .get("success") @@ -77,6 +87,17 @@ pub fn tool_mijia_bridge(Json(input): Json) -> FnResult) -> FnResultRoom Filter resetDetail(); } + function showQrPlaceholder(message = "") { + const wrap = el("qrWrap"); + const img = el("qrImage"); + if (!wrap || !img) return; + img.removeAttribute("src"); + img.style.display = "none"; + wrap.classList.add("show"); + if (message) { + setStatus(message, "error"); + } + } + function stopLoginPolling() { state.loginPolling = false; if (state.loginPollTimer) { @@ -1292,7 +1304,7 @@

Room Filter

const run = async () => { if (!state.loginPolling || state.authenticated) return; try { - const result = await callBridge("login_finish", { timeout_secs: 20 }); + const result = await callBridge("login_finish", { timeout_secs: 8 }); if (result.authenticated) { state.authenticated = true; stopLoginPolling(); @@ -1309,8 +1321,22 @@

Room Filter

} } catch (err) { const msg = err?.message || String(err); - // Polling timeout is expected before user confirms in app. - if (!msg.toLowerCase().includes("timed out")) { + if (msg.includes("No pending login session found")) { + stopLoginPolling(); + await startLogin(); + return; + } + // Polling timeout is expected before/around user confirmation. + // Keep polling silently to avoid flashing transient timeout errors. + const lower = msg.toLowerCase(); + if ( + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("time-out") || + msg.includes("超时") + ) { + // no-op + } else { setStatus(msg, "error"); } } @@ -2317,13 +2343,17 @@

Room Filter

} if (data.qr_url) { - el("qrImage").src = data.qr_url; + const img = el("qrImage"); + img.src = data.qr_url; + img.style.display = "block"; el("qrWrap").classList.add("show"); + } else { + showQrPlaceholder("QR code is not ready yet. Tap to regenerate."); } setStatus(""); scheduleLoginPolling(); } catch (err) { - setStatus(err.message || String(err), "error"); + showQrPlaceholder(err.message || String(err)); } finally { setLoading(false); }