Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
@@ -0,0 +1,443 @@
use crate::{DesktopRuntime, SettingsStore};
use serde::Serialize;
use std::{
collections::{HashSet, VecDeque},
path::{Path, PathBuf},
time::UNIX_EPOCH,
};
use tokio::fs;
const DEFAULT_FILE_SEARCH_LIMIT: usize = 60;
const MAX_FILE_SEARCH_LIMIT: usize = 400;
const FILE_SEARCH_MAX_CONCURRENCY: usize = 5;
const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[
"node_modules",
".git",
"dist",
"build",
".next",
".turbo",
".cache",
"coverage",
"tmp",
"logs",
];
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileListEntry {
name: String,
path: String,
is_directory: bool,
is_file: bool,
is_symbolic_link: bool,
size: Option<u64>,
modified_time: Option<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryListResult {
directory: String,
path: String,
entries: Vec<FileListEntry>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectoryResponse {
success: bool,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSearchHit {
name: String,
path: String,
relative_path: String,
extension: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFilesResponse {
root: String,
count: usize,
files: Vec<FileSearchHit>,
}
#[derive(Debug)]
enum FsCommandError {
NotFound,
AccessDenied,
NotDirectory,
OutsideWorkspace,
Other(String),
}
impl FsCommandError {
fn to_list_message(&self) -> String {
match self {
FsCommandError::NotFound => "Directory not found".to_string(),
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to list directory".to_string()
}
}
}
fn to_search_message(&self) -> String {
match self {
FsCommandError::NotFound => "Directory not found".to_string(),
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to search files".to_string()
}
}
}
fn to_create_message(&self) -> String {
match self {
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Parent path must be a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to create directory".to_string()
}
FsCommandError::NotFound => "Parent directory not found".to_string(),
}
}
}
impl From<std::io::Error> for FsCommandError {
fn from(error: std::io::Error) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => FsCommandError::NotFound,
std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied,
_ => FsCommandError::Other(error.to_string()),
}
}
}
#[tauri::command]
pub async fn list_directory(
path: Option<String>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<DirectoryListResult, String> {
let workspace_root = resolve_workspace_root(state.settings()).await;
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref())
.await
.map_err(|err| err.to_list_message())?;
let metadata = fs::metadata(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
if !metadata.is_dir() {
return Err(FsCommandError::NotDirectory.to_list_message());
}
// Re-check boundary after canonicalization to guard against traversal
if let Some(root) = &workspace_root {
if !resolved_path.starts_with(root) {
return Err(FsCommandError::OutsideWorkspace.to_list_message());
}
}
let mut entries = Vec::new();
let mut dir_entries = fs::read_dir(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
while let Some(entry) = dir_entries
.next_entry()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?
{
let file_type = entry
.file_type()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let mut is_directory = file_type.is_dir();
let is_symlink = file_type.is_symlink();
if !is_directory && is_symlink {
if let Ok(link_meta) = fs::metadata(&entry_path).await {
is_directory = link_meta.is_dir();
}
}
let metadata = fs::metadata(&entry_path).await.ok();
let size = metadata
.as_ref()
.filter(|meta| meta.is_file())
.map(|meta| meta.len());
let modified_time = metadata
.and_then(|meta| meta.modified().ok())
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as i64);
entries.push(FileListEntry {
name,
path: normalize_path(&entry_path),
is_directory,
is_file: file_type.is_file(),
is_symbolic_link: is_symlink,
size,
modified_time,
});
}
Ok(DirectoryListResult {
directory: normalize_path(&resolved_path),
path: normalize_path(&resolved_path),
entries,
})
}
#[tauri::command]
pub async fn search_files(
directory: Option<String>,
query: Option<String>,
max_results: Option<usize>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<SearchFilesResponse, String> {
let workspace_root = resolve_workspace_root(state.settings()).await;
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref())
.await
.map_err(|err| err.to_search_message())?;
let limit = clamp_search_limit(max_results);
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
let match_all = normalized_query.is_empty();
let mut files = Vec::new();
let mut queue = VecDeque::new();
let mut visited = HashSet::new();
queue.push_back(resolved_root.clone());
visited.insert(resolved_root.clone());
while !queue.is_empty() && files.len() < limit {
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
let Some(dir) = queue.pop_front() else {
break;
};
let mut entries = match fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(_) => continue,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(file_type) = entry.file_type().await else {
continue;
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.is_empty() || name_str.starts_with('.') {
continue;
}
let entry_path = entry.path();
if file_type.is_dir() {
if should_skip_directory(&name_str) {
continue;
}
if visited.insert(entry_path.clone()) && files.len() < limit {
queue.push_back(entry_path);
}
continue;
}
if !file_type.is_file() {
continue;
}
let relative_path = relative_path(&resolved_root, &entry_path);
if !match_all {
let lowercase_name = name_str.to_lowercase();
let lowercase_path = relative_path.to_lowercase();
if !lowercase_name.contains(&normalized_query)
&& !lowercase_path.contains(&normalized_query)
{
continue;
}
}
let extension = entry_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase());
files.push(FileSearchHit {
name: name_str.to_string(),
path: normalize_path(&entry_path),
relative_path: relative_path.replace('\\', "/"),
extension,
});
if files.len() >= limit {
break;
}
}
if files.len() >= limit {
break;
}
}
}
Ok(SearchFilesResponse {
root: normalize_path(&resolved_root),
count: files.len(),
files,
})
}
#[tauri::command]
pub async fn create_directory(
path: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<CreateDirectoryResponse, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Path is required".to_string());
}
let workspace_root = resolve_workspace_root(state.settings()).await;
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref())
.await
.map_err(|err| err.to_create_message())?;
fs::create_dir_all(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_create_message())?;
Ok(CreateDirectoryResponse {
success: true,
path: normalize_path(&resolved_path),
})
}
async fn resolve_sandboxed_path(
path: Option<String>,
workspace_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> {
let candidate_input = path
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let candidate_path = match (candidate_input, workspace_root) {
(Some(value), _) => PathBuf::from(value),
(None, Some(root)) => root.clone(),
(None, None) => default_home_directory(),
};
let resolved = if candidate_path.is_absolute() {
candidate_path
} else if let Some(root) = workspace_root {
root.join(candidate_path)
} else {
default_home_directory().join(candidate_path)
};
let canonicalized = fs::canonicalize(&resolved)
.await
.map_err(FsCommandError::from)?;
if let Some(root) = workspace_root {
if !canonicalized.starts_with(root) {
return Err(FsCommandError::OutsideWorkspace);
}
}
Ok(canonicalized)
}
async fn resolve_creatable_path(
path: &str,
workspace_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> {
let candidate = PathBuf::from(path);
if candidate.as_os_str().is_empty() {
return Err(FsCommandError::Other("Path is required".to_string()));
}
let absolute = if candidate.is_absolute() {
candidate
} else if let Some(root) = workspace_root {
root.join(candidate)
} else {
default_home_directory().join(candidate)
};
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
let canonical_parent = fs::canonicalize(parent)
.await
.map_err(FsCommandError::from)?;
if let Some(root) = workspace_root {
if !canonical_parent.starts_with(root) {
return Err(FsCommandError::OutsideWorkspace);
}
}
Ok(absolute)
}
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
if let Ok(Some(last_dir)) = settings.last_directory().await {
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
return Some(canonicalized);
}
}
None
}
fn default_home_directory() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
fn clamp_search_limit(value: Option<usize>) -> usize {
let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT);
limit.clamp(1, MAX_FILE_SEARCH_LIMIT)
}
fn should_skip_directory(name: &str) -> bool {
if name.starts_with('.') {
return true;
}
FILE_SEARCH_EXCLUDED_DIRS
.iter()
.any(|dir| dir.eq_ignore_ascii_case(name))
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn relative_path(root: &Path, target: &Path) -> String {
target
.strip_prefix(root)
.map(|relative| normalize_path(relative))
.unwrap_or_else(|_| normalize_path(target))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
use crate::logging::log_file_path;
use serde::Serialize;
use tokio::fs;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopLogFile {
pub file_name: String,
pub content: String,
}
#[tauri::command]
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
let content = fs::read_to_string(&path)
.await
.map_err(|err| format!("Failed to read log file: {err}"))?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("desktop.log")
.to_string();
Ok(DesktopLogFile { file_name, content })
}
@@ -0,0 +1,7 @@
pub mod files;
pub mod git;
pub mod logs;
pub mod permissions;
pub mod settings;
pub mod terminal;
pub mod notifications;
@@ -0,0 +1,37 @@
use serde::Deserialize;
use tauri::{AppHandle, Runtime};
use tauri_plugin_notification::NotificationExt;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPayload {
pub title: Option<String>,
pub body: Option<String>,
}
#[tauri::command]
pub async fn desktop_notify<R: Runtime>(
app: AppHandle<R>,
payload: Option<NotificationPayload>,
) -> Result<bool, String> {
let title = payload
.as_ref()
.and_then(|p| p.title.as_deref())
.unwrap_or("OpenChamber");
let body = payload
.as_ref()
.and_then(|p| p.body.as_deref())
.unwrap_or("Task completed");
match app
.notification()
.builder()
.title(title)
.body(body)
.sound("Glass")
.show()
{
Ok(_) => Ok(true),
Err(e) => Err(e.to_string()),
}
}
@@ -0,0 +1,209 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri::State;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPermissionRequest {
path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPermissionResult {
success: bool,
path: Option<String>,
error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartAccessingResult {
success: bool,
error: Option<String>,
}
/// Process directory selection from frontend
/// Updates settings with lastDirectory
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
#[tauri::command]
pub async fn process_directory_selection(
path: String,
state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
use std::path::PathBuf;
// Validate directory exists
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Path is not a directory".to_string()),
});
}
// Update settings with lastDirectory
let mut settings = state
.settings()
.load()
.await
.map_err(|e| format!("Failed to load settings: {}", e))?;
if let Some(obj) = settings.as_object_mut() {
obj.insert(
"lastDirectory".to_string(),
serde_json::Value::String(path.clone()),
);
}
state
.settings()
.save(settings)
.await
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
info!(
"[permissions] Updated settings with lastDirectory: {}",
path
);
Ok(DirectoryPermissionResult {
success: true,
path: Some(path),
error: None,
})
}
/// Legacy directory picker command (frontend handles actual dialog)
#[tauri::command]
pub async fn pick_directory(
_app_handle: AppHandle,
_state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some(
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
),
})
}
/// Request directory access (desktop implementation)
/// For unsandboxed apps, just validates the path is accessible
#[tauri::command]
pub async fn request_directory_access(
request: DirectoryPermissionRequest,
_state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
let path = request.path;
let path_buf = std::path::PathBuf::from(&path);
if !path_buf.exists() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Path is not a directory".to_string()),
});
}
// For unsandboxed apps, no bookmark needed - just verify access
match std::fs::read_dir(&path_buf) {
Ok(_) => Ok(DirectoryPermissionResult {
success: true,
path: Some(path),
error: None,
}),
Err(e) => Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some(format!("Cannot access directory: {}", e)),
}),
}
}
/// Start accessing directory (desktop implementation)
#[tauri::command]
pub async fn start_accessing_directory(
path: String,
_state: State<'_, DesktopRuntime>,
) -> Result<StartAccessingResult, String> {
// Check if directory exists and is accessible
let path_buf = std::path::PathBuf::from(&path);
if !path_buf.exists() {
return Ok(StartAccessingResult {
success: false,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(StartAccessingResult {
success: false,
error: Some("Path is not a directory".to_string()),
});
}
// Try to read the directory to verify access
match std::fs::read_dir(&path_buf) {
Ok(_) => {
info!("Successfully started accessing directory: {}", path);
Ok(StartAccessingResult {
success: true,
error: None,
})
}
Err(e) => {
warn!("Failed to access directory {}: {}", path, e);
Ok(StartAccessingResult {
success: false,
error: Some(format!("Failed to access directory: {}", e)),
})
}
}
}
/// Stop accessing directory (desktop implementation)
#[tauri::command]
pub async fn stop_accessing_directory(
_path: String,
_state: State<'_, DesktopRuntime>,
) -> Result<StartAccessingResult, String> {
// For Stage 1, just confirm the operation
// Full implementation would call stopAccessingSecurityScopedResource
info!("Stopped accessing directory");
Ok(StartAccessingResult {
success: true,
error: None,
})
}
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
#[tauri::command]
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
// For unsandboxed apps, no bookmarks needed
// Directory access is restored from settings.lastDirectory
info!("[permissions] Bookmark restore not needed for unsandboxed app");
Ok(())
}
@@ -0,0 +1,343 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashSet;
use tauri::State;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsLoadResult {
settings: Value,
source: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestartResult {
restarted: bool,
}
/// Load settings from disk (matches Express handler behavior)
#[tauri::command]
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
let settings = state
.settings()
.load()
.await
.map_err(|e| format!("Failed to load settings: {}", e))?;
Ok(SettingsLoadResult {
settings,
source: "desktop".to_string(),
})
}
/// Save settings to disk with merge logic matching Express implementation
#[tauri::command]
pub async fn save_settings(
changes: Value,
state: State<'_, DesktopRuntime>,
) -> Result<Value, String> {
// Load current settings
let current = state
.settings()
.load()
.await
.map_err(|e| format!("Failed to load current settings: {}", e))?;
// Sanitize incoming changes
let sanitized_changes = sanitize_settings_update(&changes);
// Merge changes into current settings
let merged = merge_persisted_settings(&current, &sanitized_changes);
// Save merged settings
state
.settings()
.save(merged.clone())
.await
.map_err(|e| format!("Failed to save settings: {}", e))?;
// Format response
Ok(format_settings_response(&merged))
}
/// Restart OpenCode CLI (matches Express /api/config/reload)
#[tauri::command]
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
state
.opencode
.restart()
.await
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
Ok(RestartResult { restarted: true })
}
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
fn sanitize_settings_update(payload: &Value) -> Value {
let mut result = json!({});
if let Some(obj) = payload.as_object() {
let result_obj = result.as_object_mut().unwrap();
// String fields
if let Some(Value::String(s)) = obj.get("themeId") {
if !s.is_empty() {
result_obj.insert("themeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("themeVariant") {
if s == "light" || s == "dark" {
result_obj.insert("themeVariant".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("lightThemeId") {
if !s.is_empty() {
result_obj.insert("lightThemeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("darkThemeId") {
if !s.is_empty() {
result_obj.insert("darkThemeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("lastDirectory") {
if !s.is_empty() {
result_obj.insert("lastDirectory".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("homeDirectory") {
if !s.is_empty() {
result_obj.insert("homeDirectory".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("uiFont") {
if !s.is_empty() {
result_obj.insert("uiFont".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("monoFont") {
if !s.is_empty() {
result_obj.insert("monoFont".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
if !s.is_empty() {
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
}
}
// Boolean fields
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
result_obj.insert("useSystemTheme".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
result_obj.insert("showReasoningTraces".to_string(), json!(b));
}
// Array fields
if let Some(arr) = obj.get("approvedDirectories") {
result_obj.insert(
"approvedDirectories".to_string(),
normalize_string_array(arr),
);
}
if let Some(arr) = obj.get("securityScopedBookmarks") {
result_obj.insert(
"securityScopedBookmarks".to_string(),
normalize_string_array(arr),
);
}
if let Some(arr) = obj.get("pinnedDirectories") {
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
}
// Typography sizes object (partial)
if let Some(typo) = obj.get("typographySizes") {
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
result_obj.insert("typographySizes".to_string(), sanitized);
}
}
}
result
}
/// Merge persisted settings (port of Express mergePersistedSettings)
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
let mut result = current.clone();
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
// First apply all changes
for (key, value) in changes_obj {
result_obj.insert(key.clone(), value.clone());
}
// Build approvedDirectories from base + additional
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
extract_string_vec(arr)
} else if let Some(arr) = current.get("approvedDirectories") {
extract_string_vec(arr)
} else {
vec![]
};
let mut additional_approved = vec![];
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
if !s.is_empty() {
additional_approved.push(s.clone());
}
}
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
if !s.is_empty() {
additional_approved.push(s.clone());
}
}
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
for item in additional_approved {
approved_set.insert(item);
}
let approved_vec: Vec<String> = approved_set.into_iter().collect();
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
// Security scoped bookmarks
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
extract_string_vec(arr)
} else if let Some(arr) = current.get("securityScopedBookmarks") {
extract_string_vec(arr)
} else {
vec![]
};
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
// Merge typography sizes if present
if changes_obj.contains_key("typographySizes") {
let current_typo = current
.get("typographySizes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let changes_typo = changes_obj
.get("typographySizes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let mut merged_typo = current_typo;
for (key, value) in changes_typo {
merged_typo.insert(key, value);
}
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
}
}
result
}
/// Format settings response (port of Express formatSettingsResponse)
fn format_settings_response(settings: &Value) -> Value {
let mut result = sanitize_settings_update(settings);
if let Some(obj) = result.as_object_mut() {
// Ensure array fields are normalized
obj.insert(
"approvedDirectories".to_string(),
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
);
obj.insert(
"securityScopedBookmarks".to_string(),
normalize_string_array(
settings
.get("securityScopedBookmarks")
.unwrap_or(&json!([])),
),
);
obj.insert(
"pinnedDirectories".to_string(),
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
);
// Typography sizes
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
settings.get("typographySizes").unwrap_or(&json!(null)),
) {
obj.insert("typographySizes".to_string(), sanitized_typo);
}
// showReasoningTraces with fallback
let show_reasoning = settings
.get("showReasoningTraces")
.and_then(|v| v.as_bool())
.or_else(|| {
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
Some(*b)
} else {
None
}
})
.unwrap_or(false);
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
}
result
}
/// Normalize string array helper
fn normalize_string_array(input: &Value) -> Value {
if let Some(arr) = input.as_array() {
let strings: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let unique: HashSet<String> = strings.into_iter().collect();
json!(unique.into_iter().collect::<Vec<_>>())
} else {
json!([])
}
}
/// Sanitize typography sizes partial helper
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
if let Some(obj) = input.as_object() {
let mut result = serde_json::Map::new();
let mut populated = false;
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
if let Some(Value::String(s)) = obj.get(*key) {
if !s.is_empty() {
result.insert(key.to_string(), json!(s));
populated = true;
}
}
}
if populated {
Some(json!(result))
} else {
None
}
} else {
None
}
}
/// Extract string vector from JSON value
fn extract_string_vec(value: &Value) -> Vec<String> {
if let Some(arr) = value.as_array() {
arr.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
} else {
vec![]
}
}
@@ -0,0 +1,308 @@
use log::error;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
env,
io::{Read, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use tauri::{Emitter, State, Window};
const DEFAULT_SHELL: &str = "/bin/zsh";
const DEFAULT_TERM: &str = "xterm-256color";
const DEFAULT_COLORTERM: &str = "truecolor";
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
const TERM_PROGRAM_NAME: &str = "OpenChamber";
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct TerminalSession {
pub master: Box<dyn MasterPty + Send>,
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
}
pub struct TerminalState {
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
}
impl TerminalState {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
}
#[derive(Deserialize)]
pub struct CreateTerminalPayload {
pub cols: u16,
pub rows: u16,
pub cwd: Option<String>,
}
#[derive(Serialize)]
pub struct CreateTerminalResponse {
pub session_id: String,
}
#[tauri::command]
pub async fn create_terminal_session(
payload: CreateTerminalPayload,
state: State<'_, TerminalState>,
window: Window,
) -> Result<CreateTerminalResponse, String> {
let pty_system = NativePtySystem::default();
let size = PtySize {
rows: payload.rows,
cols: payload.cols,
pixel_width: 0,
pixel_height: 0,
};
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
let shell_path = resolve_shell();
let mut cmd = CommandBuilder::new(&shell_path);
if shell_accepts_login_flag(&shell_path) {
cmd.arg("-l");
}
if let Some(cwd) = working_dir.to_str() {
cmd.cwd(cwd);
}
apply_terminal_environment(&mut cmd, &shell_path);
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let writer = Arc::new(Mutex::new(
pair.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
));
let master = pair.master;
let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().unwrap().insert(
session_id.clone(),
TerminalSession {
master,
writer: writer.clone(),
child: child.clone(),
},
);
spawn_reader_thread(reader, window.clone(), session_id.clone());
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
Ok(CreateTerminalResponse { session_id })
}
#[tauri::command]
pub async fn send_terminal_input(
session_id: String,
data: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let sessions = state.sessions.lock().unwrap();
let Some(session) = sessions.get(&session_id) else {
return Err("Terminal session not found".to_string());
};
let mut writer = session
.writer
.lock()
.map_err(|_| "Terminal busy".to_string())?;
writer
.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
writer
.flush()
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn resize_terminal(
session_id: String,
cols: u16,
rows: u16,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let mut sessions = state.sessions.lock().unwrap();
let Some(session) = sessions.get_mut(&session_id) else {
return Err("Terminal session not found".to_string());
};
session
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn close_terminal(
session_id: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let session = {
let mut sessions = state.sessions.lock().unwrap();
sessions.remove(&session_id)
};
if let Some(session) = session {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
}
Ok(())
}
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
thread::spawn(move || {
let mut buffer = [0u8; 4096];
let event_name = format!("terminal://{}", session_id);
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
if data.is_empty() {
continue;
}
if let Err(error) =
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
{
error!("Failed to emit terminal data: {error}");
break;
}
}
Err(error) => {
error!("Terminal read error: {error}");
break;
}
}
}
});
}
fn spawn_exit_watcher(
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
window: Window,
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
session_id: String,
) {
thread::spawn(move || {
let status = {
let mut guard = child.lock().expect("terminal child poisoned");
guard.wait()
};
let (exit_code, signal) = match status {
Ok(status) => (
status.exit_code() as i32,
status.signal().map(|sig| sig.to_string()),
),
Err(err) => {
error!("Failed to wait for terminal exit: {err}");
(1, Some("Terminal crashed".to_string()))
}
};
let event_name = format!("terminal://{}", session_id);
let payload = serde_json::json!({
"type": "exit",
"exitCode": exit_code,
"signal": signal
});
let _ = window.emit(&event_name, payload);
let mut sessions = sessions.lock().unwrap();
sessions.remove(&session_id);
});
}
fn resolve_shell() -> String {
env::var("SHELL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
}
fn shell_accepts_login_flag(shell_path: &str) -> bool {
let shell_name = Path::new(shell_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(shell_path)
.to_lowercase();
matches!(
shell_name.as_str(),
name if name.contains("zsh")
|| name.contains("bash")
|| name.contains("sh")
|| name.contains("fish")
|| name.contains("ksh")
)
}
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
let maybe_path = input
.map(|value| PathBuf::from(value))
.or_else(|| dirs::home_dir());
let Some(path) = maybe_path else {
return Err("Unable to determine working directory".to_string());
};
if !path.exists() || !path.is_dir() {
return Err(format!(
"Working directory is not accessible: {}",
path.display()
));
}
Ok(path)
}
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
cmd.env(
"TERM",
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
);
cmd.env(
"COLORTERM",
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
);
cmd.env(
"LC_ALL",
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
);
cmd.env(
"LANG",
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
);
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
cmd.env("OPENCHAMBER_DESKTOP", "1");
cmd.env("SHELL", shell_path);
}