source dump of claude code
at main 348 lines 12 kB view raw
1/** 2 * Protocol Handler Registration 3 * 4 * Registers the `claude-cli://` custom URI scheme with the OS, 5 * so that clicking a `claude-cli://` link in a browser (or any app) will 6 * invoke `claude --handle-uri <url>`. 7 * 8 * Platform details: 9 * macOS — Creates a minimal .app trampoline in ~/Applications with 10 * CFBundleURLTypes in its Info.plist 11 * Linux — Creates a .desktop file in $XDG_DATA_HOME/applications 12 * (default ~/.local/share/applications) and registers it with xdg-mime 13 * Windows — Writes registry keys under HKEY_CURRENT_USER\Software\Classes 14 */ 15 16import { promises as fs } from 'fs' 17import * as os from 'os' 18import * as path from 'path' 19import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 20import { 21 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 22 logEvent, 23} from 'src/services/analytics/index.js' 24import { logForDebugging } from '../debug.js' 25import { getClaudeConfigHomeDir } from '../envUtils.js' 26import { getErrnoCode } from '../errors.js' 27import { execFileNoThrow } from '../execFileNoThrow.js' 28import { getInitialSettings } from '../settings/settings.js' 29import { which } from '../which.js' 30import { getUserBinDir, getXDGDataHome } from '../xdg.js' 31import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js' 32 33export const MACOS_BUNDLE_ID = 'com.anthropic.claude-code-url-handler' 34const APP_NAME = 'Claude Code URL Handler' 35const DESKTOP_FILE_NAME = 'claude-code-url-handler.desktop' 36const MACOS_APP_NAME = 'Claude Code URL Handler.app' 37 38// Shared between register* (writes these paths/values) and 39// isProtocolHandlerCurrent (reads them back). Keep the writer and reader 40// in lockstep — drift here means the check returns a perpetual false. 41const MACOS_APP_DIR = path.join(os.homedir(), 'Applications', MACOS_APP_NAME) 42const MACOS_SYMLINK_PATH = path.join( 43 MACOS_APP_DIR, 44 'Contents', 45 'MacOS', 46 'claude', 47) 48function linuxDesktopPath(): string { 49 return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME) 50} 51const WINDOWS_REG_KEY = `HKEY_CURRENT_USER\\Software\\Classes\\${DEEP_LINK_PROTOCOL}` 52const WINDOWS_COMMAND_KEY = `${WINDOWS_REG_KEY}\\shell\\open\\command` 53 54const FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000 55 56function linuxExecLine(claudePath: string): string { 57 return `Exec="${claudePath}" --handle-uri %u` 58} 59function windowsCommandValue(claudePath: string): string { 60 return `"${claudePath}" --handle-uri "%1"` 61} 62 63/** 64 * Register the protocol handler on macOS. 65 * 66 * Creates a .app bundle where the CFBundleExecutable is a symlink to the 67 * already-installed (and signed) `claude` binary. When macOS opens a 68 * `claude-cli://` URL, it launches `claude` through this app bundle. 69 * Claude then uses the url-handler NAPI module to read the URL from the 70 * Apple Event and handles it normally. 71 * 72 * This approach avoids shipping a separate executable (which would need 73 * to be signed and allowlisted by endpoint security tools like Santa). 74 */ 75async function registerMacos(claudePath: string): Promise<void> { 76 const contentsDir = path.join(MACOS_APP_DIR, 'Contents') 77 78 // Remove any existing app bundle to start clean 79 try { 80 await fs.rm(MACOS_APP_DIR, { recursive: true }) 81 } catch (e: unknown) { 82 const code = getErrnoCode(e) 83 if (code !== 'ENOENT') { 84 throw e 85 } 86 } 87 88 await fs.mkdir(path.dirname(MACOS_SYMLINK_PATH), { recursive: true }) 89 90 // Info.plist — registers the URL scheme with claude as the executable 91 const infoPlist = `<?xml version="1.0" encoding="UTF-8"?> 92<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 93<plist version="1.0"> 94<dict> 95 <key>CFBundleIdentifier</key> 96 <string>${MACOS_BUNDLE_ID}</string> 97 <key>CFBundleName</key> 98 <string>${APP_NAME}</string> 99 <key>CFBundleExecutable</key> 100 <string>claude</string> 101 <key>CFBundleVersion</key> 102 <string>1.0</string> 103 <key>CFBundlePackageType</key> 104 <string>APPL</string> 105 <key>LSBackgroundOnly</key> 106 <true/> 107 <key>CFBundleURLTypes</key> 108 <array> 109 <dict> 110 <key>CFBundleURLName</key> 111 <string>Claude Code Deep Link</string> 112 <key>CFBundleURLSchemes</key> 113 <array> 114 <string>${DEEP_LINK_PROTOCOL}</string> 115 </array> 116 </dict> 117 </array> 118</dict> 119</plist>` 120 121 await fs.writeFile(path.join(contentsDir, 'Info.plist'), infoPlist) 122 123 // Symlink to the already-signed claude binary — avoids a new executable 124 // that would need signing and endpoint-security allowlisting. 125 // Written LAST among the throwing fs calls: isProtocolHandlerCurrent reads 126 // this symlink, so it acts as the commit marker. If Info.plist write 127 // failed above, no symlink → next session retries. 128 await fs.symlink(claudePath, MACOS_SYMLINK_PATH) 129 130 // Re-register the app with LaunchServices so macOS picks up the URL scheme. 131 const lsregister = 132 '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister' 133 await execFileNoThrow(lsregister, ['-R', MACOS_APP_DIR], { useCwd: false }) 134 135 logForDebugging( 136 `Registered ${DEEP_LINK_PROTOCOL}:// protocol handler at ${MACOS_APP_DIR}`, 137 ) 138} 139 140/** 141 * Register the protocol handler on Linux. 142 * Creates a .desktop file and registers it with xdg-mime. 143 */ 144async function registerLinux(claudePath: string): Promise<void> { 145 await fs.mkdir(path.dirname(linuxDesktopPath()), { recursive: true }) 146 147 const desktopEntry = `[Desktop Entry] 148Name=${APP_NAME} 149Comment=Handle ${DEEP_LINK_PROTOCOL}:// deep links for Claude Code 150${linuxExecLine(claudePath)} 151Type=Application 152NoDisplay=true 153MimeType=x-scheme-handler/${DEEP_LINK_PROTOCOL}; 154` 155 156 await fs.writeFile(linuxDesktopPath(), desktopEntry) 157 158 // Register as the default handler for the scheme. On headless boxes 159 // (WSL, Docker, CI) xdg-utils isn't installed — not a failure: there's 160 // no desktop to click links from, and some apps read the .desktop 161 // MimeType line directly. The artifact check still short-circuits 162 // next session since the .desktop file is present. 163 const xdgMime = await which('xdg-mime') 164 if (xdgMime) { 165 const { code } = await execFileNoThrow( 166 xdgMime, 167 ['default', DESKTOP_FILE_NAME, `x-scheme-handler/${DEEP_LINK_PROTOCOL}`], 168 { useCwd: false }, 169 ) 170 if (code !== 0) { 171 throw Object.assign(new Error(`xdg-mime exited with code ${code}`), { 172 code: 'XDG_MIME_FAILED', 173 }) 174 } 175 } 176 177 logForDebugging( 178 `Registered ${DEEP_LINK_PROTOCOL}:// protocol handler at ${linuxDesktopPath()}`, 179 ) 180} 181 182/** 183 * Register the protocol handler on Windows via the registry. 184 */ 185async function registerWindows(claudePath: string): Promise<void> { 186 for (const args of [ 187 ['add', WINDOWS_REG_KEY, '/ve', '/d', `URL:${APP_NAME}`, '/f'], 188 ['add', WINDOWS_REG_KEY, '/v', 'URL Protocol', '/d', '', '/f'], 189 [ 190 'add', 191 WINDOWS_COMMAND_KEY, 192 '/ve', 193 '/d', 194 windowsCommandValue(claudePath), 195 '/f', 196 ], 197 ]) { 198 const { code } = await execFileNoThrow('reg', args, { useCwd: false }) 199 if (code !== 0) { 200 throw Object.assign(new Error(`reg add exited with code ${code}`), { 201 code: 'REG_FAILED', 202 }) 203 } 204 } 205 206 logForDebugging( 207 `Registered ${DEEP_LINK_PROTOCOL}:// protocol handler in Windows registry`, 208 ) 209} 210 211/** 212 * Register the `claude-cli://` protocol handler with the operating system. 213 * After registration, clicking a `claude-cli://` link will invoke claude. 214 */ 215export async function registerProtocolHandler( 216 claudePath?: string, 217): Promise<void> { 218 const resolved = claudePath ?? (await resolveClaudePath()) 219 220 switch (process.platform) { 221 case 'darwin': 222 await registerMacos(resolved) 223 break 224 case 'linux': 225 await registerLinux(resolved) 226 break 227 case 'win32': 228 await registerWindows(resolved) 229 break 230 default: 231 throw new Error(`Unsupported platform: ${process.platform}`) 232 } 233} 234 235/** 236 * Resolve the claude binary path for protocol registration. Prefers the 237 * native installer's stable symlink (~/.local/bin/claude) which survives 238 * auto-updates; falls back to process.execPath when the symlink is absent 239 * (dev builds, non-native installs). 240 */ 241async function resolveClaudePath(): Promise<string> { 242 const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude' 243 const stablePath = path.join(getUserBinDir(), binaryName) 244 try { 245 await fs.realpath(stablePath) 246 return stablePath 247 } catch { 248 return process.execPath 249 } 250} 251 252/** 253 * Check whether the OS-level protocol handler is already registered AND 254 * points at the expected `claude` binary. Reads the registration artifact 255 * directly (symlink target, .desktop Exec line, registry value) rather than 256 * a cached flag in ~/.claude.json, so: 257 * - the check is per-machine (config can sync across machines; OS state can't) 258 * - stale paths self-heal (install-method change → re-register next session) 259 * - deleted artifacts self-heal 260 * 261 * Any read error (ENOENT, EACCES, reg nonzero) → false → re-register. 262 */ 263export async function isProtocolHandlerCurrent( 264 claudePath: string, 265): Promise<boolean> { 266 try { 267 switch (process.platform) { 268 case 'darwin': { 269 const target = await fs.readlink(MACOS_SYMLINK_PATH) 270 return target === claudePath 271 } 272 case 'linux': { 273 const content = await fs.readFile(linuxDesktopPath(), 'utf8') 274 return content.includes(linuxExecLine(claudePath)) 275 } 276 case 'win32': { 277 const { stdout, code } = await execFileNoThrow( 278 'reg', 279 ['query', WINDOWS_COMMAND_KEY, '/ve'], 280 { useCwd: false }, 281 ) 282 return code === 0 && stdout.includes(windowsCommandValue(claudePath)) 283 } 284 default: 285 return false 286 } 287 } catch { 288 return false 289 } 290} 291 292/** 293 * Auto-register the claude-cli:// deep link protocol handler when missing 294 * or stale. Runs every session from backgroundHousekeeping (fire-and-forget), 295 * but the artifact check makes it a no-op after the first successful run 296 * unless the install path moves or the OS artifact is deleted. 297 */ 298export async function ensureDeepLinkProtocolRegistered(): Promise<void> { 299 if (getInitialSettings().disableDeepLinkRegistration === 'disable') { 300 return 301 } 302 if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_lodestone_enabled', false)) { 303 return 304 } 305 306 const claudePath = await resolveClaudePath() 307 if (await isProtocolHandlerCurrent(claudePath)) { 308 return 309 } 310 311 // EACCES/ENOSPC are deterministic — retrying next session won't help. 312 // Throttle to once per 24h so a read-only ~/.local/share/applications 313 // doesn't generate a failure event on every startup. Marker lives in 314 // ~/.claude (per-machine, not synced) rather than ~/.claude.json (can sync). 315 const failureMarkerPath = path.join( 316 getClaudeConfigHomeDir(), 317 '.deep-link-register-failed', 318 ) 319 try { 320 const stat = await fs.stat(failureMarkerPath) 321 if (Date.now() - stat.mtimeMs < FAILURE_BACKOFF_MS) { 322 return 323 } 324 } catch { 325 // Marker absent — proceed. 326 } 327 328 try { 329 await registerProtocolHandler(claudePath) 330 logEvent('tengu_deep_link_registered', { success: true }) 331 logForDebugging('Auto-registered claude-cli:// deep link protocol handler') 332 await fs.rm(failureMarkerPath, { force: true }).catch(() => {}) 333 } catch (error) { 334 const code = getErrnoCode(error) 335 logEvent('tengu_deep_link_registered', { 336 success: false, 337 error_code: 338 code as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 339 }) 340 logForDebugging( 341 `Failed to auto-register deep link protocol handler: ${error instanceof Error ? error.message : String(error)}`, 342 { level: 'warn' }, 343 ) 344 if (code === 'EACCES' || code === 'ENOSPC') { 345 await fs.writeFile(failureMarkerPath, '').catch(() => {}) 346 } 347 } 348}