/ utils / deepLink / registerProtocol.ts
registerProtocol.ts
  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  
 16  import { promises as fs } from 'fs'
 17  import * as os from 'os'
 18  import * as path from 'path'
 19  import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
 20  import {
 21    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 22    logEvent,
 23  } from 'src/services/analytics/index.js'
 24  import { logForDebugging } from '../debug.js'
 25  import { getClaudeConfigHomeDir } from '../envUtils.js'
 26  import { getErrnoCode } from '../errors.js'
 27  import { execFileNoThrow } from '../execFileNoThrow.js'
 28  import { getInitialSettings } from '../settings/settings.js'
 29  import { which } from '../which.js'
 30  import { getUserBinDir, getXDGDataHome } from '../xdg.js'
 31  import { DEEP_LINK_PROTOCOL } from './parseDeepLink.js'
 32  
 33  export const MACOS_BUNDLE_ID = 'com.anthropic.claude-code-url-handler'
 34  const APP_NAME = 'Claude Code URL Handler'
 35  const DESKTOP_FILE_NAME = 'claude-code-url-handler.desktop'
 36  const 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.
 41  const MACOS_APP_DIR = path.join(os.homedir(), 'Applications', MACOS_APP_NAME)
 42  const MACOS_SYMLINK_PATH = path.join(
 43    MACOS_APP_DIR,
 44    'Contents',
 45    'MacOS',
 46    'claude',
 47  )
 48  function linuxDesktopPath(): string {
 49    return path.join(getXDGDataHome(), 'applications', DESKTOP_FILE_NAME)
 50  }
 51  const WINDOWS_REG_KEY = `HKEY_CURRENT_USER\\Software\\Classes\\${DEEP_LINK_PROTOCOL}`
 52  const WINDOWS_COMMAND_KEY = `${WINDOWS_REG_KEY}\\shell\\open\\command`
 53  
 54  const FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000
 55  
 56  function linuxExecLine(claudePath: string): string {
 57    return `Exec="${claudePath}" --handle-uri %u`
 58  }
 59  function 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   */
 75  async 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   */
144  async function registerLinux(claudePath: string): Promise<void> {
145    await fs.mkdir(path.dirname(linuxDesktopPath()), { recursive: true })
146  
147    const desktopEntry = `[Desktop Entry]
148  Name=${APP_NAME}
149  Comment=Handle ${DEEP_LINK_PROTOCOL}:// deep links for Claude Code
150  ${linuxExecLine(claudePath)}
151  Type=Application
152  NoDisplay=true
153  MimeType=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   */
185  async 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   */
215  export 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   */
241  async 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   */
263  export 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   */
298  export 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  }