diff --git a/Cargo.lock b/Cargo.lock index 22edf84..fcc6cd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2028,7 +2028,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opensoft-open-profiler" -version = "0.1.2" +version = "0.1.3" dependencies = [ "opensoft-open-profiler-core", "serde", @@ -2039,7 +2039,7 @@ dependencies = [ [[package]] name = "opensoft-open-profiler-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "dirs", "libc", diff --git a/README.md b/README.md index 5b1a827..413ed3e 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ cross-platform icons, CI, Dependabot, security policy, and contribution guide. Version tags publish Windows installers through the [`Windows Release`](.github/workflows/windows-release.yml) GitHub Actions workflow. The tag must match the version in `src-tauri/tauri.conf.json`; for -example, version `0.1.2` is released with tag `v0.1.2`. +example, version `0.1.3` is released with tag `v0.1.3`. The tagged GitHub prerelease contains: diff --git a/crates/open-profiler-core/Cargo.toml b/crates/open-profiler-core/Cargo.toml index 8186ea1..5a288f3 100644 --- a/crates/open-profiler-core/Cargo.toml +++ b/crates/open-profiler-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opensoft-open-profiler-core" -version = "0.1.2" +version = "0.1.3" description = "Secure LLM profile discovery and activation for openProfiler" edition.workspace = true license.workspace = true diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index 2ce480f..ce14c75 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -14,6 +14,7 @@ const LEGACY_ACTIVE_MARKER: &str = ".profile-switcher-active.json"; const DESKTOP_ROLLBACK_CREDENTIAL: &str = ".openprofiler-desktop-auth.rollback.json"; const DESKTOP_ROLLBACK_MARKER: &str = ".openprofiler-desktop-rollback.json"; const MAX_CREDENTIAL_BYTES: u64 = 1024 * 1024; +const MAX_PROFILE_DIRECTORY_DEPTH: usize = 7; #[derive(Debug, Error)] pub enum ProfileError { @@ -608,6 +609,15 @@ pub fn activate_profile( } pub fn codex_desktop_status(config: &DiscoveryConfig, desktop_home: &Path) -> CodexDesktopStatus { + let inventory = discover_profiles(config); + codex_desktop_status_from_inventory(config, desktop_home, &inventory) +} + +pub fn codex_desktop_status_from_inventory( + config: &DiscoveryConfig, + desktop_home: &Path, + inventory: &ProfileInventory, +) -> CodexDesktopStatus { let Some(provider_config) = provider_config(config, Provider::Codex) else { return CodexDesktopStatus { file_activation_supported: false, @@ -620,7 +630,7 @@ pub fn codex_desktop_status(config: &DiscoveryConfig, desktop_home: &Path) -> Co }; let credential_state = codex_desktop_credential_state(desktop_home); - let profiles = codex_profile_credentials(config, provider_config); + let profiles = codex_profile_credentials_from_inventory(inventory, provider_config); let mut eligible_profile_paths = profiles .iter() .map(|profile| profile.profile_path.clone()) @@ -866,19 +876,27 @@ fn provider_config(config: &DiscoveryConfig, provider: Provider) -> Option<&Prov fn codex_profile_credentials( config: &DiscoveryConfig, provider_config: &ProviderConfig, +) -> Vec { + let inventory = discover_profiles(config); + codex_profile_credentials_from_inventory(&inventory, provider_config) +} + +fn codex_profile_credentials_from_inventory( + inventory: &ProfileInventory, + provider_config: &ProviderConfig, ) -> Vec { let profile_root = provider_config.profiles_home.join("profiles"); - discover_profiles(config) + inventory .profiles - .into_iter() + .iter() .filter(|profile| profile.provider == Provider::Codex && profile.credential_present) .filter_map(|profile| { let profile_dir = checked_profile_dir(&profile_root, &profile.profile_path).ok()?; let credential_path = profile_dir.join(Provider::Codex.credential_name()); let identity = read_codex_credential_identity(&credential_path).ok()?; Some(CodexProfileCredential { - name: profile.name, - profile_path: profile.profile_path, + name: profile.name.clone(), + profile_path: profile.profile_path.clone(), credential_path, identity, }) @@ -1052,66 +1070,66 @@ fn discover_provider(config: &ProviderConfig) -> (Vec, Vec) { let profile_root = config.profiles_home.join("profiles"); if profile_root.is_dir() { - for entry in WalkDir::new(&profile_root) - .min_depth(2) - .max_depth(8) + let mut entries = WalkDir::new(&profile_root) + .min_depth(1) + .max_depth(MAX_PROFILE_DIRECTORY_DEPTH) .follow_links(false) - .into_iter() - .filter_map(std::result::Result::ok) - .filter(|entry| entry.file_type().is_file() && entry.file_name() == ".profile.json") - { - let metadata_path = entry.into_path(); - let result = read_manifest_profile(&metadata_path).and_then(|mut profile| { - if profile.profile_path.is_none() { - let parent = - metadata_path - .parent() - .ok_or_else(|| ProfileError::InvalidProfile { - profile: profile.name.clone(), - reason: "metadata file has no parent directory".to_string(), - })?; - let relative = parent - .strip_prefix(&profile_root) - .map_err(|_| ProfileError::EscapedProfileRoot(parent.to_path_buf()))?; - profile.profile_path = Some(relative.to_string_lossy().into_owned()); - } - resolve_profile( - profile, - config, - ProfileSource::ProfileMetadata, - &active_marker, - ) - }); + .into_iter(); + + while let Some(entry) = entries.next() { + let Ok(entry) = entry else { + continue; + }; + if !entry.file_type().is_dir() { + continue; + } - match result { - Ok(profile) => { - profiles - .entry(normalized_path_key(&profile.profile_path)) - .or_insert(profile); + let profile_dir = entry.into_path(); + let metadata_path = profile_dir.join(".profile.json"); + let credential_path = profile_dir.join(config.provider.credential_name()); + let has_metadata = ensure_regular_nonempty_file(&metadata_path).is_some(); + let has_credential = ensure_regular_nonempty_file(&credential_path).is_some(); + if !has_metadata && !has_credential { + continue; + } + + // A profile owns everything beneath its directory. Avoid walking + // provider caches, histories, logs, and databases over WSL UNC. + entries.skip_current_dir(); + + if has_metadata { + let result = read_manifest_profile(&metadata_path).and_then(|mut profile| { + if profile.profile_path.is_none() { + let relative = profile_dir + .strip_prefix(&profile_root) + .map_err(|_| ProfileError::EscapedProfileRoot(profile_dir.clone()))?; + profile.profile_path = Some(relative_profile_path(relative)); + } + resolve_profile( + profile, + config, + ProfileSource::ProfileMetadata, + &active_marker, + ) + }); + + match result { + Ok(profile) => { + profiles + .entry(normalized_path_key(&profile.profile_path)) + .or_insert(profile); + } + Err(error) => issues.push(error.to_string()), } - Err(error) => issues.push(error.to_string()), } - } - for entry in WalkDir::new(&profile_root) - .min_depth(2) - .max_depth(8) - .follow_links(false) - .into_iter() - .filter_map(std::result::Result::ok) - .filter(|entry| { - entry.file_type().is_file() - && entry.file_name() == config.provider.credential_name() - }) - { - let credential_path = entry.into_path(); - let Some(parent) = credential_path.parent() else { + if !has_credential { continue; - }; - let Ok(relative) = parent.strip_prefix(&profile_root) else { + } + let Ok(relative) = profile_dir.strip_prefix(&profile_root) else { continue; }; - let profile_path = relative.to_string_lossy().into_owned(); + let profile_path = relative_profile_path(relative); let key = normalized_path_key(&profile_path); if profiles.contains_key(&key) { continue; @@ -1565,6 +1583,10 @@ fn normalized_path_key(path: &str) -> String { path.replace('\\', "/").to_lowercase() } +fn relative_profile_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + fn read_text(path: &Path) -> Result { let mut file = open_file_no_follow(path)?; let mut text = String::new(); @@ -1738,6 +1760,46 @@ mod tests { assert_eq!(profile.source, ProfileSource::ProfileDirectory); } + #[test] + fn stops_walking_when_a_profile_directory_is_found() { + let temp = TempDir::new().unwrap(); + let config = config(&temp); + let codex = provider_config(&config, Provider::Codex); + let profile_dir = codex.profiles_home.join("profiles/client/account-one"); + write_file( + &profile_dir.join(".profile.json"), + r#"{"name":"account-one","family":"client"}"#, + ); + write_file(&profile_dir.join("auth.json"), "codex-secret"); + write_file( + &profile_dir.join("cache/nested/.profile.json"), + r#"{"name":"should-not-be-scanned","family":"cache"}"#, + ); + write_file(&profile_dir.join("cache/nested/auth.json"), "cached-secret"); + + let inventory = discover_profiles(&config); + assert_eq!(inventory.profiles.len(), 1); + assert_eq!(inventory.profiles[0].name, "account-one"); + assert_eq!(inventory.profiles[0].profile_path, "client/account-one"); + } + + #[test] + fn empty_placeholders_do_not_hide_nested_profiles() { + let temp = TempDir::new().unwrap(); + let config = config(&temp); + let codex = provider_config(&config, Provider::Codex); + let family_dir = codex.profiles_home.join("profiles/client"); + write_file(&family_dir.join(".profile.json"), ""); + write_file(&family_dir.join("auth.json"), ""); + write_file(&family_dir.join("account-one/auth.json"), "codex-secret"); + + let inventory = discover_profiles(&config); + assert_eq!(inventory.profiles.len(), 1); + assert_eq!(inventory.profiles[0].name, "account-one"); + assert_eq!(inventory.profiles[0].profile_path, "client/account-one"); + assert!(inventory.profiles[0].credential_present); + } + #[test] fn malformed_provider_manifest_does_not_hide_other_provider() { let temp = TempDir::new().unwrap(); diff --git a/package.json b/package.json index 5a568e8..658f271 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@opensoft/open-profiler", "private": true, - "version": "0.1.2", + "version": "0.1.3", "description": "A full LLM profile manager", "license": "Apache-2.0", "type": "module", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 452918b..334bd79 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opensoft-open-profiler" -version = "0.1.2" +version = "0.1.3" description = "A full LLM profile manager" edition.workspace = true license.workspace = true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 587e960..575d705 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ #[cfg(windows)] use opensoft_open_profiler_core::{ activate_codex_desktop_profile as activate_desktop, - codex_desktop_status as inspect_desktop_credentials, + codex_desktop_status_from_inventory as inspect_desktop_credentials, confirm_codex_desktop_profile as confirm_desktop, rollback_codex_desktop_profile as rollback_desktop, CodexDesktopActivationResult, CodexDesktopStatus, @@ -19,12 +19,6 @@ use std::path::PathBuf; #[cfg(windows)] mod windows_desktop; -#[tauri::command] -fn list_profiles() -> Result { - let config = DiscoveryConfig::from_env().map_err(|error| error.to_string())?; - Ok(discover_profiles(&config)) -} - #[tauri::command] fn activate_profile(provider: Provider, profile_path: String) -> Result { let config = DiscoveryConfig::from_env().map_err(|error| error.to_string())?; @@ -54,38 +48,60 @@ struct DesktopActivationOutcome { relaunched: bool, } -#[tauri::command] -fn desktop_app_status() -> Result { +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AppSnapshot { + inventory: ProfileInventory, + desktop: DesktopAppStatus, +} + +fn app_snapshot_blocking() -> Result { + let config = DiscoveryConfig::from_env().map_err(|error| error.to_string())?; + let inventory = discover_profiles(&config); + #[cfg(not(windows))] { - Ok(DesktopAppStatus { - platform_supported: false, - installed: false, - running: false, - file_activation_supported: false, - credential_store: "unavailable".to_string(), - eligible_profile_paths: Vec::new(), - active_profile_paths: Vec::new(), - rollback_available: false, - message: "GPT app switching requires the native Windows openProfiler app".to_string(), + Ok(AppSnapshot { + inventory, + desktop: DesktopAppStatus { + platform_supported: false, + installed: false, + running: false, + file_activation_supported: false, + credential_store: "unavailable".to_string(), + eligible_profile_paths: Vec::new(), + active_profile_paths: Vec::new(), + rollback_available: false, + message: "GPT app switching requires the native Windows openProfiler app" + .to_string(), + }, }) } #[cfg(windows)] { - let config = DiscoveryConfig::from_env().map_err(|error| error.to_string())?; let home = desktop_home(&config)?; - let credential_status = inspect_desktop_credentials(&config, &home); + let credential_status = inspect_desktop_credentials(&config, &home, &inventory); let app = windows_desktop::inspect(); let message = if !app.installed { "The Windows ChatGPT desktop app was not detected".to_string() } else { credential_status.message.clone() }; - Ok(desktop_status_view(credential_status, app, message)) + Ok(AppSnapshot { + inventory, + desktop: desktop_status_view(credential_status, app, message), + }) } } +#[tauri::command] +async fn app_snapshot() -> Result { + tauri::async_runtime::spawn_blocking(app_snapshot_blocking) + .await + .map_err(|error| format!("profile scan task failed: {error}"))? +} + #[tauri::command] fn activate_codex_desktop_profile( profile_path: String, @@ -224,9 +240,8 @@ fn desktop_activation_outcome( pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ - list_profiles, + app_snapshot, activate_profile, - desktop_app_status, activate_codex_desktop_profile, rollback_codex_desktop_profile, confirm_codex_desktop_profile diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7ebc7dd..b3fb70a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "openProfiler", - "version": "0.1.2", + "version": "0.1.3", "identifier": "com.opensoft.openprofiler", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.tsx b/src/App.tsx index b17ee51..4bd4dac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { } from "./profile-utils"; import type { ActivationResult, + AppSnapshot, DesktopActivationOutcome, DesktopAppStatus, Profile, @@ -21,6 +22,25 @@ import type { Provider, } from "./types"; +const PROFILE_SCAN_TIMEOUT_MS = 20_000; + +async function scanAppSnapshot(): Promise { + let timeoutId = 0; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject( + `Profile scanning did not finish within ${PROFILE_SCAN_TIMEOUT_MS / 1000} seconds. Confirm the configured profile stores are available, then scan again.`, + ); + }, PROFILE_SCAN_TIMEOUT_MS); + }); + + try { + return await Promise.race([invoke("app_snapshot"), timeout]); + } finally { + window.clearTimeout(timeoutId); + } +} + const PROVIDER_FILTERS = [ { value: ALL_PROVIDERS, label: "All" }, { value: "codex", label: "Codex" }, @@ -338,8 +358,9 @@ export default function App() { }); return; } - setInventory(await invoke("list_profiles")); - setDesktop(await invoke("desktop_app_status")); + const snapshot = await scanAppSnapshot(); + setInventory(snapshot.inventory); + setDesktop(snapshot.desktop); } catch (reason) { setError(String(reason)); } finally { diff --git a/src/types.ts b/src/types.ts index 2eaf539..5a9eb02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,11 @@ export interface DesktopAppStatus { message: string; } +export interface AppSnapshot { + inventory: ProfileInventory; + desktop: DesktopAppStatus; +} + export interface DesktopActivationOutcome { profile: string; outgoingProfilesUpdated: number;