diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6d361119..c35c3dda 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -139,6 +139,8 @@ jobs: - name: Build Rust targets for analysis if: needs.changes.outputs.docs_only != 'true' && matrix.language == 'rust' + env: + TOUCHAI_OPTIONAL_BUNDLED_DOWNLOAD: '1' run: cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets --profile ci-check - name: Analyze diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 039be4b1..018b528e 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -6990,10 +6990,12 @@ dependencies = [ "time", "tokio", "ureq 2.12.1", + "uuid", "velopack", "webview2-com", "windows 0.58.0", "windows-core 0.61.2", + "winreg 0.10.1", "zip 2.4.2", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1f0012cc..7a9233ca 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -67,6 +67,8 @@ clipboard-rs = "0.3.4" html5gum = { version = "0.8.3", default-features = false } sha2 = "0.10" velopack = { version = "=0.0.1589-ga2c5a97", features = ["public-utils"] } +uuid = { version = "1.23.1", features = ["v4"] } +zip = { version = "2.4", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile = "3" @@ -116,6 +118,7 @@ windows = { version = "0.58", features = [ ] } webview2-com = "0.38.0" windows-core = "0.61.2" +winreg = "0.10.1" [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 0189affd..f71f76eb 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -5,11 +5,17 @@ use std::{ fs, io::{Cursor, Read}, path::{Path, PathBuf}, + thread, + time::Duration, }; use quote::ToTokens; use tauri_codegen::embedded_assets::{AssetOptions, EmbeddedAssets}; +const BUNDLED_DOWNLOAD_MAX_ATTEMPTS: usize = 6; +const BUNDLED_DOWNLOAD_RETRY_BASE_DELAY_MS: u64 = 1_500; +const BUNDLED_DOWNLOAD_RETRY_MAX_DELAY_MS: u64 = 30_000; + #[derive(Debug, Deserialize)] struct BundledManifest { version: String, @@ -175,7 +181,17 @@ fn prepare_bundled(name: &str) -> Result<(), Box> { .join(&target_triple); if let Some(target) = manifest.targets.get(&target_triple) { - let binary_path = materialize_binary(name, target, &cache_dir)?; + let binary_path = match materialize_binary(name, target, &cache_dir) { + Ok(binary_path) => binary_path, + Err(error) if bundled_downloads_are_optional() => { + println!( + "cargo:warning={name}: bundled binary unavailable in optional mode; generating empty asset: {error}" + ); + generate_empty_asset_module(name, &out_dir)?; + return Ok(()); + } + Err(error) => return Err(error), + }; let binary_hash = if !target.binary_digest.is_empty() { target.binary_digest.clone() } else { @@ -232,11 +248,7 @@ fn materialize_binary( } } - let response = ureq::get(&target.url).call()?; - let bytes = response - .into_reader() - .bytes() - .collect::, _>>()?; + let bytes = download_bundled_archive(name, &target.url)?; if bytes.len() as u64 != target.size { return Err(format!("{name}: download size mismatch for {}", target.url).into()); } @@ -251,6 +263,57 @@ fn materialize_binary( Ok(binary_path) } +fn download_bundled_archive(name: &str, url: &str) -> Result, Box> { + let mut last_error = None; + + for attempt in 1..=BUNDLED_DOWNLOAD_MAX_ATTEMPTS { + match download_bundled_archive_once(url) { + Ok(bytes) => return Ok(bytes), + Err(error) => { + last_error = Some(error); + if attempt < BUNDLED_DOWNLOAD_MAX_ATTEMPTS { + let delay_ms = bundled_download_retry_delay_ms(attempt); + println!( + "cargo:warning={name}: bundled download attempt {attempt}/{BUNDLED_DOWNLOAD_MAX_ATTEMPTS} failed for {url}; retrying in {delay_ms}ms" + ); + thread::sleep(Duration::from_millis(delay_ms)); + } + } + } + } + + Err(format!( + "{name}: failed to download bundled archive {url} after {BUNDLED_DOWNLOAD_MAX_ATTEMPTS} attempts: {}", + last_error.unwrap_or_else(|| "unknown error".to_string()) + ) + .into()) +} + +fn bundled_downloads_are_optional() -> bool { + matches!( + std::env::var("TOUCHAI_OPTIONAL_BUNDLED_DOWNLOAD"), + Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") + ) +} + +fn bundled_download_retry_delay_ms(attempt: usize) -> u64 { + let exponent = attempt.saturating_sub(1).min(4) as u32; + BUNDLED_DOWNLOAD_RETRY_BASE_DELAY_MS + .saturating_mul(1_u64 << exponent) + .min(BUNDLED_DOWNLOAD_RETRY_MAX_DELAY_MS) +} + +fn download_bundled_archive_once(url: &str) -> Result, String> { + let response = ureq::get(url) + .call() + .map_err(|error| format!("request failed: {error}"))?; + response + .into_reader() + .bytes() + .collect::, _>>() + .map_err(|error| format!("response body read failed: {error}")) +} + fn generate_asset_module( name: &str, out_dir: &Path, diff --git a/apps/desktop/src-tauri/src/commands/app_use.rs b/apps/desktop/src-tauri/src/commands/app_use.rs new file mode 100644 index 00000000..abef3bc3 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/app_use.rs @@ -0,0 +1,40 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +use crate::core::app_use::{ + AppUseActRequest, AppUseActResponse, AppUseAuthorizeActRequest, AppUseAuthorizeActResponse, + AppUseObserveRequest, AppUseObserveResponse, AppUseRuntime, AppUseSessionRequest, + AppUseSessionResponse, +}; +use tauri::State; + +#[tauri::command] +pub fn app_use_session( + request: AppUseSessionRequest, + runtime: State<'_, AppUseRuntime>, +) -> Result { + Ok(runtime.session(request)) +} + +#[tauri::command] +pub fn app_use_observe( + request: AppUseObserveRequest, + runtime: State<'_, AppUseRuntime>, +) -> Result { + Ok(runtime.observe(request)) +} + +#[tauri::command] +pub fn app_use_authorize_act( + request: AppUseAuthorizeActRequest, + runtime: State<'_, AppUseRuntime>, +) -> Result { + Ok(runtime.authorize_act(request)) +} + +#[tauri::command] +pub fn app_use_act( + request: AppUseActRequest, + runtime: State<'_, AppUseRuntime>, +) -> Result { + Ok(runtime.act(request)) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 987d6465..18e28d99 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3. //! 命令入口模块。 +pub mod app_use; pub mod autostart; pub mod built_in_tools; pub mod clipboard; @@ -76,5 +77,9 @@ pub fn invoke_handler( updater::updater_check_for_updates, updater::updater_download_update, updater::updater_install_update, + app_use::app_use_session, + app_use::app_use_observe, + app_use::app_use_authorize_act, + app_use::app_use_act, ] } diff --git a/apps/desktop/src-tauri/src/core/app_use/adobe.rs b/apps/desktop/src-tauri/src/core/app_use/adobe.rs new file mode 100644 index 00000000..78da86e9 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/app_use/adobe.rs @@ -0,0 +1,698 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +use super::{ + discovery, AppUseActRequest, AppUseActResponse, AppUseAdapter, AppUseAuthorizeActRequest, + AppUseObserveRequest, AppUseObserveResponse, +}; +use base64::{engine::general_purpose, Engine as _}; +use serde_json::{json, Value}; +use std::{ + path::PathBuf, + process::{Command, Stdio}, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +pub trait AdobeAutomationRunner: Send + Sync { + fn run_script(&self, script: &str, timeout_ms: u64) -> Result; +} + +struct PowerShellAdobeAutomationRunner; + +fn powershell_executable() -> Result { + #[cfg(windows)] + { + let windows_root = PathBuf::from(r"C:\Windows"); + let wow64_powershell = windows_root + .join("SysWOW64") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + + if wow64_powershell.exists() { + return Ok(wow64_powershell); + } + + return Err(format!( + "Trusted Windows PowerShell executable is unavailable at {}.", + wow64_powershell.display() + )); + } + + #[cfg(not(windows))] + { + Err("Adobe App Use automation is only available on Windows.".to_string()) + } +} + +fn powershell_arguments(encoded_script: &str) -> Vec { + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Sta", + "-EncodedCommand", + encoded_script, + ] + .into_iter() + .map(str::to_string) + .collect() +} + +impl AdobeAutomationRunner for PowerShellAdobeAutomationRunner { + fn run_script(&self, script: &str, timeout_ms: u64) -> Result { + let encoded_script = general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let shell_path = powershell_executable()?; + let mut child = Command::new(&shell_path) + .args(powershell_arguments(&encoded_script)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "Failed to start Adobe automation shell ({}): {error}", + shell_path.display() + ) + })?; + + let started_at = Instant::now(); + let timeout = Duration::from_millis(timeout_ms.max(1_000)); + loop { + match child + .try_wait() + .map_err(|error| format!("Failed to wait for Adobe automation shell: {error}"))? + { + Some(_) => { + let output = child.wait_with_output().map_err(|error| { + format!("Failed to collect Adobe automation output: {error}") + })?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + return Ok(stdout); + } + + return Err(if stderr.is_empty() { stdout } else { stderr }); + } + None if started_at.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Adobe automation timed out after {timeout_ms}ms while reading active document state." + )); + } + None => thread::sleep(Duration::from_millis(50)), + } + } + } +} + +pub struct PhotoshopAdapter { + runner: Arc, +} + +impl PhotoshopAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellAdobeAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for PhotoshopAdapter { + fn default() -> Self { + Self::new() + } +} + +pub struct IllustratorAdapter { + runner: Arc, +} + +impl IllustratorAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellAdobeAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for IllustratorAdapter { + fn default() -> Self { + Self::new() + } +} + +impl AppUseAdapter for PhotoshopAdapter { + fn id(&self) -> &'static str { + "photoshop" + } + + fn label(&self) -> &'static str { + "Adobe Photoshop" + } + + fn capabilities(&self) -> &'static [&'static str] { + &["discover", "observe_layers"] + } + + fn vendor(&self) -> &'static str { + "Adobe" + } + + fn contract_version(&self) -> &'static str { + "adobe-readonly-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["layers"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + observe_active_adobe_document( + self.runner.as_ref(), + request, + photoshop_observe_script(), + format_photoshop_observe_content, + ) + } + + fn validate_act(&self, _request: &AppUseAuthorizeActRequest) -> Result<(), String> { + Err("Adobe Photoshop App Use is read-only in this phase.".to_string()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + unsupported_adobe_action(request, self.label()) + } +} + +impl AppUseAdapter for IllustratorAdapter { + fn id(&self) -> &'static str { + "illustrator" + } + + fn label(&self) -> &'static str { + "Adobe Illustrator" + } + + fn capabilities(&self) -> &'static [&'static str] { + &["discover", "observe_artboards"] + } + + fn vendor(&self) -> &'static str { + "Adobe" + } + + fn contract_version(&self) -> &'static str { + "adobe-readonly-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["artboards"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + observe_active_adobe_document( + self.runner.as_ref(), + request, + illustrator_observe_script(), + format_illustrator_observe_content, + ) + } + + fn validate_act(&self, _request: &AppUseAuthorizeActRequest) -> Result<(), String> { + Err("Adobe Illustrator App Use is read-only in this phase.".to_string()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + unsupported_adobe_action(request, self.label()) + } +} + +fn observe_active_adobe_document( + runner: &dyn AdobeAutomationRunner, + request: &AppUseObserveRequest, + script: &'static str, + format_content: fn(&Value) -> String, +) -> AppUseObserveResponse { + if request + .target_id + .as_deref() + .is_some_and(|target_id| !target_id.trim().is_empty()) + { + return AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some( + "Adobe App Use adapters only observe the active foreground document in this phase." + .to_string(), + ), + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_supported", + }), + truncated: false, + }; + } + + match run_json_script(runner, script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_content(&metadata); + let (content, truncated) = truncate_content(content, request.max_output_chars); + AppUseObserveResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: metadata + .get("fullName") + .and_then(Value::as_str) + .map(str::to_string), + content: Some(content), + metadata, + truncated, + } + } + Err(error) => AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "adobe_automation_failed", + }), + truncated: false, + }, + } +} + +fn unsupported_adobe_action(request: &AppUseActRequest, label: &str) -> AppUseActResponse { + AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "{label} App Use currently supports structured read-only observation; mutating or export actions are not enabled yet." + ), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "read_only_adapter", + }), + } +} + +fn run_json_script( + runner: &dyn AdobeAutomationRunner, + script: &str, + timeout_ms: u64, +) -> Result { + let output = runner.run_script(script, timeout_ms)?; + serde_json::from_str(output.trim()) + .map_err(|error| format!("Adobe automation returned invalid JSON: {error}")) +} + +fn truncate_content(content: String, max_chars: usize) -> (String, bool) { + if content.chars().count() <= max_chars { + return (content, false); + } + + (content.chars().take(max_chars).collect(), true) +} + +fn format_photoshop_observe_content(metadata: &Value) -> String { + let document_name = metadata + .get("documentName") + .and_then(Value::as_str) + .unwrap_or("active Photoshop document"); + let width = metadata.get("width").and_then(Value::as_str).unwrap_or(""); + let height = metadata.get("height").and_then(Value::as_str).unwrap_or(""); + let active_layer = metadata + .get("activeLayerName") + .and_then(Value::as_str) + .unwrap_or(""); + let layers = metadata + .get("layers") + .map(Value::to_string) + .unwrap_or_else(|| "[]".to_string()); + + format!( + "Document: {document_name}\nCanvas: {width} x {height}\nActive layer: {active_layer}\nLayers: {layers}" + ) +} + +fn format_illustrator_observe_content(metadata: &Value) -> String { + let document_name = metadata + .get("documentName") + .and_then(Value::as_str) + .unwrap_or("active Illustrator document"); + let active_layer = metadata + .get("activeLayerName") + .and_then(Value::as_str) + .unwrap_or(""); + let artboards = metadata + .get("artboards") + .map(Value::to_string) + .unwrap_or_else(|| "[]".to_string()); + let layers = metadata + .get("layers") + .map(Value::to_string) + .unwrap_or_else(|| "[]".to_string()); + + format!( + "Document: {document_name}\nActive layer: {active_layer}\nArtboards: {artboards}\nLayers: {layers}" + ) +} + +fn photoshop_observe_script() -> &'static str { + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) { + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + return & $Operation + } catch { + $lastError = $_ + Start-Sleep -Milliseconds 150 + } + } + throw $lastError +} +$app = Invoke-WithComRetry { [Runtime.InteropServices.Marshal]::GetActiveObject('Photoshop.Application') } +$doc = Invoke-WithComRetry { $app.ActiveDocument } +if ($null -eq $doc) { + throw 'Photoshop does not have an active document.' +} +$layers = @() +$layerCount = Invoke-WithComRetry { [int]$doc.Layers.Count } +$maxLayerCount = [Math]::Min($layerCount, 100) +for ($i = 1; $i -le $maxLayerCount; $i++) { + $layer = Invoke-WithComRetry { $doc.Layers.Item($i) } + $layers += [ordered]@{ + name = Invoke-WithComRetry { [string]$layer.Name } + visible = Invoke-WithComRetry { [bool]$layer.Visible } + index = $i + } +} +$fullName = $null +try { + $fullName = Invoke-WithComRetry { [string]$doc.FullName } +} catch { + $fullName = $null +} +$activeLayerName = $null +try { + $activeLayerName = Invoke-WithComRetry { [string]$doc.ActiveLayer.Name } +} catch { + $activeLayerName = $null +} +$result = [ordered]@{ + documentName = Invoke-WithComRetry { [string]$doc.Name } + fullName = $fullName + width = Invoke-WithComRetry { [string]$doc.Width } + height = Invoke-WithComRetry { [string]$doc.Height } + layerCount = $layerCount + activeLayerName = $activeLayerName + layers = $layers + truncatedLayers = $layerCount -gt $maxLayerCount +} +$result | ConvertTo-Json -Compress -Depth 6 +"# + .trim() +} + +fn illustrator_observe_script() -> &'static str { + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) { + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + return & $Operation + } catch { + $lastError = $_ + Start-Sleep -Milliseconds 150 + } + } + throw $lastError +} +$app = Invoke-WithComRetry { [Runtime.InteropServices.Marshal]::GetActiveObject('Illustrator.Application') } +$doc = Invoke-WithComRetry { $app.ActiveDocument } +if ($null -eq $doc) { + throw 'Illustrator does not have an active document.' +} +$layers = @() +$layerCount = Invoke-WithComRetry { [int]$doc.Layers.Count } +$maxLayerCount = [Math]::Min($layerCount, 100) +for ($i = 1; $i -le $maxLayerCount; $i++) { + $layer = Invoke-WithComRetry { $doc.Layers.Item($i) } + $layers += [ordered]@{ + name = Invoke-WithComRetry { [string]$layer.Name } + visible = Invoke-WithComRetry { [bool]$layer.Visible } + locked = Invoke-WithComRetry { [bool]$layer.Locked } + index = $i + } +} +$artboards = @() +$artboardCount = Invoke-WithComRetry { [int]$doc.Artboards.Count } +$maxArtboardCount = [Math]::Min($artboardCount, 100) +for ($i = 1; $i -le $maxArtboardCount; $i++) { + $artboard = Invoke-WithComRetry { $doc.Artboards.Item($i) } + $artboards += [ordered]@{ + name = Invoke-WithComRetry { [string]$artboard.Name } + index = $i + } +} +$fullName = $null +try { + $fullName = Invoke-WithComRetry { [string]$doc.FullName } +} catch { + $fullName = $null +} +$activeLayerName = $null +try { + $activeLayerName = Invoke-WithComRetry { [string]$doc.ActiveLayer.Name } +} catch { + $activeLayerName = $null +} +$result = [ordered]@{ + documentName = Invoke-WithComRetry { [string]$doc.Name } + fullName = $fullName + layerCount = $layerCount + artboardCount = $artboardCount + activeLayerName = $activeLayerName + layers = $layers + artboards = $artboards + truncatedLayers = $layerCount -gt $maxLayerCount + truncatedArtboards = $artboardCount -gt $maxArtboardCount +} +$result | ConvertTo-Json -Compress -Depth 6 +"# + .trim() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app_use::types::{ + AppUseActRequest, AppUseAdvancedConfig, AppUseConfig, AppUseObserveRequest, + }; + use crate::core::app_use::AppUseAdapter; + use serde_json::json; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + struct RecordingRunner { + output: Result, + scripts: Mutex>, + } + + impl RecordingRunner { + fn new(output: Result) -> Self { + Self { + output, + scripts: Mutex::new(Vec::new()), + } + } + + fn scripts(&self) -> Vec { + self.scripts.lock().expect("scripts").clone() + } + } + + impl AdobeAutomationRunner for RecordingRunner { + fn run_script(&self, script: &str, _timeout_ms: u64) -> Result { + self.scripts + .lock() + .expect("scripts") + .push(script.to_string()); + self.output.clone() + } + } + + fn config() -> AppUseConfig { + AppUseConfig { + mode: "read_only".to_string(), + adapters: HashMap::from([ + ("photoshop".to_string(), true), + ("illustrator".to_string(), true), + ]), + mutating_approval_mode: "always".to_string(), + read_scope: "active".to_string(), + allow_background_operation: false, + allow_raw_automation: false, + timeout_ms: 15_000, + max_output_chars: 12_000, + advanced: AppUseAdvancedConfig::default(), + } + } + + #[test] + fn photoshop_observe_reads_active_document_layers_without_raw_target() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "hero.psd", + "fullName": "C:\\design\\hero.psd", + "width": "1024 px", + "height": "768 px", + "layerCount": 2, + "activeLayerName": "Logo", + "layers": [ + { "name": "Logo", "visible": true, "index": 1 }, + { "name": "Background", "visible": true, "index": 2 } + ], + "truncatedLayers": false + }) + .to_string()))); + let adapter = PhotoshopAdapter::with_runner(runner.clone()); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "ps-observe-1".to_string(), + adapter_id: "photoshop".to_string(), + scope: "layers".to_string(), + description: "read Photoshop layers".to_string(), + target_id: None, + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, true); + assert!(response.content.unwrap_or_default().contains("Logo")); + let script = runner.scripts().join("\n"); + assert!(script.contains("Photoshop.Application")); + assert!(script.contains("ActiveDocument")); + assert!(script.contains(".Layers")); + assert!(!script.contains("UIAutomation")); + } + + #[test] + fn photoshop_observe_rejects_explicit_target_before_running_adobe() { + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = PhotoshopAdapter::with_runner(runner.clone()); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "ps-observe-2".to_string(), + adapter_id: "photoshop".to_string(), + scope: "layers".to_string(), + description: "read explicit Photoshop target".to_string(), + target_id: Some("C:\\design\\hero.psd".to_string()), + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.metadata["reason"], "target_not_supported"); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn illustrator_observe_reads_active_document_artboards_and_layers() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "poster.ai", + "fullName": "C:\\design\\poster.ai", + "layerCount": 1, + "artboardCount": 1, + "activeLayerName": "Artwork", + "layers": [ + { "name": "Artwork", "visible": true, "locked": false, "index": 1 } + ], + "artboards": [ + { "name": "A1", "index": 1 } + ], + "truncatedLayers": false, + "truncatedArtboards": false + }) + .to_string()))); + let adapter = IllustratorAdapter::with_runner(runner.clone()); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "ai-observe-1".to_string(), + adapter_id: "illustrator".to_string(), + scope: "artboards".to_string(), + description: "read Illustrator artboards".to_string(), + target_id: None, + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, true); + assert!(response.content.unwrap_or_default().contains("A1")); + let script = runner.scripts().join("\n"); + assert!(script.contains("Illustrator.Application")); + assert!(script.contains("ActiveDocument")); + assert!(script.contains(".Artboards")); + assert!(script.contains(".Layers")); + assert!(!script.contains("UIAutomation")); + } + + #[test] + fn adobe_actions_are_explicitly_read_only_before_running_adobe() { + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = IllustratorAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "ai-act-1".to_string(), + adapter_id: "illustrator".to_string(), + action: "export_preview".to_string(), + description: "export Illustrator preview".to_string(), + target_id: None, + parameters: None, + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "read_only_adapter"); + assert!(runner.scripts().is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/core/app_use/discovery.rs b/apps/desktop/src-tauri/src/core/app_use/discovery.rs new file mode 100644 index 00000000..45f3a167 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/app_use/discovery.rs @@ -0,0 +1,305 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AdapterInstallStatus { + pub installed: bool, + pub evidence: Option, +} + +pub trait RegistryReader { + fn read_default_value(&self, path: &str) -> Option; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AdapterVendor { + Wps, + MicrosoftOffice, + Adobe, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AdapterDetectionRule { + adapter_id: &'static str, + prog_ids: &'static [&'static str], + vendor: AdapterVendor, + expected_executable: &'static str, +} + +const DETECTION_RULES: &[AdapterDetectionRule] = &[ + AdapterDetectionRule { + adapter_id: "office_word", + prog_ids: &["Word.Application"], + vendor: AdapterVendor::MicrosoftOffice, + expected_executable: "winword.exe", + }, + AdapterDetectionRule { + adapter_id: "office_excel", + prog_ids: &["Excel.Application"], + vendor: AdapterVendor::MicrosoftOffice, + expected_executable: "excel.exe", + }, + AdapterDetectionRule { + adapter_id: "office_powerpoint", + prog_ids: &["PowerPoint.Application"], + vendor: AdapterVendor::MicrosoftOffice, + expected_executable: "powerpnt.exe", + }, + AdapterDetectionRule { + adapter_id: "wps_writer", + prog_ids: &["KWPS.Application"], + vendor: AdapterVendor::Wps, + expected_executable: "wps.exe", + }, + AdapterDetectionRule { + adapter_id: "wps_spreadsheet", + prog_ids: &["KET.Application"], + vendor: AdapterVendor::Wps, + expected_executable: "wps.exe", + }, + AdapterDetectionRule { + adapter_id: "wps_presentation", + prog_ids: &["KWPP.Application"], + vendor: AdapterVendor::Wps, + expected_executable: "wps.exe", + }, + AdapterDetectionRule { + adapter_id: "photoshop", + prog_ids: &["Photoshop.Application"], + vendor: AdapterVendor::Adobe, + expected_executable: "photoshop.exe", + }, + AdapterDetectionRule { + adapter_id: "illustrator", + prog_ids: &["Illustrator.Application"], + vendor: AdapterVendor::Adobe, + expected_executable: "illustrator.exe", + }, +]; + +pub fn discover_adapter_install_status(adapter_id: &str) -> AdapterInstallStatus { + #[cfg(windows)] + { + let registry = WindowsRegistryReader; + return discover_installed_adapter(®istry, adapter_id); + } + + #[cfg(not(windows))] + { + let _ = adapter_id; + AdapterInstallStatus::default() + } +} + +pub fn discover_installed_adapter( + registry: &dyn RegistryReader, + adapter_id: &str, +) -> AdapterInstallStatus { + let Some(rule) = DETECTION_RULES + .iter() + .find(|rule| rule.adapter_id == adapter_id) + else { + return AdapterInstallStatus::default(); + }; + + for prog_id in rule.prog_ids { + let Some(local_server) = resolve_local_server(registry, prog_id) else { + continue; + }; + + if local_server_matches_rule(&local_server, rule) { + return AdapterInstallStatus { + installed: true, + evidence: Some(local_server), + }; + } + } + + AdapterInstallStatus::default() +} + +fn resolve_local_server(registry: &dyn RegistryReader, prog_id: &str) -> Option { + let clsid = registry.read_default_value(&format!("{prog_id}\\CLSID"))?; + registry + .read_default_value(&format!("CLSID\\{clsid}\\LocalServer32")) + .or_else(|| { + registry.read_default_value(&format!("WOW6432Node\\CLSID\\{clsid}\\LocalServer32")) + }) +} + +fn local_server_matches_rule(local_server: &str, rule: &AdapterDetectionRule) -> bool { + let normalized = local_server.to_ascii_lowercase(); + + match rule.vendor { + AdapterVendor::Wps => { + let executable_matches = normalized.contains(rule.expected_executable) + || normalized.contains("\\wpsoffice.exe"); + executable_matches + && (normalized.contains("kingsoft") + || normalized.contains("wps office") + || normalized.contains("\\wps.exe") + || normalized.contains("\\wpsoffice.exe")) + } + AdapterVendor::MicrosoftOffice => { + normalized.contains(rule.expected_executable) + && !normalized.contains("kingsoft") + && !normalized.contains("wps office") + && !normalized.contains("\\wps.exe") + } + AdapterVendor::Adobe => { + normalized.contains(rule.expected_executable) && normalized.contains("adobe") + } + } +} + +#[cfg(windows)] +struct WindowsRegistryReader; + +#[cfg(windows)] +impl RegistryReader for WindowsRegistryReader { + fn read_default_value(&self, path: &str) -> Option { + use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_CURRENT_USER}; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey(format!("Software\\Classes\\{path}")) { + if let Ok(value) = key.get_value::("") { + return Some(value); + } + } + + let hkcr = RegKey::predef(HKEY_CLASSES_ROOT); + hkcr.open_subkey(path) + .ok() + .and_then(|key| key.get_value::("").ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct MockRegistry { + values: HashMap, + } + + impl MockRegistry { + fn with_value(mut self, path: &str, value: &str) -> Self { + self.values + .insert(path.to_ascii_lowercase(), value.to_string()); + self + } + } + + impl RegistryReader for MockRegistry { + fn read_default_value(&self, path: &str) -> Option { + self.values.get(&path.to_ascii_lowercase()).cloned() + } + } + + #[test] + fn discovers_wps_from_wps_specific_progids_without_marking_hijacked_office() { + let registry = MockRegistry::default() + .with_value( + r"KWPS.Application\CLSID", + "{000209FF-0000-4b30-A977-D214852036FF}", + ) + .with_value( + r"Word.Application\CLSID", + "{000209FF-0000-4b30-A977-D214852036FF}", + ) + .with_value( + r"CLSID\{000209FF-0000-4b30-A977-D214852036FF}\LocalServer32", + r"C:\Users\demo\AppData\Local\Kingsoft\WPS Office\office6\wps.exe /prometheus /wps /Automation", + ); + + assert_eq!( + discover_installed_adapter(®istry, "wps_writer").installed, + true + ); + assert_eq!( + discover_installed_adapter(®istry, "office_word").installed, + false + ); + } + + #[test] + fn discovers_wps_writer_from_unified_wpsoffice_local_server() { + let registry = MockRegistry::default() + .with_value( + r"KWPS.Application\CLSID", + "{000209FF-0000-4b30-A977-D214852036FF}", + ) + .with_value( + r"CLSID\{000209FF-0000-4b30-A977-D214852036FF}\LocalServer32", + r#""C:\Users\demo\AppData\Local\Kingsoft\WPS Office\12.1.0.26895\office6\wpsoffice.exe" /Automation"#, + ); + + let status = discover_installed_adapter(®istry, "wps_writer"); + + assert_eq!(status.installed, true); + assert!(status + .evidence + .unwrap_or_default() + .contains("wpsoffice.exe")); + } + + #[test] + fn discovers_32_bit_wps_from_wow6432node_clsid_registration() { + let registry = MockRegistry::default() + .with_value( + r"KWPS.Application\CLSID", + "{000209FF-0000-4b30-A977-D214852036FF}", + ) + .with_value( + r"WOW6432Node\CLSID\{000209FF-0000-4b30-A977-D214852036FF}\LocalServer32", + r"C:\Users\demo\AppData\Local\Kingsoft\WPS Office\office6\wps.exe /prometheus /wps /Automation", + ); + + assert_eq!( + discover_installed_adapter(®istry, "wps_writer").installed, + true + ); + } + + #[test] + fn discovers_microsoft_office_only_when_local_server_matches_office_executable() { + let registry = MockRegistry::default() + .with_value( + r"Word.Application\CLSID", + "{00020906-0000-0000-C000-000000000046}", + ) + .with_value( + r"CLSID\{00020906-0000-0000-C000-000000000046}\LocalServer32", + r"C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE /Automation", + ); + + let status = discover_installed_adapter(®istry, "office_word"); + + assert_eq!(status.installed, true); + assert!(status.evidence.unwrap_or_default().contains("WINWORD.EXE")); + } + + #[test] + fn discovers_adobe_apps_from_their_com_local_server() { + let registry = MockRegistry::default() + .with_value( + r"Photoshop.Application\CLSID", + "{6DECC242-87EF-11CF-86B4-444553540000}", + ) + .with_value( + r"CLSID\{6DECC242-87EF-11CF-86B4-444553540000}\LocalServer32", + r"C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe", + ); + + assert_eq!( + discover_installed_adapter(®istry, "photoshop").installed, + true + ); + assert_eq!( + discover_installed_adapter(®istry, "illustrator").installed, + false + ); + } +} diff --git a/apps/desktop/src-tauri/src/core/app_use/mod.rs b/apps/desktop/src-tauri/src/core/app_use/mod.rs new file mode 100644 index 00000000..19d3a184 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/app_use/mod.rs @@ -0,0 +1,1636 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +pub mod adobe; +pub mod discovery; +pub mod office; +pub mod types; +pub mod wps; + +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, + sync::Mutex, + time::{Duration, Instant}, +}; +pub use types::{ + AppUseActPermit, AppUseActRequest, AppUseActResponse, AppUseAdapterContract, + AppUseAdapterDescriptor, AppUseAuthorizeActRequest, AppUseAuthorizeActResponse, + AppUseObserveRequest, AppUseObserveResponse, AppUseSessionRequest, AppUseSessionResponse, +}; +use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; + +const APP_USE_PERMIT_TTL: Duration = Duration::from_secs(30); +const APP_USE_CREATE_TARGET_OPERATION: &str = "create_owned_target"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AppUseAdapterDefinition { + id: &'static str, + label: &'static str, + capabilities: &'static [&'static str], +} + +const APP_USE_ADAPTERS: &[AppUseAdapterDefinition] = &[ + AppUseAdapterDefinition { + id: "office_word", + label: "Microsoft Word", + capabilities: &[ + "discover", + "observe_active_document", + "observe_selection", + "replace_document_text", + "format_document_text", + ], + }, + AppUseAdapterDefinition { + id: "office_excel", + label: "Microsoft Excel", + capabilities: &[ + "discover", + "observe_workbook", + "observe_worksheet", + "write_cells", + ], + }, + AppUseAdapterDefinition { + id: "office_powerpoint", + label: "Microsoft PowerPoint", + capabilities: &[ + "discover", + "observe_presentation", + "observe_slide", + "add_slide_text", + ], + }, + AppUseAdapterDefinition { + id: "wps_writer", + label: "WPS Writer", + capabilities: &[ + "discover", + "observe_active_document", + "observe_selection", + "replace_document_text", + "format_document_text", + ], + }, + AppUseAdapterDefinition { + id: "wps_spreadsheet", + label: "WPS Spreadsheet", + capabilities: &[ + "discover", + "observe_workbook", + "observe_worksheet", + "write_cells", + ], + }, + AppUseAdapterDefinition { + id: "wps_presentation", + label: "WPS Presentation", + capabilities: &[ + "discover", + "observe_presentation", + "observe_slide", + "add_slide_text", + ], + }, + AppUseAdapterDefinition { + id: "photoshop", + label: "Adobe Photoshop", + capabilities: &["discover", "observe_layers"], + }, + AppUseAdapterDefinition { + id: "illustrator", + label: "Adobe Illustrator", + capabilities: &["discover", "observe_artboards"], + }, +]; + +pub trait AppUseAdapter: Send + Sync { + fn id(&self) -> &'static str; + + fn label(&self) -> &'static str; + + fn capabilities(&self) -> &'static [&'static str]; + + fn vendor(&self) -> &'static str { + "builtin" + } + + fn contract_version(&self) -> &'static str { + "app-use-adapter/v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &[] + } + + fn actions(&self) -> &'static [&'static str] { + &[] + } + + fn risk_level(&self) -> &'static str { + "high" + } + + fn raw_automation_allowed(&self) -> bool { + false + } + + fn contract(&self) -> AppUseAdapterContract { + AppUseAdapterContract { + vendor: self.vendor().to_string(), + version: self.contract_version().to_string(), + observe_scopes: self + .observe_scopes() + .iter() + .map(|scope| scope.to_string()) + .collect(), + actions: self + .actions() + .iter() + .map(|action| action.to_string()) + .collect(), + risk_level: self.risk_level().to_string(), + raw_automation_allowed: self.raw_automation_allowed(), + } + } + + fn installed(&self) -> bool { + false + } + + fn running(&self) -> bool { + false + } + + fn active_target_name(&self) -> Option { + None + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some( + "No structured App Use adapter is available for this application yet.".to_string(), + ), + metadata: json!({ + "executionId": request.execution_id, + "reason": "adapter_unimplemented", + }), + truncated: false, + } + } + + fn validate_act(&self, _request: &AppUseAuthorizeActRequest) -> Result<(), String> { + Err("No structured App Use action adapter is available yet.".to_string()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: "No structured App Use action adapter is available yet.".to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "adapter_unimplemented", + }), + } + } +} + +struct StaticAppUseAdapter { + definition: AppUseAdapterDefinition, +} + +impl AppUseAdapter for StaticAppUseAdapter { + fn id(&self) -> &'static str { + self.definition.id + } + + fn label(&self) -> &'static str { + self.definition.label + } + + fn capabilities(&self) -> &'static [&'static str] { + self.definition.capabilities + } + + fn vendor(&self) -> &'static str { + "builtin" + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.definition.id).installed + } +} + +pub struct AppUseRuntime { + adapters: Vec>, + act_permits: Mutex>, +} + +#[derive(Clone, Debug)] +struct PermitRecord { + permit: AppUseActPermit, + expires_at: Instant, +} + +impl AppUseRuntime { + pub fn new() -> Self { + Self::with_adapters( + APP_USE_ADAPTERS + .iter() + .copied() + .map(|definition| match definition.id { + "office_word" => { + Box::new(office::OfficeWordAdapter::new()) as Box + } + "office_excel" => { + Box::new(office::OfficeExcelAdapter::new()) as Box + } + "office_powerpoint" => { + Box::new(office::OfficePowerPointAdapter::new()) as Box + } + "wps_writer" => { + Box::new(wps::WpsWriterAdapter::new()) as Box + } + "wps_spreadsheet" => { + Box::new(wps::WpsSpreadsheetAdapter::new()) as Box + } + "wps_presentation" => { + Box::new(wps::WpsPresentationAdapter::new()) as Box + } + "photoshop" => { + Box::new(adobe::PhotoshopAdapter::new()) as Box + } + "illustrator" => { + Box::new(adobe::IllustratorAdapter::new()) as Box + } + _ => Box::new(StaticAppUseAdapter { definition }) as Box, + }) + .collect(), + ) + } + + pub fn with_adapters(adapters: Vec>) -> Self { + Self { + adapters, + act_permits: Mutex::new(HashMap::new()), + } + } + + fn find_adapter(&self, adapter_id: &str) -> Option<&dyn AppUseAdapter> { + self.adapters + .iter() + .find(|adapter| adapter.id() == adapter_id) + .map(|adapter| adapter.as_ref()) + } + + fn adapter_supports_action(adapter: &dyn AppUseAdapter, action: &str) -> bool { + adapter + .actions() + .iter() + .any(|supported_action| *supported_action == action) + } + + fn adapter_supports_observe_scope(adapter: &dyn AppUseAdapter, scope: &str) -> bool { + adapter + .observe_scopes() + .iter() + .any(|supported_scope| *supported_scope == scope) + } + + fn create_owned_target( + &self, + adapter_id: &str, + target_kind: Option<&str>, + ) -> Result<(String, String), String> { + let spec = OwnedTargetSpec::from_request(adapter_id, target_kind)?; + let target_path = create_owned_target_file(&spec)?; + let canonical_path = match spec.family { + OwnedTargetFamily::Office => { + office::mark_owned_office_target(&target_path, spec.app_label, "TouchAI App Use")? + } + OwnedTargetFamily::Wps => { + wps::mark_owned_wps_target(&target_path, spec.app_label, "TouchAI App Use")? + } + }; + Ok(( + canonical_path.to_string_lossy().to_string(), + spec.kind.to_string(), + )) + } + + fn normalize_config(&self, mut config: types::AppUseConfig) -> types::AppUseConfig { + if config.mode != "interactive" { + config.mode = "read_only".to_string(); + } + config.mutating_approval_mode = "always".to_string(); + config.read_scope = "active".to_string(); + config.allow_background_operation = false; + config.allow_raw_automation = false; + config.timeout_ms = match config.timeout_ms { + 1_000..=120_000 => config.timeout_ms, + _ => 15_000, + }; + config.max_output_chars = match config.max_output_chars { + 1_000..=50_000 => config.max_output_chars, + _ => 12_000, + }; + config.advanced = types::AppUseAdvancedConfig::default(); + config + .adapters + .retain(|adapter_id, _| self.find_adapter(adapter_id).is_some()); + config + } + + fn cleanup_expired_permits(&self, now: Instant) { + let mut permits = self.act_permits.lock().expect("app use permits"); + permits.retain(|_, record| record.expires_at > now); + } + + fn advanced_action_enabled(&self, request: &AppUseAuthorizeActRequest) -> bool { + match request.action.as_str() { + "export_preview" => request.config.advanced.export_previews, + "batch_export" => request.config.advanced.batch_workflows, + "cross_app_transfer" => request.config.advanced.cross_app_workflows, + _ => true, + } + } + + fn advanced_act_enabled(&self, request: &AppUseActRequest) -> bool { + match request.action.as_str() { + "export_preview" => request.config.advanced.export_previews, + "batch_export" => request.config.advanced.batch_workflows, + "cross_app_transfer" => request.config.advanced.cross_app_workflows, + _ => true, + } + } + + fn issue_permit(&self, request: &AppUseAuthorizeActRequest) -> AppUseActPermit { + let permit = AppUseActPermit { + call_id: request.execution_id.clone(), + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + target_id: request.target_id.clone(), + parameters_hash: hash_parameters(request.parameters.as_ref()), + token: uuid::Uuid::new_v4().to_string(), + }; + let record = PermitRecord { + permit: permit.clone(), + expires_at: Instant::now() + APP_USE_PERMIT_TTL, + }; + self.act_permits + .lock() + .expect("app use permits") + .insert(permit.token.clone(), record); + permit + } + + fn consume_valid_permit(&self, request: &AppUseActRequest) -> bool { + let Some(permit) = request.permit.as_ref() else { + return false; + }; + let now = Instant::now(); + self.cleanup_expired_permits(now); + + let Some(record) = self + .act_permits + .lock() + .expect("app use permits") + .remove(&permit.token) + else { + return false; + }; + + record.expires_at > now + && record.permit == *permit + && permit.call_id == request.execution_id + && permit.adapter_id == request.adapter_id + && permit.action == request.action + && permit.target_id == request.target_id + && permit.parameters_hash == hash_parameters(request.parameters.as_ref()) + } + + pub fn session(&self, request: AppUseSessionRequest) -> AppUseSessionResponse { + let config = self.normalize_config(request.config); + if request.operation == APP_USE_CREATE_TARGET_OPERATION { + let target_response = request + .adapter_id + .as_deref() + .ok_or_else(|| "create_owned_target requires adapterId.".to_string()) + .and_then(|adapter_id| { + if !config.adapter_enabled(adapter_id) { + return Err( + "App Use adapter is disabled. Enable it in Settings > App Use before creating an owned target." + .to_string(), + ); + } + self.create_owned_target(adapter_id, request.target_kind.as_deref()) + .map(|(target, target_kind)| { + (adapter_id.to_string(), target_kind, target) + }) + }); + + return match target_response { + Ok((adapter_id, target_kind, target)) => AppUseSessionResponse { + ok: true, + operation: request.operation, + adapters: Vec::new(), + message: Some("TouchAI-owned App Use target created.".to_string()), + adapter_id: Some(adapter_id), + target_kind: Some(target_kind), + target: Some(target), + }, + Err(error) => AppUseSessionResponse { + ok: false, + operation: request.operation, + adapters: Vec::new(), + message: Some(error), + adapter_id: request.adapter_id, + target_kind: request.target_kind, + target: None, + }, + }; + } + + let adapters = self + .adapters + .iter() + .map(|adapter| AppUseAdapterDescriptor { + id: adapter.id().to_string(), + label: adapter.label().to_string(), + installed: adapter.installed(), + running: adapter.running(), + enabled: config.adapter_enabled(adapter.id()), + capabilities: adapter + .capabilities() + .iter() + .map(|capability| capability.to_string()) + .collect(), + contract: adapter.contract(), + active_target_name: adapter.active_target_name(), + }) + .collect(); + + AppUseSessionResponse { + ok: true, + operation: request.operation, + adapters, + message: None, + adapter_id: None, + target_kind: None, + target: None, + } + } + + pub fn observe(&self, mut request: AppUseObserveRequest) -> AppUseObserveResponse { + request.config = self.normalize_config(request.config); + if !request.config.adapter_enabled(&request.adapter_id) { + return AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id, + scope: request.scope, + target: request.target_id, + content: Some( + "App Use adapter is disabled. Enable it in Settings > App Use before observing." + .to_string(), + ), + metadata: json!({ + "executionId": request.execution_id, + "reason": "adapter_disabled", + }), + truncated: false, + }; + } + + self.find_adapter(&request.adapter_id) + .map(|adapter| { + if !Self::adapter_supports_observe_scope(adapter, &request.scope) { + return AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(format!( + "App Use adapter does not declare support for observe scope {}.", + request.scope + )), + metadata: json!({ + "executionId": request.execution_id, + "reason": "unsupported_scope", + }), + truncated: false, + }; + } + + adapter.observe(&request) + }) + .unwrap_or_else(|| AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id, + scope: request.scope, + target: request.target_id, + content: Some("Unknown App Use adapter.".to_string()), + metadata: json!({ + "executionId": request.execution_id, + "reason": "adapter_unknown", + }), + truncated: false, + }) + } + + pub fn authorize_act( + &self, + mut request: AppUseAuthorizeActRequest, + ) -> AppUseAuthorizeActResponse { + request.config = self.normalize_config(request.config); + if request.config.is_read_only() || !request.config.adapter_enabled(&request.adapter_id) { + return AppUseAuthorizeActResponse { + permit: None, + expires_in_ms: 0, + }; + } + if !self.advanced_action_enabled(&request) { + return AppUseAuthorizeActResponse { + permit: None, + expires_in_ms: 0, + }; + } + + let Some(adapter) = self.find_adapter(&request.adapter_id) else { + return AppUseAuthorizeActResponse { + permit: None, + expires_in_ms: 0, + }; + }; + if !Self::adapter_supports_action(adapter, &request.action) { + return AppUseAuthorizeActResponse { + permit: None, + expires_in_ms: 0, + }; + } + if adapter.validate_act(&request).is_err() { + return AppUseAuthorizeActResponse { + permit: None, + expires_in_ms: 0, + }; + } + + AppUseAuthorizeActResponse { + permit: Some(self.issue_permit(&request)), + expires_in_ms: APP_USE_PERMIT_TTL.as_millis() as u64, + } + } + + pub fn act(&self, mut request: AppUseActRequest) -> AppUseActResponse { + request.config = self.normalize_config(request.config); + if request.config.is_read_only() { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id, + action: request.action, + receipt: + "App Use is in read-only mode; enable interactive mode before running actions." + .to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "read_only", + }), + }; + } + + if !request.config.adapter_enabled(&request.adapter_id) { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id, + action: request.action, + receipt: + "App Use adapter is disabled. Enable it in Settings > App Use before acting." + .to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "adapter_disabled", + }), + }; + } + + if !self.advanced_act_enabled(&request) { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id, + action: request.action, + receipt: "This App Use workflow is planned for a later phase and is not available." + .to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "workflow_not_available", + }), + }; + } + + if !self.consume_valid_permit(&request) { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id, + action: request.action, + receipt: "App Use action requires a fresh user-approved native permit.".to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "approval_required", + }), + }; + } + + self.find_adapter(&request.adapter_id) + .map(|adapter| { + if !Self::adapter_supports_action(adapter, &request.action) { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "App Use adapter does not declare support for action {}.", + request.action + ), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "unsupported_action", + }), + }; + } + + adapter.act(&request) + }) + .unwrap_or_else(|| AppUseActResponse { + ok: false, + adapter_id: request.adapter_id, + action: request.action, + receipt: "Unknown App Use adapter.".to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "adapter_unknown", + }), + }) + } +} + +fn hash_parameters(parameters: Option<&serde_json::Value>) -> String { + let canonical = parameters + .map(|value| serde_json::to_string(value).unwrap_or_default()) + .unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OwnedTargetFamily { + Office, + Wps, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OwnedTargetKind { + Document, + Spreadsheet, + Presentation, +} + +impl OwnedTargetKind { + fn extension(self) -> &'static str { + match self { + OwnedTargetKind::Document => "docx", + OwnedTargetKind::Spreadsheet => "xlsx", + OwnedTargetKind::Presentation => "pptx", + } + } +} + +impl std::fmt::Display for OwnedTargetKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OwnedTargetKind::Document => formatter.write_str("document"), + OwnedTargetKind::Spreadsheet => formatter.write_str("spreadsheet"), + OwnedTargetKind::Presentation => formatter.write_str("presentation"), + } + } +} + +struct OwnedTargetSpec { + family: OwnedTargetFamily, + kind: OwnedTargetKind, + adapter_id: &'static str, + app_label: &'static str, + root: PathBuf, +} + +impl OwnedTargetSpec { + fn from_request(adapter_id: &str, target_kind: Option<&str>) -> Result { + let spec = match adapter_id { + "office_word" => Self { + family: OwnedTargetFamily::Office, + kind: OwnedTargetKind::Document, + adapter_id: "office_word", + app_label: "Microsoft Word", + root: office::owned_office_target_root_path(), + }, + "office_excel" => Self { + family: OwnedTargetFamily::Office, + kind: OwnedTargetKind::Spreadsheet, + adapter_id: "office_excel", + app_label: "Microsoft Excel", + root: office::owned_office_target_root_path(), + }, + "office_powerpoint" => Self { + family: OwnedTargetFamily::Office, + kind: OwnedTargetKind::Presentation, + adapter_id: "office_powerpoint", + app_label: "Microsoft PowerPoint", + root: office::owned_office_target_root_path(), + }, + "wps_writer" => Self { + family: OwnedTargetFamily::Wps, + kind: OwnedTargetKind::Document, + adapter_id: "wps_writer", + app_label: "WPS Writer", + root: wps::owned_wps_target_root_path(), + }, + "wps_spreadsheet" => Self { + family: OwnedTargetFamily::Wps, + kind: OwnedTargetKind::Spreadsheet, + adapter_id: "wps_spreadsheet", + app_label: "WPS Spreadsheet", + root: wps::owned_wps_target_root_path(), + }, + "wps_presentation" => Self { + family: OwnedTargetFamily::Wps, + kind: OwnedTargetKind::Presentation, + adapter_id: "wps_presentation", + app_label: "WPS Presentation", + root: wps::owned_wps_target_root_path(), + }, + _ => { + return Err( + "create_owned_target supports only Office and WPS App Use write adapters." + .to_string(), + ) + } + }; + + if let Some(target_kind) = target_kind.map(str::trim).filter(|value| !value.is_empty()) { + if target_kind != spec.kind.to_string() { + return Err(format!( + "{} creates {} targets, not {target_kind} targets.", + spec.adapter_id, spec.kind + )); + } + } + + Ok(spec) + } +} + +fn create_owned_target_file(spec: &OwnedTargetSpec) -> Result { + fs::create_dir_all(&spec.root) + .map_err(|error| format!("App Use owned target root is unavailable: {error}"))?; + ensure_owned_target_root_is_plain_directory(&spec.root)?; + let target_path = unique_owned_target_path(spec); + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&target_path) + .map_err(|error| format!("App Use owned target could not be created: {error}"))?; + let result = match spec.kind { + OwnedTargetKind::Document => write_minimal_docx(file), + OwnedTargetKind::Spreadsheet => write_minimal_xlsx(file), + OwnedTargetKind::Presentation => write_minimal_pptx(file), + }; + if let Err(error) = result { + let _ = fs::remove_file(&target_path); + return Err(error); + } + Ok(target_path) +} + +fn ensure_owned_target_root_is_plain_directory(root: &Path) -> Result<(), String> { + ensure_path_chain_not_reparse(root, "target root")?; + root.canonicalize() + .map_err(|error| format!("App Use owned target root is unavailable: {error}"))?; + Ok(()) +} + +fn ensure_path_chain_not_reparse(path: &Path, label: &str) -> Result<(), String> { + for candidate in path.ancestors() { + if candidate.as_os_str().is_empty() || !candidate.exists() { + continue; + } + if is_path_symlink(candidate) || has_windows_reparse_point(candidate) { + return Err(format!( + "App Use owned {label} path must not include a symlink or reparse point." + )); + } + } + Ok(()) +} + +fn is_path_symlink(path: &Path) -> bool { + fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) +} + +#[cfg(windows)] +fn has_windows_reparse_point(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + fs::symlink_metadata(path) + .map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn has_windows_reparse_point(_path: &Path) -> bool { + false +} + +fn unique_owned_target_path(spec: &OwnedTargetSpec) -> PathBuf { + let name = format!( + "touchai-{}-{}.{}", + spec.kind, + uuid::Uuid::new_v4(), + spec.kind.extension() + ); + spec.root.join(name) +} + +fn write_zip_entries(file: File, entries: &[(&str, &str)]) -> Result<(), String> { + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let mut zip = ZipWriter::new(file); + for (path, contents) in entries { + zip.start_file(path, options) + .map_err(|error| format!("App Use owned target package is invalid: {error}"))?; + zip.write_all(contents.as_bytes()).map_err(|error| { + format!("App Use owned target package could not be written: {error}") + })?; + } + zip.finish() + .map_err(|error| format!("App Use owned target package could not be finalized: {error}"))?; + Ok(()) +} + +fn write_minimal_docx(file: File) -> Result<(), String> { + write_zip_entries( + file, + &[ + ( + "[Content_Types].xml", + r#""#, + ), + ( + "_rels/.rels", + r#""#, + ), + ( + "word/document.xml", + r#""#, + ), + ], + ) +} + +fn write_minimal_xlsx(file: File) -> Result<(), String> { + write_zip_entries( + file, + &[ + ( + "[Content_Types].xml", + r#""#, + ), + ( + "_rels/.rels", + r#""#, + ), + ( + "xl/workbook.xml", + r#""#, + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ( + "xl/worksheets/sheet1.xml", + r#""#, + ), + ], + ) +} + +fn write_minimal_pptx(file: File) -> Result<(), String> { + write_zip_entries( + file, + &[ + ( + "[Content_Types].xml", + r#""#, + ), + ( + "_rels/.rels", + r#""#, + ), + ( + "ppt/presentation.xml", + r#""#, + ), + ( + "ppt/_rels/presentation.xml.rels", + r#""#, + ), + ( + "ppt/slides/slide1.xml", + r#""#, + ), + ], + ) +} + +impl Default for AppUseRuntime { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + use types::{AppUseAdvancedConfig, AppUseConfig, AppUseObserveRequest, AppUseSessionRequest}; + + struct MockAdapter; + + impl AppUseAdapter for MockAdapter { + fn id(&self) -> &'static str { + "mock_app" + } + + fn label(&self) -> &'static str { + "Mock App" + } + + fn capabilities(&self) -> &'static [&'static str] { + &["observe_selection", "replace_document_text"] + } + + fn vendor(&self) -> &'static str { + "test-extension" + } + + fn contract_version(&self) -> &'static str { + "mock-contract-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["selection"] + } + + fn actions(&self) -> &'static [&'static str] { + &[ + "replace_document_text", + "export_preview", + "batch_export", + "cross_app_transfer", + ] + } + + fn installed(&self) -> bool { + true + } + + fn running(&self) -> bool { + true + } + + fn active_target_name(&self) -> Option { + Some("Mock Document".to_string()) + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + AppUseObserveResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some("mock observation".to_string()), + metadata: json!({ "adapter": self.id() }), + truncated: false, + } + } + + fn validate_act(&self, _request: &AppUseAuthorizeActRequest) -> Result<(), String> { + Ok(()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: "mock action applied".to_string(), + changed: true, + metadata: json!({ "adapter": self.id() }), + } + } + } + + fn enabled_config(mode: &str) -> AppUseConfig { + AppUseConfig { + mode: mode.to_string(), + adapters: HashMap::from([("mock_app".to_string(), true)]), + mutating_approval_mode: "always".to_string(), + read_scope: "active".to_string(), + allow_background_operation: false, + allow_raw_automation: false, + timeout_ms: 15_000, + max_output_chars: 12_000, + advanced: AppUseAdvancedConfig::default(), + } + } + + fn default_runtime_config(mode: &str, enabled_adapter: &str) -> AppUseConfig { + let mut adapters = HashMap::from([ + ("office_word".to_string(), false), + ("office_excel".to_string(), false), + ("office_powerpoint".to_string(), false), + ("wps_writer".to_string(), false), + ("wps_spreadsheet".to_string(), false), + ("wps_presentation".to_string(), false), + ("photoshop".to_string(), false), + ("illustrator".to_string(), false), + ]); + adapters.insert(enabled_adapter.to_string(), true); + + AppUseConfig { + mode: mode.to_string(), + adapters, + mutating_approval_mode: "always".to_string(), + read_scope: "active".to_string(), + allow_background_operation: false, + allow_raw_automation: false, + timeout_ms: 15_000, + max_output_chars: 12_000, + advanced: AppUseAdvancedConfig::default(), + } + } + + fn session_request( + operation: &str, + adapter_id: Option<&str>, + target_kind: Option<&str>, + config: AppUseConfig, + ) -> AppUseSessionRequest { + AppUseSessionRequest { + execution_id: "runtime-session-test".to_string(), + operation: operation.to_string(), + description: "run App Use session operation".to_string(), + adapter_id: adapter_id.map(str::to_string), + target_kind: target_kind.map(str::to_string), + config, + } + } + + #[test] + fn session_reports_state_from_registered_adapters() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + + let response = runtime.session(AppUseSessionRequest { + execution_id: "adapter-test-1".to_string(), + operation: "discover".to_string(), + description: "discover mock app".to_string(), + adapter_id: None, + target_kind: None, + config: enabled_config("read_only"), + }); + + assert_eq!(response.ok, true, "{:?}", response.message); + assert_eq!(response.adapters.len(), 1); + assert_eq!(response.adapters[0].id, "mock_app"); + assert_eq!(response.adapters[0].label, "Mock App"); + assert_eq!(response.adapters[0].installed, true); + assert_eq!(response.adapters[0].running, true); + assert_eq!(response.adapters[0].enabled, true); + assert_eq!( + response.adapters[0].active_target_name.as_deref(), + Some("Mock Document") + ); + assert_eq!(response.adapters[0].contract.vendor, "test-extension"); + assert_eq!(response.adapters[0].contract.version, "mock-contract-v1"); + assert_eq!( + response.adapters[0].contract.observe_scopes, + vec!["selection"] + ); + assert!(response.adapters[0] + .contract + .actions + .contains(&"replace_document_text".to_string())); + assert_eq!(response.adapters[0].contract.raw_automation_allowed, false); + } + + #[test] + fn create_owned_target_returns_signed_wps_target_and_marker() { + let runtime = AppUseRuntime::new(); + let response = runtime.session(session_request( + APP_USE_CREATE_TARGET_OPERATION, + Some("wps_spreadsheet"), + Some("spreadsheet"), + default_runtime_config("interactive", "wps_spreadsheet"), + )); + + assert_eq!(response.ok, true); + assert_eq!(response.adapter_id.as_deref(), Some("wps_spreadsheet")); + assert_eq!(response.target_kind.as_deref(), Some("spreadsheet")); + let target = response.target.expect("owned target"); + assert!(target.ends_with(".xlsx")); + let target_path = PathBuf::from(&target); + assert!(target_path.exists()); + let marker_path = target_path.with_file_name(format!( + "{}.touchai-owned.json", + target_path + .file_name() + .and_then(|value| value.to_str()) + .expect("target filename") + )); + let marker = std::fs::read_to_string(&marker_path).expect("owned marker"); + assert!(marker.contains("\"signature\"")); + let _ = std::fs::remove_file(&target); + let _ = std::fs::remove_file(marker_path); + } + + #[test] + fn create_owned_target_rejects_disabled_adapter_and_wrong_kind() { + let runtime = AppUseRuntime::new(); + let disabled = runtime.session(session_request( + APP_USE_CREATE_TARGET_OPERATION, + Some("wps_spreadsheet"), + Some("spreadsheet"), + default_runtime_config("interactive", "wps_writer"), + )); + assert_eq!(disabled.ok, false); + assert!(disabled.message.unwrap_or_default().contains("disabled")); + + let wrong_kind = runtime.session(session_request( + APP_USE_CREATE_TARGET_OPERATION, + Some("wps_spreadsheet"), + Some("document"), + default_runtime_config("interactive", "wps_spreadsheet"), + )); + assert_eq!(wrong_kind.ok, false); + assert!(wrong_kind + .message + .unwrap_or_default() + .contains("spreadsheet")); + } + + #[test] + fn authorization_requires_adapter_declared_action_contract() { + struct PermissiveUndeclaredAdapter; + + impl AppUseAdapter for PermissiveUndeclaredAdapter { + fn id(&self) -> &'static str { + "permissive_extension" + } + + fn label(&self) -> &'static str { + "Permissive Extension" + } + + fn capabilities(&self) -> &'static [&'static str] { + &["observe_selection", "replace_document_text"] + } + + fn installed(&self) -> bool { + true + } + + fn validate_act(&self, _request: &AppUseAuthorizeActRequest) -> Result<(), String> { + Ok(()) + } + } + + let runtime = AppUseRuntime::with_adapters(vec![Box::new(PermissiveUndeclaredAdapter)]); + let config = AppUseConfig { + mode: "interactive".to_string(), + adapters: HashMap::from([("permissive_extension".to_string(), true)]), + mutating_approval_mode: "always".to_string(), + read_scope: "active".to_string(), + allow_background_operation: false, + allow_raw_automation: false, + timeout_ms: 15_000, + max_output_chars: 12_000, + advanced: AppUseAdvancedConfig::default(), + }; + + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "contract-undeclared-action".to_string(), + adapter_id: "permissive_extension".to_string(), + action: "replace_document_text".to_string(), + target_id: Some("target-1".to_string()), + parameters: Some(json!({ "text": "hello" })), + config, + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_refuses_invalid_wps_spreadsheet_authorization() { + let runtime = AppUseRuntime::new(); + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-wps-sheet-1".to_string(), + adapter_id: "wps_spreadsheet".to_string(), + action: "write_cells".to_string(), + target_id: Some("not-owned.xlsx".to_string()), + parameters: Some(json!({ "range": "A1", "values": [] })), + config: default_runtime_config("interactive", "wps_spreadsheet"), + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_refuses_invalid_wps_presentation_authorization() { + let runtime = AppUseRuntime::new(); + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-wps-presentation-1".to_string(), + adapter_id: "wps_presentation".to_string(), + action: "add_slide_text".to_string(), + target_id: Some("not-owned.pptx".to_string()), + parameters: Some(json!({ "text": "" })), + config: default_runtime_config("interactive", "wps_presentation"), + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_refuses_adobe_action_authorization() { + let runtime = AppUseRuntime::new(); + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-adobe-1".to_string(), + adapter_id: "photoshop".to_string(), + action: "export_preview".to_string(), + target_id: None, + parameters: None, + config: default_runtime_config("interactive", "photoshop"), + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_refuses_future_workflow_actions() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + + let export_authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-advanced-export".to_string(), + adapter_id: "mock_app".to_string(), + action: "export_preview".to_string(), + target_id: Some("target-1".to_string()), + parameters: None, + config: enabled_config("interactive"), + }); + assert_eq!(export_authorization.permit, None); + + let batch_authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-advanced-batch".to_string(), + adapter_id: "mock_app".to_string(), + action: "batch_export".to_string(), + target_id: Some("target-1".to_string()), + parameters: None, + config: enabled_config("interactive"), + }); + assert_eq!(batch_authorization.permit, None); + + let cross_app_authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-advanced-cross-app".to_string(), + adapter_id: "mock_app".to_string(), + action: "cross_app_transfer".to_string(), + target_id: Some("target-1".to_string()), + parameters: None, + config: enabled_config("interactive"), + }); + assert_eq!(cross_app_authorization.permit, None); + } + + #[test] + fn default_runtime_ignores_legacy_future_workflow_config() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + let mut config = enabled_config("interactive"); + config.advanced = AppUseAdvancedConfig { + export_previews: true, + batch_workflows: true, + cross_app_workflows: true, + }; + + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-advanced-enabled".to_string(), + adapter_id: "mock_app".to_string(), + action: "cross_app_transfer".to_string(), + target_id: Some("target-1".to_string()), + parameters: None, + config, + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_refuses_office_action_without_owned_target() { + let runtime = AppUseRuntime::new(); + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-office-1".to_string(), + adapter_id: "office_word".to_string(), + action: "replace_document_text".to_string(), + target_id: None, + parameters: Some(json!({ "text": "hello" })), + config: default_runtime_config("interactive", "office_word"), + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_refuses_non_owned_wps_authorization() { + let runtime = AppUseRuntime::new(); + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "runtime-wps-unowned-1".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + target_id: Some("target-1".to_string()), + parameters: Some(json!({ "text": "hello" })), + config: default_runtime_config("interactive", "wps_writer"), + }); + + assert_eq!(authorization.permit, None); + assert_eq!(authorization.expires_in_ms, 0); + } + + #[test] + fn default_runtime_adobe_future_actions_stay_unavailable_before_approval() { + let runtime = AppUseRuntime::new(); + let response = runtime.act(AppUseActRequest { + execution_id: "runtime-adobe-1".to_string(), + adapter_id: "photoshop".to_string(), + action: "export_preview".to_string(), + description: "try Photoshop export preview".to_string(), + target_id: None, + parameters: None, + permit: None, + config: default_runtime_config("interactive", "photoshop"), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "workflow_not_available"); + } + + #[test] + fn default_runtime_adobe_descriptors_expose_only_read_only_capabilities() { + let runtime = AppUseRuntime::new(); + let response = runtime.session(AppUseSessionRequest { + execution_id: "runtime-adobe-session".to_string(), + operation: "capabilities".to_string(), + description: "inspect Adobe capabilities".to_string(), + adapter_id: None, + target_kind: None, + config: default_runtime_config("read_only", "photoshop"), + }); + + let photoshop = response + .adapters + .iter() + .find(|adapter| adapter.id == "photoshop") + .expect("photoshop descriptor"); + let illustrator = response + .adapters + .iter() + .find(|adapter| adapter.id == "illustrator") + .expect("illustrator descriptor"); + + assert_eq!(photoshop.capabilities, vec!["discover", "observe_layers"]); + assert_eq!( + illustrator.capabilities, + vec!["discover", "observe_artboards"] + ); + } + + #[test] + fn default_runtime_spreadsheet_descriptors_keep_cell_reads_observe_only() { + let runtime = AppUseRuntime::new(); + let response = runtime.session(AppUseSessionRequest { + execution_id: "runtime-spreadsheet-session".to_string(), + operation: "capabilities".to_string(), + description: "inspect spreadsheet capabilities".to_string(), + adapter_id: None, + target_kind: None, + config: default_runtime_config("read_only", "office_excel"), + }); + + for adapter_id in ["office_excel", "wps_spreadsheet"] { + let adapter = response + .adapters + .iter() + .find(|adapter| adapter.id == adapter_id) + .expect("spreadsheet descriptor"); + + assert!(adapter + .capabilities + .contains(&"observe_worksheet".to_string())); + assert!(!adapter.capabilities.contains(&"read_cells".to_string())); + assert_eq!(adapter.contract.actions, vec!["write_cells"]); + } + } + + #[test] + fn observe_delegates_to_enabled_adapter() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + + let response = runtime.observe(AppUseObserveRequest { + execution_id: "adapter-test-2".to_string(), + adapter_id: "mock_app".to_string(), + scope: "selection".to_string(), + description: "read mock selection".to_string(), + target_id: Some("target-1".to_string()), + max_output_chars: 12_000, + config: enabled_config("read_only"), + }); + + assert_eq!(response.ok, true); + assert_eq!(response.content.as_deref(), Some("mock observation")); + assert_eq!(response.metadata["adapter"], "mock_app"); + } + + #[test] + fn observe_requires_adapter_declared_scope_contract() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + + let response = runtime.observe(AppUseObserveRequest { + execution_id: "adapter-test-undeclared-scope".to_string(), + adapter_id: "mock_app".to_string(), + scope: "workbook".to_string(), + description: "read unsupported scope".to_string(), + target_id: Some("target-1".to_string()), + max_output_chars: 12_000, + config: enabled_config("read_only"), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.metadata["reason"], "unsupported_scope"); + } + + #[test] + fn act_rejects_forged_approval_payload_in_interactive_mode() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + + let response = runtime.act(AppUseActRequest { + execution_id: "adapter-test-3".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + description: "replace mock selection".to_string(), + target_id: Some("target-1".to_string()), + parameters: Some(json!({ "text": "hello" })), + permit: Some(types::AppUseActPermit { + call_id: "adapter-test-3".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + target_id: Some("target-1".to_string()), + parameters_hash: "forged".to_string(), + token: "forged".to_string(), + }), + config: enabled_config("interactive"), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "approval_required"); + } + + #[test] + fn act_consumes_runtime_authorized_permit_once() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + let parameters = json!({ "text": "hello" }); + let authorization = runtime.authorize_act(AppUseAuthorizeActRequest { + execution_id: "adapter-test-3".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + target_id: Some("target-1".to_string()), + parameters: Some(parameters.clone()), + config: enabled_config("interactive"), + }); + let permit = authorization.permit.expect("permit"); + + let response = runtime.act(AppUseActRequest { + execution_id: "adapter-test-3".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + description: "replace mock selection".to_string(), + target_id: Some("target-1".to_string()), + parameters: Some(parameters.clone()), + permit: Some(permit.clone()), + config: enabled_config("interactive"), + }); + + assert_eq!(response.ok, true); + assert_eq!(response.changed, true); + assert_eq!(response.receipt, "mock action applied"); + assert_eq!(response.metadata["adapter"], "mock_app"); + + let replay = runtime.act(AppUseActRequest { + execution_id: "adapter-test-3".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + description: "replace mock selection again".to_string(), + target_id: Some("target-1".to_string()), + parameters: Some(parameters), + permit: Some(permit), + config: enabled_config("interactive"), + }); + + assert_eq!(replay.ok, false); + assert_eq!(replay.changed, false); + assert_eq!(replay.metadata["reason"], "approval_required"); + } + + #[test] + fn act_rejects_interactive_request_without_approval_capability() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + + let response = runtime.act(AppUseActRequest { + execution_id: "adapter-test-4".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + description: "replace mock selection".to_string(), + target_id: None, + parameters: Some(json!({ "text": "hello" })), + permit: None, + config: enabled_config("interactive"), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "approval_required"); + } + + #[test] + fn act_normalizes_invalid_native_config_to_read_only() { + let runtime = AppUseRuntime::with_adapters(vec![Box::new(MockAdapter)]); + let mut config = enabled_config("full_auto"); + config.allow_raw_automation = true; + config.timeout_ms = 10; + config.max_output_chars = 500_000; + + let response = runtime.act(AppUseActRequest { + execution_id: "adapter-test-5".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + description: "replace mock selection".to_string(), + target_id: None, + parameters: Some(json!({ "text": "hello" })), + permit: Some(types::AppUseActPermit { + call_id: "adapter-test-5".to_string(), + adapter_id: "mock_app".to_string(), + action: "replace_document_text".to_string(), + target_id: None, + parameters_hash: "forged".to_string(), + token: "forged".to_string(), + }), + config, + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "read_only"); + } +} diff --git a/apps/desktop/src-tauri/src/core/app_use/office.rs b/apps/desktop/src-tauri/src/core/app_use/office.rs new file mode 100644 index 00000000..6d630756 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/app_use/office.rs @@ -0,0 +1,2444 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +use super::{ + discovery, AppUseActRequest, AppUseActResponse, AppUseAdapter, AppUseAuthorizeActRequest, + AppUseObserveRequest, AppUseObserveResponse, +}; +use base64::{engine::general_purpose, Engine as _}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +pub trait OfficeAutomationRunner: Send + Sync { + fn run_script(&self, script: &str, timeout_ms: u64) -> Result; +} + +struct PowerShellOfficeAutomationRunner; + +fn powershell_executable() -> Result { + #[cfg(windows)] + { + let windows_root = PathBuf::from(r"C:\Windows"); + let powershell = windows_root + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + + if powershell.exists() { + return Ok(powershell); + } + + return Err(format!( + "Trusted Windows PowerShell executable is unavailable at {}.", + powershell.display() + )); + } + + #[cfg(not(windows))] + { + Err("Office App Use automation is only available on Windows.".to_string()) + } +} + +fn powershell_arguments(encoded_script: &str) -> Vec { + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Sta", + "-EncodedCommand", + encoded_script, + ] + .into_iter() + .map(str::to_string) + .collect() +} + +impl OfficeAutomationRunner for PowerShellOfficeAutomationRunner { + fn run_script(&self, script: &str, timeout_ms: u64) -> Result { + let encoded_script = general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let shell_path = powershell_executable()?; + let mut child = Command::new(&shell_path) + .args(powershell_arguments(&encoded_script)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "Failed to start Office automation shell ({}): {error}", + shell_path.display() + ) + })?; + + let started_at = Instant::now(); + let timeout = Duration::from_millis(timeout_ms.max(1_000)); + loop { + match child + .try_wait() + .map_err(|error| format!("Failed to wait for Office automation shell: {error}"))? + { + Some(_) => { + let output = child.wait_with_output().map_err(|error| { + format!("Failed to collect Office automation output: {error}") + })?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + return Ok(stdout); + } + + return Err(if stderr.is_empty() { stdout } else { stderr }); + } + None if started_at.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Office automation timed out after {timeout_ms}ms while waiting for the Office COM server." + )); + } + None => thread::sleep(Duration::from_millis(50)), + } + } + } +} + +pub struct OfficeWordAdapter { + runner: Arc, +} + +impl OfficeWordAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellOfficeAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for OfficeWordAdapter { + fn default() -> Self { + Self::new() + } +} + +pub struct OfficeExcelAdapter { + runner: Arc, +} + +impl OfficeExcelAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellOfficeAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for OfficeExcelAdapter { + fn default() -> Self { + Self::new() + } +} + +pub struct OfficePowerPointAdapter { + runner: Arc, +} + +impl OfficePowerPointAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellOfficeAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for OfficePowerPointAdapter { + fn default() -> Self { + Self::new() + } +} + +impl AppUseAdapter for OfficeWordAdapter { + fn id(&self) -> &'static str { + "office_word" + } + + fn label(&self) -> &'static str { + "Microsoft Word" + } + + fn capabilities(&self) -> &'static [&'static str] { + &[ + "discover", + "observe_active_document", + "observe_selection", + "replace_document_text", + "format_document_text", + ] + } + + fn vendor(&self) -> &'static str { + "Microsoft Office" + } + + fn contract_version(&self) -> &'static str { + "office-com-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["active_document", "selection"] + } + + fn actions(&self) -> &'static [&'static str] { + &["replace_document_text", "format_document_text"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + false, + self.label(), + ) { + Ok(owned_target) => owned_target, + Err(error) => return observe_error(request, error, "target_not_owned"), + }; + let target_path = owned_target.as_ref().map(OwnedTargetGuard::path_string); + let script = word_observe_script(target_path.as_deref(), &request.scope); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_word_observe_content(&metadata, &request.scope); + let (content, truncated) = truncate_content(content, request.max_output_chars); + observe_success(request, metadata, content, truncated, target_path) + } + Err(error) => observe_error(request, error, "office_automation_failed"), + } + } + + fn validate_act(&self, request: &AppUseAuthorizeActRequest) -> Result<(), String> { + match request.action.as_str() { + "replace_document_text" => { + text_parameter(request.parameters.as_ref(), "Microsoft Word")?; + } + "format_document_text" => { + format_document_text_parameters(request.parameters.as_ref(), "Microsoft Word") + .map(|_| ())?; + } + _ => { + return Err(format!( + "Unsupported Microsoft Word action: {}", + request.action + )) + } + } + + validate_owned_target_for_app(request.target_id.as_deref(), true, self.label()).map(|_| ()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + true, + self.label(), + ) { + Ok(Some(owned_target)) => owned_target, + Ok(None) => { + return act_error( + request, + format!( + "{} targetId must reference a TouchAI-owned document before running write actions.", + self.label() + ), + "target_not_owned", + ); + } + Err(error) => return act_error(request, error, "target_not_owned"), + }; + let target_path = owned_target.path_string(); + let script = match request.action.as_str() { + "replace_document_text" => { + let text = match text_parameter(request.parameters.as_ref(), "Microsoft Word") { + Ok(text) => text, + Err(error) => return act_error(request, error, "invalid_parameters"), + }; + word_text_script(&text, request.action.as_str(), Some(&target_path)) + } + "format_document_text" => { + let format = match format_document_text_parameters( + request.parameters.as_ref(), + "Microsoft Word", + ) { + Ok(format) => format, + Err(error) => return act_error(request, error, "invalid_parameters"), + }; + word_format_document_text_script(&format, Some(&target_path)) + } + _ => { + return act_error( + request, + format!("Unsupported Microsoft Word action: {}", request.action), + "unsupported_action", + ); + } + }; + + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let document_name = metadata + .get("documentName") + .and_then(Value::as_str) + .unwrap_or("owned Word document"); + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "Microsoft Word {} completed for {document_name}", + request.action + ), + changed: metadata + .get("changed") + .and_then(Value::as_bool) + .unwrap_or(true), + metadata, + } + } + Err(error) => act_error(request, error, "office_automation_failed"), + } + } +} + +impl AppUseAdapter for OfficeExcelAdapter { + fn id(&self) -> &'static str { + "office_excel" + } + + fn label(&self) -> &'static str { + "Microsoft Excel" + } + + fn capabilities(&self) -> &'static [&'static str] { + &[ + "discover", + "observe_workbook", + "observe_worksheet", + "write_cells", + ] + } + + fn vendor(&self) -> &'static str { + "Microsoft Office" + } + + fn contract_version(&self) -> &'static str { + "office-com-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["workbook", "worksheet"] + } + + fn actions(&self) -> &'static [&'static str] { + &["write_cells"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + false, + self.label(), + ) { + Ok(owned_target) => owned_target, + Err(error) => return observe_error(request, error, "target_not_owned"), + }; + let target_path = owned_target.as_ref().map(OwnedTargetGuard::path_string); + let script = excel_observe_script(target_path.as_deref()); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_excel_observe_content(&metadata, &request.scope); + let (content, truncated) = truncate_content(content, request.max_output_chars); + observe_success(request, metadata, content, truncated, target_path) + } + Err(error) => observe_error(request, error, "office_automation_failed"), + } + } + + fn validate_act(&self, request: &AppUseAuthorizeActRequest) -> Result<(), String> { + if request.action != "write_cells" { + return Err(format!( + "Unsupported Microsoft Excel action: {}", + request.action + )); + } + + spreadsheet_write_parameters(request.parameters.as_ref(), "Microsoft Excel")?; + validate_owned_target_for_app(request.target_id.as_deref(), true, self.label()).map(|_| ()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + if request.action != "write_cells" { + return act_error( + request, + format!("Unsupported Microsoft Excel action: {}", request.action), + "unsupported_action", + ); + } + + let cells = + match spreadsheet_write_parameters(request.parameters.as_ref(), "Microsoft Excel") { + Ok(cells) => cells, + Err(error) => return act_error(request, error, "invalid_parameters"), + }; + + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + true, + self.label(), + ) { + Ok(Some(owned_target)) => owned_target, + Ok(None) => { + return act_error( + request, + format!( + "{} targetId must reference a TouchAI-owned document before running write actions.", + self.label() + ), + "target_not_owned", + ); + } + Err(error) => return act_error(request, error, "target_not_owned"), + }; + let target_path = owned_target.path_string(); + + let script = excel_write_cells_script(&cells, Some(&target_path)); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let workbook_name = metadata + .get("workbookName") + .and_then(Value::as_str) + .unwrap_or("owned Excel workbook"); + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!("Microsoft Excel write_cells completed for {workbook_name}"), + changed: metadata + .get("changed") + .and_then(Value::as_bool) + .unwrap_or(true), + metadata, + } + } + Err(error) => act_error(request, error, "office_automation_failed"), + } + } +} + +impl AppUseAdapter for OfficePowerPointAdapter { + fn id(&self) -> &'static str { + "office_powerpoint" + } + + fn label(&self) -> &'static str { + "Microsoft PowerPoint" + } + + fn capabilities(&self) -> &'static [&'static str] { + &[ + "discover", + "observe_presentation", + "observe_slide", + "add_slide_text", + ] + } + + fn vendor(&self) -> &'static str { + "Microsoft Office" + } + + fn contract_version(&self) -> &'static str { + "office-com-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["presentation", "slide"] + } + + fn actions(&self) -> &'static [&'static str] { + &["add_slide_text"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + false, + self.label(), + ) { + Ok(owned_target) => owned_target, + Err(error) => return observe_error(request, error, "target_not_owned"), + }; + let target_path = owned_target.as_ref().map(OwnedTargetGuard::path_string); + let script = powerpoint_observe_script(target_path.as_deref()); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_powerpoint_observe_content(&metadata, &request.scope); + let (content, truncated) = truncate_content(content, request.max_output_chars); + observe_success(request, metadata, content, truncated, target_path) + } + Err(error) => observe_error(request, error, "office_automation_failed"), + } + } + + fn validate_act(&self, request: &AppUseAuthorizeActRequest) -> Result<(), String> { + if request.action != "add_slide_text" { + return Err(format!( + "Unsupported Microsoft PowerPoint action: {}", + request.action + )); + } + + presentation_text_parameters(request.parameters.as_ref(), "Microsoft PowerPoint")?; + validate_owned_target_for_app(request.target_id.as_deref(), true, self.label()).map(|_| ()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + if request.action != "add_slide_text" { + return act_error( + request, + format!( + "Unsupported Microsoft PowerPoint action: {}", + request.action + ), + "unsupported_action", + ); + } + + let slide_text = + match presentation_text_parameters(request.parameters.as_ref(), "Microsoft PowerPoint") + { + Ok(slide_text) => slide_text, + Err(error) => return act_error(request, error, "invalid_parameters"), + }; + + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + true, + self.label(), + ) { + Ok(Some(owned_target)) => owned_target, + Ok(None) => { + return act_error( + request, + format!( + "{} targetId must reference a TouchAI-owned document before running write actions.", + self.label() + ), + "target_not_owned", + ); + } + Err(error) => return act_error(request, error, "target_not_owned"), + }; + let target_path = owned_target.path_string(); + + let script = powerpoint_add_slide_text_script(&slide_text, Some(&target_path)); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let presentation_name = metadata + .get("presentationName") + .and_then(Value::as_str) + .unwrap_or("owned PowerPoint presentation"); + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "Microsoft PowerPoint add_slide_text completed for {presentation_name}" + ), + changed: metadata + .get("changed") + .and_then(Value::as_bool) + .unwrap_or(true), + metadata, + } + } + Err(error) => act_error(request, error, "office_automation_failed"), + } + } +} + +fn observe_success( + request: &AppUseObserveRequest, + metadata: Value, + content: String, + truncated: bool, + target: Option, +) -> AppUseObserveResponse { + AppUseObserveResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target, + content: Some(content), + metadata, + truncated, + } +} + +fn observe_error( + request: &AppUseObserveRequest, + error: String, + reason: &str, +) -> AppUseObserveResponse { + AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": reason, + }), + truncated: false, + } +} + +fn act_error(request: &AppUseActRequest, receipt: String, reason: &str) -> AppUseActResponse { + AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": reason, + }), + } +} + +fn run_json_script( + runner: &dyn OfficeAutomationRunner, + script: &str, + timeout_ms: u64, +) -> Result { + let output = runner.run_script(script, timeout_ms)?; + serde_json::from_str(output.trim()) + .map_err(|error| format!("Office automation returned invalid JSON: {error}")) +} + +fn encoded_utf8_script_value(value: Option<&str>) -> String { + value + .map(|value| general_purpose::STANDARD.encode(value.as_bytes())) + .unwrap_or_default() +} + +fn truncate_content(content: String, max_chars: usize) -> (String, bool) { + if content.chars().count() <= max_chars { + return (content, false); + } + + (content.chars().take(max_chars).collect(), true) +} + +fn text_parameter(parameters: Option<&Value>, app_label: &str) -> Result { + let text = parameters + .and_then(|value| value.get("text")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + + if text.trim().is_empty() { + return Err(format!( + "{app_label} action requires a non-empty text parameter." + )); + } + if text.chars().count() > 20_000 { + return Err(format!( + "{app_label} text must be 20000 characters or fewer." + )); + } + + Ok(text) +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct SelectionFormat { + bold: Option, + italic: Option, + underline: Option, + font_size: Option, + font_name: Option, +} + +impl SelectionFormat { + fn is_empty(&self) -> bool { + self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.font_size.is_none() + && self.font_name.is_none() + } +} + +fn format_document_text_parameters( + parameters: Option<&Value>, + app_label: &str, +) -> Result { + let Some(parameters) = parameters.and_then(Value::as_object) else { + return Err(format!( + "{app_label} format_document_text requires structured format parameters." + )); + }; + + let mut format = SelectionFormat::default(); + if let Some(value) = parameters.get("bold") { + format.bold = + Some(value.as_bool().ok_or_else(|| { + format!("{app_label} format_document_text bold must be a boolean.") + })?); + } + if let Some(value) = parameters.get("italic") { + format.italic = Some(value.as_bool().ok_or_else(|| { + format!("{app_label} format_document_text italic must be a boolean.") + })?); + } + if let Some(value) = parameters.get("underline") { + format.underline = Some(value.as_bool().ok_or_else(|| { + format!("{app_label} format_document_text underline must be a boolean.") + })?); + } + if let Some(value) = parameters.get("fontSize") { + let font_size = value.as_f64().ok_or_else(|| { + format!("{app_label} format_document_text fontSize must be a number.") + })?; + if !(6.0..=96.0).contains(&font_size) { + return Err(format!( + "{app_label} format_document_text fontSize must be between 6 and 96." + )); + } + format.font_size = Some(font_size); + } + if let Some(value) = parameters.get("fontName") { + let font_name = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!("{app_label} format_document_text fontName must be a non-empty string.") + })?; + if font_name.chars().count() > 128 { + return Err(format!( + "{app_label} format_document_text fontName must be 128 characters or fewer." + )); + } + format.font_name = Some(font_name.to_string()); + } + + if format.is_empty() { + return Err(format!( + "{app_label} format_document_text requires at least one format option." + )); + } + + Ok(format) +} + +#[derive(Clone, Debug, PartialEq)] +struct SpreadsheetWriteCells { + range: String, + sheet_name: Option, + values: Vec>, +} + +const SPREADSHEET_MAX_ROWS: usize = 100; +const SPREADSHEET_MAX_COLUMNS: usize = 50; +const SPREADSHEET_MAX_CELLS: usize = 5_000; +const SPREADSHEET_MAX_CELL_TEXT_CHARS: usize = 4_096; +const SPREADSHEET_MAX_VALUES_JSON_BYTES: usize = 256 * 1024; + +fn validate_spreadsheet_cell(cell: &Value, app_label: &str) -> Result<(), String> { + let Value::String(text) = cell else { + return Ok(()); + }; + + if text.chars().count() > SPREADSHEET_MAX_CELL_TEXT_CHARS { + return Err(format!( + "{app_label} write_cells cell text must be {SPREADSHEET_MAX_CELL_TEXT_CHARS} characters or fewer." + )); + } + + let trimmed = text.trim_start(); + if trimmed.starts_with(['=', '+', '-', '@']) { + return Err(format!( + "{app_label} write_cells rejects formula-like strings; formulas require an explicit future action." + )); + } + + Ok(()) +} + +fn spreadsheet_write_parameters( + parameters: Option<&Value>, + app_label: &str, +) -> Result { + let Some(parameters) = parameters.and_then(Value::as_object) else { + return Err(format!( + "{app_label} write_cells requires structured cell parameters." + )); + }; + + let range = parameters + .get("range") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("{app_label} write_cells requires a non-empty range."))?; + if range.chars().count() > 64 + || !range + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '$')) + { + return Err(format!( + "{app_label} write_cells range must be an A1-style address." + )); + } + + let sheet_name = parameters + .get("sheetName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if sheet_name + .as_ref() + .is_some_and(|value| value.chars().count() > 128) + { + return Err(format!( + "{app_label} write_cells sheetName must be 128 characters or fewer." + )); + } + + let Some(rows) = parameters.get("values").and_then(Value::as_array) else { + return Err(format!("{app_label} write_cells requires values.")); + }; + if rows.is_empty() { + return Err(format!("{app_label} write_cells values cannot be empty.")); + } + if rows.len() > SPREADSHEET_MAX_ROWS { + return Err(format!( + "{app_label} write_cells supports at most {SPREADSHEET_MAX_ROWS} rows per action." + )); + } + + let mut values = Vec::with_capacity(rows.len()); + let mut width = None; + for row in rows { + let Some(cells) = row.as_array() else { + return Err(format!( + "{app_label} write_cells values must be a two-dimensional array." + )); + }; + if cells.is_empty() { + return Err(format!("{app_label} write_cells rows cannot be empty.")); + } + if cells.len() > SPREADSHEET_MAX_COLUMNS { + return Err(format!( + "{app_label} write_cells supports at most {SPREADSHEET_MAX_COLUMNS} columns per action." + )); + } + if width.is_some_and(|expected| expected != cells.len()) { + return Err(format!( + "{app_label} write_cells values must use rectangular rows." + )); + } + width = Some(cells.len()); + + let mut normalized_row = Vec::with_capacity(cells.len()); + for cell in cells { + if !matches!( + cell, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) { + return Err(format!( + "{app_label} write_cells values can only contain scalar cells." + )); + } + validate_spreadsheet_cell(cell, app_label)?; + normalized_row.push(cell.clone()); + } + values.push(normalized_row); + } + if values.len() * width.unwrap_or_default() > SPREADSHEET_MAX_CELLS { + return Err(format!( + "{app_label} write_cells supports at most {SPREADSHEET_MAX_CELLS} cells per action." + )); + } + let values_json = serde_json::to_string(&values).unwrap_or_default(); + if values_json.len() > SPREADSHEET_MAX_VALUES_JSON_BYTES { + return Err(format!( + "{app_label} write_cells payload must be {SPREADSHEET_MAX_VALUES_JSON_BYTES} bytes or fewer." + )); + } + + Ok(SpreadsheetWriteCells { + range: range.to_string(), + sheet_name, + values, + }) +} + +#[derive(Clone, Debug, PartialEq)] +struct SlideText { + text: String, + slide_index: Option, +} + +fn presentation_text_parameters( + parameters: Option<&Value>, + app_label: &str, +) -> Result { + let Some(parameters) = parameters.and_then(Value::as_object) else { + return Err(format!( + "{app_label} add_slide_text requires structured text parameters." + )); + }; + + let text = parameters + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!("{app_label} add_slide_text requires a non-empty text parameter.") + })?; + if text.chars().count() > 2_000 { + return Err(format!( + "{app_label} add_slide_text text must be 2000 characters or fewer." + )); + } + + let slide_index = match parameters.get("slideIndex") { + Some(value) => { + let index = value.as_i64().ok_or_else(|| { + format!("{app_label} add_slide_text slideIndex must be an integer.") + })?; + if index < 1 { + return Err(format!( + "{app_label} add_slide_text slideIndex must be at least 1." + )); + } + Some(index) + } + None => None, + }; + + Ok(SlideText { + text: text.to_string(), + slide_index, + }) +} + +const OFFICE_OWNED_ROOT_ENV: &str = "TOUCHAI_APP_USE_OFFICE_OWNED_ROOT"; +const OWNED_SECRET_ENV: &str = "TOUCHAI_APP_USE_OWNED_SECRET"; + +fn owned_office_target_root() -> PathBuf { + if cfg!(debug_assertions) { + if let Some(path) = std::env::var_os(OFFICE_OWNED_ROOT_ENV) { + return PathBuf::from(path); + } + } + + app_use_owned_data_root().join("office") +} + +pub(crate) fn owned_office_target_root_path() -> PathBuf { + owned_office_target_root() +} + +fn app_use_owned_data_root() -> PathBuf { + crate::core::system::paths::app_directory_path_without_app_root_override( + crate::core::system::paths::AppDirectory::Data, + ) + .unwrap_or_else(|_| fallback_app_data_root()) + .join("app-use") + .join("owned-targets") +} + +fn fallback_app_data_root() -> PathBuf { + if cfg!(target_os = "windows") { + if let Some(path) = std::env::var_os("LOCALAPPDATA") + .or_else(|| std::env::var_os("APPDATA")) + .map(PathBuf::from) + { + return path.join("TouchAI").join("data"); + } + } + + if let Some(path) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + return path.join("TouchAI").join("data"); + } + + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + return home + .join(".local") + .join("share") + .join("TouchAI") + .join("data"); + } + + PathBuf::from(".").join("touchai-data") +} + +fn owned_marker_path(target_path: &Path) -> PathBuf { + let mut marker_name = target_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("target") + .to_string(); + marker_name.push_str(".touchai-owned.json"); + target_path.with_file_name(marker_name) +} + +fn hash_owned_path(target_path: &Path) -> String { + let canonical = target_path.to_string_lossy().to_ascii_lowercase(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[cfg(windows)] +fn owned_file_information( + file: &File, + label: &str, +) -> Result { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { + GetFileInformationByHandle( + HANDLE(file.as_raw_handle() as *mut core::ffi::c_void), + &mut information, + ) + } + .map_err(|error| format!("TouchAI-owned {label} metadata is unavailable: {error}"))?; + + Ok(information) +} + +#[cfg(windows)] +fn file_identity(file: &File) -> Result { + let information = owned_file_information(file, "file identity")?; + + Ok(format!( + "{}:{}:{}", + information.dwVolumeSerialNumber, information.nFileIndexHigh, information.nFileIndexLow + )) +} + +#[cfg(not(windows))] +fn file_identity(_file: &File) -> Result { + Ok("non-windows-test-file".to_string()) +} + +fn hash_owned_marker(canonical_path: &Path, file_identity: &str, nonce: Option<&str>) -> String { + let canonical = canonical_path.to_string_lossy().to_ascii_lowercase(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + hasher.update(b"\0"); + hasher.update(file_identity.as_bytes()); + hasher.update(b"\0"); + hasher.update(nonce.unwrap_or_default().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn read_owned_marker_secret(secret_path: &Path) -> Result, String> { + if !secret_path.exists() { + return Ok(None); + } + + if let Some(parent) = secret_path.parent() { + ensure_path_chain_not_reparse(parent, "secret root")?; + } + let mut secret_file = match open_owned_guard_file(secret_path, "secret", false) { + Ok(file) => file, + Err(_) if !secret_path.exists() => return Ok(None), + Err(error) => { + return Err(format!( + "TouchAI owned-target secret could not be opened: {error}" + )); + } + }; + let mut secret = String::new(); + secret_file + .read_to_string(&mut secret) + .map_err(|error| format!("TouchAI owned-target secret could not be read: {error}"))?; + let secret = secret.trim().to_string(); + if secret.is_empty() { + Ok(None) + } else { + Ok(Some(secret)) + } +} + +fn owned_marker_secret() -> Result { + if cfg!(debug_assertions) { + if let Some(secret) = std::env::var_os(OWNED_SECRET_ENV) + .and_then(|value| value.into_string().ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Ok(secret); + } + } + + let root = app_use_owned_data_root(); + if root.exists() { + ensure_path_chain_not_reparse(&root, "secret root")?; + } + let secret_path = root.join(".ownership-secret"); + if let Some(secret) = read_owned_marker_secret(&secret_path)? { + return Ok(secret); + } + + fs::create_dir_all(&root) + .map_err(|error| format!("TouchAI owned-target secret root is unavailable: {error}"))?; + ensure_path_chain_not_reparse(&root, "secret root")?; + let secret = uuid::Uuid::new_v4().to_string(); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&secret_path) + { + Ok(mut file) => file.write_all(secret.as_bytes()).map_err(|error| { + format!("TouchAI owned-target secret could not be written: {error}") + })?, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if let Some(secret) = read_owned_marker_secret(&secret_path)? { + return Ok(secret); + } + return Err("TouchAI owned-target secret already exists but is empty.".to_string()); + } + Err(error) => { + return Err(format!( + "TouchAI owned-target secret could not be created: {error}" + )); + } + } + Ok(secret) +} + +fn hmac_sha256_hex(secret: &str, parts: &[&[u8]]) -> String { + const BLOCK_SIZE: usize = 64; + + let mut key = secret.as_bytes().to_vec(); + if key.len() > BLOCK_SIZE { + key = Sha256::digest(&key).to_vec(); + } + key.resize(BLOCK_SIZE, 0); + + let mut inner_pad = [0x36; BLOCK_SIZE]; + let mut outer_pad = [0x5c; BLOCK_SIZE]; + for (index, byte) in key.iter().enumerate() { + inner_pad[index] ^= byte; + outer_pad[index] ^= byte; + } + + let mut inner = Sha256::new(); + inner.update(inner_pad); + for part in parts { + inner.update(part); + } + let inner_hash = inner.finalize(); + + let mut outer = Sha256::new(); + outer.update(outer_pad); + outer.update(inner_hash); + format!("{:x}", outer.finalize()) +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut diff = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + diff |= (left_byte ^ right_byte) as usize; + } + diff == 0 +} + +fn sign_owned_marker( + canonical_path: &Path, + file_identity: &str, + nonce: &str, +) -> Result { + let secret = owned_marker_secret()?; + let canonical = canonical_path.to_string_lossy().to_ascii_lowercase(); + Ok(hmac_sha256_hex( + &secret, + &[ + b"touchai-owned-target-v1", + canonical.as_bytes(), + file_identity.as_bytes(), + nonce.as_bytes(), + ], + )) +} + +fn create_owned_marker_file( + marker_path: &Path, + marker: &Value, + app_label: &str, +) -> Result<(), String> { + if marker_path.exists() { + ensure_path_chain_not_reparse(marker_path, "marker")?; + if is_path_symlink(marker_path) || has_windows_reparse_point(marker_path) { + return Err(format!( + "{app_label} owned marker must not be a symlink or reparse point." + )); + } + return Err(format!( + "{app_label} owned target marker already exists; create a fresh TouchAI-owned target before issuing a new marker." + )); + } + + if let Some(parent) = marker_path.parent() { + ensure_path_chain_not_reparse(parent, "marker parent")?; + } + let marker_json = marker.to_string(); + let mut marker_file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(marker_path) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + format!("{app_label} owned target marker already exists.") + } else { + format!("{app_label} owned target marker could not be created: {error}") + } + })?; + if let Err(error) = marker_file.write_all(marker_json.as_bytes()) { + drop(marker_file); + let _ = fs::remove_file(marker_path); + return Err(format!( + "{app_label} owned target marker could not be written: {error}" + )); + } + + Ok(()) +} + +pub(crate) fn mark_owned_office_target( + target_path: &Path, + app_label: &str, + created_by: &str, +) -> Result { + if !target_path.is_absolute() { + return Err(format!( + "{app_label} owned target must be an absolute document path." + )); + } + + let root_path = owned_office_target_root(); + fs::create_dir_all(&root_path) + .map_err(|error| format!("{app_label} owned target root is unavailable: {error}"))?; + ensure_path_chain_not_reparse(&root_path, "target root")?; + let canonical_root = root_path.canonicalize().map_err(|_| { + format!("{app_label} owned target root is not available for marker issuance.") + })?; + + let canonical_candidate = target_path.canonicalize().map_err(|_| { + format!("{app_label} owned target must reference an existing TouchAI document.") + })?; + validate_owned_target_extension(&canonical_candidate, app_label)?; + if !canonical_candidate.starts_with(&canonical_root) { + return Err(format!( + "{app_label} owned target must live inside the TouchAI-owned document root." + )); + } + ensure_path_chain_not_reparse(&canonical_candidate, "target")?; + + let target_file = + open_owned_guard_file(&canonical_candidate, "target", true).map_err(|error| { + format!("{app_label} owned target must reference an existing TouchAI document: {error}") + })?; + + let marker_path = owned_marker_path(&canonical_candidate); + let identity = file_identity(&target_file)?; + let nonce = uuid::Uuid::new_v4().to_string(); + let marker = json!({ + "version": "touchai-owned-target/v1", + "createdBy": created_by, + "pathHash": hash_owned_path(&canonical_candidate), + "nonce": nonce, + "identityHash": hash_owned_marker(&canonical_candidate, &identity, Some(&nonce)), + "signature": sign_owned_marker(&canonical_candidate, &identity, &nonce)?, + }); + create_owned_marker_file(&marker_path, &marker, app_label)?; + let _marker_file = verify_owned_marker(&canonical_candidate, &target_file)?; + + Ok(canonical_candidate) +} + +#[cfg(windows)] +fn open_owned_guard_file( + path: &Path, + label: &str, + allow_shared_writes: bool, +) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x00000001; + const FILE_SHARE_WRITE: u32 = 0x00000002; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x00200000; + + let share_mode = if allow_shared_writes { + FILE_SHARE_READ | FILE_SHARE_WRITE + } else { + FILE_SHARE_READ + }; + + let file = std::fs::OpenOptions::new() + .read(true) + .share_mode(share_mode) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| format!("TouchAI-owned {label} guard could not be opened: {error}"))?; + ensure_owned_guard_file_handle(&file, label)?; + Ok(file) +} + +#[cfg(not(windows))] +fn open_owned_guard_file( + path: &Path, + label: &str, + _allow_shared_writes: bool, +) -> Result { + File::open(path) + .map_err(|error| format!("TouchAI-owned {label} guard could not be opened: {error}")) +} + +fn is_path_symlink(path: &Path) -> bool { + std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) +} + +#[cfg(windows)] +fn has_windows_reparse_point(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn has_windows_reparse_point(_path: &Path) -> bool { + false +} + +fn ensure_path_chain_not_reparse(path: &Path, label: &str) -> Result<(), String> { + for candidate in path.ancestors() { + if candidate.as_os_str().is_empty() || !candidate.exists() { + continue; + } + if is_path_symlink(candidate) || has_windows_reparse_point(candidate) { + return Err(format!( + "TouchAI-owned {label} path must not include a symlink or reparse point." + )); + } + } + Ok(()) +} + +#[cfg(windows)] +fn path_has_root_prefix(candidate: &Path, root: &Path) -> bool { + let candidate = candidate + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + let root = root + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + candidate == root || candidate.starts_with(&format!("{root}\\")) +} + +#[cfg(not(windows))] +fn path_has_root_prefix(candidate: &Path, root: &Path) -> bool { + candidate.starts_with(root) +} + +#[cfg(windows)] +fn ensure_owned_guard_file_handle(file: &File, label: &str) -> Result<(), String> { + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let information = owned_file_information(file, label)?; + if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "TouchAI-owned {label} must not be a symlink or reparse point." + )); + } + if information.nNumberOfLinks > 1 { + return Err(format!("TouchAI-owned {label} must not be a hard link.")); + } + Ok(()) +} + +#[cfg(not(windows))] +fn ensure_owned_guard_file_handle(_file: &File, _label: &str) -> Result<(), String> { + Ok(()) +} + +struct OwnedTargetGuard { + canonical_path: PathBuf, + _target_file: File, + _marker_file: File, +} + +impl OwnedTargetGuard { + fn path_string(&self) -> String { + self.canonical_path.to_string_lossy().to_string() + } +} + +fn verify_owned_marker(target_path: &Path, target_file: &File) -> Result { + let marker_path = owned_marker_path(target_path); + ensure_path_chain_not_reparse(&marker_path, "marker")?; + if is_path_symlink(&marker_path) || has_windows_reparse_point(&marker_path) { + return Err("TouchAI-owned marker must not be a symlink or reparse point.".to_string()); + } + + let mut marker_file = open_owned_guard_file(&marker_path, "marker", false) + .map_err(|error| format!("TouchAI-owned marker is missing for this target: {error}"))?; + let mut marker = String::new(); + marker_file + .read_to_string(&mut marker) + .map_err(|_| "TouchAI-owned marker is unreadable.".to_string())?; + let marker: Value = serde_json::from_str(&marker) + .map_err(|_| "TouchAI-owned marker is not valid JSON.".to_string())?; + let marker_hash = marker + .get("pathHash") + .and_then(Value::as_str) + .unwrap_or_default(); + let marker_nonce = marker + .get("nonce") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "TouchAI-owned marker nonce is missing.".to_string())?; + let identity = file_identity(target_file)?; + let identity_hash = marker + .get("identityHash") + .and_then(Value::as_str) + .unwrap_or_default(); + let marker_signature = marker + .get("signature") + .and_then(Value::as_str) + .unwrap_or_default(); + let expected_identity_hash = hash_owned_marker(target_path, &identity, Some(marker_nonce)); + let expected_signature = sign_owned_marker(target_path, &identity, marker_nonce)?; + + if marker_hash != hash_owned_path(target_path) { + return Err("TouchAI-owned marker does not match this target path.".to_string()); + } + if identity_hash != expected_identity_hash { + return Err("TouchAI-owned marker does not match this target identity.".to_string()); + } + if !constant_time_eq(marker_signature, &expected_signature) { + return Err("TouchAI-owned marker is not signed by this TouchAI installation.".to_string()); + } + + Ok(marker_file) +} + +fn validate_owned_target_extension(target_path: &Path, app_label: &str) -> Result<(), String> { + let extension = target_path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let allowed = match app_label { + "Microsoft Word" => ["docx"].contains(&extension.as_str()), + "Microsoft Excel" => ["xlsx"].contains(&extension.as_str()), + "Microsoft PowerPoint" => ["pptx"].contains(&extension.as_str()), + _ => false, + }; + + if allowed { + Ok(()) + } else { + Err(format!( + "{app_label} targetId must use a macro-free TouchAI-owned document format." + )) + } +} + +fn validate_owned_target_for_app( + target_path: Option<&str>, + required: bool, + app_label: &str, +) -> Result, String> { + let Some(target_path) = target_path else { + return if required { + Err(format!( + "{app_label} targetId must reference a TouchAI-owned document before running write actions." + )) + } else { + Ok(None) + }; + }; + + let trimmed_path = target_path.trim(); + if trimmed_path.is_empty() { + return if required { + Err(format!( + "{app_label} targetId must reference a TouchAI-owned document before running write actions." + )) + } else { + Ok(None) + }; + } + + let candidate = Path::new(trimmed_path); + if !candidate.is_absolute() { + return Err(format!( + "{app_label} targetId must reference a TouchAI-owned absolute document path." + )); + } + + let root_path = owned_office_target_root(); + if is_path_symlink(&root_path) || has_windows_reparse_point(&root_path) { + return Err(format!( + "{app_label} targetId root must not be a symlink or reparse point." + )); + } + let canonical_root = root_path.canonicalize().map_err(|_| { + format!("{app_label} targetId root is not available for owned document access.") + })?; + if !path_has_root_prefix(candidate, &root_path) + && !path_has_root_prefix(candidate, &canonical_root) + { + return Err(format!( + "{app_label} targetId is not a TouchAI-owned document path." + )); + } + let canonical_candidate = candidate.canonicalize().map_err(|_| { + format!("{app_label} targetId must reference an existing TouchAI-owned document.") + })?; + validate_owned_target_extension(&canonical_candidate, app_label)?; + if !canonical_candidate.starts_with(&canonical_root) { + return Err(format!( + "{app_label} targetId is not a TouchAI-owned document path." + )); + } + ensure_path_chain_not_reparse(&canonical_candidate, "target")?; + let target_file = + open_owned_guard_file(&canonical_candidate, "target", true).map_err(|error| { + format!( + "{app_label} targetId must reference an existing TouchAI-owned document: {error}" + ) + })?; + let marker_file = verify_owned_marker(&canonical_candidate, &target_file).map_err(|error| { + format!("{app_label} targetId is missing valid TouchAI ownership proof: {error}") + })?; + Ok(Some(OwnedTargetGuard { + canonical_path: canonical_candidate, + _target_file: target_file, + _marker_file: marker_file, + })) +} + +fn format_word_observe_content(metadata: &Value, scope: &str) -> String { + let document_name = metadata + .get("documentName") + .and_then(Value::as_str) + .unwrap_or("active Word document"); + let selection = metadata + .get("selectionText") + .and_then(Value::as_str) + .unwrap_or(""); + let character_count = metadata + .get("characterCount") + .and_then(Value::as_i64) + .unwrap_or_default(); + let document_text = metadata + .get("documentText") + .and_then(Value::as_str) + .unwrap_or(""); + + if scope == "selection" { + return format!("Document: {document_name}\nSelection: {selection}"); + } + + format!( + "Document: {document_name}\nCharacters: {character_count}\nSelection: {selection}\nText: {document_text}" + ) +} + +fn format_excel_observe_content(metadata: &Value, scope: &str) -> String { + let workbook_name = metadata + .get("workbookName") + .and_then(Value::as_str) + .unwrap_or("active Excel workbook"); + let active_sheet = metadata + .get("activeSheetName") + .and_then(Value::as_str) + .unwrap_or("active sheet"); + let used_range = metadata + .get("usedRange") + .and_then(Value::as_str) + .unwrap_or(""); + let values = metadata + .get("values") + .map(Value::to_string) + .unwrap_or_else(|| "[]".to_string()); + + if scope == "workbook" { + let sheets = metadata + .get("sheetNames") + .and_then(Value::as_array) + .map(|sheets| { + sheets + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + return format!( + "Workbook: {workbook_name}\nSheets: {sheets}\nActive sheet: {active_sheet}" + ); + } + + format!( + "Workbook: {workbook_name}\nSheet: {active_sheet}\nUsed range: {used_range}\nValues: {values}" + ) +} + +fn format_powerpoint_observe_content(metadata: &Value, scope: &str) -> String { + let presentation_name = metadata + .get("presentationName") + .and_then(Value::as_str) + .unwrap_or("active PowerPoint presentation"); + let slide_count = metadata + .get("slideCount") + .and_then(Value::as_i64) + .unwrap_or_default(); + let active_slide_index = metadata + .get("activeSlideIndex") + .and_then(Value::as_i64) + .unwrap_or_default(); + let slide_text = metadata + .get("slideText") + .and_then(Value::as_str) + .unwrap_or(""); + + if scope == "slide" { + return format!( + "Presentation: {presentation_name}\nSlide: {active_slide_index}\nText: {slide_text}" + ); + } + + format!( + "Presentation: {presentation_name}\nSlides: {slide_count}\nActive slide: {active_slide_index}\nText: {slide_text}" + ) +} + +fn word_observe_script(target_path: Option<&str>, scope: &str) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let include_document_text = if scope == "active_document" { + "$true" + } else { + "$false" + }; + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$includeDocumentText = {include_document_text} +$ownsDocument = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('Word.Application') }} + $doc = Invoke-WithComRetry {{ $app.ActiveDocument }} +}} else {{ + $app = Invoke-WithComRetry {{ New-Object -ComObject Word.Application }} + $oldAutomationSecurity = $null + try {{ $oldAutomationSecurity = $app.AutomationSecurity; $app.AutomationSecurity = 3 }} catch {{}} + $doc = Invoke-WithComRetry {{ $app.Documents.Open($targetPath, $false, $true, $false) }} + $ownsDocument = $true +}} +if ($null -eq $doc) {{ throw 'Microsoft Word does not have an active document.' }} +$selectionText = '' +try {{ if ($null -ne $app.Selection) {{ $selectionText = Invoke-WithComRetry {{ [string]$app.Selection.Text }} }} }} catch {{}} +$documentText = '' +if ($includeDocumentText) {{ $documentText = Invoke-WithComRetry {{ [string]$doc.Content.Text }} }} +$fullName = $null +try {{ $fullName = Invoke-WithComRetry {{ [string]$doc.FullName }} }} catch {{ $fullName = $null }} +$result = [ordered]@{{ + documentName = Invoke-WithComRetry {{ [string]$doc.Name }} + fullName = $fullName + documentText = $documentText + selectionText = $selectionText + characterCount = Invoke-WithComRetry {{ [int]$doc.Characters.Count }} +}} +if ($ownsDocument) {{ Invoke-WithComRetry {{ $doc.Close([ref]$false) }} | Out-Null; if ($null -ne $oldAutomationSecurity) {{ $app.AutomationSecurity = $oldAutomationSecurity }}; Invoke-WithComRetry {{ $app.Quit() }} | Out-Null }} +$result | ConvertTo-Json -Compress -Depth 4 +"# + ) + .trim() + .to_string() +} + +fn word_text_script(text: &str, action: &str, target_path: Option<&str>) -> String { + let encoded_text = encoded_utf8_script_value(Some(text)); + let encoded_target_path = encoded_utf8_script_value(target_path); + let replace_document = if action == "replace_document_text" { + "$true" + } else { + "$false" + }; + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +$text = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_text}')) +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$replaceDocument = {replace_document} +$app = Invoke-WithComRetry {{ New-Object -ComObject Word.Application }} +$oldAutomationSecurity = $null +try {{ $oldAutomationSecurity = $app.AutomationSecurity; $app.AutomationSecurity = 3 }} catch {{}} +$doc = Invoke-WithComRetry {{ $app.Documents.Open($targetPath, $false, $false, $false) }} +if ($null -eq $doc) {{ throw 'Microsoft Word does not have an active document.' }} +if ($replaceDocument) {{ + Invoke-WithComRetry {{ $doc.Content.Select() }} | Out-Null + Invoke-WithComRetry {{ $app.Selection.TypeText($text) }} | Out-Null +}} else {{ + $range = Invoke-WithComRetry {{ $doc.Range($doc.Content.End - 1, $doc.Content.End - 1) }} + Invoke-WithComRetry {{ $range.InsertAfter($text) }} | Out-Null +}} +$fullName = $null +try {{ $fullName = Invoke-WithComRetry {{ [string]$doc.FullName }} }} catch {{ $fullName = $null }} +Invoke-WithComRetry {{ $doc.Save() }} | Out-Null +$result = [ordered]@{{ + documentName = Invoke-WithComRetry {{ [string]$doc.Name }} + fullName = $fullName + changed = $true +}} +Invoke-WithComRetry {{ $doc.Close([ref]$false) }} | Out-Null +if ($null -ne $oldAutomationSecurity) {{ $app.AutomationSecurity = $oldAutomationSecurity }} +Invoke-WithComRetry {{ $app.Quit() }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 4 +"# + ) + .trim() + .to_string() +} + +fn powershell_optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "$true", + Some(false) => "$false", + None => "$null", + } +} + +fn powershell_optional_number(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "$null".to_string()) +} + +fn word_format_document_text_script(format: &SelectionFormat, target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let encoded_font_name = encoded_utf8_script_value(format.font_name.as_deref()); + let bold = powershell_optional_bool(format.bold); + let italic = powershell_optional_bool(format.italic); + let underline = powershell_optional_bool(format.underline); + let font_size = powershell_optional_number(format.font_size); + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$fontName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_font_name}')) +$formatBold = {bold} +$formatItalic = {italic} +$formatUnderline = {underline} +$formatFontSize = {font_size} +$app = Invoke-WithComRetry {{ New-Object -ComObject Word.Application }} +$oldAutomationSecurity = $null +try {{ $oldAutomationSecurity = $app.AutomationSecurity; $app.AutomationSecurity = 3 }} catch {{}} +$doc = Invoke-WithComRetry {{ $app.Documents.Open($targetPath, $false, $false, $false) }} +if ($null -eq $doc) {{ throw 'Microsoft Word does not have an active document.' }} +Invoke-WithComRetry {{ $doc.Content.Select() }} | Out-Null +$selection = Invoke-WithComRetry {{ $app.Selection }} +if ($null -ne $formatBold) {{ if ([bool]$formatBold) {{ Invoke-WithComRetry {{ $selection.Font.Bold = 1 }} | Out-Null }} else {{ Invoke-WithComRetry {{ $selection.Font.Bold = 0 }} | Out-Null }} }} +if ($null -ne $formatItalic) {{ if ([bool]$formatItalic) {{ Invoke-WithComRetry {{ $selection.Font.Italic = 1 }} | Out-Null }} else {{ Invoke-WithComRetry {{ $selection.Font.Italic = 0 }} | Out-Null }} }} +if ($null -ne $formatUnderline) {{ if ([bool]$formatUnderline) {{ Invoke-WithComRetry {{ $selection.Font.Underline = 1 }} | Out-Null }} else {{ Invoke-WithComRetry {{ $selection.Font.Underline = 0 }} | Out-Null }} }} +if ($null -ne $formatFontSize) {{ Invoke-WithComRetry {{ $selection.Font.Size = [double]$formatFontSize }} | Out-Null }} +if (-not [string]::IsNullOrWhiteSpace($fontName)) {{ Invoke-WithComRetry {{ $selection.Font.Name = $fontName }} | Out-Null }} +$fullName = $null +try {{ $fullName = Invoke-WithComRetry {{ [string]$doc.FullName }} }} catch {{ $fullName = $null }} +Invoke-WithComRetry {{ $doc.Save() }} | Out-Null +$result = [ordered]@{{ + documentName = Invoke-WithComRetry {{ [string]$doc.Name }} + fullName = $fullName + changed = $true +}} +Invoke-WithComRetry {{ $doc.Close([ref]$false) }} | Out-Null +if ($null -ne $oldAutomationSecurity) {{ $app.AutomationSecurity = $oldAutomationSecurity }} +Invoke-WithComRetry {{ $app.Quit() }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 4 +"# + ) + .trim() + .to_string() +} + +fn excel_observe_script(target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +function Convert-UsedRangeValues($range) {{ + $values = @() + $rowCount = Invoke-WithComRetry {{ [int]$range.Rows.Count }} + $columnCount = Invoke-WithComRetry {{ [int]$range.Columns.Count }} + for ($row = 1; $row -le $rowCount; $row++) {{ + $rowValues = @() + for ($column = 1; $column -le $columnCount; $column++) {{ + $rowValues += Invoke-WithComRetry {{ $range.Cells.Item($row, $column).Value2 }} + }} + $values += ,$rowValues + }} + return $values +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$ownsWorkbook = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('Excel.Application') }} + $workbook = Invoke-WithComRetry {{ $app.ActiveWorkbook }} +}} else {{ + $app = Invoke-WithComRetry {{ New-Object -ComObject Excel.Application }} + $oldAutomationSecurity = $app.AutomationSecurity + $app.AutomationSecurity = 3 + $workbook = Invoke-WithComRetry {{ $app.Workbooks.Open($targetPath, 0, $true) }} + $ownsWorkbook = $true +}} +if ($null -eq $workbook) {{ throw 'Microsoft Excel does not have an active workbook.' }} +$sheet = Invoke-WithComRetry {{ $app.ActiveSheet }} +$sheetNames = @() +for ($i = 1; $i -le $workbook.Worksheets.Count; $i++) {{ $sheetNames += Invoke-WithComRetry {{ [string]$workbook.Worksheets.Item($i).Name }} }} +$usedRange = Invoke-WithComRetry {{ $sheet.UsedRange }} +$fullName = $null +try {{ $fullName = Invoke-WithComRetry {{ [string]$workbook.FullName }} }} catch {{ $fullName = $null }} +$result = [ordered]@{{ + workbookName = Invoke-WithComRetry {{ [string]$workbook.Name }} + fullName = $fullName + activeSheetName = Invoke-WithComRetry {{ [string]$sheet.Name }} + sheetNames = $sheetNames + usedRange = Invoke-WithComRetry {{ [string]$usedRange.Address($false, $false) }} + values = Convert-UsedRangeValues $usedRange +}} +if ($ownsWorkbook) {{ Invoke-WithComRetry {{ $workbook.Close($false) }} | Out-Null; $app.AutomationSecurity = $oldAutomationSecurity; Invoke-WithComRetry {{ $app.Quit() }} | Out-Null }} +$result | ConvertTo-Json -Compress -Depth 6 +"# + ) + .trim() + .to_string() +} + +fn excel_write_cells_script(cells: &SpreadsheetWriteCells, target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let encoded_range = encoded_utf8_script_value(Some(&cells.range)); + let encoded_sheet_name = encoded_utf8_script_value(cells.sheet_name.as_deref()); + let values_json = serde_json::to_string(&cells.values).unwrap_or_else(|_| "[]".to_string()); + let encoded_values = general_purpose::STANDARD.encode(values_json.as_bytes()); + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$rangeAddress = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_range}')) +$sheetName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_sheet_name}')) +$valuesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_values}')) +$rows = $valuesJson | ConvertFrom-Json +$app = Invoke-WithComRetry {{ New-Object -ComObject Excel.Application }} +$oldAutomationSecurity = $app.AutomationSecurity +$app.AutomationSecurity = 3 +$workbook = Invoke-WithComRetry {{ $app.Workbooks.Open($targetPath, 0, $false) }} +if ($null -eq $workbook) {{ throw 'Microsoft Excel does not have an active workbook.' }} +if ([string]::IsNullOrWhiteSpace($sheetName)) {{ $sheet = Invoke-WithComRetry {{ $workbook.Worksheets.Item(1) }} }} else {{ $sheet = Invoke-WithComRetry {{ $workbook.Worksheets.Item($sheetName) }} }} +$openedFullName = Invoke-WithComRetry {{ [string]$workbook.FullName }} +if ([string]::Compare($openedFullName, $targetPath, $true, [Globalization.CultureInfo]::InvariantCulture) -ne 0) {{ throw 'Microsoft Excel opened a workbook that does not match the requested target.' }} +$range = Invoke-WithComRetry {{ $sheet.Range($rangeAddress) }} +for ($rowIndex = 0; $rowIndex -lt $rows.Count; $rowIndex++) {{ + $row = @($rows[$rowIndex]) + for ($columnIndex = 0; $columnIndex -lt $row.Count; $columnIndex++) {{ + $value = $row[$columnIndex] + Invoke-WithComRetry {{ $range.Cells.Item($rowIndex + 1, $columnIndex + 1).Value2 = $value }} | Out-Null + }} +}} +$fullName = $null +try {{ $fullName = $openedFullName }} catch {{ $fullName = $null }} +Invoke-WithComRetry {{ $workbook.Save() }} | Out-Null +$result = [ordered]@{{ + workbookName = Invoke-WithComRetry {{ [string]$workbook.Name }} + fullName = $fullName + sheetName = Invoke-WithComRetry {{ [string]$sheet.Name }} + range = Invoke-WithComRetry {{ [string]$range.Address($false, $false) }} + changed = $true + values = $rows +}} +Invoke-WithComRetry {{ $workbook.Close($false) }} | Out-Null +$app.AutomationSecurity = $oldAutomationSecurity +Invoke-WithComRetry {{ $app.Quit() }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 6 +"# + ) + .trim() + .to_string() +} + +fn powerpoint_observe_script(target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +function Get-SlideText($slide) {{ + $parts = @() + for ($i = 1; $i -le $slide.Shapes.Count; $i++) {{ + $shape = $slide.Shapes.Item($i) + try {{ if ($shape.HasTextFrame -and $shape.TextFrame.HasText) {{ $parts += [string]$shape.TextFrame.TextRange.Text }} }} catch {{}} + }} + return ($parts -join "`n") +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$ownsPresentation = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') }} + $presentation = Invoke-WithComRetry {{ $app.ActivePresentation }} +}} else {{ + $app = Invoke-WithComRetry {{ New-Object -ComObject PowerPoint.Application }} + $oldAutomationSecurity = $null + try {{ $oldAutomationSecurity = $app.AutomationSecurity; $app.AutomationSecurity = 3 }} catch {{}} + $presentation = Invoke-WithComRetry {{ $app.Presentations.Open($targetPath, $true, $false, $false) }} + $ownsPresentation = $true +}} +if ($null -eq $presentation) {{ throw 'Microsoft PowerPoint does not have an active presentation.' }} +$slide = $null +$activeSlideIndex = 0 +try {{ $slide = Invoke-WithComRetry {{ $app.ActiveWindow.View.Slide }}; $activeSlideIndex = Invoke-WithComRetry {{ [int]$slide.SlideIndex }} }} catch {{ if ($presentation.Slides.Count -gt 0) {{ $slide = Invoke-WithComRetry {{ $presentation.Slides.Item(1) }}; $activeSlideIndex = 1 }} }} +$fullName = $null +try {{ $fullName = Invoke-WithComRetry {{ [string]$presentation.FullName }} }} catch {{ $fullName = $null }} +$result = [ordered]@{{ + presentationName = Invoke-WithComRetry {{ [string]$presentation.Name }} + fullName = $fullName + slideCount = Invoke-WithComRetry {{ [int]$presentation.Slides.Count }} + activeSlideIndex = $activeSlideIndex + slideText = if ($null -eq $slide) {{ '' }} else {{ Get-SlideText $slide }} +}} +if ($ownsPresentation) {{ Invoke-WithComRetry {{ $presentation.Close() }} | Out-Null; if ($null -ne $oldAutomationSecurity) {{ $app.AutomationSecurity = $oldAutomationSecurity }}; Invoke-WithComRetry {{ $app.Quit() }} | Out-Null }} +$result | ConvertTo-Json -Compress -Depth 5 +"# + ) + .trim() + .to_string() +} + +fn powerpoint_add_slide_text_script(slide_text: &SlideText, target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let encoded_text = encoded_utf8_script_value(Some(&slide_text.text)); + let slide_index = slide_text + .slide_index + .map(|value| value.to_string()) + .unwrap_or_else(|| "$null".to_string()); + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ return & $Operation }} catch {{ $lastError = $_; Start-Sleep -Milliseconds 150 }} + }} + throw $lastError +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$text = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_text}')) +$slideIndex = {slide_index} +$app = Invoke-WithComRetry {{ New-Object -ComObject PowerPoint.Application }} +$oldAutomationSecurity = $null +try {{ $oldAutomationSecurity = $app.AutomationSecurity; $app.AutomationSecurity = 3 }} catch {{}} +$presentation = Invoke-WithComRetry {{ $app.Presentations.Open($targetPath, $false, $false, $false) }} +if ($null -eq $presentation) {{ throw 'Microsoft PowerPoint does not have an active presentation.' }} +if ($presentation.Slides.Count -eq 0) {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Add(1, 12) }} +}} elseif ($null -eq $slideIndex) {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Item($presentation.Slides.Count) }} + $slideIndex = Invoke-WithComRetry {{ [int]$slide.SlideIndex }} +}} else {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Item([int]$slideIndex) }} +}} +$shape = Invoke-WithComRetry {{ $slide.Shapes.AddTextbox(1, 48, 48, 600, 120) }} +Invoke-WithComRetry {{ $shape.TextFrame.TextRange.Text = $text }} | Out-Null +$fullName = $null +try {{ $fullName = Invoke-WithComRetry {{ [string]$presentation.FullName }} }} catch {{ $fullName = $null }} +Invoke-WithComRetry {{ $presentation.Save() }} | Out-Null +$result = [ordered]@{{ + presentationName = Invoke-WithComRetry {{ [string]$presentation.Name }} + fullName = $fullName + slideIndex = Invoke-WithComRetry {{ [int]$slide.SlideIndex }} + text = $text + changed = $true +}} +Invoke-WithComRetry {{ $presentation.Close() }} | Out-Null +if ($null -ne $oldAutomationSecurity) {{ $app.AutomationSecurity = $oldAutomationSecurity }} +Invoke-WithComRetry {{ $app.Quit() }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 5 +"# + ) + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app_use::types::{AppUseAdvancedConfig, AppUseConfig}; + use serde_json::json; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + struct RecordingRunner { + output: Result, + scripts: Mutex>, + } + + impl RecordingRunner { + fn new(output: Result) -> Self { + Self { + output, + scripts: Mutex::new(Vec::new()), + } + } + + fn scripts(&self) -> Vec { + self.scripts.lock().expect("scripts").clone() + } + } + + impl OfficeAutomationRunner for RecordingRunner { + fn run_script(&self, script: &str, _timeout_ms: u64) -> Result { + self.scripts + .lock() + .expect("scripts") + .push(script.to_string()); + self.output.clone() + } + } + + fn config() -> AppUseConfig { + AppUseConfig { + mode: "interactive".to_string(), + adapters: HashMap::from([("office_word".to_string(), true)]), + mutating_approval_mode: "always".to_string(), + read_scope: "active".to_string(), + allow_background_operation: false, + allow_raw_automation: false, + timeout_ms: 15_000, + max_output_chars: 12_000, + advanced: AppUseAdvancedConfig::default(), + } + } + + fn prepare_owned_test_root() -> PathBuf { + static OWNED_TEST_ROOT_LOCK: Mutex<()> = Mutex::new(()); + let _guard = OWNED_TEST_ROOT_LOCK.lock().expect("owned test root lock"); + let root = std::env::temp_dir().join("touchai-office-owned-tests"); + std::env::set_var(OFFICE_OWNED_ROOT_ENV, &root); + std::env::set_var(OWNED_SECRET_ENV, "app-use-owned-test-secret"); + std::fs::create_dir_all(&root).expect("owned root"); + root + } + + fn write_owned_test_file(path: &Path) { + remove_owned_test_file(path); + std::fs::write(path, b"owned").expect("owned file"); + let app_label = match path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("docx") => "Microsoft Word", + Some("xlsx") => "Microsoft Excel", + Some("pptx") => "Microsoft PowerPoint", + _ => panic!("unsupported office test target extension"), + }; + mark_owned_office_target(path, app_label, "TouchAI App Use test").expect("owned marker"); + } + + fn remove_owned_test_file(path: &Path) { + let marker = path + .canonicalize() + .ok() + .map(|canonical_path| owned_marker_path(&canonical_path)) + .unwrap_or_else(|| owned_marker_path(path)); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(marker); + } + + #[test] + fn word_observe_uses_microsoft_word_com_and_structured_content() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "demo.docx", + "documentText": "full Word text", + "selectionText": "selected Word text", + "characterCount": 42 + }) + .to_string()))); + let adapter = OfficeWordAdapter::with_runner(runner.clone()); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "office-word-observe".to_string(), + adapter_id: "office_word".to_string(), + scope: "selection".to_string(), + description: "read Word selection".to_string(), + target_id: None, + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, true); + assert!(response + .content + .unwrap_or_default() + .contains("selected Word text")); + let script = runner.scripts().join("\n"); + assert!(script.contains("Word.Application")); + assert!(!script.contains("KWPS.Application")); + assert!(!script.contains("UIAutomation")); + } + + #[test] + fn word_selection_observe_disables_full_document_collection() { + let selection_script = word_observe_script(None, "selection"); + let document_script = word_observe_script(None, "active_document"); + + assert!(selection_script.contains("$includeDocumentText = $false")); + assert!(document_script.contains("$includeDocumentText = $true")); + } + + #[test] + fn word_replace_with_owned_target_uses_base64_payload() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-word-target.docx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "owned-word-target.docx", + "fullName": owned_path_string.clone(), + "changed": true + }) + .to_string()))); + let adapter = OfficeWordAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "office-word-act".to_string(), + adapter_id: "office_word".to_string(), + action: "replace_document_text".to_string(), + description: "replace owned Word content".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ "text": "hello from app use" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("Word.Application")); + assert!(script.contains("Documents.Open")); + assert!(script.contains("TypeText")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("hello from app use")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn word_rejects_missing_owned_target_before_running_office() { + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = OfficeWordAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "office-word-no-target".to_string(), + adapter_id: "office_word".to_string(), + action: "replace_document_text".to_string(), + description: "replace owned Word content".to_string(), + target_id: None, + parameters: Some(json!({ "text": "hello" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn word_rejects_self_minted_owned_target_marker_before_running_office() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-word-unsigned-marker.docx"); + std::fs::write(&owned_path, b"owned").expect("owned file"); + let canonical_path = owned_path.canonicalize().expect("canonical owned file"); + let target_file = std::fs::File::open(&canonical_path).expect("owned target file"); + let identity = file_identity(&target_file).expect("owned identity"); + let nonce = "self-minted-office-marker"; + let marker = json!({ + "createdBy": "legacy TouchAI App Use test", + "pathHash": hash_owned_path(&canonical_path), + "nonce": nonce, + "identityHash": hash_owned_marker(&canonical_path, &identity, Some(nonce)), + }); + std::fs::write(owned_marker_path(&canonical_path), marker.to_string()) + .expect("owned marker"); + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = OfficeWordAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "office-word-unsigned-marker".to_string(), + adapter_id: "office_word".to_string(), + action: "replace_document_text".to_string(), + description: "replace self-minted Word document".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ "text": "safe" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert!(response.receipt.contains("signed")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn mark_owned_office_target_refuses_to_overwrite_existing_marker() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-word-existing-marker.docx"); + remove_owned_test_file(&owned_path); + std::fs::write(&owned_path, b"owned").expect("owned file"); + let canonical_path = owned_path.canonicalize().expect("canonical owned file"); + std::fs::write(owned_marker_path(&canonical_path), "{}").expect("existing marker"); + + let error = mark_owned_office_target(&owned_path, "Microsoft Word", "TouchAI App Use test") + .expect_err("existing marker must not be overwritten"); + + assert!(error.contains("already exists")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn excel_write_cells_uses_macro_safe_excel_com_and_base64_payload() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-excel-target.xlsx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "workbookName": "owned-excel-target.xlsx", + "fullName": owned_path_string.clone(), + "sheetName": "Sheet1", + "range": "A1:B1", + "changed": true + }) + .to_string()))); + let adapter = OfficeExcelAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "office-excel-act".to_string(), + adapter_id: "office_excel".to_string(), + action: "write_cells".to_string(), + description: "write owned Excel cells".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ "range": "A1:B1", "values": [["CellAlphaUserValue", "CellBetaUserValue"]] })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("Excel.Application")); + assert!(script.contains("AutomationSecurity = 3")); + assert!(script.contains("Workbooks.Open")); + assert!(script.contains("$workbook.Worksheets.Item(1)")); + assert!(script.contains("$openedFullName")); + assert!(script.contains("does not match the requested target")); + assert!(script.contains(".Value2")); + assert!(!script.contains("$app.ActiveSheet")); + assert!(!script.contains("$rows = @($valuesJson | ConvertFrom-Json)")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("CellAlphaUserValue")); + assert!(!script.contains("CellBetaUserValue")); + assert!(!script.contains("KET.Application")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn excel_rejects_formula_like_cells_before_running_office() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-excel-formula.xlsx"); + write_owned_test_file(&owned_path); + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = OfficeExcelAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "office-excel-formula".to_string(), + adapter_id: "office_excel".to_string(), + action: "write_cells".to_string(), + description: "write formula-like owned Excel cell".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some( + json!({ "range": "A1", "values": [["=HYPERLINK(\"https://example.com\")"]] }), + ), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.metadata["reason"], "invalid_parameters"); + assert!(response.receipt.contains("formula")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn powerpoint_add_slide_text_uses_powerpoint_com_and_base64_payload() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-powerpoint-target.pptx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "presentationName": "owned-powerpoint-target.pptx", + "fullName": owned_path_string.clone(), + "slideIndex": 1, + "text": "hello slide", + "changed": true + }) + .to_string()))); + let adapter = OfficePowerPointAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "office-powerpoint-act".to_string(), + adapter_id: "office_powerpoint".to_string(), + action: "add_slide_text".to_string(), + description: "add owned PowerPoint text".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ "text": "hello slide", "slideIndex": 1 })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("PowerPoint.Application")); + assert!(script.contains("Presentations.Open")); + assert!(script.contains(".Shapes.AddTextbox")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("hello slide")); + assert!(!script.contains("KWPP.Application")); + remove_owned_test_file(&owned_path); + } +} diff --git a/apps/desktop/src-tauri/src/core/app_use/types.rs b/apps/desktop/src-tauri/src/core/app_use/types.rs new file mode 100644 index 00000000..18b40e1e --- /dev/null +++ b/apps/desktop/src-tauri/src/core/app_use/types.rs @@ -0,0 +1,213 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +fn default_mode() -> String { + "read_only".to_string() +} + +fn default_mutating_approval_mode() -> String { + "always".to_string() +} + +fn default_read_scope() -> String { + "active".to_string() +} + +fn default_timeout_ms() -> u64 { + 15_000 +} + +fn default_max_output_chars() -> usize { + 12_000 +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseAdvancedConfig { + #[serde(default)] + pub export_previews: bool, + #[serde(default)] + pub batch_workflows: bool, + #[serde(default)] + pub cross_app_workflows: bool, +} + +impl Default for AppUseAdvancedConfig { + fn default() -> Self { + Self { + export_previews: false, + batch_workflows: false, + cross_app_workflows: false, + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseConfig { + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default)] + pub adapters: HashMap, + #[serde(default = "default_mutating_approval_mode")] + pub mutating_approval_mode: String, + #[serde(default = "default_read_scope")] + pub read_scope: String, + #[serde(default)] + pub allow_background_operation: bool, + #[serde(default)] + pub allow_raw_automation: bool, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, + #[serde(default = "default_max_output_chars")] + pub max_output_chars: usize, + #[serde(default)] + pub advanced: AppUseAdvancedConfig, +} + +impl AppUseConfig { + pub fn adapter_enabled(&self, adapter_id: &str) -> bool { + self.adapters.get(adapter_id).copied().unwrap_or(false) + } + + pub fn is_read_only(&self) -> bool { + self.mode == "read_only" + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseActPermit { + pub call_id: String, + pub adapter_id: String, + pub action: String, + pub target_id: Option, + pub parameters_hash: String, + pub token: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseAuthorizeActRequest { + pub execution_id: String, + pub adapter_id: String, + pub action: String, + pub target_id: Option, + #[serde(default)] + pub parameters: Option, + pub config: AppUseConfig, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseAuthorizeActResponse { + pub permit: Option, + pub expires_in_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseSessionRequest { + pub execution_id: String, + pub operation: String, + pub description: String, + #[serde(default)] + pub adapter_id: Option, + #[serde(default)] + pub target_kind: Option, + pub config: AppUseConfig, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseObserveRequest { + pub execution_id: String, + pub adapter_id: String, + pub scope: String, + pub description: String, + #[serde(default)] + pub target_id: Option, + pub max_output_chars: usize, + pub config: AppUseConfig, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseActRequest { + pub execution_id: String, + pub adapter_id: String, + pub action: String, + pub description: String, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub parameters: Option, + #[serde(default)] + pub permit: Option, + pub config: AppUseConfig, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseAdapterContract { + pub vendor: String, + pub version: String, + pub observe_scopes: Vec, + pub actions: Vec, + pub risk_level: String, + pub raw_automation_allowed: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseAdapterDescriptor { + pub id: String, + pub label: String, + pub installed: bool, + pub running: bool, + pub enabled: bool, + pub capabilities: Vec, + pub contract: AppUseAdapterContract, + pub active_target_name: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseSessionResponse { + pub ok: bool, + pub operation: String, + pub adapters: Vec, + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseObserveResponse { + pub ok: bool, + pub adapter_id: String, + pub scope: String, + pub target: Option, + pub content: Option, + pub metadata: Value, + pub truncated: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AppUseActResponse { + pub ok: bool, + pub adapter_id: String, + pub action: String, + pub receipt: String, + pub changed: bool, + pub metadata: Value, +} diff --git a/apps/desktop/src-tauri/src/core/app_use/wps.rs b/apps/desktop/src-tauri/src/core/app_use/wps.rs new file mode 100644 index 00000000..2485dcb8 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/app_use/wps.rs @@ -0,0 +1,3232 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +use super::{ + discovery, AppUseActRequest, AppUseActResponse, AppUseAdapter, AppUseAuthorizeActRequest, + AppUseObserveRequest, AppUseObserveResponse, +}; +use base64::{engine::general_purpose, Engine as _}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +pub trait WpsAutomationRunner: Send + Sync { + fn run_script(&self, script: &str, timeout_ms: u64) -> Result; +} + +struct PowerShellWpsAutomationRunner; + +fn powershell_executable() -> Result { + #[cfg(windows)] + { + let windows_root = PathBuf::from(r"C:\Windows"); + let wow64_powershell = windows_root + .join("SysWOW64") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + + if wow64_powershell.exists() { + return Ok(wow64_powershell); + } + + return Err(format!( + "Trusted Windows PowerShell executable is unavailable at {}.", + wow64_powershell.display() + )); + } + + #[cfg(not(windows))] + { + Err("WPS App Use automation is only available on Windows.".to_string()) + } +} + +fn powershell_arguments(encoded_script: &str) -> Vec { + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Sta", + "-EncodedCommand", + encoded_script, + ] + .into_iter() + .map(str::to_string) + .collect() +} + +impl WpsAutomationRunner for PowerShellWpsAutomationRunner { + fn run_script(&self, script: &str, timeout_ms: u64) -> Result { + let encoded_script = general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let shell_path = powershell_executable()?; + let mut child = Command::new(&shell_path) + .args(powershell_arguments(&encoded_script)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "Failed to start WPS automation shell ({}): {error}", + shell_path.display() + ) + })?; + + let started_at = Instant::now(); + let timeout = Duration::from_millis(timeout_ms.max(1_000)); + loop { + match child + .try_wait() + .map_err(|error| format!("Failed to wait for WPS automation shell: {error}"))? + { + Some(_) => { + let output = child.wait_with_output().map_err(|error| { + format!("Failed to collect WPS automation output: {error}") + })?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + return Ok(stdout); + } + + return Err(if stderr.is_empty() { stdout } else { stderr }); + } + None if started_at.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "WPS automation timed out after {timeout_ms}ms while waiting for KWPS.Application. Check WPS COM registration and close any blocking WPS first-run or modal prompts." + )); + } + None => thread::sleep(Duration::from_millis(50)), + } + } + } +} + +pub struct WpsWriterAdapter { + runner: Arc, +} + +impl WpsWriterAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellWpsAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for WpsWriterAdapter { + fn default() -> Self { + Self::new() + } +} + +pub struct WpsSpreadsheetAdapter { + runner: Arc, +} + +impl WpsSpreadsheetAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellWpsAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for WpsSpreadsheetAdapter { + fn default() -> Self { + Self::new() + } +} + +pub struct WpsPresentationAdapter { + runner: Arc, +} + +impl WpsPresentationAdapter { + pub fn new() -> Self { + Self { + runner: Arc::new(PowerShellWpsAutomationRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +impl Default for WpsPresentationAdapter { + fn default() -> Self { + Self::new() + } +} + +impl AppUseAdapter for WpsWriterAdapter { + fn id(&self) -> &'static str { + "wps_writer" + } + + fn label(&self) -> &'static str { + "WPS Writer" + } + + fn capabilities(&self) -> &'static [&'static str] { + &[ + "discover", + "observe_active_document", + "observe_selection", + "replace_document_text", + "format_document_text", + ] + } + + fn vendor(&self) -> &'static str { + "WPS Office" + } + + fn contract_version(&self) -> &'static str { + "wps-com-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["active_document", "selection"] + } + + fn actions(&self) -> &'static [&'static str] { + &["replace_document_text", "format_document_text"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + let owned_target = match validate_owned_target(request.target_id.as_deref(), false) { + Ok(owned_target) => owned_target, + Err(error) => { + return AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + truncated: false, + }; + } + }; + let target_path = owned_target.as_ref().map(OwnedTargetGuard::path_string); + let script = wps_observe_script(target_path.as_deref(), &request.scope); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_observe_content(&metadata, &request.scope); + let (content, truncated) = truncate_content(content, request.max_output_chars); + AppUseObserveResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: target_path, + content: Some(content), + metadata, + truncated, + } + } + Err(error) => AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "wps_automation_failed", + }), + truncated: false, + }, + } + } + + fn validate_act(&self, request: &AppUseAuthorizeActRequest) -> Result<(), String> { + if !matches!( + request.action.as_str(), + "replace_document_text" | "format_document_text" + ) { + return Err(format!("Unsupported WPS Writer action: {}", request.action)); + } + + if request.action == "format_document_text" { + format_document_text_parameters(request.parameters.as_ref()).map(|_| ())?; + } else { + text_parameter(request.parameters.as_ref()).map(|_| ())?; + } + validate_owned_target(request.target_id.as_deref(), true).map(|_| ()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + if !matches!( + request.action.as_str(), + "replace_document_text" | "format_document_text" + ) { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!("Unsupported WPS Writer action: {}", request.action), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "unsupported_action", + }), + }; + } + + enum WpsWriterActionPayload { + Format(WpsSelectionFormat), + Replace(String), + } + + let payload = if request.action == "format_document_text" { + match format_document_text_parameters(request.parameters.as_ref()) { + Ok(format) => WpsWriterActionPayload::Format(format), + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "invalid_parameters", + }), + }; + } + } + } else { + match text_parameter(request.parameters.as_ref()) { + Ok(text) => WpsWriterActionPayload::Replace(text), + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "invalid_parameters", + }), + }; + } + } + }; + + let owned_target = match validate_owned_target(request.target_id.as_deref(), true) { + Ok(Some(owned_target)) => owned_target, + Ok(None) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: "WPS Writer targetId must reference a TouchAI-owned document before running write actions.".to_string(), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + }; + } + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + }; + } + }; + let target_path = owned_target.path_string(); + let script = match payload { + WpsWriterActionPayload::Format(format) => { + wps_format_document_text_script(&format, Some(&target_path)) + } + WpsWriterActionPayload::Replace(text) => { + wps_type_text_script(&text, Some(&target_path)) + } + }; + + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let document_name = metadata + .get("documentName") + .and_then(Value::as_str) + .unwrap_or("active WPS document"); + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "WPS Writer {} completed for {document_name}", + request.action + ), + changed: metadata + .get("changed") + .and_then(Value::as_bool) + .unwrap_or(true), + metadata, + } + } + Err(error) => AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "wps_automation_failed", + }), + }, + } + } +} + +impl AppUseAdapter for WpsSpreadsheetAdapter { + fn id(&self) -> &'static str { + "wps_spreadsheet" + } + + fn label(&self) -> &'static str { + "WPS Spreadsheet" + } + + fn capabilities(&self) -> &'static [&'static str] { + &[ + "discover", + "observe_workbook", + "observe_worksheet", + "write_cells", + ] + } + + fn vendor(&self) -> &'static str { + "WPS Office" + } + + fn contract_version(&self) -> &'static str { + "wps-com-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["workbook", "worksheet"] + } + + fn actions(&self) -> &'static [&'static str] { + &["write_cells"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + false, + self.label(), + ) { + Ok(owned_target) => owned_target, + Err(error) => { + return AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + truncated: false, + }; + } + }; + let target_path = owned_target.as_ref().map(OwnedTargetGuard::path_string); + let script = wps_spreadsheet_observe_script(target_path.as_deref()); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_spreadsheet_observe_content(&metadata, &request.scope); + let (content, truncated) = truncate_content(content, request.max_output_chars); + AppUseObserveResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: target_path, + content: Some(content), + metadata, + truncated, + } + } + Err(error) => AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "wps_automation_failed", + }), + truncated: false, + }, + } + } + + fn validate_act(&self, request: &AppUseAuthorizeActRequest) -> Result<(), String> { + if request.action != "write_cells" { + return Err(format!( + "Unsupported WPS Spreadsheet action: {}", + request.action + )); + } + + spreadsheet_write_parameters(request.parameters.as_ref()).map(|_| ())?; + validate_owned_target_for_app(request.target_id.as_deref(), true, self.label()).map(|_| ()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + if request.action != "write_cells" { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!("Unsupported WPS Spreadsheet action: {}", request.action), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "unsupported_action", + }), + }; + } + + let cells = match spreadsheet_write_parameters(request.parameters.as_ref()) { + Ok(cells) => cells, + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "invalid_parameters", + }), + }; + } + }; + + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + true, + self.label(), + ) { + Ok(Some(owned_target)) => owned_target, + Ok(None) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "{} targetId must reference a TouchAI-owned document before running write actions.", + self.label() + ), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + }; + } + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + }; + } + }; + let target_path = owned_target.path_string(); + let script = wps_spreadsheet_write_cells_script(&cells, Some(&target_path)); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let workbook_name = metadata + .get("workbookName") + .and_then(Value::as_str) + .unwrap_or("active WPS workbook"); + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!("WPS Spreadsheet write_cells completed for {workbook_name}"), + changed: metadata + .get("changed") + .and_then(Value::as_bool) + .unwrap_or(true), + metadata, + } + } + Err(error) => AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "wps_automation_failed", + }), + }, + } + } +} + +impl AppUseAdapter for WpsPresentationAdapter { + fn id(&self) -> &'static str { + "wps_presentation" + } + + fn label(&self) -> &'static str { + "WPS Presentation" + } + + fn capabilities(&self) -> &'static [&'static str] { + &[ + "discover", + "observe_presentation", + "observe_slide", + "add_slide_text", + ] + } + + fn vendor(&self) -> &'static str { + "WPS Office" + } + + fn contract_version(&self) -> &'static str { + "wps-com-v1" + } + + fn observe_scopes(&self) -> &'static [&'static str] { + &["presentation", "slide"] + } + + fn actions(&self) -> &'static [&'static str] { + &["add_slide_text"] + } + + fn installed(&self) -> bool { + discovery::discover_adapter_install_status(self.id()).installed + } + + fn observe(&self, request: &AppUseObserveRequest) -> AppUseObserveResponse { + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + false, + self.label(), + ) { + Ok(owned_target) => owned_target, + Err(error) => { + return AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + truncated: false, + }; + } + }; + let target_path = owned_target.as_ref().map(OwnedTargetGuard::path_string); + let script = wps_presentation_observe_script(target_path.as_deref()); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let content = format_presentation_observe_content(&metadata, &request.scope); + let (content, truncated) = truncate_content(content, request.max_output_chars); + AppUseObserveResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: target_path, + content: Some(content), + metadata, + truncated, + } + } + Err(error) => AppUseObserveResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + scope: request.scope.clone(), + target: request.target_id.clone(), + content: Some(error), + metadata: json!({ + "executionId": request.execution_id, + "reason": "wps_automation_failed", + }), + truncated: false, + }, + } + } + + fn validate_act(&self, request: &AppUseAuthorizeActRequest) -> Result<(), String> { + if request.action != "add_slide_text" { + return Err(format!( + "Unsupported WPS Presentation action: {}", + request.action + )); + } + + presentation_text_parameters(request.parameters.as_ref()).map(|_| ())?; + validate_owned_target_for_app(request.target_id.as_deref(), true, self.label()).map(|_| ()) + } + + fn act(&self, request: &AppUseActRequest) -> AppUseActResponse { + if request.action != "add_slide_text" { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!("Unsupported WPS Presentation action: {}", request.action), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "unsupported_action", + }), + }; + } + + let slide_text = match presentation_text_parameters(request.parameters.as_ref()) { + Ok(slide_text) => slide_text, + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "invalid_parameters", + }), + }; + } + }; + + let owned_target = match validate_owned_target_for_app( + request.target_id.as_deref(), + true, + self.label(), + ) { + Ok(Some(owned_target)) => owned_target, + Ok(None) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "{} targetId must reference a TouchAI-owned document before running write actions.", + self.label() + ), + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + }; + } + Err(error) => { + return AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "target_not_owned", + }), + }; + } + }; + let target_path = owned_target.path_string(); + let script = wps_presentation_add_slide_text_script(&slide_text, Some(&target_path)); + match run_json_script(self.runner.as_ref(), &script, request.config.timeout_ms) { + Ok(metadata) => { + let presentation_name = metadata + .get("presentationName") + .and_then(Value::as_str) + .unwrap_or("active WPS presentation"); + AppUseActResponse { + ok: true, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: format!( + "WPS Presentation add_slide_text completed for {presentation_name}" + ), + changed: metadata + .get("changed") + .and_then(Value::as_bool) + .unwrap_or(true), + metadata, + } + } + Err(error) => AppUseActResponse { + ok: false, + adapter_id: request.adapter_id.clone(), + action: request.action.clone(), + receipt: error, + changed: false, + metadata: json!({ + "executionId": request.execution_id, + "reason": "wps_automation_failed", + }), + }, + } + } +} + +fn run_json_script( + runner: &dyn WpsAutomationRunner, + script: &str, + timeout_ms: u64, +) -> Result { + let output = runner.run_script(script, timeout_ms)?; + serde_json::from_str(output.trim()) + .map_err(|error| format!("WPS automation returned invalid JSON: {error}")) +} + +fn text_parameter(parameters: Option<&Value>) -> Result { + let text = parameters + .and_then(|value| value.get("text")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + + if text.trim().is_empty() { + return Err("WPS Writer action requires a non-empty text parameter.".to_string()); + } + if text.chars().count() > 20_000 { + return Err("WPS Writer text must be 20000 characters or fewer.".to_string()); + } + + Ok(text) +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct WpsSelectionFormat { + bold: Option, + italic: Option, + underline: Option, + font_size: Option, + font_name: Option, +} + +impl WpsSelectionFormat { + fn is_empty(&self) -> bool { + self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.font_size.is_none() + && self.font_name.is_none() + } +} + +fn format_document_text_parameters( + parameters: Option<&Value>, +) -> Result { + let Some(parameters) = parameters.and_then(Value::as_object) else { + return Err( + "WPS Writer format_document_text requires structured format parameters.".to_string(), + ); + }; + + let mut format = WpsSelectionFormat::default(); + if let Some(value) = parameters.get("bold") { + format.bold = Some(value.as_bool().ok_or_else(|| { + "WPS Writer format_document_text bold must be a boolean.".to_string() + })?); + } + if let Some(value) = parameters.get("italic") { + format.italic = Some(value.as_bool().ok_or_else(|| { + "WPS Writer format_document_text italic must be a boolean.".to_string() + })?); + } + if let Some(value) = parameters.get("underline") { + format.underline = Some(value.as_bool().ok_or_else(|| { + "WPS Writer format_document_text underline must be a boolean.".to_string() + })?); + } + if let Some(value) = parameters.get("fontSize") { + let font_size = value.as_f64().ok_or_else(|| { + "WPS Writer format_document_text fontSize must be a number.".to_string() + })?; + if !(6.0..=96.0).contains(&font_size) { + return Err( + "WPS Writer format_document_text fontSize must be between 6 and 96.".to_string(), + ); + } + format.font_size = Some(font_size); + } + if let Some(value) = parameters.get("fontName") { + let font_name = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "WPS Writer format_document_text fontName must be a non-empty string.".to_string() + })?; + if font_name.chars().count() > 128 { + return Err( + "WPS Writer format_document_text fontName must be 128 characters or fewer." + .to_string(), + ); + } + format.font_name = Some(font_name.to_string()); + } + + if format.is_empty() { + return Err( + "WPS Writer format_document_text requires at least one format option.".to_string(), + ); + } + + Ok(format) +} + +#[derive(Clone, Debug, PartialEq)] +struct WpsSpreadsheetWriteCells { + range: String, + sheet_name: Option, + values: Vec>, +} + +const WPS_SPREADSHEET_MAX_ROWS: usize = 100; +const WPS_SPREADSHEET_MAX_COLUMNS: usize = 50; +const WPS_SPREADSHEET_MAX_CELLS: usize = 5_000; +const WPS_SPREADSHEET_MAX_CELL_TEXT_CHARS: usize = 4_096; +const WPS_SPREADSHEET_MAX_VALUES_JSON_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, PartialEq)] +struct WpsPresentationSlideText { + text: String, + slide_index: Option, +} + +fn presentation_text_parameters( + parameters: Option<&Value>, +) -> Result { + let Some(parameters) = parameters.and_then(Value::as_object) else { + return Err( + "WPS Presentation add_slide_text requires structured text parameters.".to_string(), + ); + }; + + let text = parameters + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "WPS Presentation add_slide_text requires a non-empty text parameter.".to_string() + })?; + if text.chars().count() > 2_000 { + return Err( + "WPS Presentation add_slide_text text must be 2000 characters or fewer.".to_string(), + ); + } + + let slide_index = match parameters.get("slideIndex") { + Some(value) => { + let index = value.as_i64().ok_or_else(|| { + "WPS Presentation add_slide_text slideIndex must be an integer.".to_string() + })?; + if index < 1 { + return Err( + "WPS Presentation add_slide_text slideIndex must be at least 1.".to_string(), + ); + } + Some(index) + } + None => None, + }; + + Ok(WpsPresentationSlideText { + text: text.to_string(), + slide_index, + }) +} + +fn validate_spreadsheet_cell(cell: &Value) -> Result<(), String> { + let Value::String(text) = cell else { + return Ok(()); + }; + + if text.chars().count() > WPS_SPREADSHEET_MAX_CELL_TEXT_CHARS { + return Err(format!( + "WPS Spreadsheet write_cells cell text must be {WPS_SPREADSHEET_MAX_CELL_TEXT_CHARS} characters or fewer." + )); + } + + let trimmed = text.trim_start(); + if trimmed.starts_with(['=', '+', '-', '@']) { + return Err( + "WPS Spreadsheet write_cells rejects formula-like strings; formulas require an explicit future action." + .to_string(), + ); + } + + Ok(()) +} + +fn spreadsheet_write_parameters( + parameters: Option<&Value>, +) -> Result { + let Some(parameters) = parameters.and_then(Value::as_object) else { + return Err("WPS Spreadsheet write_cells requires structured cell parameters.".to_string()); + }; + + let range = parameters + .get("range") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "WPS Spreadsheet write_cells requires a non-empty range.".to_string())?; + if range.chars().count() > 64 + || !range + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '$')) + { + return Err("WPS Spreadsheet write_cells range must be an A1-style address.".to_string()); + } + + let sheet_name = parameters + .get("sheetName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if sheet_name + .as_ref() + .is_some_and(|value| value.chars().count() > 128) + { + return Err( + "WPS Spreadsheet write_cells sheetName must be 128 characters or fewer.".to_string(), + ); + } + + let Some(rows) = parameters.get("values").and_then(Value::as_array) else { + return Err("WPS Spreadsheet write_cells requires values.".to_string()); + }; + if rows.is_empty() { + return Err("WPS Spreadsheet write_cells values cannot be empty.".to_string()); + } + if rows.len() > WPS_SPREADSHEET_MAX_ROWS { + return Err(format!( + "WPS Spreadsheet write_cells supports at most {WPS_SPREADSHEET_MAX_ROWS} rows per action." + )); + } + + let mut values = Vec::with_capacity(rows.len()); + let mut width = None; + for row in rows { + let Some(cells) = row.as_array() else { + return Err( + "WPS Spreadsheet write_cells values must be a two-dimensional array.".to_string(), + ); + }; + if cells.is_empty() { + return Err("WPS Spreadsheet write_cells rows cannot be empty.".to_string()); + } + if cells.len() > WPS_SPREADSHEET_MAX_COLUMNS { + return Err(format!( + "WPS Spreadsheet write_cells supports at most {WPS_SPREADSHEET_MAX_COLUMNS} columns per action." + )); + } + if width.is_some_and(|expected| expected != cells.len()) { + return Err( + "WPS Spreadsheet write_cells values must use rectangular rows.".to_string(), + ); + } + width = Some(cells.len()); + + let mut normalized_row = Vec::with_capacity(cells.len()); + for cell in cells { + if !matches!( + cell, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) { + return Err( + "WPS Spreadsheet write_cells values can only contain scalar cells.".to_string(), + ); + } + validate_spreadsheet_cell(cell)?; + normalized_row.push(cell.clone()); + } + values.push(normalized_row); + } + if values.len() * width.unwrap_or_default() > WPS_SPREADSHEET_MAX_CELLS { + return Err(format!( + "WPS Spreadsheet write_cells supports at most {WPS_SPREADSHEET_MAX_CELLS} cells per action." + )); + } + let values_json = serde_json::to_string(&values).unwrap_or_default(); + if values_json.len() > WPS_SPREADSHEET_MAX_VALUES_JSON_BYTES { + return Err(format!( + "WPS Spreadsheet write_cells payload must be {WPS_SPREADSHEET_MAX_VALUES_JSON_BYTES} bytes or fewer." + )); + } + + Ok(WpsSpreadsheetWriteCells { + range: range.to_string(), + sheet_name, + values, + }) +} + +const WPS_OWNED_ROOT_ENV: &str = "TOUCHAI_APP_USE_WPS_OWNED_ROOT"; +const OWNED_SECRET_ENV: &str = "TOUCHAI_APP_USE_OWNED_SECRET"; + +fn owned_wps_target_root() -> PathBuf { + if cfg!(debug_assertions) { + if let Some(path) = std::env::var_os(WPS_OWNED_ROOT_ENV) { + return PathBuf::from(path); + } + } + + app_use_owned_data_root().join("wps") +} + +pub(crate) fn owned_wps_target_root_path() -> PathBuf { + owned_wps_target_root() +} + +fn app_use_owned_data_root() -> PathBuf { + crate::core::system::paths::app_directory_path_without_app_root_override( + crate::core::system::paths::AppDirectory::Data, + ) + .unwrap_or_else(|_| fallback_app_data_root()) + .join("app-use") + .join("owned-targets") +} + +fn fallback_app_data_root() -> PathBuf { + if cfg!(target_os = "windows") { + if let Some(path) = std::env::var_os("LOCALAPPDATA") + .or_else(|| std::env::var_os("APPDATA")) + .map(PathBuf::from) + { + return path.join("TouchAI").join("data"); + } + } + + if let Some(path) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + return path.join("TouchAI").join("data"); + } + + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + return home + .join(".local") + .join("share") + .join("TouchAI") + .join("data"); + } + + PathBuf::from(".").join("touchai-data") +} + +fn owned_marker_path(target_path: &Path) -> PathBuf { + let mut marker_name = target_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("target") + .to_string(); + marker_name.push_str(".touchai-owned.json"); + target_path.with_file_name(marker_name) +} + +fn hash_owned_path(target_path: &Path) -> String { + let canonical = target_path.to_string_lossy().to_ascii_lowercase(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +#[cfg(windows)] +fn owned_file_information( + file: &File, + label: &str, +) -> Result { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { + GetFileInformationByHandle( + HANDLE(file.as_raw_handle() as *mut core::ffi::c_void), + &mut information, + ) + } + .map_err(|error| format!("TouchAI-owned {label} metadata is unavailable: {error}"))?; + + Ok(information) +} + +#[cfg(windows)] +fn file_identity(file: &File) -> Result { + let information = owned_file_information(file, "file identity")?; + + Ok(format!( + "{}:{}:{}", + information.dwVolumeSerialNumber, information.nFileIndexHigh, information.nFileIndexLow + )) +} + +#[cfg(not(windows))] +fn file_identity(_file: &File) -> Result { + Ok("non-windows-test-file".to_string()) +} + +fn hash_owned_marker(canonical_path: &Path, file_identity: &str, nonce: Option<&str>) -> String { + let canonical = canonical_path.to_string_lossy().to_ascii_lowercase(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + hasher.update(b"\0"); + hasher.update(file_identity.as_bytes()); + hasher.update(b"\0"); + hasher.update(nonce.unwrap_or_default().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn read_owned_marker_secret(secret_path: &Path) -> Result, String> { + if !secret_path.exists() { + return Ok(None); + } + + if let Some(parent) = secret_path.parent() { + ensure_path_chain_not_reparse(parent, "secret root")?; + } + let mut secret_file = match open_owned_guard_file(secret_path, "secret", false) { + Ok(file) => file, + Err(_) if !secret_path.exists() => return Ok(None), + Err(error) => { + return Err(format!( + "TouchAI owned-target secret could not be opened: {error}" + )); + } + }; + let mut secret = String::new(); + secret_file + .read_to_string(&mut secret) + .map_err(|error| format!("TouchAI owned-target secret could not be read: {error}"))?; + let secret = secret.trim().to_string(); + if secret.is_empty() { + Ok(None) + } else { + Ok(Some(secret)) + } +} + +fn owned_marker_secret() -> Result { + if cfg!(debug_assertions) { + if let Some(secret) = std::env::var_os(OWNED_SECRET_ENV) + .and_then(|value| value.into_string().ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Ok(secret); + } + } + + let root = app_use_owned_data_root(); + if root.exists() { + ensure_path_chain_not_reparse(&root, "secret root")?; + } + let secret_path = root.join(".ownership-secret"); + if let Some(secret) = read_owned_marker_secret(&secret_path)? { + return Ok(secret); + } + + fs::create_dir_all(&root) + .map_err(|error| format!("TouchAI owned-target secret root is unavailable: {error}"))?; + ensure_path_chain_not_reparse(&root, "secret root")?; + let secret = uuid::Uuid::new_v4().to_string(); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&secret_path) + { + Ok(mut file) => file.write_all(secret.as_bytes()).map_err(|error| { + format!("TouchAI owned-target secret could not be written: {error}") + })?, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if let Some(secret) = read_owned_marker_secret(&secret_path)? { + return Ok(secret); + } + return Err("TouchAI owned-target secret already exists but is empty.".to_string()); + } + Err(error) => { + return Err(format!( + "TouchAI owned-target secret could not be created: {error}" + )); + } + } + Ok(secret) +} + +fn hmac_sha256_hex(secret: &str, parts: &[&[u8]]) -> String { + const BLOCK_SIZE: usize = 64; + + let mut key = secret.as_bytes().to_vec(); + if key.len() > BLOCK_SIZE { + key = Sha256::digest(&key).to_vec(); + } + key.resize(BLOCK_SIZE, 0); + + let mut inner_pad = [0x36; BLOCK_SIZE]; + let mut outer_pad = [0x5c; BLOCK_SIZE]; + for (index, byte) in key.iter().enumerate() { + inner_pad[index] ^= byte; + outer_pad[index] ^= byte; + } + + let mut inner = Sha256::new(); + inner.update(inner_pad); + for part in parts { + inner.update(part); + } + let inner_hash = inner.finalize(); + + let mut outer = Sha256::new(); + outer.update(outer_pad); + outer.update(inner_hash); + format!("{:x}", outer.finalize()) +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut diff = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + diff |= (left_byte ^ right_byte) as usize; + } + diff == 0 +} + +fn sign_owned_marker( + canonical_path: &Path, + file_identity: &str, + nonce: &str, +) -> Result { + let secret = owned_marker_secret()?; + let canonical = canonical_path.to_string_lossy().to_ascii_lowercase(); + Ok(hmac_sha256_hex( + &secret, + &[ + b"touchai-owned-target-v1", + canonical.as_bytes(), + file_identity.as_bytes(), + nonce.as_bytes(), + ], + )) +} + +fn create_owned_marker_file( + marker_path: &Path, + marker: &Value, + app_label: &str, +) -> Result<(), String> { + if marker_path.exists() { + ensure_path_chain_not_reparse(marker_path, "marker")?; + if is_path_symlink(marker_path) || has_windows_reparse_point(marker_path) { + return Err(format!( + "{app_label} owned marker must not be a symlink or reparse point." + )); + } + return Err(format!( + "{app_label} owned target marker already exists; create a fresh TouchAI-owned target before issuing a new marker." + )); + } + + if let Some(parent) = marker_path.parent() { + ensure_path_chain_not_reparse(parent, "marker parent")?; + } + let marker_json = marker.to_string(); + let mut marker_file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(marker_path) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + format!("{app_label} owned target marker already exists.") + } else { + format!("{app_label} owned target marker could not be created: {error}") + } + })?; + if let Err(error) = marker_file.write_all(marker_json.as_bytes()) { + drop(marker_file); + let _ = fs::remove_file(marker_path); + return Err(format!( + "{app_label} owned target marker could not be written: {error}" + )); + } + + Ok(()) +} + +pub(crate) fn mark_owned_wps_target( + target_path: &Path, + app_label: &str, + created_by: &str, +) -> Result { + if !target_path.is_absolute() { + return Err(format!( + "{app_label} owned target must be an absolute document path." + )); + } + + let root_path = owned_wps_target_root(); + fs::create_dir_all(&root_path) + .map_err(|error| format!("{app_label} owned target root is unavailable: {error}"))?; + ensure_path_chain_not_reparse(&root_path, "target root")?; + let canonical_root = root_path.canonicalize().map_err(|_| { + format!("{app_label} owned target root is not available for marker issuance.") + })?; + + let canonical_candidate = target_path.canonicalize().map_err(|_| { + format!("{app_label} owned target must reference an existing TouchAI document.") + })?; + validate_owned_target_extension(&canonical_candidate, app_label)?; + if !canonical_candidate.starts_with(&canonical_root) { + return Err(format!( + "{app_label} owned target must live inside the TouchAI-owned document root." + )); + } + ensure_path_chain_not_reparse(&canonical_candidate, "target")?; + + let target_file = + open_owned_guard_file(&canonical_candidate, "target", true).map_err(|error| { + format!("{app_label} owned target must reference an existing TouchAI document: {error}") + })?; + + let marker_path = owned_marker_path(&canonical_candidate); + let identity = file_identity(&target_file)?; + let nonce = uuid::Uuid::new_v4().to_string(); + let marker = json!({ + "version": "touchai-owned-target/v1", + "createdBy": created_by, + "pathHash": hash_owned_path(&canonical_candidate), + "nonce": nonce, + "identityHash": hash_owned_marker(&canonical_candidate, &identity, Some(&nonce)), + "signature": sign_owned_marker(&canonical_candidate, &identity, &nonce)?, + }); + create_owned_marker_file(&marker_path, &marker, app_label)?; + let _marker_file = verify_owned_marker(&canonical_candidate, &target_file)?; + + Ok(canonical_candidate) +} + +#[cfg(windows)] +fn open_owned_guard_file( + path: &Path, + label: &str, + allow_shared_writes: bool, +) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x00000001; + const FILE_SHARE_WRITE: u32 = 0x00000002; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x00200000; + + let share_mode = if allow_shared_writes { + FILE_SHARE_READ | FILE_SHARE_WRITE + } else { + FILE_SHARE_READ + }; + + let file = std::fs::OpenOptions::new() + .read(true) + .share_mode(share_mode) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| format!("TouchAI-owned {label} guard could not be opened: {error}"))?; + ensure_owned_guard_file_handle(&file, label)?; + Ok(file) +} + +#[cfg(not(windows))] +fn open_owned_guard_file( + path: &Path, + label: &str, + _allow_shared_writes: bool, +) -> Result { + File::open(path) + .map_err(|error| format!("TouchAI-owned {label} guard could not be opened: {error}")) +} + +fn is_path_symlink(path: &Path) -> bool { + std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) +} + +#[cfg(windows)] +fn has_windows_reparse_point(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn has_windows_reparse_point(_path: &Path) -> bool { + false +} + +fn ensure_path_chain_not_reparse(path: &Path, label: &str) -> Result<(), String> { + for candidate in path.ancestors() { + if candidate.as_os_str().is_empty() || !candidate.exists() { + continue; + } + if is_path_symlink(candidate) || has_windows_reparse_point(candidate) { + return Err(format!( + "TouchAI-owned {label} path must not include a symlink or reparse point." + )); + } + } + Ok(()) +} + +#[cfg(windows)] +fn path_has_root_prefix(candidate: &Path, root: &Path) -> bool { + let candidate = candidate + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + let root = root + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + candidate == root || candidate.starts_with(&format!("{root}\\")) +} + +#[cfg(not(windows))] +fn path_has_root_prefix(candidate: &Path, root: &Path) -> bool { + candidate.starts_with(root) +} + +#[cfg(windows)] +fn ensure_owned_guard_file_handle(file: &File, label: &str) -> Result<(), String> { + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let information = owned_file_information(file, label)?; + if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "TouchAI-owned {label} must not be a symlink or reparse point." + )); + } + if information.nNumberOfLinks > 1 { + return Err(format!("TouchAI-owned {label} must not be a hard link.")); + } + Ok(()) +} + +#[cfg(not(windows))] +fn ensure_owned_guard_file_handle(_file: &File, _label: &str) -> Result<(), String> { + Ok(()) +} + +struct OwnedTargetGuard { + canonical_path: PathBuf, + _target_file: File, + _marker_file: File, +} + +impl OwnedTargetGuard { + fn path_string(&self) -> String { + self.canonical_path.to_string_lossy().to_string() + } +} + +fn verify_owned_marker(target_path: &Path, target_file: &File) -> Result { + let marker_path = owned_marker_path(target_path); + ensure_path_chain_not_reparse(&marker_path, "marker")?; + if is_path_symlink(&marker_path) || has_windows_reparse_point(&marker_path) { + return Err("TouchAI-owned marker must not be a symlink or reparse point.".to_string()); + } + + let mut marker_file = open_owned_guard_file(&marker_path, "marker", false) + .map_err(|error| format!("TouchAI-owned marker is missing for this target: {error}"))?; + let mut marker = String::new(); + marker_file + .read_to_string(&mut marker) + .map_err(|_| "TouchAI-owned marker is unreadable.".to_string())?; + let marker: Value = serde_json::from_str(&marker) + .map_err(|_| "TouchAI-owned marker is not valid JSON.".to_string())?; + let marker_hash = marker + .get("pathHash") + .and_then(Value::as_str) + .unwrap_or_default(); + let marker_nonce = marker + .get("nonce") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "TouchAI-owned marker nonce is missing.".to_string())?; + let identity = file_identity(target_file)?; + let identity_hash = marker + .get("identityHash") + .and_then(Value::as_str) + .unwrap_or_default(); + let marker_signature = marker + .get("signature") + .and_then(Value::as_str) + .unwrap_or_default(); + let expected_identity_hash = hash_owned_marker(target_path, &identity, Some(marker_nonce)); + let expected_signature = sign_owned_marker(target_path, &identity, marker_nonce)?; + + if marker_hash != hash_owned_path(target_path) { + return Err("TouchAI-owned marker does not match this target path.".to_string()); + } + if identity_hash != expected_identity_hash { + return Err("TouchAI-owned marker does not match this target identity.".to_string()); + } + if !constant_time_eq(marker_signature, &expected_signature) { + return Err("TouchAI-owned marker is not signed by this TouchAI installation.".to_string()); + } + + Ok(marker_file) +} + +fn validate_owned_target_extension(target_path: &Path, app_label: &str) -> Result<(), String> { + let extension = target_path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let allowed = match app_label { + "WPS Writer" => ["docx"].contains(&extension.as_str()), + "WPS Spreadsheet" => ["xlsx"].contains(&extension.as_str()), + "WPS Presentation" => ["pptx"].contains(&extension.as_str()), + _ => false, + }; + + if allowed { + Ok(()) + } else { + Err(format!( + "{app_label} targetId must use a macro-free TouchAI-owned document format." + )) + } +} + +fn validate_owned_target( + target_path: Option<&str>, + required: bool, +) -> Result, String> { + validate_owned_target_for_app(target_path, required, "WPS Writer") +} + +fn validate_owned_target_for_app( + target_path: Option<&str>, + required: bool, + app_label: &str, +) -> Result, String> { + let Some(target_path) = target_path else { + return if required { + Err(format!( + "{app_label} targetId must reference a TouchAI-owned document before running write actions." + )) + } else { + Ok(None) + }; + }; + + let trimmed_path = target_path.trim(); + if trimmed_path.is_empty() { + return if required { + Err(format!( + "{app_label} targetId must reference a TouchAI-owned document before running write actions." + )) + } else { + Ok(None) + }; + } + + let candidate = Path::new(trimmed_path); + if !candidate.is_absolute() { + return Err(format!( + "{app_label} targetId must reference a TouchAI-owned absolute document path." + )); + } + + let root_path = owned_wps_target_root(); + if is_path_symlink(&root_path) || has_windows_reparse_point(&root_path) { + return Err(format!( + "{app_label} targetId root must not be a symlink or reparse point." + )); + } + let canonical_root = root_path.canonicalize().map_err(|_| { + format!("{app_label} targetId root is not available for owned document access.") + })?; + if !path_has_root_prefix(candidate, &root_path) + && !path_has_root_prefix(candidate, &canonical_root) + { + return Err(format!( + "{app_label} targetId is not a TouchAI-owned document path." + )); + } + let canonical_candidate = candidate.canonicalize().map_err(|_| { + format!("{app_label} targetId must reference an existing TouchAI-owned document.") + })?; + validate_owned_target_extension(&canonical_candidate, app_label)?; + if !canonical_candidate.starts_with(&canonical_root) { + return Err(format!( + "{app_label} targetId is not a TouchAI-owned document path." + )); + } + ensure_path_chain_not_reparse(&canonical_candidate, "target")?; + let target_file = + open_owned_guard_file(&canonical_candidate, "target", true).map_err(|error| { + format!( + "{app_label} targetId must reference an existing TouchAI-owned document: {error}" + ) + })?; + let marker_file = verify_owned_marker(&canonical_candidate, &target_file).map_err(|error| { + format!("{app_label} targetId is missing valid TouchAI ownership proof: {error}") + })?; + Ok(Some(OwnedTargetGuard { + canonical_path: canonical_candidate, + _target_file: target_file, + _marker_file: marker_file, + })) +} + +fn format_observe_content(metadata: &Value, scope: &str) -> String { + let document_name = metadata + .get("documentName") + .and_then(Value::as_str) + .unwrap_or("active WPS document"); + let selection = metadata + .get("selectionText") + .and_then(Value::as_str) + .unwrap_or(""); + let character_count = metadata + .get("characterCount") + .and_then(Value::as_i64) + .unwrap_or_default(); + let document_text = metadata + .get("documentText") + .and_then(Value::as_str) + .unwrap_or(""); + + if scope == "selection" { + return format!("Document: {document_name}\nSelection: {selection}"); + } + + format!( + "Document: {document_name}\nCharacters: {character_count}\nSelection: {selection}\nText: {document_text}" + ) +} + +fn format_spreadsheet_observe_content(metadata: &Value, scope: &str) -> String { + let workbook_name = metadata + .get("workbookName") + .and_then(Value::as_str) + .unwrap_or("active WPS workbook"); + let active_sheet = metadata + .get("activeSheetName") + .and_then(Value::as_str) + .unwrap_or("active sheet"); + let used_range = metadata + .get("usedRange") + .and_then(Value::as_str) + .unwrap_or(""); + let values = metadata + .get("values") + .map(Value::to_string) + .unwrap_or_else(|| "[]".to_string()); + + if scope == "workbook" { + let sheets = metadata + .get("sheetNames") + .and_then(Value::as_array) + .map(|sheets| { + sheets + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + return format!( + "Workbook: {workbook_name}\nSheets: {sheets}\nActive sheet: {active_sheet}" + ); + } + + format!( + "Workbook: {workbook_name}\nSheet: {active_sheet}\nUsed range: {used_range}\nValues: {values}" + ) +} + +fn format_presentation_observe_content(metadata: &Value, scope: &str) -> String { + let presentation_name = metadata + .get("presentationName") + .and_then(Value::as_str) + .unwrap_or("active WPS presentation"); + let slide_count = metadata + .get("slideCount") + .and_then(Value::as_i64) + .unwrap_or_default(); + let active_slide_index = metadata + .get("activeSlideIndex") + .and_then(Value::as_i64) + .unwrap_or_default(); + let slide_text = metadata + .get("slideText") + .and_then(Value::as_str) + .unwrap_or(""); + + if scope == "slide" { + return format!( + "Presentation: {presentation_name}\nSlide: {active_slide_index}\nText: {slide_text}" + ); + } + + format!( + "Presentation: {presentation_name}\nSlides: {slide_count}\nActive slide: {active_slide_index}\nText: {slide_text}" + ) +} + +fn truncate_content(content: String, max_chars: usize) -> (String, bool) { + if content.chars().count() <= max_chars { + return (content, false); + } + + (content.chars().take(max_chars).collect(), true) +} + +fn encoded_utf8_script_value(value: Option<&str>) -> String { + value + .map(|value| general_purpose::STANDARD.encode(value.as_bytes())) + .unwrap_or_default() +} + +fn wps_observe_script(target_path: Option<&str>, scope: &str) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let include_document_text = if scope == "active_document" { + "$true" + } else { + "$false" + }; + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) { + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + return & $Operation + } catch { + $lastError = $_ + Start-Sleep -Milliseconds 150 + } + } + throw $lastError +} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__TARGET_PATH__')) +$includeDocumentText = __INCLUDE_DOCUMENT_TEXT__ +$ownsDocument = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) { + $app = Invoke-WithComRetry { [Runtime.InteropServices.Marshal]::GetActiveObject('KWPS.Application') } + $doc = Invoke-WithComRetry { $app.ActiveDocument } +} else { + $app = Invoke-WithComRetry { New-Object -ComObject KWPS.Application } + $doc = Invoke-WithComRetry { $app.Documents.Open($targetPath) } + $ownsDocument = $true +} +if ($null -eq $doc) { + throw 'WPS Writer does not have an active document.' +} +$selectionText = '' +if ($null -ne $app.Selection) { + $selectionText = Invoke-WithComRetry { [string]$app.Selection.Text } +} +$fullName = $null +try { + $fullName = Invoke-WithComRetry { [string]$doc.FullName } +} catch { + $fullName = $null +} +$documentText = '' +if ($includeDocumentText) { + $documentText = Invoke-WithComRetry { [string]$doc.Content.Text } +} +$result = [ordered]@{ + documentName = Invoke-WithComRetry { [string]$doc.Name } + fullName = $fullName + documentText = $documentText + selectionText = $selectionText + characterCount = Invoke-WithComRetry { [int]$doc.Characters.Count } +} +if ($ownsDocument) { + Invoke-WithComRetry { $doc.Close([ref]$false) } | Out-Null +} +$result | ConvertTo-Json -Compress -Depth 4 +"# + .trim() + .replace("__TARGET_PATH__", &encoded_target_path) + .replace("__INCLUDE_DOCUMENT_TEXT__", include_document_text) + .to_string() +} + +fn wps_type_text_script(text: &str, target_path: Option<&str>) -> String { + let encoded_text = general_purpose::STANDARD.encode(text.as_bytes()); + let encoded_target_path = encoded_utf8_script_value(target_path); + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +$text = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_text}')) +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$ownsDocument = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('KWPS.Application') }} + $doc = Invoke-WithComRetry {{ $app.ActiveDocument }} +}} else {{ + $app = Invoke-WithComRetry {{ New-Object -ComObject KWPS.Application }} + $doc = Invoke-WithComRetry {{ $app.Documents.Open($targetPath) }} + $ownsDocument = $true + Invoke-WithComRetry {{ $doc.Content.Select() }} | Out-Null +}} +if ($null -eq $doc) {{ + throw 'WPS Writer does not have an active document.' +}} +$selection = Invoke-WithComRetry {{ $app.Selection }} +if ($null -eq $selection) {{ + throw 'WPS Writer does not have an active selection.' +}} +Invoke-WithComRetry {{ $selection.TypeText($text) }} +$fullName = $null +try {{ + $fullName = Invoke-WithComRetry {{ [string]$doc.FullName }} +}} catch {{ + $fullName = $null +}} +if ($ownsDocument) {{ + Invoke-WithComRetry {{ $doc.Save() }} | Out-Null +}} +$result = [ordered]@{{ + documentName = Invoke-WithComRetry {{ [string]$doc.Name }} + fullName = $fullName + changed = $true +}} +if ($ownsDocument) {{ + Invoke-WithComRetry {{ $doc.Close([ref]$false) }} | Out-Null +}} +$result | ConvertTo-Json -Compress -Depth 4 +"# + ) + .trim() + .to_string() +} + +fn wps_spreadsheet_observe_script(target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +function Convert-ColumnIndexToName([int]$index) {{ + $name = '' + while ($index -gt 0) {{ + $index-- + $name = ([char](65 + ($index % 26))).ToString() + $name + $index = [math]::Floor($index / 26) + }} + return $name +}} +function Get-CellAddress([int]$row, [int]$column) {{ + return "$(Convert-ColumnIndexToName $column)$row" +}} +function Convert-FirstInt($value) {{ + if ($value -is [array]) {{ + return [int]$value[0] + }} + return [int]$value +}} +function Convert-UsedRangeValues($sheet, $range) {{ + $values = @() + $rowCount = Invoke-WithComRetry {{ [int]$range.Rows.Count }} + $columnCount = Invoke-WithComRetry {{ [int]$range.Columns.Count }} + $startRow = Invoke-WithComRetry {{ Convert-FirstInt $range.Row }} + $startColumn = Invoke-WithComRetry {{ Convert-FirstInt $range.Column }} + for ($row = 1; $row -le $rowCount; $row++) {{ + $rowValues = @() + for ($column = 1; $column -le $columnCount; $column++) {{ + $cellAddress = Get-CellAddress ($startRow + $row - 1) ($startColumn + $column - 1) + $rowValues += Invoke-WithComRetry {{ $sheet.Range($cellAddress).Value2 }} + }} + $values += ,$rowValues + }} + return $values +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$ownsWorkbook = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('KET.Application') }} + $workbook = Invoke-WithComRetry {{ $app.ActiveWorkbook }} +}} else {{ + $app = Invoke-WithComRetry {{ New-Object -ComObject KET.Application }} + $workbook = Invoke-WithComRetry {{ $app.Workbooks.Open($targetPath) }} + $ownsWorkbook = $true +}} +if ($null -eq $workbook) {{ + throw 'WPS Spreadsheet does not have an active workbook.' +}} +$sheet = Invoke-WithComRetry {{ $app.ActiveSheet }} +$sheetNames = @() +for ($i = 1; $i -le $workbook.Worksheets.Count; $i++) {{ + $sheetNames += Invoke-WithComRetry {{ [string]$workbook.Worksheets.Item($i).Name }} +}} +$usedRange = Invoke-WithComRetry {{ $sheet.UsedRange }} +$usedRangeRows = Invoke-WithComRetry {{ [int]$usedRange.Rows.Count }} +$usedRangeColumns = Invoke-WithComRetry {{ [int]$usedRange.Columns.Count }} +$fullName = $null +try {{ + $fullName = Invoke-WithComRetry {{ [string]$workbook.FullName }} +}} catch {{ + $fullName = $null +}} +$result = [ordered]@{{ + workbookName = Invoke-WithComRetry {{ [string]$workbook.Name }} + fullName = $fullName + activeSheetName = Invoke-WithComRetry {{ [string]$sheet.Name }} + sheetNames = $sheetNames + usedRange = "$usedRangeRows x $usedRangeColumns" + values = Convert-UsedRangeValues $sheet $usedRange +}} +if ($ownsWorkbook) {{ + Invoke-WithComRetry {{ $workbook.Close($false) }} | Out-Null +}} +$result | ConvertTo-Json -Compress -Depth 6 +"# + ) + .trim() + .to_string() +} + +fn wps_spreadsheet_write_cells_script( + cells: &WpsSpreadsheetWriteCells, + target_path: Option<&str>, +) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let encoded_range = encoded_utf8_script_value(Some(&cells.range)); + let encoded_sheet_name = encoded_utf8_script_value(cells.sheet_name.as_deref()); + let values_json = serde_json::to_string(&cells.values).unwrap_or_else(|_| "[]".to_string()); + let encoded_values = general_purpose::STANDARD.encode(values_json.as_bytes()); + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +function Convert-ColumnIndexToName([int]$index) {{ + $name = '' + while ($index -gt 0) {{ + $index-- + $name = ([char](65 + ($index % 26))).ToString() + $name + $index = [math]::Floor($index / 26) + }} + return $name +}} +function Get-CellAddress([int]$row, [int]$column) {{ + return "$(Convert-ColumnIndexToName $column)$row" +}} +function Convert-FirstInt($value) {{ + if ($value -is [array]) {{ + return [int]$value[0] + }} + return [int]$value +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$rangeAddress = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_range}')) +$sheetName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_sheet_name}')) +$valuesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_values}')) +$rows = $valuesJson | ConvertFrom-Json +$app = Invoke-WithComRetry {{ New-Object -ComObject KET.Application }} +$workbook = Invoke-WithComRetry {{ $app.Workbooks.Open($targetPath) }} +if ($null -eq $workbook) {{ + throw 'WPS Spreadsheet does not have an active workbook.' +}} +if ([string]::IsNullOrWhiteSpace($sheetName)) {{ + $sheet = Invoke-WithComRetry {{ $workbook.Worksheets.Item(1) }} +}} else {{ + $sheet = Invoke-WithComRetry {{ $workbook.Worksheets.Item($sheetName) }} +}} +$openedFullName = Invoke-WithComRetry {{ [string]$workbook.FullName }} +if ([string]::Compare($openedFullName, $targetPath, $true, [Globalization.CultureInfo]::InvariantCulture) -ne 0) {{ + throw 'WPS Spreadsheet opened a workbook that does not match the requested target.' +}} +$range = Invoke-WithComRetry {{ $sheet.Range($rangeAddress) }} +$startRow = Invoke-WithComRetry {{ Convert-FirstInt $range.Row }} +$startColumn = Invoke-WithComRetry {{ Convert-FirstInt $range.Column }} +for ($rowIndex = 0; $rowIndex -lt $rows.Count; $rowIndex++) {{ + $row = @($rows[$rowIndex]) + for ($columnIndex = 0; $columnIndex -lt $row.Count; $columnIndex++) {{ + $value = $row[$columnIndex] + $cellAddress = Get-CellAddress ($startRow + $rowIndex) ($startColumn + $columnIndex) + Invoke-WithComRetry {{ $sheet.Range($cellAddress).Value2 = $value }} | Out-Null + }} +}} +$fullName = $null +try {{ + $fullName = $openedFullName +}} catch {{ + $fullName = $null +}} +Invoke-WithComRetry {{ $workbook.Save() }} | Out-Null +$result = [ordered]@{{ + workbookName = Invoke-WithComRetry {{ [string]$workbook.Name }} + fullName = $fullName + sheetName = Invoke-WithComRetry {{ [string]$sheet.Name }} + range = $rangeAddress + changed = $true + values = $rows +}} +Invoke-WithComRetry {{ $workbook.Close($false) }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 6 +"# + ) + .trim() + .to_string() +} + +fn wps_presentation_observe_script(target_path: Option<&str>) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +function Get-SlideText($slide) {{ + $parts = @() + for ($i = 1; $i -le $slide.Shapes.Count; $i++) {{ + $shape = $slide.Shapes.Item($i) + try {{ + if ($shape.HasTextFrame -and $shape.TextFrame.HasText) {{ + $parts += [string]$shape.TextFrame.TextRange.Text + }} + }} catch {{}} + }} + return ($parts -join "`n") +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$ownsPresentation = $false +if ([string]::IsNullOrWhiteSpace($targetPath)) {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('KWPP.Application') }} + $presentation = Invoke-WithComRetry {{ $app.ActivePresentation }} +}} else {{ + $app = Invoke-WithComRetry {{ New-Object -ComObject KWPP.Application }} + $presentation = Invoke-WithComRetry {{ $app.Presentations.Open($targetPath) }} + $ownsPresentation = $true +}} +if ($null -eq $presentation) {{ + throw 'WPS Presentation does not have an active presentation.' +}} +$slide = $null +$activeSlideIndex = 0 +try {{ + $slide = Invoke-WithComRetry {{ $app.ActiveWindow.View.Slide }} + $activeSlideIndex = Invoke-WithComRetry {{ [int]$slide.SlideIndex }} +}} catch {{ + if ($presentation.Slides.Count -gt 0) {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Item(1) }} + $activeSlideIndex = 1 + }} +}} +$fullName = $null +try {{ + $fullName = Invoke-WithComRetry {{ [string]$presentation.FullName }} +}} catch {{ + $fullName = $null +}} +$result = [ordered]@{{ + presentationName = Invoke-WithComRetry {{ [string]$presentation.Name }} + fullName = $fullName + slideCount = Invoke-WithComRetry {{ [int]$presentation.Slides.Count }} + activeSlideIndex = $activeSlideIndex + slideText = if ($null -eq $slide) {{ '' }} else {{ Get-SlideText $slide }} +}} +if ($ownsPresentation) {{ + Invoke-WithComRetry {{ $presentation.Close() }} | Out-Null +}} +$result | ConvertTo-Json -Compress -Depth 5 +"# + ) + .trim() + .to_string() +} + +fn wps_presentation_add_slide_text_script( + slide_text: &WpsPresentationSlideText, + target_path: Option<&str>, +) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let encoded_text = encoded_utf8_script_value(Some(&slide_text.text)); + let slide_index = slide_text + .slide_index + .map(|value| value.to_string()) + .unwrap_or_else(|| "$null".to_string()); + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$text = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_text}')) +$slideIndex = {slide_index} +$app = Invoke-WithComRetry {{ New-Object -ComObject KWPP.Application }} +$presentation = Invoke-WithComRetry {{ $app.Presentations.Open($targetPath) }} +if ($null -eq $presentation) {{ + throw 'WPS Presentation does not have an active presentation.' +}} +if ($presentation.Slides.Count -eq 0) {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Add(1, 12) }} +}} elseif ($null -eq $slideIndex) {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Item($presentation.Slides.Count) }} + $slideIndex = Invoke-WithComRetry {{ [int]$slide.SlideIndex }} +}} else {{ + $slide = Invoke-WithComRetry {{ $presentation.Slides.Item([int]$slideIndex) }} +}} +$shape = Invoke-WithComRetry {{ $slide.Shapes.AddTextbox(1, 48, 48, 600, 120) }} +Invoke-WithComRetry {{ $shape.TextFrame.TextRange.Text = $text }} | Out-Null +$fullName = $null +try {{ + $fullName = Invoke-WithComRetry {{ [string]$presentation.FullName }} +}} catch {{ + $fullName = $null +}} +Invoke-WithComRetry {{ $presentation.Save() }} | Out-Null +$result = [ordered]@{{ + presentationName = Invoke-WithComRetry {{ [string]$presentation.Name }} + fullName = $fullName + slideIndex = Invoke-WithComRetry {{ [int]$slide.SlideIndex }} + text = $text + changed = $true +}} +Invoke-WithComRetry {{ $presentation.Close() }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 5 +"# + ) + .trim() + .to_string() +} + +fn powershell_optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "$true", + Some(false) => "$false", + None => "$null", + } +} + +fn powershell_optional_number(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "$null".to_string()) +} + +fn wps_format_document_text_script( + format: &WpsSelectionFormat, + target_path: Option<&str>, +) -> String { + let encoded_target_path = encoded_utf8_script_value(target_path); + let encoded_font_name = encoded_utf8_script_value(format.font_name.as_deref()); + let bold = powershell_optional_bool(format.bold); + let italic = powershell_optional_bool(format.italic); + let underline = powershell_optional_bool(format.underline); + let font_size = powershell_optional_number(format.font_size); + + format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +$targetPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_target_path}')) +$fontName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_font_name}')) +$formatBold = {bold} +$formatItalic = {italic} +$formatUnderline = {underline} +$formatFontSize = {font_size} +$app = Invoke-WithComRetry {{ New-Object -ComObject KWPS.Application }} +$doc = Invoke-WithComRetry {{ $app.Documents.Open($targetPath) }} +if ($null -eq $doc) {{ + throw 'WPS Writer does not have an active document.' +}} +Invoke-WithComRetry {{ $doc.Content.Select() }} | Out-Null +$selection = Invoke-WithComRetry {{ $app.Selection }} +if ($null -eq $selection) {{ + throw 'WPS Writer does not have an active selection.' +}} +if ($null -ne $formatBold) {{ + if ([bool]$formatBold) {{ + Invoke-WithComRetry {{ $selection.Font.Bold = 1 }} | Out-Null + }} else {{ + Invoke-WithComRetry {{ $selection.Font.Bold = 0 }} | Out-Null + }} +}} +if ($null -ne $formatItalic) {{ + if ([bool]$formatItalic) {{ + Invoke-WithComRetry {{ $selection.Font.Italic = 1 }} | Out-Null + }} else {{ + Invoke-WithComRetry {{ $selection.Font.Italic = 0 }} | Out-Null + }} +}} +if ($null -ne $formatUnderline) {{ + if ([bool]$formatUnderline) {{ + Invoke-WithComRetry {{ $selection.Font.Underline = 1 }} | Out-Null + }} else {{ + Invoke-WithComRetry {{ $selection.Font.Underline = 0 }} | Out-Null + }} +}} +if ($null -ne $formatFontSize) {{ + Invoke-WithComRetry {{ $selection.Font.Size = [double]$formatFontSize }} | Out-Null +}} +if (-not [string]::IsNullOrWhiteSpace($fontName)) {{ + Invoke-WithComRetry {{ $selection.Font.Name = $fontName }} | Out-Null +}} +$fullName = $null +try {{ + $fullName = Invoke-WithComRetry {{ [string]$doc.FullName }} +}} catch {{ + $fullName = $null +}} +Invoke-WithComRetry {{ $doc.Save() }} | Out-Null +$format = [ordered]@{{}} +if ($null -ne $formatBold) {{ + $format.bold = [bool]$formatBold +}} +if ($null -ne $formatItalic) {{ + $format.italic = [bool]$formatItalic +}} +if ($null -ne $formatUnderline) {{ + $format.underline = [bool]$formatUnderline +}} +if ($null -ne $formatFontSize) {{ + $format.fontSize = [double]$formatFontSize +}} +if (-not [string]::IsNullOrWhiteSpace($fontName)) {{ + $format.fontName = $fontName +}} +$result = [ordered]@{{ + documentName = Invoke-WithComRetry {{ [string]$doc.Name }} + fullName = $fullName + changed = $true + format = $format +}} +Invoke-WithComRetry {{ $doc.Close([ref]$false) }} | Out-Null +$result | ConvertTo-Json -Compress -Depth 5 +"# + ) + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app_use::types::{ + AppUseActRequest, AppUseAdvancedConfig, AppUseConfig, AppUseObserveRequest, + }; + use crate::core::app_use::AppUseAdapter; + use serde_json::json; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + struct RecordingRunner { + output: Result, + scripts: Mutex>, + } + + impl RecordingRunner { + fn new(output: Result) -> Self { + Self { + output, + scripts: Mutex::new(Vec::new()), + } + } + + fn scripts(&self) -> Vec { + self.scripts.lock().expect("scripts").clone() + } + } + + impl WpsAutomationRunner for RecordingRunner { + fn run_script(&self, script: &str, _timeout_ms: u64) -> Result { + self.scripts + .lock() + .expect("scripts") + .push(script.to_string()); + self.output.clone() + } + } + + fn config() -> AppUseConfig { + AppUseConfig { + mode: "interactive".to_string(), + adapters: HashMap::from([("wps_writer".to_string(), true)]), + mutating_approval_mode: "always".to_string(), + read_scope: "active".to_string(), + allow_background_operation: false, + allow_raw_automation: false, + timeout_ms: 15_000, + max_output_chars: 12_000, + advanced: AppUseAdvancedConfig::default(), + } + } + + fn prepare_owned_test_root() -> PathBuf { + static OWNED_TEST_ROOT_LOCK: Mutex<()> = Mutex::new(()); + let _guard = OWNED_TEST_ROOT_LOCK.lock().expect("owned test root lock"); + let root = std::env::temp_dir().join("touchai-wps-owned-tests"); + std::env::set_var(WPS_OWNED_ROOT_ENV, &root); + std::env::set_var(OWNED_SECRET_ENV, "app-use-owned-test-secret"); + std::fs::create_dir_all(&root).expect("owned root"); + root + } + + fn write_owned_test_file(path: &Path) { + remove_owned_test_file(path); + std::fs::write(path, b"owned").expect("owned file"); + let app_label = match path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("docx") => "WPS Writer", + Some("xlsx") => "WPS Spreadsheet", + Some("pptx") => "WPS Presentation", + _ => panic!("unsupported WPS test target extension"), + }; + mark_owned_wps_target(path, app_label, "TouchAI App Use test").expect("owned marker"); + } + + fn remove_owned_test_file(path: &Path) { + let marker = path + .canonicalize() + .ok() + .map(|canonical_path| owned_marker_path(&canonical_path)) + .unwrap_or_else(|| owned_marker_path(path)); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(marker); + } + + #[test] + fn observe_uses_wps_specific_progid_and_returns_structured_content() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "demo.docx", + "selectionText": "selected text", + "characterCount": 42 + }) + .to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "wps-observe-1".to_string(), + adapter_id: "wps_writer".to_string(), + scope: "selection".to_string(), + description: "read WPS selection".to_string(), + target_id: None, + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, true); + assert!(response + .content + .unwrap_or_default() + .contains("selected text")); + assert_eq!(response.metadata["documentName"], "demo.docx"); + let script = runner.scripts().join("\n"); + assert!(script.contains("KWPS.Application")); + assert!(!script.contains("Word.Application")); + } + + #[test] + fn observe_active_document_includes_document_text() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "demo.docx", + "documentText": "full WPS document text", + "selectionText": "", + "characterCount": 22 + }) + .to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "wps-observe-2".to_string(), + adapter_id: "wps_writer".to_string(), + scope: "active_document".to_string(), + description: "read WPS active document".to_string(), + target_id: None, + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, true); + assert!(response + .content + .unwrap_or_default() + .contains("full WPS document text")); + } + + #[test] + fn act_rejects_missing_target_path_before_running_wps() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "owned-smoke.docx", + "changed": true + }) + .to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-1".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace WPS selection".to_string(), + target_id: None, + parameters: Some(json!({ "text": "hello from app use" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(response.receipt.contains("targetId")); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn act_with_target_path_opens_owned_document_without_raw_path_interpolation() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-target.docx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "owned-target.docx", + "fullName": owned_path_string.clone(), + "changed": true + }) + .to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-3".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace owned WPS document".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ "text": "hello target" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("Documents.Open")); + assert!(script.contains("TypeText")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("hello target")); + assert!(!script.contains("Word.Application")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn act_rejects_temp_root_target_without_owned_marker_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let unmarked_path = owned_root.join("unmarked-target.docx"); + std::fs::write(&unmarked_path, b"not owned").expect("unmarked file"); + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-unmarked".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace unmarked WPS document".to_string(), + target_id: Some(unmarked_path.to_string_lossy().to_string()), + parameters: Some(json!({ "text": "hello target" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(response.receipt.contains("ownership proof")); + assert!(runner.scripts().is_empty()); + let _ = std::fs::remove_file(&unmarked_path); + } + + #[test] + fn act_rejects_self_minted_owned_target_marker_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-unsigned-marker.docx"); + std::fs::write(&owned_path, b"owned").expect("owned file"); + let canonical_path = owned_path.canonicalize().expect("canonical owned file"); + let target_file = std::fs::File::open(&canonical_path).expect("owned target file"); + let identity = file_identity(&target_file).expect("owned identity"); + let nonce = "self-minted-wps-marker"; + let marker = json!({ + "createdBy": "legacy TouchAI App Use test", + "pathHash": hash_owned_path(&canonical_path), + "nonce": nonce, + "identityHash": hash_owned_marker(&canonical_path, &identity, Some(nonce)), + }); + std::fs::write(owned_marker_path(&canonical_path), marker.to_string()) + .expect("owned marker"); + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-unsigned-marker".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace legacy marked WPS document".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ "text": "safe" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert!(response.receipt.contains("signed")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn mark_owned_wps_target_refuses_to_overwrite_existing_marker() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-existing-marker.docx"); + remove_owned_test_file(&owned_path); + std::fs::write(&owned_path, b"owned").expect("owned file"); + let canonical_path = owned_path.canonicalize().expect("canonical owned file"); + std::fs::write(owned_marker_path(&canonical_path), "{}").expect("existing marker"); + + let error = mark_owned_wps_target(&owned_path, "WPS Writer", "TouchAI App Use test") + .expect_err("existing marker must not be overwritten"); + + assert!(error.contains("already exists")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn act_rejects_macro_enabled_owned_target_extension_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-macro-target.docm"); + std::fs::write(&owned_path, b"owned").expect("owned file"); + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-macro-extension".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace macro WPS document".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ "text": "safe" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert!(response.receipt.contains("macro-free")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[cfg(windows)] + #[test] + fn act_rejects_hardlinked_owned_target_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let source_path = owned_root.join("owned-hardlink-source.docx"); + let linked_path = owned_root.join("owned-hardlink-target.docx"); + let _ = std::fs::remove_file(&source_path); + let _ = std::fs::remove_file(&linked_path); + write_owned_test_file(&linked_path); + std::fs::hard_link(&linked_path, &source_path).expect("hardlink"); + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-hardlink".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace hardlinked WPS document".to_string(), + target_id: Some(linked_path.to_string_lossy().to_string()), + parameters: Some(json!({ "text": "do not write" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(response.receipt.contains("hard link")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&linked_path); + let _ = std::fs::remove_file(&source_path); + } + + #[cfg(windows)] + #[test] + fn act_rejects_hardlinked_owned_marker_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-hardlink-marker-target.docx"); + let marker_link_path = owned_root.join("owned-hardlink-marker-source.json"); + let _ = std::fs::remove_file(&owned_path); + let _ = std::fs::remove_file(&marker_link_path); + write_owned_test_file(&owned_path); + let marker_path = owned_marker_path(&owned_path.canonicalize().expect("canonical owned")); + let marker_content = std::fs::read_to_string(&marker_path).expect("marker content"); + std::fs::remove_file(&marker_path).expect("remove original marker"); + std::fs::write(&marker_link_path, marker_content).expect("marker source"); + std::fs::hard_link(&marker_link_path, &marker_path).expect("hardlinked marker"); + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-hardlinked-marker".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace WPS document with hardlinked marker".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ "text": "do not write" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(response.receipt.contains("hard link")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + let _ = std::fs::remove_file(&marker_link_path); + } + + #[test] + fn format_document_text_with_target_path_applies_structured_font_options() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-format-target.docx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "documentName": "owned-format-target.docx", + "fullName": owned_path_string.clone(), + "changed": true, + "format": { + "bold": true, + "italic": true, + "underline": true, + "fontSize": 18, + "fontName": "Arial" + } + }) + .to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-format-1".to_string(), + adapter_id: "wps_writer".to_string(), + action: "format_document_text".to_string(), + description: "format owned WPS document".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ + "bold": true, + "italic": true, + "underline": true, + "fontSize": 18, + "fontName": "Arial" + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + assert_eq!(response.changed, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("Documents.Open")); + assert!(script.contains(".Font.Bold")); + assert!(script.contains(".Font.Italic")); + assert!(script.contains(".Font.Underline")); + assert!(script.contains(".Font.Size")); + assert!(script.contains(".Font.Name")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("Arial")); + assert!(!script.contains("Word.Application")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn format_document_text_rejects_empty_format_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-empty-format.docx"); + write_owned_test_file(&owned_path); + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-format-2".to_string(), + adapter_id: "wps_writer".to_string(), + action: "format_document_text".to_string(), + description: "format owned WPS document".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({})), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "invalid_parameters"); + assert!(response.receipt.contains("format")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn spreadsheet_write_cells_with_target_path_uses_ket_and_base64_payload() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-sheet-target.xlsx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "workbookName": "owned-sheet-target.xlsx", + "fullName": owned_path_string.clone(), + "sheetName": "Sheet1", + "range": "A1:B1", + "changed": true, + "values": [["hello", "42"]] + }) + .to_string()))); + let adapter = WpsSpreadsheetAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-sheet-1".to_string(), + adapter_id: "wps_spreadsheet".to_string(), + action: "write_cells".to_string(), + description: "write owned WPS spreadsheet cells".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ + "range": "A1:B1", + "values": [["hello", "42"]] + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + assert_eq!(response.changed, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("KET.Application")); + assert!(script.contains("Workbooks.Open")); + assert!(script.contains("$workbook.Worksheets.Item(1)")); + assert!(script.contains("$openedFullName")); + assert!(script.contains("does not match the requested target")); + assert!(script.contains(".Range($rangeAddress)")); + assert!(script.contains(".Value2")); + assert!(!script.contains("$app.ActiveSheet")); + assert!(!script.contains("$rows = @($valuesJson | ConvertFrom-Json)")); + assert!(!script.contains("Cells.Item($rowIndex + 1, $columnIndex + 1)")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("hello")); + assert!(!script.contains("Excel.Application")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn spreadsheet_write_cells_rejects_empty_values_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-empty-cells.xlsx"); + write_owned_test_file(&owned_path); + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsSpreadsheetAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-sheet-2".to_string(), + adapter_id: "wps_spreadsheet".to_string(), + action: "write_cells".to_string(), + description: "write empty WPS spreadsheet cells".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ + "range": "A1:B1", + "values": [] + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "invalid_parameters"); + assert!(response.receipt.contains("values")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn spreadsheet_write_cells_rejects_formula_like_strings_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-formula-cells.xlsx"); + write_owned_test_file(&owned_path); + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsSpreadsheetAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-sheet-formula".to_string(), + adapter_id: "wps_spreadsheet".to_string(), + action: "write_cells".to_string(), + description: "write formula-like WPS spreadsheet cells".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ + "range": "A1", + "values": [["=HYPERLINK(\"https://example.com\")"]] + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "invalid_parameters"); + assert!(response.receipt.contains("formula")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn spreadsheet_write_cells_rejects_oversized_payload_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-large-cells.xlsx"); + write_owned_test_file(&owned_path); + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsSpreadsheetAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-sheet-large".to_string(), + adapter_id: "wps_spreadsheet".to_string(), + action: "write_cells".to_string(), + description: "write oversized WPS spreadsheet cells".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ + "range": "A1", + "values": [[String::from_utf8(vec![b'a'; 4097]).expect("large string")]] + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "invalid_parameters"); + assert!(response.receipt.contains("cell text")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn presentation_add_slide_text_with_target_path_uses_kwpp_and_base64_payload() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-presentation-target.pptx"); + write_owned_test_file(&owned_path); + let owned_path_string = owned_path.to_string_lossy().to_string(); + let runner = Arc::new(RecordingRunner::new(Ok(json!({ + "presentationName": "owned-presentation-target.pptx", + "fullName": owned_path_string.clone(), + "slideIndex": 1, + "text": "hello slide", + "changed": true + }) + .to_string()))); + let adapter = WpsPresentationAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-presentation-1".to_string(), + adapter_id: "wps_presentation".to_string(), + action: "add_slide_text".to_string(), + description: "add text to owned WPS presentation".to_string(), + target_id: Some(owned_path_string.clone()), + parameters: Some(json!({ + "text": "hello slide", + "slideIndex": 1 + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, true); + assert_eq!(response.changed, true); + let script = runner.scripts().join("\n"); + assert!(script.contains("KWPP.Application")); + assert!(script.contains("Presentations.Open")); + assert!(script.contains(".Shapes.AddTextbox")); + assert!(script.contains(".TextFrame.TextRange.Text")); + assert!(!script.contains(&owned_path_string)); + assert!(!script.contains("hello slide")); + assert!(!script.contains("PowerPoint.Application")); + remove_owned_test_file(&owned_path); + } + + #[test] + fn presentation_add_slide_text_rejects_empty_text_before_running_wps() { + let owned_root = prepare_owned_test_root(); + let owned_path = owned_root.join("owned-empty-slide-text.pptx"); + write_owned_test_file(&owned_path); + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsPresentationAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-presentation-2".to_string(), + adapter_id: "wps_presentation".to_string(), + action: "add_slide_text".to_string(), + description: "add empty text to owned WPS presentation".to_string(), + target_id: Some(owned_path.to_string_lossy().to_string()), + parameters: Some(json!({ + "text": " " + })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "invalid_parameters"); + assert!(response.receipt.contains("text")); + assert!(runner.scripts().is_empty()); + remove_owned_test_file(&owned_path); + } + + #[test] + fn observe_rejects_non_owned_target_path_before_running_wps() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.observe(&AppUseObserveRequest { + execution_id: "wps-observe-3".to_string(), + adapter_id: "wps_writer".to_string(), + scope: "active_document".to_string(), + description: "read arbitrary WPS document".to_string(), + target_id: Some("C:\\Users\\Alice\\Documents\\report.docx".to_string()), + max_output_chars: 12_000, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn act_rejects_non_owned_target_path_before_running_wps() { + let runner = Arc::new(RecordingRunner::new(Ok(json!({}).to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-4".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace arbitrary WPS document".to_string(), + target_id: Some("C:\\Users\\Alice\\Documents\\report.docx".to_string()), + parameters: Some(json!({ "text": "do not write" })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert_eq!(response.metadata["reason"], "target_not_owned"); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn act_rejects_missing_text_parameter_before_running_wps() { + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-2".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace WPS selection".to_string(), + target_id: None, + parameters: Some(json!({})), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert!(response.receipt.contains("text")); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn act_rejects_oversized_text_parameter_before_running_wps() { + let runner = Arc::new(RecordingRunner::new(Ok("{}".to_string()))); + let adapter = WpsWriterAdapter::with_runner(runner.clone()); + + let response = adapter.act(&AppUseActRequest { + execution_id: "wps-act-large-text".to_string(), + adapter_id: "wps_writer".to_string(), + action: "replace_document_text".to_string(), + description: "replace WPS selection".to_string(), + target_id: None, + parameters: Some(json!({ "text": "x".repeat(20_001) })), + permit: None, + config: config(), + }); + + assert_eq!(response.ok, false); + assert_eq!(response.changed, false); + assert!(response.receipt.contains("20000")); + assert!(runner.scripts().is_empty()); + } + + #[test] + fn powershell_runner_uses_sta_for_wps_com_automation() { + let args = powershell_arguments("encoded-script"); + + assert!(args.iter().any(|arg| arg == "-Sta")); + assert!(args.iter().any(|arg| arg == "-EncodedCommand")); + assert_eq!(args.last().map(String::as_str), Some("encoded-script")); + } +} diff --git a/apps/desktop/src-tauri/src/core/mod.rs b/apps/desktop/src-tauri/src/core/mod.rs index 70925d9d..1eb412d6 100644 --- a/apps/desktop/src-tauri/src/core/mod.rs +++ b/apps/desktop/src-tauri/src/core/mod.rs @@ -4,6 +4,7 @@ //! //! 分为窗口域与系统能力域。 +pub mod app_use; pub mod built_in_tools; pub mod database; pub mod mcp; diff --git a/apps/desktop/src-tauri/src/core/system/paths.rs b/apps/desktop/src-tauri/src/core/system/paths.rs index 5eba309d..31725072 100644 --- a/apps/desktop/src-tauri/src/core/system/paths.rs +++ b/apps/desktop/src-tauri/src/core/system/paths.rs @@ -39,6 +39,10 @@ fn resolve_program_root_directory() -> Result { return Ok(path); } + resolve_program_root_directory_without_override() +} + +fn resolve_program_root_directory_without_override() -> Result { let exe_dir = std::env::current_exe() .map_err(|err| format!("Failed to resolve current exe: {err}"))? .parent() @@ -79,8 +83,12 @@ fn resolve_user_data_root_directory() -> Result { return Ok(path); } + resolve_user_data_root_directory_without_override() +} + +fn resolve_user_data_root_directory_without_override() -> Result { if cfg!(debug_assertions) { - return resolve_program_root_directory(); + return resolve_program_root_directory_without_override(); } let base_dir = if cfg!(target_os = "windows") { @@ -132,6 +140,14 @@ pub fn app_directory_path(directory: AppDirectory) -> Result { Ok(user_data_root_directory()?.join(directory_relative_path(directory)?)) } +pub fn app_directory_path_without_app_root_override( + directory: AppDirectory, +) -> Result { + let _ = directory_relative_path(directory)?; + Ok(resolve_user_data_root_directory_without_override()? + .join(directory_relative_path(directory)?)) +} + pub fn legacy_app_directory_path(directory: AppDirectory) -> Result { Ok(program_root_directory()?.join(directory_relative_path(directory)?)) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index abc5a880..43d6c278 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -109,6 +109,7 @@ pub fn run() { .manage(core::window::search::surface::SearchSurfaceRuntime::new()) .manage(core::window::status_reminder::SessionStatusReminderNotificationRuntime::new()) .manage(core::window::tray::TrayStatusRuntime::new()) + .manage(core::app_use::AppUseRuntime::new()) .manage(BuiltInProcessExecutionRegistry::new()) .manage(McpClientManager::new()) .manage(core::updater::AppUpdaterState::default()) diff --git a/apps/desktop/src-tauri/src/testing/mod.rs b/apps/desktop/src-tauri/src/testing/mod.rs index a8bb3f28..491fda7d 100644 --- a/apps/desktop/src-tauri/src/testing/mod.rs +++ b/apps/desktop/src-tauri/src/testing/mod.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use tauri::{ test::{mock_builder, MockRuntime}, @@ -8,6 +8,7 @@ use tauri::{ use crate::{ commands, core::{ + app_use::AppUseRuntime, database::DatabaseRuntime, updater::AppUpdaterState, window::{ @@ -30,9 +31,46 @@ pub fn test_builder() -> Builder { .manage(SearchSurfaceRuntime::new()) .manage(SessionStatusReminderNotificationRuntime::for_tests()) .manage(TrayStatusRuntime::new()) + .manage(AppUseRuntime::new()) .manage(AppUpdaterState::default()) } +pub fn configure_app_use_wps_owned_root_for_tests(root: &Path) -> Result<(), String> { + std::env::set_var("TOUCHAI_APP_USE_WPS_OWNED_ROOT", root); + std::env::set_var("TOUCHAI_APP_USE_OWNED_SECRET", "app-use-owned-test-secret"); + std::fs::create_dir_all(root) + .map_err(|error| format!("Failed to create WPS App Use owned root: {error}")) +} + +pub fn configure_app_use_office_owned_root_for_tests(root: &Path) -> Result<(), String> { + std::env::set_var("TOUCHAI_APP_USE_OFFICE_OWNED_ROOT", root); + std::env::set_var("TOUCHAI_APP_USE_OWNED_SECRET", "app-use-owned-test-secret"); + std::fs::create_dir_all(root) + .map_err(|error| format!("Failed to create Office App Use owned root: {error}")) +} + +pub fn mark_app_use_wps_owned_target_for_tests( + path: &Path, + app_label: &str, +) -> Result { + crate::core::app_use::wps::mark_owned_wps_target( + path, + app_label, + "TouchAI App Use integration test", + ) +} + +pub fn mark_app_use_office_owned_target_for_tests( + path: &Path, + app_label: &str, +) -> Result { + crate::core::app_use::office::mark_owned_office_target( + path, + app_label, + "TouchAI App Use integration test", + ) +} + pub fn attach_test_database_runtime( builder: Builder, database_root: &Path, diff --git a/apps/desktop/src-tauri/tests/app_use_commands.rs b/apps/desktop/src-tauri/tests/app_use_commands.rs new file mode 100644 index 00000000..c29f2b28 --- /dev/null +++ b/apps/desktop/src-tauri/tests/app_use_commands.rs @@ -0,0 +1,297 @@ +mod common; + +use common::{build_test_app, invoke_command_ok, TestAppOptions}; +use serde_json::{json, Value}; +use std::{path::Path, sync::Mutex}; +use tempfile::TempDir; +use touchai_lib::testing; + +static APP_USE_OWNED_ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn app_use_config() -> Value { + json!({ + "mode": "read_only", + "adapters": { + "office_word": false, + "office_excel": false, + "office_powerpoint": false, + "wps_writer": false, + "wps_spreadsheet": false, + "wps_presentation": false, + "photoshop": false, + "illustrator": false + }, + "mutatingApprovalMode": "always", + "readScope": "active", + "allowRawAutomation": false, + "timeoutMs": 15000, + "maxOutputChars": 12000 + }) +} + +fn interactive_office_config() -> Value { + let mut config = app_use_config(); + config["mode"] = json!("interactive"); + config["adapters"]["office_word"] = json!(true); + config +} + +fn prepare_owned_wps_test_root() -> TempDir { + let root = TempDir::new().expect("owned WPS temp root"); + testing::configure_app_use_wps_owned_root_for_tests(root.path()).expect("owned WPS root"); + root +} + +fn write_owned_wps_test_file(path: &Path, app_label: &str) { + std::fs::create_dir_all(path.parent().expect("owned file parent")).expect("owned root"); + std::fs::write(path, b"owned").expect("owned file"); + testing::mark_app_use_wps_owned_target_for_tests(path, app_label).expect("owned marker"); +} + +fn remove_owned_wps_test_file(path: &Path) { + let _ = std::fs::remove_file(path); +} + +#[test] +fn app_use_session_reports_first_batch_adapters() { + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let response: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_session", + json!({ + "request": { + "executionId": "app-use-test-1", + "operation": "discover", + "description": "discover supported applications", + "config": app_use_config() + } + }), + ); + + assert_eq!(response["ok"], true); + assert_eq!(response["operation"], "discover"); + let adapters = response["adapters"].as_array().expect("adapter list"); + assert_eq!(adapters.len(), 8); + assert!(adapters.iter().any(|adapter| adapter["id"] == "wps_writer")); +} + +#[test] +fn app_use_session_create_owned_target_returns_signed_target() { + let _guard = APP_USE_OWNED_ENV_LOCK + .lock() + .expect("app use owned env lock"); + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + let owned_root = prepare_owned_wps_test_root(); + + let response: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_session", + json!({ + "request": { + "executionId": "app-use-create-target-1", + "operation": "create_owned_target", + "description": "create an owned WPS spreadsheet target", + "adapterId": "wps_spreadsheet", + "targetKind": "spreadsheet", + "config": { + "mode": "interactive", + "adapters": { + "office_word": false, + "office_excel": false, + "office_powerpoint": false, + "wps_writer": false, + "wps_spreadsheet": true, + "wps_presentation": false, + "photoshop": false, + "illustrator": false + }, + "mutatingApprovalMode": "always", + "readScope": "active", + "allowRawAutomation": false, + "timeoutMs": 15000, + "maxOutputChars": 12000 + } + } + }), + ); + + assert_eq!(response["ok"], true); + assert_eq!(response["adapterId"], "wps_spreadsheet"); + assert_eq!(response["targetKind"], "spreadsheet"); + let target = response["target"].as_str().expect("target path"); + assert!(target.ends_with(".xlsx")); + let canonical_owned_root = owned_root + .path() + .canonicalize() + .expect("canonical owned root"); + assert!( + Path::new(target).starts_with(&canonical_owned_root), + "target {target} should live under {}", + canonical_owned_root.display() + ); + let marker_path = Path::new(target).with_file_name(format!( + "{}.touchai-owned.json", + Path::new(target) + .file_name() + .and_then(|value| value.to_str()) + .expect("target filename") + )); + let marker = std::fs::read_to_string(&marker_path).expect("owned marker"); + assert!(marker.contains("\"signature\"")); +} + +#[test] +fn app_use_observe_rejects_disabled_adapter() { + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let response: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_observe", + json!({ + "request": { + "executionId": "app-use-test-2", + "adapterId": "wps_writer", + "scope": "selection", + "description": "read current selection", + "maxOutputChars": 12000, + "config": app_use_config() + } + }), + ); + + assert_eq!(response["ok"], false); + assert_eq!(response["adapterId"], "wps_writer"); + assert_eq!(response["scope"], "selection"); + assert!(response["content"] + .as_str() + .expect("error content") + .contains("disabled")); +} + +#[test] +fn app_use_act_rejects_read_only_mode() { + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let response: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "app-use-test-3", + "adapterId": "wps_writer", + "action": "replace_document_text", + "description": "replace current selection", + "parameters": { "text": "hello" }, + "config": app_use_config() + } + }), + ); + + assert_eq!(response["ok"], false); + assert_eq!(response["changed"], false); + assert!(response["receipt"] + .as_str() + .expect("receipt") + .contains("read-only")); +} + +#[test] +fn app_use_act_rejects_direct_native_call_without_approval() { + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let response: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "app-use-test-4", + "adapterId": "office_word", + "action": "replace_document_text", + "description": "direct native call should not mutate", + "parameters": { "text": "hello" }, + "approval": { + "callId": "app-use-test-4", + "adapterId": "office_word", + "action": "replace_document_text", + "approved": true + }, + "config": interactive_office_config() + } + }), + ); + + assert_eq!(response["ok"], false); + assert_eq!(response["changed"], false); + assert_eq!(response["metadata"]["reason"], "approval_required"); +} + +#[test] +fn app_use_authorize_act_refuses_unimplemented_adapter() { + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "app-use-test-5", + "adapterId": "office_word", + "action": "replace_document_text", + "targetId": "target-1", + "parameters": { "text": "hello" }, + "config": interactive_office_config() + } + }), + ); + + assert_eq!(authorization["permit"], Value::Null); + assert_eq!(authorization["expiresInMs"], 0); +} + +#[test] +fn app_use_authorize_act_refuses_unimplemented_writer_insert_text() { + let _guard = APP_USE_OWNED_ENV_LOCK + .lock() + .expect("app use owned env lock"); + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + let owned_root = prepare_owned_wps_test_root(); + let target_path = owned_root.path().join("command-insert-text-owned.docx"); + write_owned_wps_test_file(&target_path, "WPS Writer"); + + let authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "app-use-test-6", + "adapterId": "wps_writer", + "action": "insert_text", + "targetId": target_path.to_string_lossy(), + "parameters": { "text": "hello" }, + "config": { + "mode": "interactive", + "adapters": { + "office_word": false, + "office_excel": false, + "office_powerpoint": false, + "wps_writer": true, + "wps_spreadsheet": false, + "wps_presentation": false, + "photoshop": false, + "illustrator": false + }, + "mutatingApprovalMode": "always", + "readScope": "active", + "allowRawAutomation": false, + "timeoutMs": 15000, + "maxOutputChars": 12000 + } + } + }), + ); + + assert_eq!(authorization["permit"], Value::Null); + assert_eq!(authorization["expiresInMs"], 0); + remove_owned_wps_test_file(&target_path); +} diff --git a/apps/desktop/src-tauri/tests/app_use_wps_smoke.rs b/apps/desktop/src-tauri/tests/app_use_wps_smoke.rs new file mode 100644 index 00000000..9fd29124 --- /dev/null +++ b/apps/desktop/src-tauri/tests/app_use_wps_smoke.rs @@ -0,0 +1,790 @@ +mod common; + +use base64::Engine as _; +use common::{build_test_app, invoke_command_ok, TestAppOptions}; +use serde_json::{json, Value}; +use std::{ + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tempfile::TempDir; +use touchai_lib::testing; + +fn app_use_config(enabled_adapter: &str) -> Value { + json!({ + "mode": "interactive", + "adapters": { + "office_word": false, + "office_excel": false, + "office_powerpoint": false, + "wps_writer": enabled_adapter == "wps_writer", + "wps_spreadsheet": enabled_adapter == "wps_spreadsheet", + "wps_presentation": enabled_adapter == "wps_presentation", + "photoshop": false, + "illustrator": false + }, + "mutatingApprovalMode": "always", + "readScope": "active", + "allowRawAutomation": false, + "timeoutMs": 30000, + "maxOutputChars": 12000 + }) +} + +fn wps_writer_config() -> Value { + app_use_config("wps_writer") +} + +fn wps_spreadsheet_config() -> Value { + app_use_config("wps_spreadsheet") +} + +fn wps_presentation_config() -> Value { + app_use_config("wps_presentation") +} + +struct OwnedWpsSmokeRoot { + _root: TempDir, + path: PathBuf, +} + +impl OwnedWpsSmokeRoot { + fn new() -> Result { + let root = + TempDir::new().map_err(|error| format!("Failed to create WPS smoke root: {error}"))?; + testing::configure_app_use_wps_owned_root_for_tests(root.path())?; + Ok(Self { + path: root.path().to_path_buf(), + _root: root, + }) + } +} + +fn mark_owned_wps_target(path: String, app_label: &str) -> Result { + let canonical_path = PathBuf::from(&path) + .canonicalize() + .map_err(|error| format!("Failed to canonicalize owned WPS target: {error}"))?; + testing::mark_app_use_wps_owned_target_for_tests(&canonical_path, app_label)?; + Ok(canonical_path.to_string_lossy().to_string()) +} + +fn powershell_executable() -> PathBuf { + let windows_root = std::env::var_os("WINDIR") + .or_else(|| std::env::var_os("SystemRoot")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + let wow64_powershell = windows_root + .join("SysWOW64") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + + if wow64_powershell.exists() { + return wow64_powershell; + } + + PathBuf::from("powershell.exe") +} + +fn run_powershell(script: &str) -> Result { + let shell_path = powershell_executable(); + let mut child = Command::new(&shell_path) + .args([ + "-Sta", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "Failed to run PowerShell smoke helper ({}): {error}", + shell_path.display() + ) + })?; + + let timeout = Duration::from_secs(45); + let started_at = Instant::now(); + loop { + match child + .try_wait() + .map_err(|error| format!("Failed to wait for PowerShell smoke helper: {error}"))? + { + Some(_) => { + let output = child + .wait_with_output() + .map_err(|error| format!("Failed to collect PowerShell output: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if output.status.success() { + return Ok(stdout); + } else if stderr.is_empty() { + return Err(stdout); + } else { + return Err(stderr); + } + } + None if started_at.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "PowerShell smoke helper timed out after {}s while waiting for KWPS.Application.", + timeout.as_secs() + )); + } + None => thread::sleep(Duration::from_millis(50)), + } + } +} + +fn create_owned_wps_document(root: &Path, sentinel: &str) -> Result { + let encoded_sentinel = base64::engine::general_purpose::STANDARD.encode(sentinel.as_bytes()); + let encoded_root = + base64::engine::general_purpose::STANDARD.encode(root.to_string_lossy().as_bytes()); + let script = format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 30; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 200 + }} + }} + throw $lastError +}} +$sentinel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_sentinel}')) +$root = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_root}')) +New-Item -ItemType Directory -Force -Path $root | Out-Null +$path = Join-Path $root ($sentinel + '.docx') +$app = Invoke-WithComRetry {{ New-Object -ComObject KWPS.Application }} +$app.Visible = $true +$doc = Invoke-WithComRetry {{ $app.Documents.Add() }} +Invoke-WithComRetry {{ $app.Selection.TypeText($sentinel) }} | Out-Null +Invoke-WithComRetry {{ $doc.SaveAs($path) }} | Out-Null +Invoke-WithComRetry {{ $doc.Close([ref]$false) }} | Out-Null +$path +"# + ); + + mark_owned_wps_target(run_powershell(&script)?, "WPS Writer") +} + +fn create_owned_wps_workbook(root: &Path, sentinel: &str) -> Result { + let encoded_sentinel = base64::engine::general_purpose::STANDARD.encode(sentinel.as_bytes()); + let encoded_root = + base64::engine::general_purpose::STANDARD.encode(root.to_string_lossy().as_bytes()); + let script = format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 30; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 200 + }} + }} + throw $lastError +}} +$sentinel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_sentinel}')) +$root = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_root}')) +New-Item -ItemType Directory -Force -Path $root | Out-Null +$path = Join-Path $root ($sentinel + '.xlsx') +$app = Invoke-WithComRetry {{ New-Object -ComObject KET.Application }} +$app.Visible = $true +$workbook = Invoke-WithComRetry {{ $app.Workbooks.Add() }} +$sheet = Invoke-WithComRetry {{ $app.ActiveSheet }} +Invoke-WithComRetry {{ $sheet.Range('A1').Value2 = $sentinel }} | Out-Null +Invoke-WithComRetry {{ $workbook.SaveAs($path) }} | Out-Null +Invoke-WithComRetry {{ $workbook.Close($false) }} | Out-Null +$path +"# + ); + + mark_owned_wps_target(run_powershell(&script)?, "WPS Spreadsheet") +} + +fn create_owned_wps_presentation(root: &Path, sentinel: &str) -> Result { + let encoded_sentinel = base64::engine::general_purpose::STANDARD.encode(sentinel.as_bytes()); + let encoded_root = + base64::engine::general_purpose::STANDARD.encode(root.to_string_lossy().as_bytes()); + let script = format!( + r#" +$ErrorActionPreference = 'Stop' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 30; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 200 + }} + }} + throw $lastError +}} +$sentinel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_sentinel}')) +$root = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_root}')) +New-Item -ItemType Directory -Force -Path $root | Out-Null +$path = Join-Path $root ($sentinel + '.pptx') +$app = Invoke-WithComRetry {{ New-Object -ComObject KWPP.Application }} +$app.Visible = $true +$presentation = Invoke-WithComRetry {{ $app.Presentations.Add() }} +$slide = Invoke-WithComRetry {{ $presentation.Slides.Add(1, 12) }} +$shape = Invoke-WithComRetry {{ $slide.Shapes.AddTextbox(1, 48, 48, 600, 120) }} +Invoke-WithComRetry {{ $shape.TextFrame.TextRange.Text = $sentinel }} | Out-Null +Invoke-WithComRetry {{ $presentation.SaveAs($path) }} | Out-Null +Invoke-WithComRetry {{ $presentation.Close() }} | Out-Null +$path +"# + ); + + mark_owned_wps_target(run_powershell(&script)?, "WPS Presentation") +} + +fn close_owned_wps_target(path: &str, prog_id: &str, collection_name: &str) -> Result<(), String> { + let encoded_path = base64::engine::general_purpose::STANDARD.encode(path.as_bytes()); + let script = format!( + r#" +$ErrorActionPreference = 'Continue' +function Invoke-WithComRetry([scriptblock]$Operation) {{ + $lastError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) {{ + try {{ + return & $Operation + }} catch {{ + $lastError = $_ + Start-Sleep -Milliseconds 150 + }} + }} + throw $lastError +}} +$path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded_path}')) +try {{ + $app = Invoke-WithComRetry {{ [Runtime.InteropServices.Marshal]::GetActiveObject('{prog_id}') }} + $targets = $app.{collection_name} + for ($i = $targets.Count; $i -ge 1; $i--) {{ + $target = $targets.Item($i) + $candidatePath = '' + try {{ $candidatePath = [string]$target.FullName }} catch {{}} + if ($candidatePath -eq $path) {{ + try {{ + Invoke-WithComRetry {{ $target.Close([ref]$false) }} | Out-Null + }} catch {{ + try {{ + Invoke-WithComRetry {{ $target.Close($false) }} | Out-Null + }} catch {{ + Invoke-WithComRetry {{ $target.Close() }} | Out-Null + }} + }} + }} + }} +}} catch {{}} +"# + ); + run_powershell(&script).map(|_| ()) +} + +fn close_owned_wps_document(path: &str) -> Result<(), String> { + close_owned_wps_target(path, "KWPS.Application", "Documents") +} + +fn close_owned_wps_workbook(path: &str) -> Result<(), String> { + close_owned_wps_target(path, "KET.Application", "Workbooks") +} + +fn close_owned_wps_presentation(path: &str) -> Result<(), String> { + close_owned_wps_target(path, "KWPP.Application", "Presentations") +} + +struct OwnedWpsDocument { + path: String, +} + +impl Drop for OwnedWpsDocument { + fn drop(&mut self) { + let _ = close_owned_wps_document(&self.path); + } +} + +struct OwnedWpsWorkbook { + path: String, +} + +impl Drop for OwnedWpsWorkbook { + fn drop(&mut self) { + let _ = close_owned_wps_workbook(&self.path); + } +} + +struct OwnedWpsPresentation { + path: String, +} + +impl Drop for OwnedWpsPresentation { + fn drop(&mut self) { + let _ = close_owned_wps_presentation(&self.path); + } +} + +#[test] +#[ignore = "Runs real WPS Writer COM automation and creates an owned temp document."] +fn app_use_wps_writer_owned_document_smoke() { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_millis(); + let sentinel = format!("touchai-app-use-smoke-{millis}"); + let smoke_root = OwnedWpsSmokeRoot::new().expect("owned WPS smoke root"); + let path = create_owned_wps_document(&smoke_root.path, &sentinel).expect("owned WPS document"); + let _owned_document = OwnedWpsDocument { path: path.clone() }; + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let session: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_session", + json!({ + "request": { + "executionId": "wps-smoke-session", + "operation": "discover", + "description": "discover WPS Writer for App Use smoke", + "config": wps_writer_config() + } + }), + ); + assert_eq!(session["ok"], true); + assert!(session["adapters"] + .as_array() + .expect("adapters") + .iter() + .any(|adapter| adapter["id"] == "wps_writer" && adapter["enabled"] == true)); + + let inserted = format!("{sentinel}-inserted"); + let authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "wps-smoke-act", + "adapterId": "wps_writer", + "action": "replace_document_text", + "targetId": path, + "parameters": { "text": inserted }, + "config": wps_writer_config() + } + }), + ); + let permit = authorization["permit"].clone(); + assert!(permit["token"] + .as_str() + .is_some_and(|token| !token.is_empty())); + + let act: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "wps-smoke-act", + "adapterId": "wps_writer", + "action": "replace_document_text", + "description": "write sentinel text to owned WPS document", + "targetId": path, + "parameters": { "text": inserted }, + "permit": permit, + "config": wps_writer_config() + } + }), + ); + eprintln!("app_use_act response: {act}"); + assert_eq!(act["ok"], true); + assert_eq!(act["changed"], true); + + let observed: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_observe", + json!({ + "request": { + "executionId": "wps-smoke-observe", + "adapterId": "wps_writer", + "scope": "active_document", + "description": "verify sentinel text from owned WPS document", + "targetId": path, + "maxOutputChars": 12000, + "config": wps_writer_config() + } + }), + ); + eprintln!("app_use_observe response: {observed}"); + assert_eq!(observed["ok"], true); + assert!(observed["content"] + .as_str() + .expect("content") + .contains(&inserted)); + + let format_authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "wps-smoke-format", + "adapterId": "wps_writer", + "action": "format_document_text", + "targetId": path, + "parameters": { "bold": true, "fontSize": 18 }, + "config": wps_writer_config() + } + }), + ); + let format_permit = format_authorization["permit"].clone(); + assert!(format_permit["token"] + .as_str() + .is_some_and(|token| !token.is_empty())); + + let formatted: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "wps-smoke-format", + "adapterId": "wps_writer", + "action": "format_document_text", + "description": "format sentinel text in owned WPS document", + "targetId": path, + "parameters": { "bold": true, "fontSize": 18 }, + "permit": format_permit, + "config": wps_writer_config() + } + }), + ); + eprintln!("app_use format_document_text response: {formatted}"); + assert_eq!(formatted["ok"], true); + assert_eq!(formatted["changed"], true); + + assert!(std::path::Path::new(&path).exists()); +} + +#[test] +#[ignore = "Runs real WPS Spreadsheet COM automation and creates an owned temp workbook."] +fn app_use_wps_spreadsheet_create_owned_target_smoke() { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_millis(); + let sentinel = format!("touchai-app-use-sheet-created-smoke-{millis}"); + let smoke_root = OwnedWpsSmokeRoot::new().expect("owned WPS smoke root"); + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let created: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_session", + json!({ + "request": { + "executionId": "wps-sheet-create-target-smoke", + "operation": "create_owned_target", + "description": "create a WPS Spreadsheet target for App Use smoke", + "adapterId": "wps_spreadsheet", + "targetKind": "spreadsheet", + "config": wps_spreadsheet_config() + } + }), + ); + eprintln!("app_use create_owned_target response: {created}"); + assert_eq!(created["ok"], true); + assert_eq!(created["adapterId"], "wps_spreadsheet"); + assert_eq!(created["targetKind"], "spreadsheet"); + let path = created["target"].as_str().expect("target").to_string(); + assert!(path.ends_with(".xlsx")); + let canonical_smoke_root = smoke_root + .path + .canonicalize() + .expect("canonical smoke root"); + assert!( + Path::new(&path).starts_with(&canonical_smoke_root), + "target {path} should live under {}", + canonical_smoke_root.display() + ); + let _owned_workbook = OwnedWpsWorkbook { path: path.clone() }; + + let authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "wps-sheet-create-target-act", + "adapterId": "wps_spreadsheet", + "action": "write_cells", + "targetId": path, + "parameters": { + "range": "A1:B2", + "values": [[sentinel, "42"], ["status", "ok"]] + }, + "config": wps_spreadsheet_config() + } + }), + ); + let permit = authorization["permit"].clone(); + assert!(permit["token"] + .as_str() + .is_some_and(|token| !token.is_empty())); + + let act: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "wps-sheet-create-target-act", + "adapterId": "wps_spreadsheet", + "action": "write_cells", + "description": "write sentinel cells to an App Use-created WPS workbook", + "targetId": path, + "parameters": { + "range": "A1:B2", + "values": [[sentinel, "42"], ["status", "ok"]] + }, + "permit": permit, + "config": wps_spreadsheet_config() + } + }), + ); + eprintln!("app_use created workbook write_cells response: {act}"); + assert_eq!(act["ok"], true); + assert_eq!(act["changed"], true); + + let observed: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_observe", + json!({ + "request": { + "executionId": "wps-sheet-create-target-observe", + "adapterId": "wps_spreadsheet", + "scope": "worksheet", + "description": "verify sentinel cells from an App Use-created WPS workbook", + "targetId": path, + "maxOutputChars": 12000, + "config": wps_spreadsheet_config() + } + }), + ); + eprintln!("app_use created workbook observe response: {observed}"); + assert_eq!(observed["ok"], true); + let observed_text = observed["content"].as_str().expect("content"); + assert!(observed_text.contains(&sentinel)); + assert!(observed_text.contains("42")); + assert!(observed_text.contains("ok")); + + assert!(Path::new(&path).exists()); +} + +#[test] +#[ignore = "Runs real WPS Spreadsheet COM automation and creates an owned temp workbook."] +fn app_use_wps_spreadsheet_owned_workbook_smoke() { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_millis(); + let sentinel = format!("touchai-app-use-sheet-smoke-{millis}"); + let smoke_root = OwnedWpsSmokeRoot::new().expect("owned WPS smoke root"); + let path = create_owned_wps_workbook(&smoke_root.path, &sentinel).expect("owned WPS workbook"); + let _owned_workbook = OwnedWpsWorkbook { path: path.clone() }; + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let session: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_session", + json!({ + "request": { + "executionId": "wps-sheet-smoke-session", + "operation": "discover", + "description": "discover WPS Spreadsheet for App Use smoke", + "config": wps_spreadsheet_config() + } + }), + ); + assert_eq!(session["ok"], true); + assert!(session["adapters"] + .as_array() + .expect("adapters") + .iter() + .any(|adapter| adapter["id"] == "wps_spreadsheet" && adapter["enabled"] == true)); + + let authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "wps-sheet-smoke-act", + "adapterId": "wps_spreadsheet", + "action": "write_cells", + "targetId": path, + "parameters": { + "range": "A1:B2", + "values": [[sentinel, "42"], ["status", "ok"]] + }, + "config": wps_spreadsheet_config() + } + }), + ); + let permit = authorization["permit"].clone(); + assert!(permit["token"] + .as_str() + .is_some_and(|token| !token.is_empty())); + + let act: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "wps-sheet-smoke-act", + "adapterId": "wps_spreadsheet", + "action": "write_cells", + "description": "write sentinel cells to owned WPS workbook", + "targetId": path, + "parameters": { + "range": "A1:B2", + "values": [[sentinel, "42"], ["status", "ok"]] + }, + "permit": permit, + "config": wps_spreadsheet_config() + } + }), + ); + eprintln!("app_use write_cells response: {act}"); + assert_eq!(act["ok"], true); + assert_eq!(act["changed"], true); + + let observed: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_observe", + json!({ + "request": { + "executionId": "wps-sheet-smoke-observe", + "adapterId": "wps_spreadsheet", + "scope": "worksheet", + "description": "verify sentinel cells from owned WPS workbook", + "targetId": path, + "maxOutputChars": 12000, + "config": wps_spreadsheet_config() + } + }), + ); + eprintln!("app_use spreadsheet observe response: {observed}"); + assert_eq!(observed["ok"], true); + let observed_text = observed["content"].as_str().expect("content"); + assert!(observed_text.contains(&sentinel)); + assert!(observed_text.contains("42")); + assert!(observed_text.contains("ok")); + + assert!(std::path::Path::new(&path).exists()); +} + +#[test] +#[ignore = "Runs real WPS Presentation COM automation and creates an owned temp deck."] +fn app_use_wps_presentation_owned_deck_smoke() { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_millis(); + let sentinel = format!("touchai-app-use-deck-smoke-{millis}"); + let smoke_root = OwnedWpsSmokeRoot::new().expect("owned WPS smoke root"); + let path = + create_owned_wps_presentation(&smoke_root.path, &sentinel).expect("owned WPS presentation"); + let _owned_presentation = OwnedWpsPresentation { path: path.clone() }; + let test_app = build_test_app(TestAppOptions::default()).expect("test app"); + + let session: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_session", + json!({ + "request": { + "executionId": "wps-deck-smoke-session", + "operation": "discover", + "description": "discover WPS Presentation for App Use smoke", + "config": wps_presentation_config() + } + }), + ); + assert_eq!(session["ok"], true); + assert!(session["adapters"] + .as_array() + .expect("adapters") + .iter() + .any(|adapter| adapter["id"] == "wps_presentation" && adapter["enabled"] == true)); + + let inserted = format!("{sentinel}-inserted"); + let authorization: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_authorize_act", + json!({ + "request": { + "executionId": "wps-deck-smoke-act", + "adapterId": "wps_presentation", + "action": "add_slide_text", + "targetId": path, + "parameters": { "text": inserted, "slideIndex": 1 }, + "config": wps_presentation_config() + } + }), + ); + let permit = authorization["permit"].clone(); + assert!(permit["token"] + .as_str() + .is_some_and(|token| !token.is_empty())); + + let act: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_act", + json!({ + "request": { + "executionId": "wps-deck-smoke-act", + "adapterId": "wps_presentation", + "action": "add_slide_text", + "description": "add sentinel text to owned WPS presentation", + "targetId": path, + "parameters": { "text": inserted, "slideIndex": 1 }, + "permit": permit, + "config": wps_presentation_config() + } + }), + ); + eprintln!("app_use add_slide_text response: {act}"); + assert_eq!(act["ok"], true); + assert_eq!(act["changed"], true); + + let observed: Value = invoke_command_ok( + &test_app.main_webview, + "app_use_observe", + json!({ + "request": { + "executionId": "wps-deck-smoke-observe", + "adapterId": "wps_presentation", + "scope": "slide", + "description": "verify sentinel text from owned WPS presentation", + "targetId": path, + "maxOutputChars": 12000, + "config": wps_presentation_config() + } + }), + ); + eprintln!("app_use presentation observe response: {observed}"); + assert_eq!(observed["ok"], true); + assert!(observed["content"] + .as_str() + .expect("content") + .contains(&inserted)); + + assert!(std::path::Path::new(&path).exists()); +} diff --git a/apps/desktop/src/database/artifacts/runtime/seed.sql b/apps/desktop/src/database/artifacts/runtime/seed.sql index 3fe89d1a..12afda29 100644 --- a/apps/desktop/src/database/artifacts/runtime/seed.sql +++ b/apps/desktop/src/database/artifacts/runtime/seed.sql @@ -158,3 +158,21 @@ INSERT INTO built_in_tools ( ) SELECT 'ask_user_question', 'AskUserQuestion', '向用户提出结构化问题', 1, 'low', NULL WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'ask_user_question'); + +INSERT INTO built_in_tools ( + tool_id, display_name, description, enabled, risk_level, config_json +) +SELECT 'app_session', 'AppSession', '软件控制:发现本地应用与能力', 0, 'high', '{"mode":"read_only","adapters":{"office_word":false,"office_excel":false,"office_powerpoint":false,"wps_writer":false,"wps_spreadsheet":false,"wps_presentation":false,"photoshop":false,"illustrator":false},"mutatingApprovalMode":"always","readScope":"active","allowRawAutomation":false,"timeoutMs":15000,"maxOutputChars":12000}' +WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'app_session'); + +INSERT INTO built_in_tools ( + tool_id, display_name, description, enabled, risk_level, config_json +) +SELECT 'app_observe', 'AppObserve', '软件控制:读取本地应用上下文', 0, 'high', '{"mode":"read_only","adapters":{"office_word":false,"office_excel":false,"office_powerpoint":false,"wps_writer":false,"wps_spreadsheet":false,"wps_presentation":false,"photoshop":false,"illustrator":false},"mutatingApprovalMode":"always","readScope":"active","allowRawAutomation":false,"timeoutMs":15000,"maxOutputChars":12000}' +WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'app_observe'); + +INSERT INTO built_in_tools ( + tool_id, display_name, description, enabled, risk_level, config_json +) +SELECT 'app_act', 'AppAct', '软件控制:执行一个受控应用动作', 0, 'high', '{"mode":"read_only","adapters":{"office_word":false,"office_excel":false,"office_powerpoint":false,"wps_writer":false,"wps_spreadsheet":false,"wps_presentation":false,"photoshop":false,"illustrator":false},"mutatingApprovalMode":"always","readScope":"active","allowRawAutomation":false,"timeoutMs":15000,"maxOutputChars":12000}' +WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'app_act'); diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index bedf2d0c..5bdfb512 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -153,6 +153,7 @@ const zhCNMessages = { 'settings.loading.general': '正在加载常规设置...', 'settings.loading.aiServices': '正在加载大模型服务设置...', 'settings.loading.builtInTools': '正在加载内置工具...', + 'settings.loading.appUse': '正在加载软件控制设置...', 'settings.loading.mcpTools': '正在加载 MCP 工具...', 'settings.loading.dataManagement': '正在加载数据管理...', 'settings.loading.about': '正在加载关于页面...', @@ -162,6 +163,8 @@ const zhCNMessages = { 'settings.nav.aiServices.description': 'Provider、模型、默认模型和密钥', 'settings.nav.builtInTools.label': '内置工具', 'settings.nav.builtInTools.description': '应用自带工具的启用、配置和日志', + 'settings.nav.appUse.label': '软件控制', + 'settings.nav.appUse.description': '结构化本地应用适配器、审批与限制', 'settings.nav.mcpTools.label': 'MCP 工具', 'settings.nav.mcpTools.description': '外部 MCP 服务器与工具调用日志', 'settings.nav.dataManagement.label': '数据管理', @@ -359,6 +362,54 @@ const zhCNMessages = { 'settings.builtInTools.summary.showWidget': '聊天内联可交互可视化', 'settings.builtInTools.summary.visualizeReadMe': '读取 ShowWidget 规范', 'settings.builtInTools.summary.fallback': '暂无描述', + 'settings.appUse.title': '软件控制', + 'settings.appUse.tabs.settings': '设置', + 'settings.appUse.tabs.logs': '调用日志', + 'settings.appUse.loadFailed': '加载软件控制配置失败', + 'settings.appUse.saveFailed': '保存软件控制配置失败', + 'settings.appUse.emptyTools': '尚未发现软件控制工具', + 'settings.appUse.emptyToolsDescription': '内置工具种子同步完成后会自动展示在这里', + 'settings.appUse.access.title': '访问', + 'settings.appUse.access.description': + '统一控制软件控制工具是否提供给模型,以及是否允许执行写入动作。', + 'settings.appUse.enabled': '启用软件控制工具', + 'settings.appUse.mode.title': '运行模式', + 'settings.appUse.mode.readOnly': '只读', + 'settings.appUse.mode.interactive': '交互', + 'settings.appUse.adapters.title': '应用适配器', + 'settings.appUse.adapters.description': '只启用你希望模型读取或控制的本地应用。', + 'settings.appUse.safety.title': '安全限制', + 'settings.appUse.safety.description': + '软件控制只使用结构化适配器,不接受原始脚本或原始自动化兜底。', + 'settings.appUse.safety.approvalMode': '写入动作审批', + 'settings.appUse.safety.alwaysApprove': '每次都需要确认', + 'settings.appUse.safety.readScope': '读取范围', + 'settings.appUse.safety.activeOnly': '仅当前活动目标', + 'settings.appUse.safety.timeoutMs': '超时上限(毫秒)', + 'settings.appUse.safety.maxOutputChars': '输出上限(字符)', + 'settings.appUse.advanced.title': '阶段规划', + 'settings.appUse.advanced.description': + '按阶段列出软件控制的当前能力和后续目标;P6 属于 Computer Use,不作为软件控制功能实现。', + 'settings.appUse.advanced.p0DefinitionFramework': 'P0:定义与框架', + 'settings.appUse.advanced.p1Settings': 'P1:独立设置系统', + 'settings.appUse.advanced.p2Discovery': 'P2:软件发现', + 'settings.appUse.advanced.p3StructuredObserve': 'P3:结构化读取', + 'settings.appUse.advanced.p4ApprovalGovernance': 'P4:审批治理', + 'settings.appUse.advanced.p5OfficeWpsActions': 'P5:Office/WPS 写动作', + 'settings.appUse.advanced.p6ComputerUseBoundary': 'P6:Computer Use 边界', + 'settings.appUse.advanced.p7AdvancedWorkflows': 'P7:高级工作流', + 'settings.appUse.advanced.p8AdapterExtension': 'P8:Adapter 扩展规范', + 'settings.appUse.advanced.current': '当前可用', + 'settings.appUse.advanced.planned': '规划中', + 'settings.appUse.advanced.outOfScope': '不属于软件控制', + 'settings.appUse.adapter.officeWord': 'Microsoft Word', + 'settings.appUse.adapter.officeExcel': 'Microsoft Excel', + 'settings.appUse.adapter.officePowerPoint': 'Microsoft PowerPoint', + 'settings.appUse.adapter.wpsWriter': 'WPS Writer', + 'settings.appUse.adapter.wpsSpreadsheet': 'WPS Spreadsheet', + 'settings.appUse.adapter.wpsPresentation': 'WPS Presentation', + 'settings.appUse.adapter.photoshop': 'Adobe Photoshop', + 'settings.appUse.adapter.illustrator': 'Adobe Illustrator', 'builtInTools.presentation.pendingApproval': '等待批准', 'builtInTools.presentation.process.executing': '正在处理', 'builtInTools.presentation.process.error': '处理失败', @@ -390,6 +441,14 @@ const zhCNMessages = { 'builtInTools.presentation.ask.executing': '正在询问', 'builtInTools.presentation.ask.error': '询问失败', 'builtInTools.presentation.ask.completed': '已询问', + 'builtInTools.appUse.approval.title': '软件控制确认', + 'builtInTools.appUse.approval.riskLabel': '高风险', + 'builtInTools.appUse.approval.reason': '软件控制动作可能修改本地应用或文档状态,需要用户确认。', + 'builtInTools.appUse.approval.targetLabel': '目标', + 'builtInTools.appUse.approval.previewLabel': '预览', + 'builtInTools.appUse.approval.commandLabel': '动作', + 'builtInTools.appUse.approval.approveLabel': '批准', + 'builtInTools.appUse.approval.rejectLabel': '拒绝', 'askUser.approval.header': '需要确认', 'askUser.approval.fallbackTitle': '需要确认', 'askUser.approval.approve': '批准', @@ -900,6 +959,7 @@ const enUSMessages: Record = { 'settings.loading.general': 'Loading general settings...', 'settings.loading.aiServices': 'Loading model service settings...', 'settings.loading.builtInTools': 'Loading built-in tools...', + 'settings.loading.appUse': 'Loading App Use settings...', 'settings.loading.mcpTools': 'Loading MCP tools...', 'settings.loading.dataManagement': 'Loading data management...', 'settings.loading.about': 'Loading about page...', @@ -909,6 +969,9 @@ const enUSMessages: Record = { 'settings.nav.aiServices.description': 'Providers, models, default model, and keys', 'settings.nav.builtInTools.label': 'Built-in tools', 'settings.nav.builtInTools.description': 'Built-in tools, configuration, and logs', + 'settings.nav.appUse.label': 'App Use', + 'settings.nav.appUse.description': + 'Structured local application adapters, approvals, and limits', 'settings.nav.mcpTools.label': 'MCP tools', 'settings.nav.mcpTools.description': 'External MCP servers and tool call logs', 'settings.nav.dataManagement.label': 'Data management', @@ -1123,6 +1186,56 @@ const enUSMessages: Record = { 'settings.builtInTools.summary.showWidget': 'Inline interactive visualization in chat', 'settings.builtInTools.summary.visualizeReadMe': 'Read the ShowWidget specification', 'settings.builtInTools.summary.fallback': 'No description', + 'settings.appUse.title': 'App Use', + 'settings.appUse.tabs.settings': 'Settings', + 'settings.appUse.tabs.logs': 'Call logs', + 'settings.appUse.loadFailed': 'Failed to load App Use settings', + 'settings.appUse.saveFailed': 'Failed to save App Use settings', + 'settings.appUse.emptyTools': 'No App Use tools found', + 'settings.appUse.emptyToolsDescription': + 'They will appear here after the built-in tool seed sync finishes', + 'settings.appUse.access.title': 'Access', + 'settings.appUse.access.description': + 'Control whether App Use tools are available to the model and whether write actions are allowed.', + 'settings.appUse.enabled': 'Enable App Use tools', + 'settings.appUse.mode.title': 'Mode', + 'settings.appUse.mode.readOnly': 'Read-only', + 'settings.appUse.mode.interactive': 'Interactive', + 'settings.appUse.adapters.title': 'Application adapters', + 'settings.appUse.adapters.description': + 'Enable only the local applications that the model should read or control.', + 'settings.appUse.safety.title': 'Safety limits', + 'settings.appUse.safety.description': + 'App Use only uses structured adapters and does not accept raw scripts or raw automation fallbacks.', + 'settings.appUse.safety.approvalMode': 'Write action approval', + 'settings.appUse.safety.alwaysApprove': 'Always ask', + 'settings.appUse.safety.readScope': 'Read scope', + 'settings.appUse.safety.activeOnly': 'Active target only', + 'settings.appUse.safety.timeoutMs': 'Timeout limit (ms)', + 'settings.appUse.safety.maxOutputChars': 'Output limit (characters)', + 'settings.appUse.advanced.title': 'Phase plan', + 'settings.appUse.advanced.description': + 'Lists App Use goals across current and later phases. P6 belongs to Computer Use and is not implemented as an App Use feature.', + 'settings.appUse.advanced.p0DefinitionFramework': 'P0: Definition and framework', + 'settings.appUse.advanced.p1Settings': 'P1: Dedicated settings system', + 'settings.appUse.advanced.p2Discovery': 'P2: App discovery', + 'settings.appUse.advanced.p3StructuredObserve': 'P3: Structured observation', + 'settings.appUse.advanced.p4ApprovalGovernance': 'P4: Approval governance', + 'settings.appUse.advanced.p5OfficeWpsActions': 'P5: Office/WPS write actions', + 'settings.appUse.advanced.p6ComputerUseBoundary': 'P6: Computer Use boundary', + 'settings.appUse.advanced.p7AdvancedWorkflows': 'P7: Advanced workflows', + 'settings.appUse.advanced.p8AdapterExtension': 'P8: Adapter extension spec', + 'settings.appUse.advanced.current': 'Current', + 'settings.appUse.advanced.planned': 'Planned', + 'settings.appUse.advanced.outOfScope': 'Out of scope', + 'settings.appUse.adapter.officeWord': 'Microsoft Word', + 'settings.appUse.adapter.officeExcel': 'Microsoft Excel', + 'settings.appUse.adapter.officePowerPoint': 'Microsoft PowerPoint', + 'settings.appUse.adapter.wpsWriter': 'WPS Writer', + 'settings.appUse.adapter.wpsSpreadsheet': 'WPS Spreadsheet', + 'settings.appUse.adapter.wpsPresentation': 'WPS Presentation', + 'settings.appUse.adapter.photoshop': 'Adobe Photoshop', + 'settings.appUse.adapter.illustrator': 'Adobe Illustrator', 'builtInTools.presentation.pendingApproval': 'Pending', 'builtInTools.presentation.process.executing': 'Processing', 'builtInTools.presentation.process.error': 'Processing failed', @@ -1154,6 +1267,15 @@ const enUSMessages: Record = { 'builtInTools.presentation.ask.executing': 'Asking', 'builtInTools.presentation.ask.error': 'Ask failed', 'builtInTools.presentation.ask.completed': 'Asked', + 'builtInTools.appUse.approval.title': 'App Use confirmation', + 'builtInTools.appUse.approval.riskLabel': 'High risk', + 'builtInTools.appUse.approval.reason': + 'App Use actions may modify local application or document state and require user confirmation.', + 'builtInTools.appUse.approval.targetLabel': 'Target', + 'builtInTools.appUse.approval.previewLabel': 'Preview', + 'builtInTools.appUse.approval.commandLabel': 'Action', + 'builtInTools.appUse.approval.approveLabel': 'Approve', + 'builtInTools.appUse.approval.rejectLabel': 'Reject', 'askUser.approval.header': 'Confirmation required', 'askUser.approval.fallbackTitle': 'Confirmation required', 'askUser.approval.approve': 'Approve', diff --git a/apps/desktop/src/services/BuiltInToolService/registry.ts b/apps/desktop/src/services/BuiltInToolService/registry.ts index efd4eb2d..6710b476 100644 --- a/apps/desktop/src/services/BuiltInToolService/registry.ts +++ b/apps/desktop/src/services/BuiltInToolService/registry.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 +import { builtInTools as appUseTools } from './tools/appUse'; import { builtInTools as askUserTools } from './tools/askUser'; import { builtInTools as bashTools } from './tools/bash'; import { builtInTools as fileSearchTools } from './tools/fileSearch'; @@ -54,6 +55,9 @@ class BuiltInToolRegistry { export const builtInToolRegistry = new BuiltInToolRegistry(); builtInToolRegistry.register(askUserTools); +// App Use is disabled by default in the runtime seed, but registering the descriptors +// keeps tool resolution deterministic once a user enables the group. +builtInToolRegistry.register(appUseTools); builtInToolRegistry.register(bashTools); builtInToolRegistry.register(fileSearchTools); builtInToolRegistry.register(readTools); diff --git a/apps/desktop/src/services/BuiltInToolService/tools/appUse/config.ts b/apps/desktop/src/services/BuiltInToolService/tools/appUse/config.ts new file mode 100644 index 00000000..ec2f0a9b --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/appUse/config.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026. 千诚. Licensed under GPL v3 + +import { parseToolConfigJson, z } from '../../utils/toolSchema'; +import { APP_USE_ADAPTER_IDS, type AppUseAdapterId } from './constants'; + +export type AppUseMode = 'read_only' | 'interactive'; +export type AppUseApprovalMode = 'always'; +export type AppUseReadScope = 'active'; + +export type AppUseAdapterConfig = Record; + +export interface AppUseToolConfig { + mode: AppUseMode; + adapters: AppUseAdapterConfig; + mutatingApprovalMode: AppUseApprovalMode; + readScope: AppUseReadScope; + allowRawAutomation: false; + timeoutMs: number; + maxOutputChars: number; +} + +export const DEFAULT_APP_USE_ADAPTER_CONFIG = Object.fromEntries( + APP_USE_ADAPTER_IDS.map((adapterId) => [adapterId, false]) +) as AppUseAdapterConfig; + +export const DEFAULT_APP_USE_TOOL_CONFIG: AppUseToolConfig = { + mode: 'read_only', + adapters: { ...DEFAULT_APP_USE_ADAPTER_CONFIG }, + mutatingApprovalMode: 'always', + readScope: 'active', + allowRawAutomation: false, + timeoutMs: 15000, + maxOutputChars: 12000, +}; + +const appUseToolConfigSchema = z + .object({ + mode: z.enum(['read_only', 'interactive']).optional().catch(undefined), + adapters: z.record(z.string(), z.boolean()).optional().catch(undefined), + mutatingApprovalMode: z.literal('always').optional().catch(undefined), + readScope: z.literal('active').optional().catch(undefined), + allowRawAutomation: z.boolean().optional().catch(undefined), + timeoutMs: z.number().int().min(1000).max(120000).optional().catch(undefined), + maxOutputChars: z.number().int().min(1000).max(50000).optional().catch(undefined), + }) + .transform((value): AppUseToolConfig => { + const adapters = { ...DEFAULT_APP_USE_ADAPTER_CONFIG }; + for (const adapterId of APP_USE_ADAPTER_IDS) { + adapters[adapterId] = value.adapters?.[adapterId] === true; + } + + return { + mode: value.mode ?? DEFAULT_APP_USE_TOOL_CONFIG.mode, + adapters, + mutatingApprovalMode: 'always', + readScope: 'active', + allowRawAutomation: false, + timeoutMs: value.timeoutMs ?? DEFAULT_APP_USE_TOOL_CONFIG.timeoutMs, + maxOutputChars: value.maxOutputChars ?? DEFAULT_APP_USE_TOOL_CONFIG.maxOutputChars, + }; + }); + +export function parseAppUseToolConfig(configJson: string | null): AppUseToolConfig { + return parseToolConfigJson(appUseToolConfigSchema, configJson, DEFAULT_APP_USE_TOOL_CONFIG); +} + +export function serializeAppUseToolConfig(config: AppUseToolConfig): string { + return JSON.stringify({ + mode: config.mode, + adapters: config.adapters, + mutatingApprovalMode: 'always', + readScope: 'active', + allowRawAutomation: false, + timeoutMs: config.timeoutMs, + maxOutputChars: config.maxOutputChars, + }); +} diff --git a/apps/desktop/src/services/BuiltInToolService/tools/appUse/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/appUse/constants.ts new file mode 100644 index 00000000..6f7547d8 --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/appUse/constants.ts @@ -0,0 +1,371 @@ +// Copyright (c) 2026. 千诚. Licensed under GPL v3 + +import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; + +import { + nonEmptyTrimmedStringSchema, + optionalIntegerInRangeSchema, + z, +} from '../../utils/toolSchema'; + +export const APP_SESSION_TOOL_ID = 'app_session'; +export const APP_OBSERVE_TOOL_ID = 'app_observe'; +export const APP_ACT_TOOL_ID = 'app_act'; + +export const APP_USE_ADAPTER_IDS = [ + 'office_word', + 'office_excel', + 'office_powerpoint', + 'wps_writer', + 'wps_spreadsheet', + 'wps_presentation', + 'photoshop', + 'illustrator', +] as const; + +export type AppUseAdapterId = (typeof APP_USE_ADAPTER_IDS)[number]; + +export const APP_USE_ACT_ADAPTER_IDS = [ + 'office_word', + 'office_excel', + 'office_powerpoint', + 'wps_writer', + 'wps_spreadsheet', + 'wps_presentation', +] as const; + +type AppUseActAdapterId = (typeof APP_USE_ACT_ADAPTER_IDS)[number]; + +export const APP_USE_SESSION_OPERATIONS = [ + 'status', + 'discover', + 'capabilities', + 'create_owned_target', +] as const; +export const APP_USE_OBSERVE_SCOPES = [ + 'active_document', + 'selection', + 'workbook', + 'worksheet', + 'presentation', + 'slide', + 'layers', + 'artboards', +] as const; + +export const APP_USE_ACT_ACTIONS = [ + 'replace_document_text', + 'write_cells', + 'add_slide_text', + 'format_document_text', +] as const; + +type AppUseActAction = (typeof APP_USE_ACT_ACTIONS)[number]; +type AppUseActArgs = { + adapterId: AppUseActAdapterId; + action: AppUseActAction; + description: string; + targetId: string; + parameters: Record; +}; + +export const appUseAdapterIdSchema = z.enum(APP_USE_ADAPTER_IDS); +const appUseActAdapterIdSchema = z.enum(APP_USE_ACT_ADAPTER_IDS); + +const APP_USE_ACT_ACTIONS_BY_ADAPTER = { + office_word: ['replace_document_text', 'format_document_text'], + office_excel: ['write_cells'], + office_powerpoint: ['add_slide_text'], + wps_writer: ['replace_document_text', 'format_document_text'], + wps_spreadsheet: ['write_cells'], + wps_presentation: ['add_slide_text'], +} as const satisfies Record; + +export const appUseSessionArgsSchema = z + .object({ + operation: z.enum(APP_USE_SESSION_OPERATIONS).default('discover'), + description: nonEmptyTrimmedStringSchema, + adapterId: appUseActAdapterIdSchema.optional(), + targetKind: z.enum(['document', 'spreadsheet', 'presentation']).optional(), + }) + .strict() + .superRefine((value, context) => { + if (value.operation !== 'create_owned_target') { + return; + } + if (!value.adapterId) { + context.addIssue({ + code: 'custom', + path: ['adapterId'], + message: 'create_owned_target requires an Office or WPS write adapterId', + }); + } + }); + +export const appUseObserveArgsSchema = z + .object({ + adapterId: appUseAdapterIdSchema, + scope: z.enum(APP_USE_OBSERVE_SCOPES).default('active_document'), + description: nonEmptyTrimmedStringSchema, + targetId: z.string().trim().optional(), + maxOutputChars: optionalIntegerInRangeSchema(1000, 50000), + }) + .strict(); + +function boundedTrimmedStringSchema(maxLength: number) { + return z.preprocess( + (value) => (typeof value === 'string' ? value.trim() : value), + z.string().min(1).max(maxLength) + ); +} + +const appUseScalarCellValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const appUseTextActionParametersSchema = z + .object({ + text: boundedTrimmedStringSchema(20000), + }) + .strict(); +const appUseFormatDocumentTextParametersSchema = z + .object({ + bold: z.boolean().optional(), + italic: z.boolean().optional(), + underline: z.boolean().optional(), + fontSize: z.number().min(6).max(96).optional(), + fontName: boundedTrimmedStringSchema(128).optional(), + }) + .strict() + .refine( + (value) => + value.bold !== undefined || + value.italic !== undefined || + value.underline !== undefined || + value.fontSize !== undefined || + value.fontName !== undefined, + { message: 'format_document_text requires at least one format option' } + ); +const appUseWriteCellsParametersSchema = z + .object({ + range: boundedTrimmedStringSchema(64).refine( + (value) => /^[A-Za-z0-9:$]+$/.test(value), + 'range must be an A1-style address' + ), + sheetName: boundedTrimmedStringSchema(128).optional(), + values: z + .array(z.array(appUseScalarCellValueSchema).min(1)) + .min(1) + .refine((rows) => { + const width = rows[0]?.length; + return width !== undefined && rows.every((row) => row.length === width); + }, 'values must use rectangular rows'), + }) + .strict(); +const appUseAddSlideTextParametersSchema = z + .object({ + text: boundedTrimmedStringSchema(2000), + slideIndex: z.number().int().min(1).optional(), + }) + .strict(); + +const appUseParametersSchemaByAction: Record< + AppUseActAction, + z.ZodType> +> = { + replace_document_text: appUseTextActionParametersSchema, + format_document_text: appUseFormatDocumentTextParametersSchema, + write_cells: appUseWriteCellsParametersSchema, + add_slide_text: appUseAddSlideTextParametersSchema, +}; + +const RAW_AUTOMATION_PARAMETER_KEYS = new Set([ + 'script', + 'rawscript', + 'macro', + 'vba', + 'com', + 'uxp', + 'batchplay', + 'extendscript', + 'javascript', +]); + +function rejectRawAutomationParameters( + value: unknown, + context: z.RefinementCtx, + path: Array +) { + if (Array.isArray(value)) { + value.forEach((item, index) => + rejectRawAutomationParameters(item, context, [...path, index]) + ); + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, nestedValue] of Object.entries(value as Record)) { + const nextPath = [...path, key]; + if (RAW_AUTOMATION_PARAMETER_KEYS.has(key.toLowerCase())) { + context.addIssue({ + code: 'custom', + path: nextPath, + message: 'Raw automation payloads are not allowed in App Use parameters', + }); + } + rejectRawAutomationParameters(nestedValue, context, nextPath); + } +} + +export const appUseActArgsSchema = z + .object({ + adapterId: appUseActAdapterIdSchema, + action: z.enum(APP_USE_ACT_ACTIONS), + description: nonEmptyTrimmedStringSchema, + targetId: nonEmptyTrimmedStringSchema, + parameters: z.record(z.string(), z.unknown()), + }) + .strict() + .superRefine((value, context) => { + rejectRawAutomationParameters(value.parameters, context, ['parameters']); + const supportedActions = APP_USE_ACT_ACTIONS_BY_ADAPTER[ + value.adapterId + ] as readonly AppUseActAction[]; + if (!supportedActions.includes(value.action)) { + context.addIssue({ + code: 'custom', + path: ['action'], + message: `${value.adapterId} does not support ${value.action}`, + }); + } + + const parameters = appUseParametersSchemaByAction[value.action].safeParse(value.parameters); + if (!parameters.success) { + for (const issue of parameters.error.issues) { + context.addIssue({ + ...issue, + path: ['parameters', ...issue.path], + }); + } + } + }) + .transform((value): AppUseActArgs => { + return { + ...value, + parameters: appUseParametersSchemaByAction[value.action].parse(value.parameters), + }; + }); + +export const APP_SESSION_TOOL_DESCRIPTION = [ + 'Discover and inspect supported local desktop applications for App Use.', + 'Use create_owned_target before Office or WPS write actions to create a TouchAI-owned signed target path.', + 'This tool only reports structured status and capabilities; it does not execute raw scripts.', +].join(' '); + +export const APP_OBSERVE_TOOL_DESCRIPTION = [ + 'Read structured App Use context from an enabled local application adapter.', + 'Supported scopes include active documents, selections, workbooks, worksheets, presentations, slides, layers, and artboards.', + 'Only request the active, bounded observation scope needed for the user task.', +].join(' '); + +export const APP_ACT_TOOL_DESCRIPTION = [ + 'Execute exactly one bounded App Use action in an enabled local application adapter.', + 'Mutating actions require user approval and raw script, macro, VBA, COM, UXP, or batchPlay payloads are not accepted.', +].join(' '); + +export const APP_SESSION_TOOL_INPUT_SCHEMA: AiToolDefinition['input_schema'] = { + type: 'object', + properties: { + operation: { + type: 'string', + enum: [...APP_USE_SESSION_OPERATIONS], + description: + 'Session operation to run. Use create_owned_target to create a TouchAI-owned signed target path for Office or WPS write actions.', + default: 'discover', + }, + description: { + type: 'string', + description: 'User-facing reason for discovering or inspecting local app capabilities.', + }, + adapterId: { + type: 'string', + enum: [...APP_USE_ACT_ADAPTER_IDS], + description: + 'Required for create_owned_target. Office or WPS write adapter that will use the created target.', + }, + targetKind: { + type: 'string', + enum: ['document', 'spreadsheet', 'presentation'], + description: + 'Optional create_owned_target target kind. It must match the selected adapter family.', + }, + }, + required: ['description'], + additionalProperties: false, +}; + +export const APP_OBSERVE_TOOL_INPUT_SCHEMA: AiToolDefinition['input_schema'] = { + type: 'object', + properties: { + adapterId: { + type: 'string', + enum: [...APP_USE_ADAPTER_IDS], + description: 'Enabled App Use adapter to observe.', + }, + scope: { + type: 'string', + enum: [...APP_USE_OBSERVE_SCOPES], + description: 'Structured observation scope.', + default: 'active_document', + }, + description: { + type: 'string', + description: 'User-facing reason for reading this app context.', + }, + targetId: { + type: 'string', + description: + 'Optional TouchAI-owned signed target path returned by a trusted App Use target creation or owned-target observation flow. Do not invent or reuse arbitrary local paths.', + }, + maxOutputChars: { + type: 'number', + description: 'Optional output limit for text-heavy observations.', + }, + }, + required: ['adapterId', 'description'], + additionalProperties: false, +}; + +export const APP_ACT_TOOL_INPUT_SCHEMA: AiToolDefinition['input_schema'] = { + type: 'object', + properties: { + adapterId: { + type: 'string', + enum: [...APP_USE_ACT_ADAPTER_IDS], + description: + 'Enabled App Use adapter that should execute the action. Adobe adapters are observe-only in this phase.', + }, + action: { + type: 'string', + enum: [...APP_USE_ACT_ACTIONS], + description: + 'Single bounded action to execute. Use app_observe for read-only cells, layers, artboards, documents, and slides.', + }, + description: { + type: 'string', + description: 'User-facing reason shown in the approval UI.', + }, + targetId: { + type: 'string', + description: + 'Required for Office and WPS write actions. Must be a TouchAI-owned signed target path, not an arbitrary local path or a raw app_session/app_observe value.', + }, + parameters: { + type: 'object', + description: + 'Structured parameters for the bounded action. Use {text} for replace_document_text and add_slide_text; use {range, values, sheetName?} for write_cells; use {bold?, italic?, underline?, fontSize?, fontName?} for format_document_text. Raw scripts are not allowed.', + }, + }, + required: ['adapterId', 'action', 'description', 'targetId', 'parameters'], + additionalProperties: false, +}; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/appUse/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/appUse/index.ts new file mode 100644 index 00000000..0355f52e --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/appUse/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026. 千诚. Licensed under GPL v3 + +export { + type AppUseAdapterConfig, + type AppUseApprovalMode, + type AppUseMode, + type AppUseReadScope, + type AppUseToolConfig, + DEFAULT_APP_USE_ADAPTER_CONFIG, + DEFAULT_APP_USE_TOOL_CONFIG, + parseAppUseToolConfig, + serializeAppUseToolConfig, +} from './config'; +export { + APP_ACT_TOOL_DESCRIPTION, + APP_ACT_TOOL_ID, + APP_ACT_TOOL_INPUT_SCHEMA, + APP_OBSERVE_TOOL_DESCRIPTION, + APP_OBSERVE_TOOL_ID, + APP_OBSERVE_TOOL_INPUT_SCHEMA, + APP_SESSION_TOOL_DESCRIPTION, + APP_SESSION_TOOL_ID, + APP_SESSION_TOOL_INPUT_SCHEMA, + APP_USE_ACT_ACTIONS, + APP_USE_ADAPTER_IDS, + APP_USE_OBSERVE_SCOPES, + APP_USE_SESSION_OPERATIONS, + appUseActArgsSchema, + type AppUseAdapterId, + appUseAdapterIdSchema, + appUseObserveArgsSchema, + appUseSessionArgsSchema, +} from './constants'; +export { + appActTool, + appObserveTool, + appSessionTool, + builtInTools, + executeAppActTool, + executeAppObserveTool, + executeAppSessionTool, +} from './tool'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/appUse/tool.ts b/apps/desktop/src/services/BuiltInToolService/tools/appUse/tool.ts new file mode 100644 index 00000000..8435e8a2 --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/appUse/tool.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026. 千诚. Licensed under GPL v3 + +import { native } from '@services/NativeService'; + +import { t } from '@/i18n'; +import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; + +import { + type BaseBuiltInToolExecutionContext, + BuiltInTool, + type BuiltInToolConversationSemantic, + type BuiltInToolExecutionResult, + type BuiltInToolGroup, +} from '../../types'; +import { parseToolArguments } from '../../utils/toolSchema'; +import { + type AppUseToolConfig, + DEFAULT_APP_USE_TOOL_CONFIG, + parseAppUseToolConfig, +} from './config'; +import { + APP_ACT_TOOL_DESCRIPTION, + APP_ACT_TOOL_ID, + APP_ACT_TOOL_INPUT_SCHEMA, + APP_OBSERVE_TOOL_DESCRIPTION, + APP_OBSERVE_TOOL_ID, + APP_OBSERVE_TOOL_INPUT_SCHEMA, + APP_SESSION_TOOL_DESCRIPTION, + APP_SESSION_TOOL_ID, + APP_SESSION_TOOL_INPUT_SCHEMA, + appUseActArgsSchema, + appUseObserveArgsSchema, + appUseSessionArgsSchema, +} from './constants'; + +function success(result: unknown): BuiltInToolExecutionResult { + return { + result: JSON.stringify(result, null, 2), + isError: false, + status: 'success', + }; +} + +function appUseSemantic(target: string): BuiltInToolConversationSemantic { + return { + action: 'process', + target, + }; +} + +function truncatePreview(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim(); + return normalized.length > 200 ? `${normalized.slice(0, 200)}...` : normalized; +} + +function buildParameterPreview(parameters: Record): string { + if (typeof parameters.text === 'string') { + return truncatePreview(parameters.text); + } + + return truncatePreview(JSON.stringify(parameters)); +} + +function buildActionApprovalDescription(parsed: { + description: string; + targetId?: string; + parameters: Record; +}): string { + const lines = [parsed.description]; + if (parsed.targetId) { + lines.push(`${t('builtInTools.appUse.approval.targetLabel')}: ${parsed.targetId}`); + } + + const preview = buildParameterPreview(parsed.parameters); + lines.push(`${t('builtInTools.appUse.approval.previewLabel')}: ${preview}`); + + return lines.join('\n'); +} + +export async function executeAppSessionTool( + args: Record, + config: AppUseToolConfig, + context: BaseBuiltInToolExecutionContext +): Promise { + const parsed = parseToolArguments('AppSession', appUseSessionArgsSchema, args); + return success( + await native.appUse.session({ + executionId: context.callId, + operation: parsed.operation, + description: parsed.description, + adapterId: parsed.adapterId, + targetKind: parsed.targetKind, + config, + }) + ); +} + +export async function executeAppObserveTool( + args: Record, + config: AppUseToolConfig, + context: BaseBuiltInToolExecutionContext +): Promise { + const parsed = parseToolArguments('AppObserve', appUseObserveArgsSchema, args); + return success( + await native.appUse.observe({ + executionId: context.callId, + adapterId: parsed.adapterId, + scope: parsed.scope, + description: parsed.description, + targetId: parsed.targetId, + maxOutputChars: parsed.maxOutputChars ?? config.maxOutputChars, + config, + }) + ); +} + +export async function executeAppActTool( + args: Record, + config: AppUseToolConfig, + context: BaseBuiltInToolExecutionContext +): Promise { + const parsed = parseToolArguments('AppAct', appUseActArgsSchema, args); + if (config.mode === 'read_only') { + return { + result: 'App Use is currently in read-only mode. Enable interactive mode before running app actions.', + isError: true, + status: 'error', + errorMessage: 'App Use is in read-only mode', + }; + } + + const authorization = await native.appUse.authorizeAct({ + executionId: context.callId, + adapterId: parsed.adapterId, + action: parsed.action, + targetId: parsed.targetId, + parameters: parsed.parameters, + config, + }); + if (!authorization.permit) { + return { + result: 'App Use native authorization failed. Re-run the action after user approval.', + isError: true, + status: 'error', + errorMessage: 'App Use native authorization failed', + }; + } + + return success( + await native.appUse.act({ + executionId: context.callId, + adapterId: parsed.adapterId, + action: parsed.action, + description: parsed.description, + targetId: parsed.targetId, + parameters: parsed.parameters, + permit: authorization.permit, + config, + }) + ); +} + +abstract class AppUseTool extends BuiltInTool { + readonly defaultConfig = DEFAULT_APP_USE_TOOL_CONFIG; + + override parseConfig(configJson: string | null): AppUseToolConfig { + return parseAppUseToolConfig(configJson); + } +} + +class AppSessionTool extends AppUseTool { + readonly id = APP_SESSION_TOOL_ID; + readonly displayName = 'AppSession'; + readonly description = APP_SESSION_TOOL_DESCRIPTION; + readonly inputSchema = APP_SESSION_TOOL_INPUT_SCHEMA; + + override buildConversationSemantic() { + return appUseSemantic('App Use session'); + } + + override execute( + args: Record, + config: AppUseToolConfig, + context: BaseBuiltInToolExecutionContext + ) { + return executeAppSessionTool(args, config, context); + } +} + +class AppObserveTool extends AppUseTool { + readonly id = APP_OBSERVE_TOOL_ID; + readonly displayName = 'AppObserve'; + readonly description = APP_OBSERVE_TOOL_DESCRIPTION; + readonly inputSchema = APP_OBSERVE_TOOL_INPUT_SCHEMA; + + override buildConversationSemantic() { + return appUseSemantic('App Use observation'); + } + + override execute( + args: Record, + config: AppUseToolConfig, + context: BaseBuiltInToolExecutionContext + ) { + return executeAppObserveTool(args, config, context); + } +} + +class AppActTool extends AppUseTool { + readonly id = APP_ACT_TOOL_ID; + readonly displayName = 'AppAct'; + readonly description = APP_ACT_TOOL_DESCRIPTION; + readonly inputSchema = APP_ACT_TOOL_INPUT_SCHEMA; + + override buildApprovalRequest(args: Record): ToolApprovalRequest | null { + const parsed = parseToolArguments('AppAct', appUseActArgsSchema, args); + const command = parsed.targetId + ? `${parsed.adapterId}:${parsed.action} -> ${parsed.targetId}` + : `${parsed.adapterId}:${parsed.action}`; + return { + title: t('builtInTools.appUse.approval.title'), + description: buildActionApprovalDescription(parsed), + command, + riskLabel: t('builtInTools.appUse.approval.riskLabel'), + reason: t('builtInTools.appUse.approval.reason'), + commandLabel: t('builtInTools.appUse.approval.commandLabel'), + approveLabel: t('builtInTools.appUse.approval.approveLabel'), + rejectLabel: t('builtInTools.appUse.approval.rejectLabel'), + enterHint: 'Enter', + escHint: 'Esc', + keyboardApproveDelayMs: 450, + }; + } + + override buildConversationSemantic() { + return appUseSemantic('App Use action'); + } + + override execute( + args: Record, + config: AppUseToolConfig, + context: BaseBuiltInToolExecutionContext + ) { + return executeAppActTool(args, config, context); + } +} + +export const appSessionTool = new AppSessionTool(); +export const appObserveTool = new AppObserveTool(); +export const appActTool = new AppActTool(); +export const builtInTools: BuiltInToolGroup = [appSessionTool, appObserveTool, appActTool]; diff --git a/apps/desktop/src/services/BuiltInToolService/types.ts b/apps/desktop/src/services/BuiltInToolService/types.ts index 68f1572e..05144a24 100644 --- a/apps/desktop/src/services/BuiltInToolService/types.ts +++ b/apps/desktop/src/services/BuiltInToolService/types.ts @@ -18,6 +18,9 @@ import type { AttachmentIndex } from '@/services/AgentService/infrastructure/att * 当前内置工具体系允许暴露给模型的稳定工具标识。 */ export type BuiltInToolId = + | 'app_session' + | 'app_observe' + | 'app_act' | 'bash' | 'file_search' | 'read' diff --git a/apps/desktop/src/services/NativeService/appUse.ts b/apps/desktop/src/services/NativeService/appUse.ts new file mode 100644 index 00000000..2cd9c530 --- /dev/null +++ b/apps/desktop/src/services/NativeService/appUse.ts @@ -0,0 +1,29 @@ +import { invoke } from '@tauri-apps/api/core'; + +import type { + AppUseNativeActRequest, + AppUseNativeActResponse, + AppUseNativeAuthorizeActRequest, + AppUseNativeAuthorizeActResponse, + AppUseNativeObserveRequest, + AppUseNativeObserveResponse, + AppUseNativeSessionRequest, + AppUseNativeSessionResponse, +} from './types'; + +export const appUse = { + session(request: AppUseNativeSessionRequest): Promise { + return invoke('app_use_session', { request }); + }, + observe(request: AppUseNativeObserveRequest): Promise { + return invoke('app_use_observe', { request }); + }, + authorizeAct( + request: AppUseNativeAuthorizeActRequest + ): Promise { + return invoke('app_use_authorize_act', { request }); + }, + act(request: AppUseNativeActRequest): Promise { + return invoke('app_use_act', { request }); + }, +} as const; diff --git a/apps/desktop/src/services/NativeService/index.ts b/apps/desktop/src/services/NativeService/index.ts index b6b6f7fb..c756d2e7 100644 --- a/apps/desktop/src/services/NativeService/index.ts +++ b/apps/desktop/src/services/NativeService/index.ts @@ -1,3 +1,4 @@ +import { appUse } from './appUse'; import { autostart } from './autostart'; import { builtInTools } from './builtInTools'; import { clipboard } from './clipboard'; @@ -27,6 +28,16 @@ export type { AppUpdateDownload, AppUpdateInfo, AppUpdateRequirement, + AppUseNativeActPermit, + AppUseNativeActRequest, + AppUseNativeActResponse, + AppUseNativeAdapterDescriptor, + AppUseNativeAuthorizeActRequest, + AppUseNativeAuthorizeActResponse, + AppUseNativeObserveRequest, + AppUseNativeObserveResponse, + AppUseNativeSessionRequest, + AppUseNativeSessionResponse, BuiltInBashExecutionRequest, BuiltInBashExecutionResponse, ClipboardPayload, @@ -41,6 +52,7 @@ export type { } from './types'; export { + appUse, autostart, builtInTools, clipboard, @@ -56,6 +68,7 @@ export { }; export const native = { + appUse, window, shortcut, autostart, diff --git a/apps/desktop/src/services/NativeService/types.ts b/apps/desktop/src/services/NativeService/types.ts index a3a52379..31912bd0 100644 --- a/apps/desktop/src/services/NativeService/types.ts +++ b/apps/desktop/src/services/NativeService/types.ts @@ -35,6 +35,150 @@ export interface BuiltInBashExecutionResponse { compressed?: boolean; } +export type AppUseNativeAdapterId = + | 'office_word' + | 'office_excel' + | 'office_powerpoint' + | 'wps_writer' + | 'wps_spreadsheet' + | 'wps_presentation' + | 'photoshop' + | 'illustrator'; + +export type AppUseNativeActAdapterId = + | 'office_word' + | 'office_excel' + | 'office_powerpoint' + | 'wps_writer' + | 'wps_spreadsheet' + | 'wps_presentation'; + +export type AppUseNativeMode = 'read_only' | 'interactive'; + +export interface AppUseNativeConfig { + mode: AppUseNativeMode; + adapters: Record; + mutatingApprovalMode: 'always'; + readScope: 'active'; + allowRawAutomation: false; + timeoutMs: number; + maxOutputChars: number; +} + +export interface AppUseNativeAdapterDescriptor { + id: AppUseNativeAdapterId; + label: string; + installed: boolean; + running: boolean; + enabled: boolean; + capabilities: string[]; + contract: { + vendor: string; + version: string; + observeScopes: string[]; + actions: AppUseNativeActAction[]; + riskLevel: 'high' | string; + rawAutomationAllowed: false; + }; + activeTargetName: string | null; +} + +export interface AppUseNativeSessionRequest { + executionId: string; + operation: 'status' | 'discover' | 'capabilities' | 'create_owned_target'; + description: string; + adapterId?: AppUseNativeActAdapterId; + targetKind?: 'document' | 'spreadsheet' | 'presentation'; + config: AppUseNativeConfig; +} + +export interface AppUseNativeSessionResponse { + ok: boolean; + operation: AppUseNativeSessionRequest['operation']; + adapters: AppUseNativeAdapterDescriptor[]; + message: string | null; + adapterId?: AppUseNativeActAdapterId; + targetKind?: 'document' | 'spreadsheet' | 'presentation'; + target?: string; +} + +export interface AppUseNativeObserveRequest { + executionId: string; + adapterId: AppUseNativeAdapterId; + scope: + | 'active_document' + | 'selection' + | 'workbook' + | 'worksheet' + | 'presentation' + | 'slide' + | 'layers' + | 'artboards'; + description: string; + targetId?: string; + maxOutputChars: number; + config: AppUseNativeConfig; +} + +export interface AppUseNativeObserveResponse { + ok: boolean; + adapterId: AppUseNativeAdapterId; + scope: AppUseNativeObserveRequest['scope']; + target: string | null; + content: string | null; + metadata: Record; + truncated: boolean; +} + +export type AppUseNativeActAction = + | 'replace_document_text' + | 'write_cells' + | 'add_slide_text' + | 'format_document_text'; + +export interface AppUseNativeActPermit { + callId: string; + adapterId: AppUseNativeActAdapterId; + action: AppUseNativeActAction; + targetId: string; + parametersHash: string; + token: string; +} + +export interface AppUseNativeAuthorizeActRequest { + executionId: string; + adapterId: AppUseNativeActAdapterId; + action: AppUseNativeActAction; + targetId: string; + parameters?: Record; + config: AppUseNativeConfig; +} + +export interface AppUseNativeAuthorizeActResponse { + permit: AppUseNativeActPermit | null; + expiresInMs: number; +} + +export interface AppUseNativeActRequest { + executionId: string; + adapterId: AppUseNativeActAdapterId; + action: AppUseNativeActAction; + description: string; + targetId: string; + parameters?: Record; + permit?: AppUseNativeActPermit; + config: AppUseNativeConfig; +} + +export interface AppUseNativeActResponse { + ok: boolean; + adapterId: AppUseNativeActAdapterId; + action: AppUseNativeActAction; + receipt: string; + changed: boolean; + metadata: Record; +} + export interface ShowPopupWindowParams { x: number; y: number; diff --git a/apps/desktop/src/views/SettingsView/components/AppUse/index.vue b/apps/desktop/src/views/SettingsView/components/AppUse/index.vue new file mode 100644 index 00000000..68e7c547 --- /dev/null +++ b/apps/desktop/src/views/SettingsView/components/AppUse/index.vue @@ -0,0 +1,595 @@ + + + + + diff --git a/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts b/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts index 0a76a275..ef759685 100644 --- a/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts +++ b/apps/desktop/src/views/SettingsView/components/BuiltInTools/types.ts @@ -82,7 +82,13 @@ const BUILT_IN_TOOL_EMPTY_CONFIG_IDS = new Set([ 'visualize_read_me', ]); -const BUILT_IN_TOOL_HIDDEN_IN_SETTINGS_IDS = new Set(['visualize_read_me', 'ask_user_question']); +const BUILT_IN_TOOL_HIDDEN_IN_SETTINGS_IDS = new Set([ + 'visualize_read_me', + 'ask_user_question', + 'app_session', + 'app_observe', + 'app_act', +]); export function getBuiltInToolSummary(toolId: string, description?: string | null): string { if (toolId === 'bash') { diff --git a/apps/desktop/src/views/SettingsView/index.vue b/apps/desktop/src/views/SettingsView/index.vue index 4ca2ff20..21609b7f 100644 --- a/apps/desktop/src/views/SettingsView/index.vue +++ b/apps/desktop/src/views/SettingsView/index.vue @@ -30,6 +30,7 @@ const BuiltInToolsView = defineAsyncComponent( () => import('./components/BuiltInTools/index.vue') ); + const AppUseView = defineAsyncComponent(() => import('./components/AppUse/index.vue')); const McpToolsView = defineAsyncComponent(() => import('./components/McpTools/index.vue')); const DataManagementView = defineAsyncComponent( () => import('./components/DataManagement/index.vue') @@ -298,6 +299,19 @@ +
+ + + + +
+
diff --git a/apps/desktop/src/views/SettingsView/settingsNavigation.ts b/apps/desktop/src/views/SettingsView/settingsNavigation.ts index 1dad7331..147d840e 100644 --- a/apps/desktop/src/views/SettingsView/settingsNavigation.ts +++ b/apps/desktop/src/views/SettingsView/settingsNavigation.ts @@ -6,6 +6,7 @@ export type NavigationSection = | 'general' | 'ai-services' | 'built-in-tools' + | 'app-use' | 'mcp-tools' | 'data-management'; @@ -60,6 +61,12 @@ const settingsNavigationDefinitions: SettingsNavigationGroupDefinition[] = [ labelKey: 'settings.nav.builtInTools.label', descriptionKey: 'settings.nav.builtInTools.description', }, + { + id: 'app-use', + icon: 'wrench', + labelKey: 'settings.nav.appUse.label', + descriptionKey: 'settings.nav.appUse.description', + }, { id: 'mcp-tools', icon: 'mcp', diff --git a/apps/desktop/tests/SettingsView/built-in-tools-i18n.test.ts b/apps/desktop/tests/SettingsView/built-in-tools-i18n.test.ts index 78cd7da9..388ecb54 100644 --- a/apps/desktop/tests/SettingsView/built-in-tools-i18n.test.ts +++ b/apps/desktop/tests/SettingsView/built-in-tools-i18n.test.ts @@ -13,6 +13,7 @@ import { type BuiltInToolLogEntity, formatToolLastUsed, getBuiltInToolSummary, + isBuiltInToolVisibleInSettings, } from '@/views/SettingsView/components/BuiltInTools/types'; import { getBuiltInToolApprovalStateText, @@ -272,6 +273,13 @@ describe('built-in tools settings i18n', () => { expect(formatToolLastUsed(null)).toBe('Not called yet'); }); + it('hides App Use tools from the legacy built-in tools settings list', () => { + expect(isBuiltInToolVisibleInSettings('app_session')).toBe(false); + expect(isBuiltInToolVisibleInSettings('app_observe')).toBe(false); + expect(isBuiltInToolVisibleInSettings('app_act')).toBe(false); + expect(isBuiltInToolVisibleInSettings('bash')).toBe(true); + }); + it('localizes tool log status labels for filter chips and badges', () => { setLocale('en-US'); diff --git a/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts b/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts index 1b61b7c4..190444f2 100644 --- a/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts +++ b/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts @@ -40,6 +40,9 @@ describe('Settings navigation sidebar i18n', () => { expect(wrapper.get('[data-testid="settings-nav-built-in-tools"]').attributes('title')).toBe( 'Built-in tools' ); + expect(wrapper.get('[data-testid="settings-nav-app-use"]').attributes('title')).toBe( + 'App Use' + ); expect(wrapper.get('[data-testid="settings-nav-mcp-tools"]').attributes('title')).toBe( 'MCP tools' ); @@ -57,9 +60,14 @@ describe('Settings navigation sidebar i18n', () => { 'General', 'Providers and models', 'Built-in tools', + 'App Use', 'MCP tools', 'Data management', ]); + expect(getSettingsNavigationItem('app-use')?.description).toBe( + 'Structured local application adapters, approvals, and limits' + ); + expect(getSettingsNavigationItem('app-use')?.label).toBe('App Use'); expect(getSettingsNavigationItem('mcp-tools')?.description).toBe( 'External MCP servers and tool call logs' ); @@ -67,6 +75,8 @@ describe('Settings navigation sidebar i18n', () => { setLocale('zh-CN'); expect(settingsNavigationGroups[0]?.label).toBe('基础体验'); + expect(flattenSettingsNavigation().map((item) => item.label)).toContain('软件控制'); + expect(getSettingsNavigationItem('app-use')?.label).toBe('软件控制'); expect(getSettingsNavigationItem('mcp-tools')?.description).toBe( '外部 MCP 服务器与工具调用日志' ); diff --git a/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts b/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts index 838c11e5..080c1bf6 100644 --- a/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts +++ b/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts @@ -75,6 +75,16 @@ vi.mock('@/views/SettingsView/components/BuiltInTools/index.vue', () => ({ }, }, })); +vi.mock('@/views/SettingsView/components/AppUse/index.vue', () => ({ + __esModule: true, + default: { + name: 'AppUseViewStub', + async setup() { + await asyncViewControls.waitForResolve(); + return () => null; + }, + }, +})); vi.mock('@/views/SettingsView/components/McpTools/index.vue', () => ({ __esModule: true, default: { @@ -134,6 +144,9 @@ describe('SettingsView lazy loading i18n', () => { await wrapper.get('[data-testid="settings-nav-built-in-tools"]').trigger('click'); expect(wrapper.text()).toContain('Loading built-in tools...'); + await wrapper.get('[data-testid="settings-nav-app-use"]').trigger('click'); + expect(wrapper.text()).toContain('Loading App Use settings...'); + await wrapper.get('[data-testid="settings-nav-mcp-tools"]').trigger('click'); expect(wrapper.text()).toContain('Loading MCP tools...'); diff --git a/apps/desktop/tests/ci/bundled-download-policy.test.ts b/apps/desktop/tests/ci/bundled-download-policy.test.ts new file mode 100644 index 00000000..c1843243 --- /dev/null +++ b/apps/desktop/tests/ci/bundled-download-policy.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const buildScriptSource = readFileSync(resolve(process.cwd(), 'src-tauri/build.rs'), 'utf8'); +const securityWorkflowSource = readFileSync( + resolve(process.cwd(), '../../.github/workflows/security.yml'), + 'utf8' +); + +function readNumericConstant(name: string) { + const match = buildScriptSource.match(new RegExp(`const ${name}: \\w+ = (\\d+(?:_\\d+)*)`)); + const rawValue = match?.[1]; + + if (!rawValue) { + throw new Error(`Unable to find ${name} in build.rs`); + } + + return Number.parseInt(rawValue.replace(/_/g, ''), 10); +} + +describe('bundled binary download policy', () => { + it('keeps CI builds resilient to transient release download failures', () => { + expect(readNumericConstant('BUNDLED_DOWNLOAD_MAX_ATTEMPTS')).toBeGreaterThanOrEqual(6); + expect(readNumericConstant('BUNDLED_DOWNLOAD_RETRY_BASE_DELAY_MS')).toBeGreaterThanOrEqual( + 1500 + ); + expect(buildScriptSource).toContain('bundled_download_retry_delay_ms'); + expect(buildScriptSource).not.toContain( + 'BUNDLED_DOWNLOAD_RETRY_BASE_DELAY_MS * attempt as u64' + ); + }); + + it('allows CodeQL analysis to proceed without embedded release binaries', () => { + expect(buildScriptSource).toContain('TOUCHAI_OPTIONAL_BUNDLED_DOWNLOAD'); + expect(buildScriptSource).toContain('bundled_downloads_are_optional'); + expect(buildScriptSource).toContain('generate_empty_asset_module(name, &out_dir)?'); + expect(securityWorkflowSource).toMatch(/TOUCHAI_OPTIONAL_BUNDLED_DOWNLOAD:\s*['"]?1['"]?/); + }); +}); diff --git a/apps/desktop/tests/database/app-use-seed.test.ts b/apps/desktop/tests/database/app-use-seed.test.ts new file mode 100644 index 00000000..ea6b2214 --- /dev/null +++ b/apps/desktop/tests/database/app-use-seed.test.ts @@ -0,0 +1,21 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const runtimeSeedPath = path.resolve(__dirname, '../../src/database/artifacts/runtime/seed.sql'); + +describe('App Use runtime seed defaults', () => { + it('seeds App Use tools disabled by default as high-risk tools', async () => { + const seedSql = await readFile(runtimeSeedPath, 'utf8'); + + for (const toolId of ['app_session', 'app_observe', 'app_act']) { + expect(seedSql).toContain(`SELECT '${toolId}'`); + } + expect(seedSql).toContain("'AppSession', '软件控制:发现本地应用与能力', 0, 'high'"); + expect(seedSql).toContain("'AppObserve', '软件控制:读取本地应用上下文', 0, 'high'"); + expect(seedSql).toContain("'AppAct', '软件控制:执行一个受控应用动作', 0, 'high'"); + }); +}); diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/appUse/config.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/config.test.ts new file mode 100644 index 00000000..357688cb --- /dev/null +++ b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/config.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { + APP_USE_ADAPTER_IDS, + DEFAULT_APP_USE_TOOL_CONFIG, + parseAppUseToolConfig, + serializeAppUseToolConfig, +} from '@/services/BuiltInToolService/tools/appUse'; + +describe('App Use tool config', () => { + it('defaults to read-only with every first-batch adapter disabled', () => { + expect(parseAppUseToolConfig(null)).toEqual(DEFAULT_APP_USE_TOOL_CONFIG); + expect(DEFAULT_APP_USE_TOOL_CONFIG.mode).toBe('read_only'); + expect(DEFAULT_APP_USE_TOOL_CONFIG.mutatingApprovalMode).toBe('always'); + expect(DEFAULT_APP_USE_TOOL_CONFIG.allowRawAutomation).toBe(false); + expect(Object.keys(DEFAULT_APP_USE_TOOL_CONFIG.adapters).sort()).toEqual( + [...APP_USE_ADAPTER_IDS].sort() + ); + expect( + Object.values(DEFAULT_APP_USE_TOOL_CONFIG.adapters).every((enabled) => !enabled) + ).toBe(true); + }); + + it('normalizes known adapters and ignores unknown adapter ids', () => { + const parsed = parseAppUseToolConfig( + JSON.stringify({ + mode: 'interactive', + adapters: { + wps_writer: true, + office_word: true, + unknown_app: true, + }, + }) + ); + + expect(parsed.mode).toBe('interactive'); + expect(parsed.adapters.wps_writer).toBe(true); + expect(parsed.adapters.office_word).toBe(true); + expect(Object.keys(parsed.adapters)).not.toContain('unknown_app'); + }); + + it('keeps unsafe or invalid config values inside conservative limits', () => { + const parsed = parseAppUseToolConfig( + JSON.stringify({ + mode: 'full_auto', + mutatingApprovalMode: 'never', + readScope: 'entire_machine', + allowBackgroundOperation: true, + allowRawAutomation: true, + timeoutMs: 250, + maxOutputChars: 500000, + }) + ); + + expect(parsed.mode).toBe(DEFAULT_APP_USE_TOOL_CONFIG.mode); + expect(parsed.mutatingApprovalMode).toBe('always'); + expect(parsed.readScope).toBe(DEFAULT_APP_USE_TOOL_CONFIG.readScope); + expect(parsed.allowRawAutomation).toBe(false); + expect(parsed.timeoutMs).toBe(DEFAULT_APP_USE_TOOL_CONFIG.timeoutMs); + expect(parsed.maxOutputChars).toBe(DEFAULT_APP_USE_TOOL_CONFIG.maxOutputChars); + expect(parsed).not.toHaveProperty('allowBackgroundOperation'); + expect(JSON.parse(serializeAppUseToolConfig(parsed))).not.toHaveProperty( + 'allowBackgroundOperation' + ); + }); + + it('ignores legacy advanced workflow switches and does not persist them', () => { + const parsed = parseAppUseToolConfig( + JSON.stringify({ + advanced: { + exportPreviews: true, + batchWorkflows: 'yes', + crossAppWorkflows: true, + unknown: true, + }, + allowRawAutomation: true, + }) + ); + + expect(parsed.allowRawAutomation).toBe(false); + expect(parsed).not.toHaveProperty('advanced'); + expect(JSON.parse(serializeAppUseToolConfig(parsed))).not.toHaveProperty('advanced'); + }); +}); diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/appUse/native.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/native.test.ts new file mode 100644 index 00000000..9189fec6 --- /dev/null +++ b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/native.test.ts @@ -0,0 +1,281 @@ +import { getLastTauriInvokeCall, getTauriInvokeCalls, mockTauriCommand } from '@tests/utils/tauri'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { DEFAULT_APP_USE_TOOL_CONFIG } from '@/services/BuiltInToolService/tools/appUse'; +import { + appActTool, + appObserveTool, + appSessionTool, + executeAppActTool, + executeAppObserveTool, + executeAppSessionTool, +} from '@/services/BuiltInToolService/tools/appUse/tool'; +import type { BaseBuiltInToolExecutionContext } from '@/services/BuiltInToolService/types'; +import type { + AppUseNativeActResponse, + AppUseNativeAuthorizeActResponse, + AppUseNativeObserveResponse, + AppUseNativeSessionResponse, +} from '@/services/NativeService'; + +function fakeContext(): BaseBuiltInToolExecutionContext { + return { + callId: 'call-app-use-1', + iteration: 0, + hasExecutedBuiltInTool: () => false, + }; +} + +describe('App Use native bridge construction', () => { + beforeEach(() => { + mockTauriCommand('app_use_session', { + ok: true, + operation: 'discover', + adapters: [], + message: null, + } satisfies AppUseNativeSessionResponse); + mockTauriCommand('app_use_observe', { + ok: true, + adapterId: 'wps_writer', + scope: 'selection', + target: null, + content: 'selected text', + metadata: {}, + truncated: false, + } satisfies AppUseNativeObserveResponse); + mockTauriCommand('app_use_act', { + ok: true, + adapterId: 'wps_writer', + action: 'replace_document_text', + receipt: 'replaced document text', + changed: true, + metadata: {}, + } satisfies AppUseNativeActResponse); + mockTauriCommand('app_use_authorize_act', { + permit: { + callId: 'call-app-use-1', + adapterId: 'wps_writer', + action: 'replace_document_text', + targetId: 'owned-document-1', + parametersHash: 'hash-1', + token: 'permit-1', + }, + expiresInMs: 30000, + } satisfies AppUseNativeAuthorizeActResponse); + }); + + it('passes App Use settings and call id to app_session', async () => { + await executeAppSessionTool( + { operation: 'discover', description: '列出软件控制能力' }, + DEFAULT_APP_USE_TOOL_CONFIG, + fakeContext() + ); + + expect(getLastTauriInvokeCall('app_use_session')?.payload).toEqual({ + request: { + executionId: 'call-app-use-1', + operation: 'discover', + description: '列出软件控制能力', + adapterId: undefined, + targetKind: undefined, + config: DEFAULT_APP_USE_TOOL_CONFIG, + }, + }); + }); + + it('passes create_owned_target session fields to app_session', async () => { + await executeAppSessionTool( + { + operation: 'create_owned_target', + description: 'create owned spreadsheet target', + adapterId: 'wps_spreadsheet', + targetKind: 'spreadsheet', + }, + DEFAULT_APP_USE_TOOL_CONFIG, + fakeContext() + ); + + expect(getLastTauriInvokeCall('app_use_session')?.payload).toEqual({ + request: { + executionId: 'call-app-use-1', + operation: 'create_owned_target', + description: 'create owned spreadsheet target', + adapterId: 'wps_spreadsheet', + targetKind: 'spreadsheet', + config: DEFAULT_APP_USE_TOOL_CONFIG, + }, + }); + }); + + it('passes adapter scope and output limit to app_observe', async () => { + await executeAppObserveTool( + { + adapterId: 'wps_writer', + scope: 'selection', + description: '读取当前选区', + maxOutputChars: 3000, + }, + DEFAULT_APP_USE_TOOL_CONFIG, + fakeContext() + ); + + expect(getLastTauriInvokeCall('app_use_observe')?.payload).toEqual({ + request: { + executionId: 'call-app-use-1', + adapterId: 'wps_writer', + scope: 'selection', + description: '读取当前选区', + targetId: undefined, + maxOutputChars: 3000, + config: DEFAULT_APP_USE_TOOL_CONFIG, + }, + }); + }); + + it('authorizes one bounded app action before app_act', async () => { + const interactiveConfig = { ...DEFAULT_APP_USE_TOOL_CONFIG, mode: 'interactive' as const }; + await executeAppActTool( + { + adapterId: 'wps_writer', + action: 'replace_document_text', + description: '替换当前选区', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + }, + interactiveConfig, + fakeContext() + ); + + expect(getLastTauriInvokeCall('app_use_authorize_act')?.payload).toMatchObject({ + request: { + executionId: 'call-app-use-1', + adapterId: 'wps_writer', + action: 'replace_document_text', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + config: interactiveConfig, + }, + }); + expect(getLastTauriInvokeCall('app_use_act')?.payload).toEqual({ + request: { + executionId: 'call-app-use-1', + adapterId: 'wps_writer', + action: 'replace_document_text', + description: '替换当前选区', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + permit: { + callId: 'call-app-use-1', + adapterId: 'wps_writer', + action: 'replace_document_text', + targetId: 'owned-document-1', + parametersHash: 'hash-1', + token: 'permit-1', + }, + config: interactiveConfig, + }, + }); + }); + + it('blocks mutating app actions while App Use is read-only', async () => { + const result = await executeAppActTool( + { + adapterId: 'wps_writer', + action: 'replace_document_text', + description: 'replace text in read-only mode', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + }, + DEFAULT_APP_USE_TOOL_CONFIG, + fakeContext() + ); + + expect(result).toMatchObject({ + isError: true, + status: 'error', + errorMessage: 'App Use is in read-only mode', + }); + expect(getTauriInvokeCalls('app_use_act')).toHaveLength(0); + }); + + it('does not call app_act when native authorization refuses a permit', async () => { + mockTauriCommand('app_use_authorize_act', { + permit: null, + expiresInMs: 0, + } satisfies AppUseNativeAuthorizeActResponse); + const interactiveConfig = { ...DEFAULT_APP_USE_TOOL_CONFIG, mode: 'interactive' as const }; + + const result = await executeAppActTool( + { + adapterId: 'wps_writer', + action: 'replace_document_text', + description: 'replace current selection', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + }, + interactiveConfig, + fakeContext() + ); + + expect(result).toMatchObject({ + isError: true, + status: 'error', + errorMessage: 'App Use native authorization failed', + }); + expect(getTauriInvokeCalls('app_use_act')).toHaveLength(0); + }); + + it('delegates through App Use tool instances and exposes semantic labels', async () => { + const interactiveConfig = { ...DEFAULT_APP_USE_TOOL_CONFIG, mode: 'interactive' as const }; + + expect(appSessionTool.parseConfig(JSON.stringify({ mode: 'interactive' })).mode).toBe( + 'interactive' + ); + expect(appSessionTool.buildConversationSemantic()).toEqual({ + action: 'process', + target: 'App Use session', + }); + expect(appObserveTool.buildConversationSemantic()).toEqual({ + action: 'process', + target: 'App Use observation', + }); + expect(appActTool.buildConversationSemantic()).toEqual({ + action: 'process', + target: 'App Use action', + }); + + const sessionResult = await appSessionTool.execute( + { operation: 'discover', description: 'list app adapters' }, + DEFAULT_APP_USE_TOOL_CONFIG, + fakeContext() + ); + const observeResult = await appObserveTool.execute( + { + adapterId: 'wps_writer', + scope: 'selection', + description: 'read selection', + targetId: 'owned-doc', + }, + DEFAULT_APP_USE_TOOL_CONFIG, + fakeContext() + ); + const actResult = await appActTool.execute( + { + adapterId: 'wps_writer', + action: 'replace_document_text', + description: 'replace document text', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + }, + interactiveConfig, + fakeContext() + ); + + expect(JSON.parse(sessionResult.result)).toMatchObject({ ok: true, operation: 'discover' }); + expect(JSON.parse(observeResult.result)).toMatchObject({ + ok: true, + content: 'selected text', + }); + expect(JSON.parse(actResult.result)).toMatchObject({ ok: true, changed: true }); + }); +}); diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/appUse/registration.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/registration.test.ts new file mode 100644 index 00000000..28256e73 --- /dev/null +++ b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/registration.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { setLocale } from '@/i18n'; +import { builtInToolRegistry } from '@/services/BuiltInToolService/registry'; + +describe('App Use built-in tool registration', () => { + it('registers the three model-facing App Use tools', () => { + expect(builtInToolRegistry.get('app_session')?.displayName).toBe('AppSession'); + expect(builtInToolRegistry.get('app_observe')?.displayName).toBe('AppObserve'); + expect(builtInToolRegistry.get('app_act')?.displayName).toBe('AppAct'); + }); + + it('keeps App Use tool schemas model-facing and English-only', () => { + for (const toolId of ['app_session', 'app_observe', 'app_act']) { + const descriptor = builtInToolRegistry.get(toolId); + expect(descriptor?.description).toContain('App Use'); + expect(JSON.stringify(descriptor?.inputSchema)).not.toMatch(/\p{Script=Han}/u); + } + }); + + it('localizes action approval copy through i18n keys', async () => { + setLocale('en-US'); + const tool = builtInToolRegistry.get('app_act'); + + const approval = await Promise.resolve( + tool?.buildApprovalRequest( + { + adapterId: 'wps_writer', + action: 'replace_document_text', + description: 'Replace text in an owned WPS document', + targetId: 'owned-document-1', + parameters: { text: 'replacement preview' }, + }, + tool.defaultConfig, + 'builtin__app_act', + { + callId: 'call-1', + iteration: 1, + hasExecutedBuiltInTool: () => false, + } + ) + ); + + expect(approval).toMatchObject({ + title: 'App Use confirmation', + riskLabel: 'High risk', + reason: 'App Use actions may modify local application or document state and require user confirmation.', + commandLabel: 'Action', + approveLabel: 'Approve', + rejectLabel: 'Reject', + }); + expect(approval?.description).toContain('Replace text in an owned WPS document'); + expect(approval?.description).toContain('Target: owned-document-1'); + expect(approval?.description).toContain('Preview: replacement preview'); + expect(approval?.command).toBe('wps_writer:replace_document_text -> owned-document-1'); + }); + + it('does not build approvals for observe-only Adobe actions', async () => { + setLocale('en-US'); + const tool = builtInToolRegistry.get('app_act'); + + expect(() => + tool?.buildApprovalRequest( + { + adapterId: 'photoshop', + action: 'export_preview', + description: 'Export a Photoshop preview', + }, + tool.defaultConfig, + 'builtin__app_act', + { + callId: 'call-2', + iteration: 1, + hasExecutedBuiltInTool: () => false, + } + ) + ).toThrow(); + }); + + it('renders non-text action parameters as a compact JSON preview', async () => { + setLocale('en-US'); + const tool = builtInToolRegistry.get('app_act'); + + const approval = await Promise.resolve( + tool?.buildApprovalRequest( + { + adapterId: 'wps_spreadsheet', + action: 'write_cells', + description: 'Write spreadsheet cells', + targetId: 'owned-spreadsheet-1', + parameters: { + range: 'A1:B1', + values: [ + ['Name', 'Status'], + ['App Use', 'Ready'], + ], + }, + }, + tool.defaultConfig, + 'builtin__app_act', + { + callId: 'call-4', + iteration: 1, + hasExecutedBuiltInTool: () => false, + } + ) + ); + + expect(approval?.description).toContain( + 'Preview: {"range":"A1:B1","values":[["Name","Status"],["App Use","Ready"]]}' + ); + }); + + it('truncates long approval previews', async () => { + setLocale('en-US'); + const tool = builtInToolRegistry.get('app_act'); + const longText = 'x'.repeat(230); + + const approval = await Promise.resolve( + tool?.buildApprovalRequest( + { + adapterId: 'wps_writer', + action: 'replace_document_text', + description: 'Replace text in an owned WPS document', + targetId: 'owned-document-1', + parameters: { text: longText }, + }, + tool.defaultConfig, + 'builtin__app_act', + { + callId: 'call-5', + iteration: 1, + hasExecutedBuiltInTool: () => false, + } + ) + ); + + expect(approval?.description).toContain(`Preview: ${'x'.repeat(200)}...`); + }); +}); diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/appUse/schema.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/schema.test.ts new file mode 100644 index 00000000..cb32cf82 --- /dev/null +++ b/apps/desktop/tests/services/BuiltInToolService/tools/appUse/schema.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest'; + +import { + APP_ACT_TOOL_INPUT_SCHEMA, + APP_OBSERVE_TOOL_INPUT_SCHEMA, + APP_SESSION_TOOL_INPUT_SCHEMA, + appUseActArgsSchema, + appUseObserveArgsSchema, + appUseSessionArgsSchema, +} from '@/services/BuiltInToolService/tools/appUse'; + +describe('App Use tool schemas', () => { + function expectRawAutomationRejection( + result: ReturnType + ) { + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message.includes('Raw automation')) + ).toBe(true); + } + } + + it('requires a user-facing description for every model-facing tool', () => { + expect(appUseSessionArgsSchema.safeParse({ operation: 'discover' }).success).toBe(false); + expect( + appUseObserveArgsSchema.safeParse({ + adapterId: 'wps_writer', + scope: 'selection', + }).success + ).toBe(false); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_writer', + action: 'replace_document_text', + }).success + ).toBe(false); + }); + + it('accepts first-batch adapters and structured operation scopes', () => { + expect( + appUseSessionArgsSchema.parse({ + operation: 'discover', + description: '列出可用软件', + }) + ).toEqual({ operation: 'discover', description: '列出可用软件' }); + + expect( + appUseSessionArgsSchema.parse({ + operation: 'create_owned_target', + description: 'create owned spreadsheet target', + adapterId: 'wps_spreadsheet', + targetKind: 'spreadsheet', + }) + ).toEqual({ + operation: 'create_owned_target', + description: 'create owned spreadsheet target', + adapterId: 'wps_spreadsheet', + targetKind: 'spreadsheet', + }); + + expect( + appUseSessionArgsSchema.safeParse({ + operation: 'create_owned_target', + description: 'create owned target without adapter', + }).success + ).toBe(false); + + expect( + appUseObserveArgsSchema.parse({ + adapterId: 'photoshop', + scope: 'layers', + description: '读取当前图层列表', + }) + ).toEqual({ + adapterId: 'photoshop', + scope: 'layers', + description: '读取当前图层列表', + }); + }); + + it('rejects raw automation payloads even when hidden under common field names', () => { + for (const hiddenField of ['script', 'rawScript', 'macro', 'vba', 'batchPlay']) { + const result = appUseActArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + action: 'write_cells', + description: '替换当前选区文本', + targetId: 'owned-spreadsheet-1', + parameters: { + range: 'A1:B1', + values: [['safe']], + [hiddenField]: 'dangerous()', + }, + }); + + expectRawAutomationRejection(result); + } + + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + action: 'write_cells', + description: 'write selected cells', + targetId: 'owned-spreadsheet-1', + parameters: { + range: 'A1:B1', + values: [['safe']], + nested: { + uxp: { rawScript: 'dangerous()' }, + }, + }, + }).success + ).toBe(false); + }); + + it('keeps each app action bounded to exactly one requested action', () => { + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'office_word', + action: 'replace_document_text', + actions: ['replace_document_text', 'save_as'], + description: '替换选区并另存为', + targetId: 'owned-document-1', + }).success + ).toBe(false); + }); + + it('does not expose unsupported Writer insert or Adobe actions to models', () => { + const actionSchema = APP_ACT_TOOL_INPUT_SCHEMA.properties.action as { + enum: string[]; + description: string; + }; + const sessionOperationSchema = APP_SESSION_TOOL_INPUT_SCHEMA.properties.operation as { + enum: string[]; + description: string; + }; + const adapterSchema = APP_ACT_TOOL_INPUT_SCHEMA.properties.adapterId as { enum: string[] }; + const parameterSchema = APP_ACT_TOOL_INPUT_SCHEMA.properties.parameters as { + description: string; + }; + + expect(APP_ACT_TOOL_INPUT_SCHEMA.required).toContain('parameters'); + expect(APP_ACT_TOOL_INPUT_SCHEMA.required).toContain('targetId'); + expect(sessionOperationSchema.enum).toContain('create_owned_target'); + expect(sessionOperationSchema.description).toContain('TouchAI-owned signed target path'); + expect(actionSchema.enum).not.toContain('replace_selection'); + expect(actionSchema.enum).not.toContain('format_selection'); + expect(actionSchema.enum).not.toContain('insert_text'); + expect(actionSchema.enum).not.toContain('select_layer'); + expect(actionSchema.enum).not.toContain('export_preview'); + expect(actionSchema.enum).not.toContain('batch_export'); + expect(actionSchema.enum).not.toContain('cross_app_transfer'); + expect(actionSchema.description).not.toContain('insert_text'); + expect(parameterSchema.description).not.toContain('select_layer'); + expect(parameterSchema.description).not.toContain('export_preview'); + expect(adapterSchema.enum).not.toContain('photoshop'); + expect(adapterSchema.enum).not.toContain('illustrator'); + + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_writer', + action: 'insert_text', + description: 'insert text at the cursor', + targetId: 'owned-document-1', + parameters: { text: 'hello' }, + }).success + ).toBe(false); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'photoshop', + action: 'export_preview', + description: 'export a preview', + }).success + ).toBe(false); + }); + + it('describes targetId as a TouchAI-owned signed target path', () => { + const observeTargetSchema = APP_OBSERVE_TOOL_INPUT_SCHEMA.properties.targetId as { + description: string; + }; + const actTargetSchema = APP_ACT_TOOL_INPUT_SCHEMA.properties.targetId as { + description: string; + }; + + expect(observeTargetSchema.description).toContain('TouchAI-owned signed target path'); + expect(actTargetSchema.description).toContain('TouchAI-owned signed target path'); + expect(actTargetSchema.description).toContain('not an arbitrary local path'); + expect(actTargetSchema.description).not.toContain('returned by app_session'); + }); + + it('rejects actions for adapters that do not implement them', () => { + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_writer', + action: 'write_cells', + description: 'write cells from a writer adapter', + targetId: 'owned-document-1', + parameters: { range: 'A1:B1', values: [['Name', 'Status']] }, + }).success + ).toBe(false); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + action: 'replace_document_text', + description: 'replace text from a spreadsheet adapter', + targetId: 'owned-spreadsheet-1', + parameters: { text: 'hello' }, + }).success + ).toBe(false); + }); + + it('validates structured parameters for supported write actions', () => { + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_writer', + action: 'format_document_text', + description: 'format owned document', + targetId: 'owned-document-1', + parameters: {}, + }).success + ).toBe(false); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + action: 'write_cells', + description: 'write owned sheet', + targetId: 'owned-spreadsheet-1', + parameters: { range: 'A1:B1', values: [] }, + }).success + ).toBe(false); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_presentation', + action: 'add_slide_text', + description: 'add text to owned slide', + targetId: 'owned-presentation-1', + parameters: { text: ' ' }, + }).success + ).toBe(false); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_writer', + action: 'replace_document_text', + description: 'replace owned document', + targetId: 'owned-document-1', + parameters: { text: 'x'.repeat(20_001) }, + }).success + ).toBe(false); + + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_writer', + action: 'format_document_text', + description: 'format owned document', + targetId: 'owned-document-1', + parameters: { bold: true, fontSize: 18, fontName: 'Arial' }, + }).success + ).toBe(true); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + action: 'write_cells', + description: 'write owned sheet', + targetId: 'owned-spreadsheet-1', + parameters: { range: 'A1:B1', values: [['Name', 'Status']] }, + }).success + ).toBe(true); + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_presentation', + action: 'add_slide_text', + description: 'add text to owned slide', + targetId: 'owned-presentation-1', + parameters: { text: 'hello slide', slideIndex: 1 }, + }).success + ).toBe(true); + }); + + it('returns normalized action parameters after parsing', () => { + expect( + appUseActArgsSchema.parse({ + adapterId: 'wps_presentation', + action: 'add_slide_text', + description: 'add text to owned slide', + targetId: 'owned-presentation-1', + parameters: { text: ' hello slide ', slideIndex: 1 }, + }).parameters + ).toEqual({ text: 'hello slide', slideIndex: 1 }); + }); + + it('uses app_observe, not app_act, for structured read-only cell access', () => { + expect( + appUseObserveArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + scope: 'worksheet', + description: 'read cells', + }).success + ).toBe(true); + }); + + it('validates parameter shapes for supported actions without allowing arbitrary objects', () => { + expect( + appUseActArgsSchema.safeParse({ + adapterId: 'wps_spreadsheet', + action: 'write_cells', + description: 'write cells with raw code', + targetId: 'owned-spreadsheet-1', + parameters: { range: 'A1:B1', values: [['safe']], rawScript: 'dangerous()' }, + }).success + ).toBe(false); + }); +}); diff --git a/apps/desktop/tests/views/SettingsView/appUseComponent.test.ts b/apps/desktop/tests/views/SettingsView/appUseComponent.test.ts new file mode 100644 index 00000000..d7c0b07e --- /dev/null +++ b/apps/desktop/tests/views/SettingsView/appUseComponent.test.ts @@ -0,0 +1,325 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setLocale } from '@/i18n'; +import { + type AppUseToolConfig, + DEFAULT_APP_USE_TOOL_CONFIG, +} from '@/services/BuiltInToolService/tools/appUse'; +import AppUseSection from '@/views/SettingsView/components/AppUse/index.vue'; + +const queries = vi.hoisted(() => ({ + findAllBuiltInTools: vi.fn(), + updateBuiltInTool: vi.fn(), +})); + +vi.mock('@database/queries', () => queries); + +vi.mock('@components/AlertMessage.vue', () => ({ + default: { + name: 'AlertMessageStub', + template: '
', + methods: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, + }, +})); + +vi.mock('@components/AppIcon.vue', () => ({ + default: { + name: 'AppIconStub', + props: ['name'], + template: '', + }, +})); + +vi.mock('@/views/SettingsView/components/BuiltInTools/components/BuiltInToolLogViewer.vue', () => ({ + default: { + name: 'BuiltInToolLogViewerStub', + props: ['tool'], + template: '
{{ tool.tool_id }}
', + }, +})); + +type BuiltInToolStub = { + id: number; + tool_id: string; + display_name: string; + description: string | null; + enabled: number; + risk_level: 'high'; + config_json: string | null; + last_used_at: string | null; + created_at: string; + updated_at: string; +}; + +function appUseConfig(patch: Partial = {}): AppUseToolConfig { + return { + ...DEFAULT_APP_USE_TOOL_CONFIG, + ...patch, + adapters: { + ...DEFAULT_APP_USE_TOOL_CONFIG.adapters, + ...(patch.adapters ?? {}), + }, + }; +} + +function makeTool(id: number, toolId: string, config: AppUseToolConfig): BuiltInToolStub { + return { + id, + tool_id: toolId, + display_name: toolId, + description: null, + enabled: 0, + risk_level: 'high', + config_json: JSON.stringify(config), + last_used_at: null, + created_at: '', + updated_at: '', + }; +} + +function parseSavedConfig(callIndex: number): AppUseToolConfig { + const patch = queries.updateBuiltInTool.mock.calls[callIndex]?.[1]; + return JSON.parse(patch.config_json); +} + +describe('Settings App Use section', () => { + beforeEach(() => { + vi.clearAllMocks(); + setLocale('en-US'); + const initialConfig = appUseConfig({ + adapters: { + ...DEFAULT_APP_USE_TOOL_CONFIG.adapters, + wps_writer: true, + }, + }); + const tools = [ + makeTool(1, 'app_session', initialConfig), + makeTool(2, 'app_observe', initialConfig), + makeTool(3, 'app_act', initialConfig), + ]; + + queries.findAllBuiltInTools.mockResolvedValue(tools); + queries.updateBuiltInTool.mockImplementation(async (id: number, patch: object) => ({ + ...tools.find((tool) => tool.id === id), + ...patch, + })); + }); + + it('loads the shared App Use config into a dedicated settings panel', async () => { + const wrapper = mount(AppUseSection); + + await flushPromises(); + + expect(wrapper.get('[data-testid="settings-app-use-section"]').text()).toContain('App Use'); + expect( + wrapper.get('[data-testid="settings-app-use-mode-read-only"]').attributes() + ).toHaveProperty('aria-pressed', 'true'); + expect( + wrapper.get('[data-testid="settings-app-use-adapter-wps_writer"]').attributes() + ).toHaveProperty('aria-pressed', 'true'); + expect( + wrapper.get('[data-testid="settings-app-use-adapter-office_word"]').attributes() + ).toHaveProperty('aria-pressed', 'false'); + }); + + it('saves mode and adapter changes to all three App Use tools', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + await wrapper.get('[data-testid="settings-app-use-mode-interactive"]').trigger('click'); + await flushPromises(); + + expect(queries.updateBuiltInTool).toHaveBeenNthCalledWith(1, 1, { + config_json: expect.any(String), + }); + expect(queries.updateBuiltInTool).toHaveBeenNthCalledWith(2, 2, { + config_json: expect.any(String), + }); + expect(queries.updateBuiltInTool).toHaveBeenNthCalledWith(3, 3, { + config_json: expect.any(String), + }); + expect(parseSavedConfig(0).mode).toBe('interactive'); + + queries.updateBuiltInTool.mockClear(); + await wrapper.get('[data-testid="settings-app-use-adapter-office_word"]').trigger('click'); + await flushPromises(); + + expect(parseSavedConfig(0).adapters.office_word).toBe(true); + expect(parseSavedConfig(0).adapters.wps_writer).toBe(true); + }); + + it('enables or disables every App Use tool from the master switch', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + await wrapper.get('[data-testid="settings-app-use-enabled-toggle"]').trigger('click'); + await flushPromises(); + + expect(queries.updateBuiltInTool).toHaveBeenNthCalledWith(1, 1, { enabled: 1 }); + expect(queries.updateBuiltInTool).toHaveBeenNthCalledWith(2, 2, { enabled: 1 }); + expect(queries.updateBuiltInTool).toHaveBeenNthCalledWith(3, 3, { enabled: 1 }); + }); + + it('keeps controls usable when the master switch save fails', async () => { + queries.updateBuiltInTool.mockRejectedValueOnce(new Error('save failed')); + const wrapper = mount(AppUseSection); + await flushPromises(); + + await wrapper.get('[data-testid="settings-app-use-enabled-toggle"]').trigger('click'); + await flushPromises(); + + expect(queries.updateBuiltInTool).toHaveBeenCalledTimes(3); + expect( + wrapper.get('[data-testid="settings-app-use-enabled-toggle"]').attributes() + ).not.toHaveProperty('disabled'); + }); + + it('saves safety limits from the dedicated settings tab to all App Use tools', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + expect(wrapper.find('[data-testid="settings-app-use-background-toggle"]').exists()).toBe( + false + ); + + queries.updateBuiltInTool.mockClear(); + await wrapper.get('[data-testid="settings-app-use-timeout-ms"]').setValue('45000'); + await wrapper.get('[data-testid="settings-app-use-timeout-ms"]').trigger('change'); + await flushPromises(); + + expect(parseSavedConfig(0).timeoutMs).toBe(45000); + expect(queries.updateBuiltInTool).toHaveBeenCalledTimes(3); + + queries.updateBuiltInTool.mockClear(); + await wrapper.get('[data-testid="settings-app-use-max-output-chars"]').setValue('24000'); + await wrapper.get('[data-testid="settings-app-use-max-output-chars"]').trigger('change'); + await flushPromises(); + + expect(parseSavedConfig(0).maxOutputChars).toBe(24000); + expect(queries.updateBuiltInTool).toHaveBeenCalledTimes(3); + }); + + it('does not save unchanged mode or invalid numeric limits', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + queries.updateBuiltInTool.mockClear(); + await wrapper.get('[data-testid="settings-app-use-mode-read-only"]').trigger('click'); + await flushPromises(); + + expect(queries.updateBuiltInTool).not.toHaveBeenCalled(); + + await wrapper.get('[data-testid="settings-app-use-timeout-ms"]').setValue('not-a-number'); + await wrapper.get('[data-testid="settings-app-use-timeout-ms"]').trigger('change'); + await flushPromises(); + + expect(queries.updateBuiltInTool).not.toHaveBeenCalled(); + }); + + it('shows the full App Use phase plan as read-only planning', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + const sectionText = wrapper.get('[data-testid="settings-app-use-section"]').text(); + expect(sectionText).toContain('Phase plan'); + expect(sectionText).toContain('raw scripts'); + expect(sectionText).toContain('raw automation fallbacks'); + expect(sectionText).toContain('P6: Computer Use boundary'); + expect(sectionText).toContain('Out of scope'); + expect(sectionText).not.toContain('UI Automation fallback'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p0-definition-framework"]').text() + ).toContain('Current'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p1-settings"]').text() + ).toContain('Current'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p2-discovery"]').text() + ).toContain('Current'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p3-structured-observe"]').text() + ).toContain('Current'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p4-approval-governance"]').text() + ).toContain('Current'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p5-office-wps-actions"]').text() + ).toContain('Current'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p6-computer-use-boundary"]').text() + ).toContain('Out of scope'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p7-advanced-workflows"]').text() + ).toContain('Planned'); + expect( + wrapper.get('[data-testid="settings-app-use-advanced-p8-adapter-extension"]').text() + ).toContain('Planned'); + + queries.updateBuiltInTool.mockClear(); + await wrapper + .get('[data-testid="settings-app-use-advanced-p7-advanced-workflows"]') + .trigger('click'); + await flushPromises(); + + expect(queries.updateBuiltInTool).not.toHaveBeenCalled(); + }); + + it('keeps localized App Use naming and neutral safety copy', async () => { + setLocale('zh-CN'); + const wrapper = mount(AppUseSection); + await flushPromises(); + + const sectionText = wrapper.get('[data-testid="settings-app-use-section"]').text(); + expect(sectionText).toContain('软件控制'); + expect(sectionText).toContain('原始脚本'); + expect(sectionText).toContain('原始自动化兜底'); + expect(sectionText).toContain('P6:Computer Use 边界'); + expect(sectionText).toContain('不属于软件控制'); + expect(sectionText).not.toContain('UI Automation fallback'); + }); + + it('clamps out-of-range safety limits before saving', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + await wrapper.get('[data-testid="settings-app-use-timeout-ms"]').setValue('250'); + await wrapper.get('[data-testid="settings-app-use-timeout-ms"]').trigger('change'); + await flushPromises(); + + expect(parseSavedConfig(0).timeoutMs).toBe(1000); + + queries.updateBuiltInTool.mockClear(); + await wrapper.get('[data-testid="settings-app-use-max-output-chars"]').setValue('500000'); + await wrapper.get('[data-testid="settings-app-use-max-output-chars"]').trigger('change'); + await flushPromises(); + + expect(parseSavedConfig(0).maxOutputChars).toBe(50000); + }); + + it('keeps App Use execution logs reachable from the dedicated tab', async () => { + const wrapper = mount(AppUseSection); + await flushPromises(); + + const logsTab = wrapper + .findAll('button') + .find((button) => button.text().includes('Call logs')); + expect(logsTab).toBeTruthy(); + await logsTab?.trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-testid="settings-app-use-logs"]').exists()).toBe(true); + expect(wrapper.get('[data-testid="settings-app-use-log-viewer"]').text()).toBe('app_act'); + + await wrapper.get('[data-testid="settings-app-use-log-tool-app_observe"]').trigger('click'); + await flushPromises(); + + expect(wrapper.get('[data-testid="settings-app-use-log-viewer"]').text()).toBe( + 'app_observe' + ); + }); +}); diff --git a/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts b/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts index f6fe1dc5..8fef79bd 100644 --- a/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts @@ -59,6 +59,13 @@ vi.mock('@/views/SettingsView/components/BuiltInTools/index.vue', () => ({ }, })); +vi.mock('@/views/SettingsView/components/AppUse/index.vue', () => ({ + default: { + name: 'AppUseView', + template: '
', + }, +})); + vi.mock('@/views/SettingsView/components/McpTools/index.vue', () => ({ default: { name: 'McpToolsView', @@ -206,6 +213,10 @@ describe('SettingsWindowView', () => { await flushPromises(); expect(loadingState().attributes('variant')).toBe('brand'); + nav.vm.$emit('navigate', 'app-use'); + await flushPromises(); + expect(loadingState().attributes('message')).toBe('正在加载软件控制设置...'); + nav.vm.$emit('navigate', 'mcp-tools'); await flushPromises(); expect(loadingState().attributes('variant')).toBe('brand'); @@ -233,6 +244,10 @@ describe('SettingsWindowView', () => { await flushPromises(); expect(wrapper.find('built-in-tools-view-stub').exists()).toBe(true); + nav.vm.$emit('navigate', 'app-use'); + await flushPromises(); + expect(wrapper.find('app-use-view-stub').exists()).toBe(true); + nav.vm.$emit('navigate', 'mcp-tools'); await flushPromises(); expect(wrapper.find('mcp-tools-view-stub').exists()).toBe(true); diff --git a/docs/app-use-design.md b/docs/app-use-design.md new file mode 100644 index 00000000..9f4a36cb --- /dev/null +++ b/docs/app-use-design.md @@ -0,0 +1,277 @@ +# App Use / 软件控制设计文档 + +关联 issue: https://github.com/TouchAI-org/TouchAI/issues/417 + +## 1. 功能定义 + +**软件控制** 是 TouchAI 面向本地桌面应用的结构化使用能力,英文名为 **App Use**。 + +它的目标不是让模型直接执行 COM、VBA、JS、UXP、脚本或宏,而是把这些不同宿主的技术入口封装成安全、可审计、可配置的应用适配器,让模型通过统一的工具读取和操作受支持的软件。 + +| 能力 | 定义 | +| --- | --- | +| Computer Use | 观察屏幕并执行通用鼠标、键盘动作 | +| Browser Use | 通过浏览器协议控制浏览器 | +| App Use / 软件控制 | 通过结构化应用接口使用本地软件 | + +因此,#417 不应定义成“COM 集成”,而应定义成 **App Use / 软件控制**。COM、Office 对象模型、WPS JSAPI、Photoshop UXP、Illustrator scripting 等只是 adapter 内部可能使用的实现机制。 + +## 2. 命名 + +| 场景 | 名称 | +| --- | --- | +| 中文产品名 | 软件控制 | +| 英文产品名 | App Use | +| 内部工具前缀 | `app_*` | +| 模型工具 | `app_session`, `app_observe`, `app_act` | +| 原生模块建议 | `core::app_use` | + +不使用 `software_control` 作为工具前缀,避免中英文体系分裂。 + +## 3. 总体目标 + +1. 建立一套稳定的内置工具接口,用于发现、读取和操作受支持的本地应用。 +2. 支持首批应用族:Microsoft Office、WPS Office、Photoshop、Illustrator。 +3. 支持按应用族启用、禁用和配置能力。 +4. 默认关闭,启用后也默认偏向只读模式。 +5. 所有写入或可能改变应用状态的动作都需要审批。 +6. 所有操作都记录目标应用、目标文档、动作、审批状态、结果摘要。 +7. 不向模型暴露 raw script、raw COM、raw VBA、raw UXP batchPlay、宏或任意宿主命令。 +8. 不做 Windows UI Automation fallback;这属于 Computer Use,不属于软件控制。 +9. #417 的完整目标是分阶段实现 P0 到 P8 的全部能力;第一阶段只负责打底,不代表最终范围收缩。 +10. 先建立框架,再按 adapter 一点点实现具体功能,确保后续 Office/WPS 写动作、Adobe 深化、高级工作流和 adapter 扩展规范都能继续落地。 + +## 4. 非目标 + +1. 不在 #417 中实现通用屏幕点击、键盘输入或 UI Automation 兜底。 +2. 不允许模型提交任意脚本让宿主应用执行。 +3. 不支持后台隐藏文档的无感修改。 +4. 不支持宏执行、宏创建、插件安装、加载项安装。 +5. 不在第一阶段做完整 Adobe 批处理自动化。 +6. 不在第一阶段做跨平台实现;首版以 Windows 为主。 +7. 不把一次工具调用设计成长时间自主循环;每次 `app_act` 只执行一个有界动作。 + +## 5. 分阶段路线 + +| 阶段 | 目标 | 范围 | +| --- | --- | --- | +| P0 定义与框架 | 建立 App Use / 软件控制边界 | 工具命名、adapter 架构、风险分级、日志、测试骨架 | +| P1 设置系统 | 添加软件控制独立设置 tab | 总开关、按软件启用、只读模式、审批策略、超时、输出限制 | +| P2 软件发现 | 发现可控软件和当前文档 | 是否安装、是否运行、当前活动文档、adapter 能力 | +| P3 结构化读取 | 读取应用上下文 | 文档、选区、工作表、幻灯片、图层、画板等信息 | +| P4 审批治理 | 写动作统一审批 | 目标软件、目标文件、动作说明、影响预览、日志 | +| P5 Office/WPS 写动作 | 第一批实用闭环 | 插入/替换文本、读写单元格、简单幻灯片文本操作 | +| P6 Computer Use 边界 | 明确不在 App Use 中实现 | 通用屏幕观察、鼠标键盘、前台 UI Automation 兜底属于 Computer Use,不作为软件控制功能 | +| P7 高级工作流 | 扩展复杂软件任务 | 导出、批处理、格式调整、跨软件联动;Adobe 若增加轻动作也必须限定在结构化 adapter 内 | +| P8 Adapter 扩展规范 | 支持长期扩展 | 新 adapter 接入规范、测试规范、风险策略、示例 | + +## 6. 工具设计 + +模型只看到三个主工具: + +| 工具 | 用途 | +| --- | --- | +| `app_session` | 发现软件、查看 adapter 状态、列出当前可用目标和能力 | +| `app_observe` | 读取当前应用、文档、选区、表格、幻灯片、图层、画板等结构化上下文 | +| `app_act` | 执行一个受控动作;写动作或高风险动作必须审批 | + +三个工具都应该接受 `description` 字段,用来让模型说明这次操作的意图,并展示到审批 UI 和日志里。 + +工具参数里应使用 adapter id,而不是暴露底层实现方式。例如使用 `office_word`,而不是 `word_com`。 + +## 7. 首批 Adapter + +| Adapter | 第一阶段能力 | 后续能力 | +| --- | --- | --- | +| `office_word` | 活动文档、文档名、选区文本、基础元数据 | 插入文本、替换选区、简单格式 | +| `office_excel` | 活动工作簿、工作表列表、选区、单元格读取 | 写单元格、写区域、简单表格操作 | +| `office_powerpoint` | 活动演示文稿、幻灯片列表、当前幻灯片文本 | 插入文本框、修改简单文本 | +| `wps_writer` | 对齐 Word 的读取能力 | 对齐 Word 的基础写动作 | +| `wps_spreadsheet` | 对齐 Excel 的读取能力 | 对齐 Excel 的基础写动作 | +| `wps_presentation` | 对齐 PowerPoint 的读取能力 | 对齐 PowerPoint 的基础写动作 | +| `photoshop` | 当前文档、画布尺寸、图层列表、当前图层 | 少量安全动作,如选择图层、导出前预览 | +| `illustrator` | 当前文档、画板、图层、选中对象摘要 | 少量安全动作,如选择对象、读取对象属性 | + +Office/WPS 是第一批实用闭环重点。Adobe 先做 discovery 和 read-only observation;后续如增加轻动作,必须在 P7 之后以结构化 adapter 形式单独评估,不能滑向 P6 的通用 Computer Use。 + +## 8. 设置功能 + +软件控制必须默认关闭,并在设置中拥有独立 tab。它不应只藏在“内置工具”列表里的某个配置块中,因为后续会包含多个 adapter、权限策略、读取范围、审批策略、日志入口和高级能力开关。 + +| 设置项 | 默认值 | 说明 | +| --- | --- | --- | +| 软件控制总开关 | 关闭 | 未启用时不暴露任何 `app_*` 工具 | +| 按软件启用 | 全部关闭 | 用户分别启用 Office、WPS、Photoshop、Illustrator | +| 运行模式 | 只读优先 | 可只允许发现和读取,禁用写动作 | +| 写动作审批 | 始终审批 | v1 不提供免审批写动作 | +| 读取范围 | 当前活动应用/文档/选区 | 避免默认读取后台大量内容 | +| 后台操作 | 关闭 | 默认只操作前台或用户明确选择的目标 | +| raw script / macro | 禁止 | v1 不开放配置项 | +| 超时 | 短超时,可配置 | 防止 COM 或宿主 API 卡住执行循环 | +| 输出限制 | 截断长文本 | 避免把整篇文档塞进模型上下文 | +| 日志 | 跟随内置工具日志 | 记录 app、文档、动作、审批、结果 | + +软件控制 tab 内部应把 `app_session`、`app_observe`、`app_act` 作为同一个能力组管理,避免出现只启用一部分工具导致能力不完整的状态。这个 tab 至少应包含: + +1. 总开关和当前风险提示。 +2. 应用 adapter 列表及每个 adapter 的启用状态。 +3. 运行模式和审批策略。 +4. 读取范围、后台操作、输出限制和超时配置。 +5. 最近软件控制日志入口。 +6. 后续高级能力入口,例如导出、批处理、跨软件工作流开关。 + +## 9. 架构设计 + +软件控制应沿用现有 built-in tool 管线: + +1. TypeScript 侧定义 `app_session`、`app_observe`、`app_act` 的工具描述、schema、格式化和审批逻辑。 +2. `BuiltInToolService` 继续负责启用状态、参数解析、审批、日志、事件和执行生命周期。 +3. `NativeService` 提供薄封装,调用 Tauri command。 +4. Rust 侧新增聚合模块,例如 `core::app_use`。 +5. Rust 模块内部通过 adapter trait 分发到 Office、WPS、Photoshop、Illustrator。 +6. adapter 返回结构化 observation 或 receipt,不返回宿主原始输出。 + +建议 Rust 模块结构: + +```text +core/app_use/ + mod.rs + types.rs + runtime.rs + adapters/ + mod.rs + office.rs + wps.rs + photoshop.rs + illustrator.rs +``` + +adapter 接口分为三类: + +```text +discover() -> 应用状态、运行状态、能力列表 +observe(request) -> 结构化上下文 +act(request) -> 执行结果 receipt +``` + +Office/WPS 如果走 COM 或类似自动化接口,应使用 Windows STA 兼容的工作线程,避免阻塞 UI 线程。 + +## 10. 数据流 + +1. 模型调用 `app_session`,发现已启用 adapter、安装状态、运行状态和当前可用目标。 +2. 模型调用 `app_observe`,读取某个 adapter 的当前文档或选区上下文。 +3. TouchAI 返回结构化 observation,包含目标应用、文档、选区、能力和必要摘要。 +4. 模型调用 `app_act`,请求一个有界动作。 +5. TouchAI 根据设置和动作风险生成审批。 +6. 用户批准后,Rust adapter 执行动作并返回 receipt。 +7. TouchAI 记录日志,模型可以再次 observe 确认结果。 + +## 11. 审批与安全策略 + +`app_act` 在 v1 中所有写动作都需要审批。 + +审批 UI 应展示: + +1. 应用名称和 adapter id。 +2. 目标文档、工作簿、演示文稿、图层或画板。 +3. 动作名称。 +4. 模型提供的操作说明。 +5. 变更预览,例如要插入的文本、要写入的单元格、要修改的幻灯片。 +6. 风险标签和风险原因。 + +必须阻止: + +1. raw script、宏、任意宿主命令。 +2. 隐藏窗口或后台文档修改。 +3. 删除文档、关闭不保存、覆盖导出、批量删除等破坏性动作。 +4. 可识别的密码、凭据字段读取或写入。 +5. 目标 app 在设置中未启用的请求。 +6. 一次 `app_act` 中包含多个动作的请求。 + +读取结果应支持截断、摘要和敏感内容提示。需要读取更大范围时,应让模型再次请求更明确的范围。 + +## 12. 第一阶段实现范围 + +第一阶段建议实现 P0 到 P4 的框架,不急着实现大量写动作。它是完整 P0 到 P8 路线的第一步,不是最终交付边界。 + +交付内容: + +1. 新增 `app_session`、`app_observe`、`app_act` built-in tool,默认关闭。 +2. 新增软件控制设置模型和 Settings 独立 tab。 +3. 新增 TypeScript schema、配置解析、结果格式化、审批构造。 +4. 新增 NativeService 命令封装。 +5. 新增 Rust `core::app_use` runtime 和 mockable adapter trait。 +6. 实现或预留 Office/WPS/Photoshop/Illustrator 的 capability discovery。 +7. 实现 read-only observation 的基础结构,至少能返回 active target metadata。 +8. `app_act` 建立审批路径;具体写动作未实现时返回清晰拒绝或 unsupported。 +9. 添加 TS 单测覆盖工具注册、设置、审批、格式化和 native wiring。 +10. 添加 Rust mock adapter 测试覆盖 discover、observe、act gating、超时和 disabled-app 行为。 + +第一阶段验收标准: + +1. 软件控制关闭时,模型看不到任何 `app_*` 工具。 +2. 软件控制开启且处于只读模式时,`app_session` 和 `app_observe` 可工作。 +3. 只读模式下 `app_act` 对写动作给出清晰拒绝。 +4. 未启用的 adapter 不作为可操作目标返回。 +5. 写动作开启后,所有 mutating action 都必须走审批。 +6. 日志包含 app id、operation、status、approval state、result summary。 +7. 任意模型可见 schema 都不接受 raw script 字段。 + +## 13. 后续实现顺序 + +后续目标仍然属于 #417 的整体规划,应按这个顺序逐步推进直到 P0 到 P8 全部完成: + +1. 建立工具组、设置和日志框架。 +2. 做 adapter runtime 和 mock 测试。 +3. 做 `app_session` discovery。 +4. 做 `app_observe` read-only 基础能力。 +5. 打通 `app_act` 审批路径,但先不做复杂写动作。 +6. 补 Office Word / WPS Writer 的选区文本读取和替换。 +7. 补 Excel / WPS Spreadsheet 的选区和单元格读写。 +8. 补 PowerPoint / WPS Presentation 的幻灯片文本读取和简单写入。 +9. 补 Photoshop / Illustrator 的 read-only 深化。 +10. 明确保留 P6 Computer Use 边界,不实现通用前台鼠标、键盘或 UI Automation fallback。 +11. 实现高级工作流,例如导出、批处理、格式调整和跨软件联动;如需 Adobe 安全轻动作,应作为结构化 adapter 的高级工作流单独推进。 +12. 补充 adapter 扩展规范,让新增软件可以按统一接口接入。 + +## 14. 测试计划 + +TypeScript 测试: + +1. built-in tool 注册和启用状态。 +2. 软件控制设置解析、序列化和默认值。 +3. `app_session`、`app_observe`、`app_act` 工具组设置联动。 +4. schema 拒绝 raw script 和隐藏字段。 +5. 审批 payload 格式化。 +6. observation 和 receipt 格式化、截断。 +7. NativeService 命令调用参数。 + +Rust 测试: + +1. runtime 分发到正确 adapter。 +2. disabled adapter 拒绝。 +3. mock discovery 和 observe 输出。 +4. mutating action gating。 +5. timeout 和 cancellation。 +6. Windows ignored smoke tests:真实 Office/WPS/Adobe discovery。 + +完整 PR 前应跑仓库现有要求的验证命令;开发中优先跑 targeted tests。 + +## 15. 参考资料 + +1. #417: https://github.com/TouchAI-org/TouchAI/issues/417 +2. Computer Use RFC #111: https://github.com/TouchAI-org/TouchAI/issues/111 +3. Native computer tools PR #400: https://github.com/TouchAI-org/TouchAI/pull/400 +4. Native browser tools PR #414: https://github.com/TouchAI-org/TouchAI/pull/414 +5. Microsoft COM `CoInitializeEx`: https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex +6. Microsoft Office VBA object model: https://learn.microsoft.com/en-us/office/vba/api/overview/ +7. WPS 开放平台: https://open.wps.cn/docs/ +8. Photoshop UXP: https://developer.adobe.com/photoshop/uxp/ +9. Illustrator scripting: https://ai-scripting.docsforadobe.dev/ + +## 16. 待确认问题 + +这些问题不阻塞执行;实现过程中会优先调研上游产品并自行决定,最终报告决策: + +1. Adobe 是否进入第一阶段 discovery-only,还是等 Office/WPS 读取稳定后再进。 +2. 读取完整选区文本是否需要审批,还是只对超过选区范围的读取请求审批。