Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ windows = { version = "0.62", features = ["UI_Notifications"] }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.6.2"
objc2 = "0.6"
sha2 = "0.10"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSButton", "NSColor", "NSControl", "NSDockTile", "NSGraphics", "NSImage", "NSLayoutAnchor", "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSResponder", "NSUserInterfaceItemIdentification", "NSView", "NSVisualEffectView", "NSWindow", "objc2-core-foundation"] }
objc2-foundation = { version = "0.3", features = ["NSGeometry", "NSError", "NSString"] }
objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "UNNotification", "UNNotificationAction", "UNNotificationCategory", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationSound", "block2"] }
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ pub fn harness_spawn(
command: String,
args: Vec<String>,
cwd: String,
env: Option<HashMap<String, String>>,
) -> Result<u32, String> {
let (epoch, kill_all, prev) = host.begin_spawn(&session_id);
if let Some(prev) = prev {
Expand All @@ -347,6 +348,14 @@ pub fn harness_spawn(
.stdout(Stdio::piped())
.stderr(Stdio::piped());
prepare_child(&mut cmd, &command);
// Caller-supplied overrides (e.g. a per-session CLAUDE_CONFIG_DIR for
// profile switching) — applied last so they win over `prepare_child`'s
// defaults.
if let Some(env) = &env {
for (key, value) in env {
cmd.env(key, value);
}
}

crate::control::configure_child(&app, &session_id, &mut cmd);

Expand Down
91 changes: 70 additions & 21 deletions src-tauri/src/rate_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::Serialize;
use serde_json::Value;
#[cfg(target_os = "macos")]
use sha2::{Digest, Sha256};

use crate::dirs_home;

Expand Down Expand Up @@ -48,15 +50,21 @@ fn usage_result(

/// Fetch Claude Code 5-hour / weekly usage via the local OAuth token.
/// The token never leaves the host process.
///
/// `config_dir` is the CLAUDE_CONFIG_DIR the caller's session is actually
/// running under (None for the default, no-override account) — each
/// profile's login is stored separately (a suffixed Keychain service, or a
/// credentials file inside that config dir), so reading the wrong one
/// silently reports a different account's usage than the one in use.
#[tauri::command]
pub async fn fetch_claude_usage() -> Result<ClaudeUsageFetch, String> {
tauri::async_runtime::spawn_blocking(fetch_claude_usage_sync)
pub async fn fetch_claude_usage(config_dir: Option<String>) -> Result<ClaudeUsageFetch, String> {
tauri::async_runtime::spawn_blocking(move || fetch_claude_usage_sync(config_dir.as_deref()))
.await
.map_err(|e| e.to_string())?
}

fn fetch_claude_usage_sync() -> Result<ClaudeUsageFetch, String> {
let Some(creds) = read_claude_credentials() else {
fn fetch_claude_usage_sync(config_dir: Option<&str>) -> Result<ClaudeUsageFetch, String> {
let Some(creds) = read_claude_credentials(config_dir) else {
return Ok(usage_result(
"unavailable",
None,
Expand Down Expand Up @@ -119,23 +127,26 @@ fn usage_error(status: u16) -> ClaudeUsageFetch {
usage_result("error", Some(status), None, Some(message))
}

fn read_claude_credentials() -> Option<ClaudeCredentials> {
fn read_claude_credentials(config_dir: Option<&str>) -> Option<ClaudeCredentials> {
#[cfg(target_os = "macos")]
{
if let Some(creds) = read_macos_keychain_credentials() {
if let Some(creds) = read_macos_keychain_credentials(config_dir) {
return Some(creds);
}
}
read_credentials_file()
read_credentials_file(config_dir)
}

fn read_credentials_file() -> Option<ClaudeCredentials> {
let path = claude_credentials_path()?;
fn read_credentials_file(config_dir: Option<&str>) -> Option<ClaudeCredentials> {
let path = claude_credentials_path(config_dir)?;
let raw = std::fs::read_to_string(&path).ok()?;
credentials_from_blob(&raw)
}

fn claude_credentials_path() -> Option<PathBuf> {
fn claude_credentials_path(config_dir: Option<&str>) -> Option<PathBuf> {
if let Some(dir) = config_dir {
return Some(crate::fs::expand_home(dir).join(".credentials.json"));
}
let home = dirs_home().or_else(|| {
std::env::var_os("USERPROFILE").map(|value| value.to_string_lossy().into_owned())
})?;
Expand Down Expand Up @@ -201,20 +212,22 @@ fn now_ms() -> i64 {
}

#[cfg(target_os = "macos")]
fn read_macos_keychain_credentials() -> Option<ClaudeCredentials> {
fn read_macos_keychain_credentials(config_dir: Option<&str>) -> Option<ClaudeCredentials> {
let service = keychain_service_for(config_dir);
let user = keychain_user();
let candidates = [
{
let mut args = keychain_find_args();
let mut args = keychain_find_args(&service);
args.push("-w".into());
args
},
{
let mut args = keychain_find_args();
args.extend(["-a".into(), keychain_user(), "-w".into()]);
let mut args = keychain_find_args(&service);
args.extend(["-a".into(), user.clone(), "-w".into()]);
args
},
{
let mut args = keychain_find_args();
let mut args = keychain_find_args(&service);
args.extend(["-a".into(), KEYCHAIN_FALLBACK_USER.into(), "-w".into()]);
args
},
Expand All @@ -229,13 +242,31 @@ fn read_macos_keychain_credentials() -> Option<ClaudeCredentials> {
None
}

/// Claude CLI keeps the default account's login under the plain
/// "Claude Code-credentials" Keychain service, and every other
/// CLAUDE_CONFIG_DIR's login under that same name suffixed with the first
/// 8 hex chars of SHA-256(absolute config dir path) — e.g.
/// `~/.claude-personal` → `Claude Code-credentials-d8a6e19b`. Verified
/// against this machine's real Keychain entries.
#[cfg(target_os = "macos")]
fn keychain_service_for(config_dir: Option<&str>) -> String {
let Some(dir) = config_dir else {
return LEGACY_KEYCHAIN_SERVICE.to_string();
};
let absolute = crate::fs::expand_home(dir);
let mut hasher = Sha256::new();
hasher.update(absolute.to_string_lossy().as_bytes());
let digest = hasher.finalize();
let mut suffix = String::with_capacity(8);
for byte in digest.iter().take(4) {
suffix.push_str(&format!("{byte:02x}"));
}
format!("{LEGACY_KEYCHAIN_SERVICE}-{suffix}")
}

#[cfg(target_os = "macos")]
fn keychain_find_args() -> Vec<String> {
vec![
"find-generic-password".into(),
"-s".into(),
LEGACY_KEYCHAIN_SERVICE.into(),
]
fn keychain_find_args(service: &str) -> Vec<String> {
vec!["find-generic-password".into(), "-s".into(), service.into()]
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -329,6 +360,24 @@ mod tests {
assert_eq!(extract_access_token("not json"), None);
}

#[cfg(target_os = "macos")]
#[test]
fn keychain_service_for_matches_observed_claude_cli_scheme() {
// Claude CLI suffixes the default Keychain service with the first 8
// hex chars of SHA-256(absolute config dir) for a non-default
// CLAUDE_CONFIG_DIR — verified against real Keychain entries. These
// paths are generic examples; the algorithm itself is what's tested.
assert_eq!(keychain_service_for(None), LEGACY_KEYCHAIN_SERVICE);
assert_eq!(
keychain_service_for(Some("/Users/alice/.claude-work")),
"Claude Code-credentials-be865d75"
);
assert_eq!(
keychain_service_for(Some("/Users/alice/.claude-client")),
"Claude Code-credentials-a90ccfe6"
);
}

#[test]
fn token_expired_uses_actual_expiry() {
let now = 1_000_000;
Expand Down
51 changes: 27 additions & 24 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1135,9 +1135,17 @@ export default function App({
return {
id: active.id,
harness: active.harness,
cwd: active.cwd,
profile: active.modelSettings?.profile,
authRequired: latestTurnNeedsHarnessLogin(active.blocks),
};
}, [active?.id, active?.harness, active?.blocks]);
}, [
active?.id,
active?.harness,
active?.cwd,
active?.modelSettings?.profile,
active?.blocks,
]);
const activeProviderSignInRequest = useMemo(() => {
if (
!active ||
Expand Down Expand Up @@ -1688,7 +1696,7 @@ export default function App({
setSearchViewOpen(false);
setInboxViewOpen(false);
setNotesViewOpen(false);
const cwd = active?.cwd ?? sessionDefaults?.cwd ?? projectCwd;
const cwd = active?.cwd ?? projectCwd;
const session = newDefaultSession(cwd, sessionDefaults?.runtimeMode);
const tab = newTab(session.id);
setSessions((prev) => [...prev, session]);
Expand All @@ -1699,7 +1707,6 @@ export default function App({
}, [
active?.cwd,
appendTab,
sessionDefaults?.cwd,
sessionDefaults?.runtimeMode,
projectCwd,
]);
Expand All @@ -1710,8 +1717,7 @@ export default function App({
setInboxViewOpen(false);
setNotesViewOpen(false);
setSidebarTab("sessions");
const cwd =
item.projectPath || active?.cwd || sessionDefaults?.cwd || projectCwd;
const cwd = item.projectPath || active?.cwd || projectCwd;
const ref =
item.provider === "linear"
? item.identifier?.trim() || `#${item.number}`
Expand Down Expand Up @@ -1749,13 +1755,7 @@ export default function App({
const details = await linearIssueDetails(item.id);
start(details.body);
},
[
active?.cwd,
appendTab,
sessionDefaults?.cwd,
sessionDefaults?.runtimeMode,
projectCwd,
],
[active?.cwd, appendTab, sessionDefaults?.runtimeMode, projectCwd],
);

const onAddNoteToChat = useCallback(
Expand All @@ -1770,7 +1770,6 @@ export default function App({
? card.sourceCwd
: undefined) ||
active?.cwd ||
sessionDefaults?.cwd ||
projectCwd;
const title = card.title.trim();
const session = {
Expand All @@ -1784,13 +1783,7 @@ export default function App({
setActiveTabId(tab.id);
setComposerFocused(true);
},
[
active?.cwd,
appendTab,
sessionDefaults?.cwd,
sessionDefaults?.runtimeMode,
projectCwd,
],
[active?.cwd, appendTab, sessionDefaults?.runtimeMode, projectCwd],
);

useEffect(() => {
Expand Down Expand Up @@ -1866,7 +1859,7 @@ export default function App({
(dir: SplitDir) => {
if (!activeTab) return;
const session = newDefaultSession(
sessionDefaults?.cwd ?? projectCwd,
active?.cwd ?? projectCwd,
sessionDefaults?.runtimeMode,
);
setSessions((prev) => [...prev, session]);
Expand All @@ -1882,7 +1875,7 @@ export default function App({
);
setComposerFocused(true);
},
[activeTab, projectCwd, sessionDefaults?.cwd, sessionDefaults?.runtimeMode],
[activeTab, active?.cwd, projectCwd, sessionDefaults?.runtimeMode],
);

const focusProjectTerminal = useCallback(() => {
Expand Down Expand Up @@ -3054,12 +3047,22 @@ export default function App({
tabsRef.current[0];
if (!tab) return false;

const paneId = isBlankSession(
// A blank pane only stands in for the session being opened if it's
// already scoped to the same project — otherwise this grafts the
// session into an unrelated project's tab instead of opening it there.
const isBlankForSameProject = (candidate: Session | undefined) =>
!!candidate &&
isBlankSession(candidate) &&
sameProjectPath(candidate.cwd, session.cwd);

const paneId = isBlankForSameProject(
sessionsRef.current.find((entry) => entry.id === tab.focusedId),
)
? tab.focusedId
: leafIds(tab.layout).find((id) =>
isBlankSession(sessionsRef.current.find((entry) => entry.id === id)),
isBlankForSameProject(
sessionsRef.current.find((entry) => entry.id === id),
),
);
if (!paneId || paneId === session.id) return false;

Expand Down
Loading