chore: initialize recovered claude workspace
This commit is contained in:
348
src/utils/deepLink/registerProtocol.ts
Normal file
348
src/utils/deepLink/registerProtocol.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user