Skip to content

Commit 194f69c

Browse files
MarkShawn2020claude
andcommitted
feat(chat): integrate Claude desktop app data — Code sessions, pinned starredIds, live claude.ai sync
- 读取 Claude desktop app Code 栏 sessions(claude-code-sessions/local_*.json + 关联到 ~/.claude/projects 的 cliSessionId.jsonl),文件系统反查 project_id 处理 CLI 中文 cwd lossy 编码 - 同步 app 的 pin 状态:从 IndexedDB LevelDB 的 .log 抓取最新 starredIds(无锁、不占用 Claude app),独立存储不污染本地 pinnedIds - 实时拉取 claude.ai web 端聊天记录:解密 Chromium Cookies SQLite(Keychain "Claude Safe Storage"/"Claude Key" + AES-128-CBC + PBKDF2),调 /api/organizations 列表 + detail 端点,并发 6,流式增量 invalidate - Web/App tab 无条件常驻;Pinned section 独立分组,按 dataSource 过滤;list_all_sessions 末尾按 id 去重保留 message_count 最高条 - 修复 read_session_head/get_session_messages/build_search_index/list_all_chats/read_session_usage 对 Claude app 嵌套 message 格式的兼容;改用全文 count_session_messages 取代 head sampling - SessionItemButton 增加 Pin 操作(DropdownMenu + ContextMenu)+ 视觉 accent bar;Web tab 状态条独占,不打扰其它 tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 595d38d commit 194f69c

9 files changed

Lines changed: 1371 additions & 94 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 110 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ filetime = "0.2"
4242
libc = "0.2.179"
4343
tauri-plugin-updater = { version = "2", features = ["rustls-tls"] }
4444
tauri-plugin-process = "2"
45+
rusqlite = { version = "0.32", features = ["bundled"] }
46+
futures = "0.3"
47+
aes = "0.8"
48+
cbc = { version = "0.1", features = ["std"] }
49+
pbkdf2 = "0.12"
50+
sha1 = "0.10"
4551

4652
[target.'cfg(target_os = "macos")'.dependencies]
4753
cocoa = "0.26"
4854
objc = "0.2"
55+
security-framework = "3"
4956

src-tauri/src/claude_web_sync.rs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//! Cookie extraction for the Claude desktop app's claude.ai session.
2+
//!
3+
//! Reads `~/Library/Application Support/Claude/Cookies` (Chromium-format SQLite,
4+
//! AES-128-CBC encrypted with a Keychain-stored master key). The DB is copied
5+
//! to a tempfile first to avoid lock contention while the app is running.
6+
7+
use std::path::Path;
8+
9+
const COOKIE_NAMES: &[&str] = &["sessionKey", "lastActiveOrg"];
10+
11+
pub fn read_claude_app_cookies() -> Result<std::collections::HashMap<String, String>, String> {
12+
let home = dirs::home_dir().ok_or_else(|| "no home dir".to_string())?;
13+
let cookies_db = home.join("Library/Application Support/Claude/Cookies");
14+
if !cookies_db.exists() {
15+
return Err("Claude desktop app cookies db not found".to_string());
16+
}
17+
18+
let tmp = std::env::temp_dir().join(format!("lovcode-cookies-{}.db", std::process::id()));
19+
std::fs::copy(&cookies_db, &tmp).map_err(|e| format!("copy cookies db: {}", e))?;
20+
let result = read_cookies_from_db(&tmp);
21+
let _ = std::fs::remove_file(&tmp);
22+
result
23+
}
24+
25+
fn read_cookies_from_db(db_path: &Path) -> Result<std::collections::HashMap<String, String>, String> {
26+
let conn = rusqlite::Connection::open_with_flags(
27+
db_path,
28+
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
29+
).map_err(|e| format!("open cookies db: {}", e))?;
30+
31+
let names_in: String = COOKIE_NAMES.iter().map(|n| format!("'{}'", n)).collect::<Vec<_>>().join(",");
32+
let q = format!(
33+
"SELECT name, value, encrypted_value FROM cookies \
34+
WHERE host_key LIKE '%claude.ai%' AND name IN ({})",
35+
names_in
36+
);
37+
38+
let mut stmt = conn.prepare(&q).map_err(|e| e.to_string())?;
39+
let rows = stmt.query_map([], |row| {
40+
let name: String = row.get(0)?;
41+
let value: String = row.get(1)?;
42+
let enc: Vec<u8> = row.get(2)?;
43+
Ok((name, value, enc))
44+
}).map_err(|e| e.to_string())?;
45+
46+
let key = derive_keychain_key()?;
47+
let mut out = std::collections::HashMap::new();
48+
for r in rows {
49+
let (name, plain, enc) = r.map_err(|e| e.to_string())?;
50+
let v = if !plain.is_empty() {
51+
plain
52+
} else if enc.is_empty() {
53+
continue
54+
} else {
55+
decrypt_cookie(&enc, &key)?
56+
};
57+
out.insert(name, v);
58+
}
59+
Ok(out)
60+
}
61+
62+
#[cfg(target_os = "macos")]
63+
fn derive_keychain_key() -> Result<[u8; 16], String> {
64+
use security_framework::passwords::get_generic_password;
65+
// Chromium-derived apps store an AES master password under a service named
66+
// `<AppName> Safe Storage`. We don't know the exact string, so try common
67+
// variants. The account field is typically the same as the app name.
68+
let candidates: &[(&str, &str)] = &[
69+
("Claude Safe Storage", "Claude Key"),
70+
];
71+
let mut tried = Vec::new();
72+
for (service, account) in candidates {
73+
match get_generic_password(service, account) {
74+
Ok(pw) => return derive_aes_key(&pw),
75+
Err(e) => {
76+
let s = e.to_string();
77+
tried.push(format!("'{}'/'{}'->{}", service, account, s));
78+
// ACL denial vs entry-missing have very different fixes.
79+
if s.contains("not correct") || s.contains("not authorized") || s.contains("user name") {
80+
return Err(format!(
81+
"macOS keychain blocked lovcode from reading 'Claude Safe Storage'. \
82+
Open Keychain Access, search 'Claude Safe Storage', double-click → \
83+
Access Control tab → add this binary or check 'Allow all applications'. \
84+
Original error: {}",
85+
s
86+
));
87+
}
88+
}
89+
}
90+
}
91+
Err(format!(
92+
"Claude Safe Storage keychain entry not found. Tried: {}",
93+
tried.join("; ")
94+
))
95+
}
96+
97+
#[cfg(not(target_os = "macos"))]
98+
fn derive_keychain_key() -> Result<[u8; 16], String> {
99+
Err("cookie decryption only implemented on macOS".to_string())
100+
}
101+
102+
fn derive_aes_key(passphrase: &[u8]) -> Result<[u8; 16], String> {
103+
use pbkdf2::pbkdf2_hmac;
104+
use sha1::Sha1;
105+
let salt = b"saltysalt";
106+
let mut key = [0u8; 16];
107+
pbkdf2_hmac::<Sha1>(passphrase, salt, 1003, &mut key);
108+
Ok(key)
109+
}
110+
111+
fn decrypt_cookie(enc: &[u8], key: &[u8; 16]) -> Result<String, String> {
112+
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
113+
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
114+
115+
if enc.len() < 3 || (&enc[0..3] != b"v10" && &enc[0..3] != b"v11") {
116+
let prefix_hex: String = enc.iter().take(8).map(|b| format!("{:02x}", b)).collect();
117+
return Err(format!(
118+
"unknown cookie encryption prefix: first 8 bytes = {}",
119+
prefix_hex
120+
));
121+
}
122+
let body = &enc[3..];
123+
let iv = [b' '; 16];
124+
let cipher = Aes128CbcDec::new(key.into(), &iv.into());
125+
let mut buf = body.to_vec();
126+
let plain = cipher.decrypt_padded_mut::<Pkcs7>(&mut buf)
127+
.map_err(|e| format!("decrypt cookie: {}", e))?;
128+
129+
// Chromium >=v10 prepends a 32-byte SHA256 of the host. Strip if it parses
130+
// cleaner without it; otherwise return the raw decoded bytes.
131+
if plain.len() > 32 {
132+
if let Ok(s) = std::str::from_utf8(&plain[32..]) {
133+
if !s.is_empty() {
134+
return Ok(s.to_string());
135+
}
136+
}
137+
}
138+
String::from_utf8(plain.to_vec()).map_err(|e| format!("utf8: {}", e))
139+
}

0 commit comments

Comments
 (0)