* 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
(claudePath: string)
| 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' |
no test coverage detected