Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions src-tauri/src/auth/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,63 @@ use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::types::{AccountsStore, AuthData, StoredAccount};

// ============================================================================
// App Settings (persisted to ~/.codex-switcher/settings.json)
// ============================================================================

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
/// Whether to sync credentials to OpenCode's auth.json on switch
#[serde(default = "default_true")]
pub opencode_sync_enabled: bool,
}

fn default_true() -> bool {
true
}

impl Default for AppSettings {
fn default() -> Self {
Self {
opencode_sync_enabled: true,
}
}
}

/// Get the path to settings.json
fn get_settings_file() -> Result<PathBuf> {
Ok(get_config_dir()?.join("settings.json"))
}

/// Load app settings from disk
pub fn load_settings() -> Result<AppSettings> {
let path = get_settings_file()?;
if !path.exists() {
return Ok(AppSettings::default());
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read settings file: {}", path.display()))?;
let settings: AppSettings = serde_json::from_str(&content).unwrap_or_default();
Ok(settings)
}

/// Save app settings to disk
pub fn save_settings(settings: &AppSettings) -> Result<()> {
let path = get_settings_file()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(settings)
.context("Failed to serialize settings")?;
fs::write(&path, content)
.with_context(|| format!("Failed to write settings file: {}", path.display()))?;
Ok(())
}

/// Get the path to the codex-switcher config directory
pub fn get_config_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not find home directory")?;
Expand Down
103 changes: 103 additions & 0 deletions src-tauri/src/auth/switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,106 @@ pub fn has_active_login() -> Result<bool> {
None => Ok(false),
}
}

// ============================================================================
// OpenCode auth.json support
// ============================================================================

/// Get the path to OpenCode's auth.json file
fn get_opencode_auth_file() -> Result<PathBuf> {
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
// OpenCode uses ~/.local/share on macOS and Windows.
// See: https://github.com/anomalyco/opencode/issues/5238
let home = dirs::home_dir().context("Could not find home directory")?;
Ok(home
.join(".local")
.join("share")
.join("opencode")
.join("auth.json"))
}

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
// On Linux: $XDG_DATA_HOME/opencode/auth.json (or ~/.local/share/...)
let data_dir = dirs::data_dir().context("Could not find data directory")?;
Ok(data_dir.join("opencode").join("auth.json"))
}
}

/// Switch account in OpenCode's auth.json.
///
/// This function **merges** with the existing file content so that other
/// provider entries (e.g. `"groq"`) are preserved untouched. Only the
/// `"openai"` key is updated.
pub fn switch_to_opencode(account: &StoredAccount) -> Result<()> {
let auth_path = get_opencode_auth_file()?;

// 1. Read existing file into a generic JSON map to preserve other keys
let mut auth_map: serde_json::Map<String, serde_json::Value> = if auth_path.exists() {
let content = fs::read_to_string(&auth_path).with_context(|| {
format!("Failed to read OpenCode auth.json: {}", auth_path.display())
})?;
serde_json::from_str(&content).unwrap_or_default()
} else {
serde_json::Map::new()
};

// 2. Build the new "openai" provider value from account credentials
let openai_value = match &account.auth_data {
AuthData::ChatGPT {
access_token,
refresh_token,
account_id,
..
} => {
serde_json::json!({
"type": "oauth",
"refresh": refresh_token,
"access": access_token,
"expires": opencode_expires_timestamp(),
"accountId": account_id.clone().unwrap_or_default(),
})
}
AuthData::ApiKey { key } => {
serde_json::json!({
"type": "api",
"key": key,
})
}
};

// 3. Replace only the "openai" key, leaving everything else intact
auth_map.insert("openai".to_string(), openai_value);

// 4. Write back the full merged content
if let Some(parent) = auth_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create OpenCode config dir: {}", parent.display())
})?;
}

let content = serde_json::to_string_pretty(&auth_map)
.context("Failed to serialize OpenCode auth.json")?;

fs::write(&auth_path, &content).with_context(|| {
format!(
"Failed to write OpenCode auth.json: {}",
auth_path.display()
)
})?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&auth_path, perms)?;
}

Ok(())
}

/// Calculate an expiry timestamp ~7 days from now, in milliseconds (OpenCode format).
fn opencode_expires_timestamp() -> i64 {
(Utc::now() + chrono::Duration::days(7)).timestamp_millis()
}
40 changes: 38 additions & 2 deletions src-tauri/src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,16 @@ pub async fn add_account_from_file(path: String, name: String) -> Result<Account
Ok(AccountInfo::from_stored(&stored, active_id))
}

/// Result of a switch operation, telling the frontend what happened
#[derive(Debug, serde::Serialize)]
pub struct SwitchResult {
/// Whether OpenCode auth.json was also updated
pub opencode_synced: bool,
}

/// Switch to a different account
#[tauri::command]
pub async fn switch_account(account_id: String) -> Result<(), String> {
pub async fn switch_account(account_id: String) -> Result<SwitchResult, String> {
let store = load_accounts().map_err(|e| e.to_string())?;

// Find the account
Expand All @@ -124,6 +131,20 @@ pub async fn switch_account(account_id: String) -> Result<(), String> {
// Write to ~/.codex/auth.json
switch_to_account(account).map_err(|e| e.to_string())?;

// Conditionally sync to OpenCode's auth.json (merge, not overwrite)
let settings = crate::auth::load_settings().unwrap_or_default();
let opencode_synced = if settings.opencode_sync_enabled {
match crate::auth::switch_to_opencode(account) {
Ok(()) => true,
Err(e) => {
eprintln!("Warning: Failed to update OpenCode auth.json: {e}");
false
}
}
} else {
false
};

// Update the active account in our store
set_active_account(&account_id).map_err(|e| e.to_string())?;

Expand All @@ -150,7 +171,7 @@ pub async fn switch_account(account_id: String) -> Result<(), String> {
}
}

Ok(())
Ok(SwitchResult { opencode_synced })
}

/// Remove an account
Expand Down Expand Up @@ -702,3 +723,18 @@ pub async fn get_masked_account_ids() -> Result<Vec<String>, String> {
pub async fn set_masked_account_ids(ids: Vec<String>) -> Result<(), String> {
crate::auth::storage::set_masked_account_ids(ids).map_err(|e| e.to_string())
}

/// Get whether OpenCode sync is enabled
#[tauri::command]
pub async fn get_opencode_sync_enabled() -> Result<bool, String> {
let settings = crate::auth::load_settings().map_err(|e| e.to_string())?;
Ok(settings.opencode_sync_enabled)
}

/// Set whether OpenCode sync is enabled
#[tauri::command]
pub async fn set_opencode_sync_enabled(enabled: bool) -> Result<(), String> {
let mut settings = crate::auth::load_settings().unwrap_or_default();
settings.opencode_sync_enabled = enabled;
crate::auth::save_settings(&settings).map_err(|e| e.to_string())
}
10 changes: 7 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ pub mod types;
use commands::{
add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account,
export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info,
get_masked_account_ids, get_usage, import_accounts_full_encrypted_file,
import_accounts_slim_text, list_accounts, refresh_all_accounts_usage, rename_account,
set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts,
get_masked_account_ids, get_opencode_sync_enabled, get_usage,
import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts,
refresh_all_accounts_usage, rename_account, set_masked_account_ids,
set_opencode_sync_enabled, start_login, switch_account, warmup_account, warmup_all_accounts,
};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand Down Expand Up @@ -51,6 +52,9 @@ pub fn run() {
warmup_all_accounts,
// Process detection
check_codex_processes,
// Settings
get_opencode_sync_enabled,
set_opencode_sync_enabled,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
20 changes: 20 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -1 +1,21 @@
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

:root {
color-scheme: light;
}

:root.dark {
color-scheme: dark;
}

body {
background: #f8fafc;
color: #0f172a;
}

:root.dark body {
background: #020617;
color: #e2e8f0;
}
Loading