349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
/**
|
|
* Protocol Handler Registration
|
|
*
|
|
* Registers the `claude-cli://` custom URI scheme with the OS,
|
|
* so that clicking a `claude-cli://` link in a browser (or any app) will
|
|
* invoke `claude --handle-uri <url>`.
|
|
*
|
|
* Platform details:
|
|
* macOS — Creates a minimal .app trampoline in ~/Applications with
|
|
* CFBundleURLTypes in its Info.plist
|
|
* Linux — Creates a .desktop file in $XDG_DATA_HOME/applications
|
|
* (default ~/.local/share/applications) and registers it with xdg-mime
|
|
* Windows — Writes registry keys under HKEY_CURRENT_USER\Software\Classes
|
|
*/
|
|
|
|
import { promises as fs } from 'fs'
|
|
import * as os from 'os'
|
|
import * as path from 'path'
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from 'src/services/analytics/index.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
|
import { getErrnoCode } from '../errors.js'
|
|
import { execFileNoThrow } from '../execFileNoThrow.js'
|
|
import { getInitialSettings } from '../settings/settings.js'
|
|
import { which } from '../which.js'
|
|
import { getUserBinDir, getXDGDataHome } from '../xdg.js'
|
|
import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js'
|
|
|
|
export const MACOS_BUNDLE_ID = 'com.anthropic.claude-code-url-handler'
|
|
const APP_NAME = 'Claude Code URL Handler'
|
|
const DESKTOP_FILE_NAME = 'claude-code-url-handler.desktop'
|
|
const MACOS_APP_NAME = 'Claude Code URL Handler.app'
|
|
|
|
// Shared between register* (writes these paths/values) and
|
|
// isProtocolHandlerCurrent (reads them back). Keep the writer and reader
|
|
// in lockstep — drift here means the check returns a perpetual false.
|
|
const MACOS_APP_DIR = path.join(os.homedir(), 'Applications', MACOS_APP_NAME)
|
|
const MACOS_SYMLINK_PATH = path.join(
|
|
MACOS_APP_DIR,
|
|
'Contents',
|
|
'MacOS',
|
|
'claude',
|
|
)
|
|
function linuxDesktopPath(): string {
|
|
return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME)
|
|
}
|
|
const WINDOWS_REG_KEY = `HKEY_CURRENT_USER\\Software\\Classes\\${DEEP_LINK_PROTOCOL}`
|
|
const WINDOWS_COMMAND_KEY = `${WINDOWS_REG_KEY}\\shell\\open\\command`
|
|
|
|
const FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000
|
|
|
|
function linuxExecLine(claudePath: string): string {
|
|
return `Exec="${claudePath}" --handle-uri %u`
|
|
}
|
|
function windowsCommandValue(claudePath: string): string {
|
|
return `"${claudePath}" --handle-uri "%1"`
|
|
}
|
|
|
|
/**
|
|
* Register the protocol handler on macOS.
|
|
*
|
|
* Creates a .app bundle where the CFBundleExecutable is a symlink to the
|
|
* already-installed (and signed) `claude` binary. When macOS opens a
|
|
* `claude-cli://` URL, it launches `claude` through this app bundle.
|
|
* Claude then uses the url-handler NAPI module to read the URL from the
|
|
* Apple Event and handles it normally.
|
|
*
|
|
* This approach avoids shipping a separate executable (which would need
|
|
* to be signed and allowlisted by endpoint security tools like Santa).
|
|
*/
|
|
async function registerMacos(claudePath: string): Promise<void> {
|
|
const contentsDir = path.join(MACOS_APP_DIR, 'Contents')
|
|
|
|
// Remove any existing app bundle to start clean
|
|
try {
|
|
await fs.rm(MACOS_APP_DIR, { recursive: true })
|
|
} catch (e: unknown) {
|
|
const code = getErrnoCode(e)
|
|
if (code !== 'ENOENT') {
|
|
throw e
|
|
}
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(MACOS_SYMLINK_PATH), { recursive: true })
|
|
|
|
// Info.plist — registers the URL scheme with claude as the executable
|
|
const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleIdentifier</key>
|
|
<string>${MACOS_BUNDLE_ID}</string>
|
|
<key>CFBundleName</key>
|
|
<string>${APP_NAME}</string>
|
|
<key>CFBundleExecutable</key>
|
|
<string>claude</string>
|
|
<key>CFBundleVersion</key>
|
|
<string>1.0</string>
|
|
<key>CFBundlePackageType</key>
|
|
<string>APPL</string>
|
|
<key>LSBackgroundOnly</key>
|
|
<true/>
|
|
<key>CFBundleURLTypes</key>
|
|
<array>
|
|
<dict>
|
|
<key>CFBundleURLName</key>
|
|
<string>Claude Code Deep Link</string>
|
|
<key>CFBundleURLSchemes</key>
|
|
<array>
|
|
<string>${DEEP_LINK_PROTOCOL}</string>
|
|
</array>
|
|
</dict>
|
|
</array>
|
|
</dict>
|
|
</plist>`
|
|
|
|
await fs.writeFile(path.join(contentsDir, 'Info.plist'), infoPlist)
|
|
|
|
// Symlink to the already-signed claude binary — avoids a new executable
|
|
// that would need signing and endpoint-security allowlisting.
|
|
// Written LAST among the throwing fs calls: isProtocolHandlerCurrent reads
|
|
// this symlink, so it acts as the commit marker. If Info.plist write
|
|
// failed above, no symlink → next session retries.
|
|
await fs.symlink(claudePath, MACOS_SYMLINK_PATH)
|
|
|
|
// Re-register the app with LaunchServices so macOS picks up the URL scheme.
|
|
const lsregister =
|
|
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'
|
|
await execFileNoThrow(lsregister, ['-R', MACOS_APP_DIR], { useCwd: false })
|
|
|
|
logForDebugging(
|
|
`Registered ${DEEP_LINK_PROTOCOL}:// protocol handler at ${MACOS_APP_DIR}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Register the protocol handler on Linux.
|
|
* Creates a .desktop file and registers it with xdg-mime.
|
|
*/
|
|
async function registerLinux(claudePath: string): Promise<void> {
|
|
await fs.mkdir(path.dirname(linuxDesktopPath()), { recursive: true })
|
|
|
|
const desktopEntry = `[Desktop Entry]
|
|
Name=${APP_NAME}
|
|
Comment=Handle ${DEEP_LINK_PROTOCOL}:// deep links for Claude Code
|
|
${linuxExecLine(claudePath)}
|
|
Type=Application
|
|
NoDisplay=true
|
|
MimeType=x-scheme-handler/${DEEP_LINK_PROTOCOL};
|
|
`
|
|
|
|
await fs.writeFile(linuxDesktopPath(), desktopEntry)
|
|
|
|
// Register as the default handler for the scheme. On headless boxes
|
|
// (WSL, Docker, CI) xdg-utils isn't installed — not a failure: there's
|
|
// no desktop to click links from, and some apps read the .desktop
|
|
// MimeType line directly. The artifact check still short-circuits
|
|
// next session since the .desktop file is present.
|
|
const xdgMime = await which('xdg-mime')
|
|
if (xdgMime) {
|
|
const { code } = await execFileNoThrow(
|
|
xdgMime,
|
|
['default', DESKTOP_FILE_NAME, `x-scheme-handler/${DEEP_LINK_PROTOCOL}`],
|
|
{ useCwd: false },
|
|
)
|
|
if (code !== 0) {
|
|
throw Object.assign(new Error(`xdg-mime exited with code ${code}`), {
|
|
code: 'XDG_MIME_FAILED',
|
|
})
|
|
}
|
|
}
|
|
|
|
logForDebugging(
|
|
`Registered ${DEEP_LINK_PROTOCOL}:// protocol handler at ${linuxDesktopPath()}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Register the protocol handler on Windows via the registry.
|
|
*/
|
|
async function registerWindows(claudePath: string): Promise<void> {
|
|
for (const args of [
|
|
['add', WINDOWS_REG_KEY, '/ve', '/d', `URL:${APP_NAME}`, '/f'],
|
|
['add', WINDOWS_REG_KEY, '/v', 'URL Protocol', '/d', '', '/f'],
|
|
[
|
|
'add',
|
|
WINDOWS_COMMAND_KEY,
|
|
'/ve',
|
|
'/d',
|
|
windowsCommandValue(claudePath),
|
|
'/f',
|
|
],
|
|
]) {
|
|
const { code } = await execFileNoThrow('reg', args, { useCwd: false })
|
|
if (code !== 0) {
|
|
throw Object.assign(new Error(`reg add exited with code ${code}`), {
|
|
code: 'REG_FAILED',
|
|
})
|
|
}
|
|
}
|
|
|
|
logForDebugging(
|
|
`Registered ${DEEP_LINK_PROTOCOL}:// protocol handler in Windows registry`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Register the `claude-cli://` protocol handler with the operating system.
|
|
* After registration, clicking a `claude-cli://` link will invoke claude.
|
|
*/
|
|
export async function registerProtocolHandler(
|
|
claudePath?: string,
|
|
): Promise<void> {
|
|
const resolved = claudePath ?? (await resolveClaudePath())
|
|
|
|
switch (process.platform) {
|
|
case 'darwin':
|
|
await registerMacos(resolved)
|
|
break
|
|
case 'linux':
|
|
await registerLinux(resolved)
|
|
break
|
|
case 'win32':
|
|
await registerWindows(resolved)
|
|
break
|
|
default:
|
|
throw new Error(`Unsupported platform: ${process.platform}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the claude binary path for protocol registration. Prefers the
|
|
* native installer's stable symlink (~/.local/bin/claude) which survives
|
|
* auto-updates; falls back to process.execPath when the symlink is absent
|
|
* (dev builds, non-native installs).
|
|
*/
|
|
async function resolveClaudePath(): Promise<string> {
|
|
const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude'
|
|
const stablePath = path.join(getUserBinDir(), binaryName)
|
|
try {
|
|
await fs.realpath(stablePath)
|
|
return stablePath
|
|
} catch {
|
|
return process.execPath
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether the OS-level protocol handler is already registered AND
|
|
* points at the expected `claude` binary. Reads the registration artifact
|
|
* directly (symlink target, .desktop Exec line, registry value) rather than
|
|
* a cached flag in ~/.claude.json, so:
|
|
* - the check is per-machine (config can sync across machines; OS state can't)
|
|
* - stale paths self-heal (install-method change → re-register next session)
|
|
* - deleted artifacts self-heal
|
|
*
|
|
* Any read error (ENOENT, EACCES, reg nonzero) → false → re-register.
|
|
*/
|
|
export async function isProtocolHandlerCurrent(
|
|
claudePath: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
switch (process.platform) {
|
|
case 'darwin': {
|
|
const target = await fs.readlink(MACOS_SYMLINK_PATH)
|
|
return target === claudePath
|
|
}
|
|
case 'linux': {
|
|
const content = await fs.readFile(linuxDesktopPath(), 'utf8')
|
|
return content.includes(linuxExecLine(claudePath))
|
|
}
|
|
case 'win32': {
|
|
const { stdout, code } = await execFileNoThrow(
|
|
'reg',
|
|
['query', WINDOWS_COMMAND_KEY, '/ve'],
|
|
{ useCwd: false },
|
|
)
|
|
return code === 0 && stdout.includes(windowsCommandValue(claudePath))
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-register the claude-cli:// deep link protocol handler when missing
|
|
* or stale. Runs every session from backgroundHousekeeping (fire-and-forget),
|
|
* but the artifact check makes it a no-op after the first successful run
|
|
* unless the install path moves or the OS artifact is deleted.
|
|
*/
|
|
export async function ensureDeepLinkProtocolRegistered(): Promise<void> {
|
|
if (getInitialSettings().disableDeepLinkRegistration === 'disable') {
|
|
return
|
|
}
|
|
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_lodestone_enabled', false)) {
|
|
return
|
|
}
|
|
|
|
const claudePath = await resolveClaudePath()
|
|
if (await isProtocolHandlerCurrent(claudePath)) {
|
|
return
|
|
}
|
|
|
|
// EACCES/ENOSPC are deterministic — retrying next session won't help.
|
|
// Throttle to once per 24h so a read-only ~/.local/share/applications
|
|
// doesn't generate a failure event on every startup. Marker lives in
|
|
// ~/.claude (per-machine, not synced) rather than ~/.claude.json (can sync).
|
|
const failureMarkerPath = path.join(
|
|
getClaudeConfigHomeDir(),
|
|
'.deep-link-register-failed',
|
|
)
|
|
try {
|
|
const stat = await fs.stat(failureMarkerPath)
|
|
if (Date.now() - stat.mtimeMs < FAILURE_BACKOFF_MS) {
|
|
return
|
|
}
|
|
} catch {
|
|
// Marker absent — proceed.
|
|
}
|
|
|
|
try {
|
|
await registerProtocolHandler(claudePath)
|
|
logEvent('tengu_deep_link_registered', { success: true })
|
|
logForDebugging('Auto-registered claude-cli:// deep link protocol handler')
|
|
await fs.rm(failureMarkerPath, { force: true }).catch(() => {})
|
|
} catch (error) {
|
|
const code = getErrnoCode(error)
|
|
logEvent('tengu_deep_link_registered', {
|
|
success: false,
|
|
error_code:
|
|
code as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
logForDebugging(
|
|
`Failed to auto-register deep link protocol handler: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ level: 'warn' },
|
|
)
|
|
if (code === 'EACCES' || code === 'ENOSPC') {
|
|
await fs.writeFile(failureMarkerPath, '').catch(() => {})
|
|
}
|
|
}
|
|
}
|