/ src / utils / desktopDeepLink.ts
desktopDeepLink.ts
  1  import { readdir } from 'fs/promises'
  2  import { join } from 'path'
  3  import { coerce as semverCoerce } from 'semver'
  4  import { getSessionId } from '../bootstrap/state.js'
  5  import { getCwd } from './cwd.js'
  6  import { logForDebugging } from './debug.js'
  7  import { execFileNoThrow } from './execFileNoThrow.js'
  8  import { pathExists } from './file.js'
  9  import { gte as semverGte } from './semver.js'
 10  
 11  const MIN_DESKTOP_VERSION = '1.1.2396'
 12  
 13  function isDevMode(): boolean {
 14    if ((process.env.NODE_ENV as string) === 'development') {
 15      return true
 16    }
 17  
 18    // Local builds from build directories are dev mode even with NODE_ENV=production
 19    const pathsToCheck = [process.argv[1] || '', process.execPath || '']
 20    const buildDirs = [
 21      '/build-ant/',
 22      '/build-ant-native/',
 23      '/build-external/',
 24      '/build-external-native/',
 25    ]
 26  
 27    return pathsToCheck.some(p => buildDirs.some(dir => p.includes(dir)))
 28  }
 29  
 30  /**
 31   * Builds a deep link URL for Claude Desktop to resume a CLI session.
 32   * Format: claude://resume?session={sessionId}&cwd={cwd}
 33   * In dev mode: claude-dev://resume?session={sessionId}&cwd={cwd}
 34   */
 35  function buildDesktopDeepLink(sessionId: string): string {
 36    const protocol = isDevMode() ? 'claude-dev' : 'claude'
 37    const url = new URL(`${protocol}://resume`)
 38    url.searchParams.set('session', sessionId)
 39    url.searchParams.set('cwd', getCwd())
 40    return url.toString()
 41  }
 42  
 43  /**
 44   * Check if Claude Desktop app is installed.
 45   * On macOS, checks for /Applications/Claude.app.
 46   * On Linux, checks if xdg-open can handle claude:// protocol.
 47   * On Windows, checks if the protocol handler exists.
 48   * In dev mode, always returns true (assumes dev Desktop is running).
 49   */
 50  async function isDesktopInstalled(): Promise<boolean> {
 51    // In dev mode, assume the dev Desktop app is running
 52    if (isDevMode()) {
 53      return true
 54    }
 55  
 56    const platform = process.platform
 57  
 58    if (platform === 'darwin') {
 59      // Check for Claude.app in /Applications
 60      return pathExists('/Applications/Claude.app')
 61    } else if (platform === 'linux') {
 62      // Check if xdg-mime can find a handler for claude://
 63      // Note: xdg-mime returns exit code 0 even with no handler, so check stdout too
 64      const { code, stdout } = await execFileNoThrow('xdg-mime', [
 65        'query',
 66        'default',
 67        'x-scheme-handler/claude',
 68      ])
 69      return code === 0 && stdout.trim().length > 0
 70    } else if (platform === 'win32') {
 71      // On Windows, try to query the registry for the protocol handler
 72      const { code } = await execFileNoThrow('reg', [
 73        'query',
 74        'HKEY_CLASSES_ROOT\\claude',
 75        '/ve',
 76      ])
 77      return code === 0
 78    }
 79  
 80    return false
 81  }
 82  
 83  /**
 84   * Detect the installed Claude Desktop version.
 85   * On macOS, reads CFBundleShortVersionString from the app plist.
 86   * On Windows, finds the highest app-X.Y.Z directory in the Squirrel install.
 87   * Returns null if version cannot be determined.
 88   */
 89  async function getDesktopVersion(): Promise<string | null> {
 90    const platform = process.platform
 91  
 92    if (platform === 'darwin') {
 93      const { code, stdout } = await execFileNoThrow('defaults', [
 94        'read',
 95        '/Applications/Claude.app/Contents/Info.plist',
 96        'CFBundleShortVersionString',
 97      ])
 98      if (code !== 0) {
 99        return null
100      }
101      const version = stdout.trim()
102      return version.length > 0 ? version : null
103    } else if (platform === 'win32') {
104      const localAppData = process.env.LOCALAPPDATA
105      if (!localAppData) {
106        return null
107      }
108      const installDir = join(localAppData, 'AnthropicClaude')
109      try {
110        const entries = await readdir(installDir)
111        const versions = entries
112          .filter(e => e.startsWith('app-'))
113          .map(e => e.slice(4))
114          .filter(v => semverCoerce(v) !== null)
115          .sort((a, b) => {
116            const ca = semverCoerce(a)!
117            const cb = semverCoerce(b)!
118            return ca.compare(cb)
119          })
120        return versions.length > 0 ? versions[versions.length - 1]! : null
121      } catch {
122        return null
123      }
124    }
125  
126    return null
127  }
128  
129  export type DesktopInstallStatus =
130    | { status: 'not-installed' }
131    | { status: 'version-too-old'; version: string }
132    | { status: 'ready'; version: string }
133  
134  /**
135   * Check Desktop install status including version compatibility.
136   */
137  export async function getDesktopInstallStatus(): Promise<DesktopInstallStatus> {
138    const installed = await isDesktopInstalled()
139    if (!installed) {
140      return { status: 'not-installed' }
141    }
142  
143    let version: string | null
144    try {
145      version = await getDesktopVersion()
146    } catch {
147      // Best effort — proceed with handoff if version detection fails
148      return { status: 'ready', version: 'unknown' }
149    }
150  
151    if (!version) {
152      // Can't determine version — assume it's ready (dev mode or unknown install)
153      return { status: 'ready', version: 'unknown' }
154    }
155  
156    const coerced = semverCoerce(version)
157    if (!coerced || !semverGte(coerced.version, MIN_DESKTOP_VERSION)) {
158      return { status: 'version-too-old', version }
159    }
160  
161    return { status: 'ready', version }
162  }
163  
164  /**
165   * Opens a deep link URL using the platform-specific mechanism.
166   * Returns true if the command succeeded, false otherwise.
167   */
168  async function openDeepLink(deepLinkUrl: string): Promise<boolean> {
169    const platform = process.platform
170    logForDebugging(`Opening deep link: ${deepLinkUrl}`)
171  
172    if (platform === 'darwin') {
173      if (isDevMode()) {
174        // In dev mode, `open` launches a bare Electron binary (without app code)
175        // because setAsDefaultProtocolClient registers just the Electron executable.
176        // Use AppleScript to route the URL to the already-running Electron app.
177        const { code } = await execFileNoThrow('osascript', [
178          '-e',
179          `tell application "Electron" to open location "${deepLinkUrl}"`,
180        ])
181        return code === 0
182      }
183      const { code } = await execFileNoThrow('open', [deepLinkUrl])
184      return code === 0
185    } else if (platform === 'linux') {
186      const { code } = await execFileNoThrow('xdg-open', [deepLinkUrl])
187      return code === 0
188    } else if (platform === 'win32') {
189      // On Windows, use cmd /c start to open URLs
190      const { code } = await execFileNoThrow('cmd', [
191        '/c',
192        'start',
193        '',
194        deepLinkUrl,
195      ])
196      return code === 0
197    }
198  
199    return false
200  }
201  
202  /**
203   * Build and open a deep link to resume the current session in Claude Desktop.
204   * Returns an object with success status and any error message.
205   */
206  export async function openCurrentSessionInDesktop(): Promise<{
207    success: boolean
208    error?: string
209    deepLinkUrl?: string
210  }> {
211    const sessionId = getSessionId()
212  
213    // Check if Desktop is installed
214    const installed = await isDesktopInstalled()
215    if (!installed) {
216      return {
217        success: false,
218        error:
219          'Claude Desktop is not installed. Install it from https://claude.ai/download',
220      }
221    }
222  
223    // Build and open the deep link
224    const deepLinkUrl = buildDesktopDeepLink(sessionId)
225    const opened = await openDeepLink(deepLinkUrl)
226  
227    if (!opened) {
228      return {
229        success: false,
230        error: 'Failed to open Claude Desktop. Please try opening it manually.',
231        deepLinkUrl,
232      }
233    }
234  
235    return { success: true, deepLinkUrl }
236  }