| 559 | } |
| 560 | |
| 561 | async function getMacKeychainPassword(service: string): Promise<string> { |
| 562 | // Use async Bun.spawn with timeout to avoid blocking the event loop. |
| 563 | // macOS may show an Allow/Deny dialog that blocks until the user responds. |
| 564 | const proc = Bun.spawn( |
| 565 | ['security', 'find-generic-password', '-s', service, '-w'], |
| 566 | { stdout: 'pipe', stderr: 'pipe' }, |
| 567 | ); |
| 568 | |
| 569 | const timeout = new Promise<never>((_, reject) => |
| 570 | setTimeout(() => { |
| 571 | proc.kill(); |
| 572 | reject(new CookieImportError( |
| 573 | `macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`, |
| 574 | 'keychain_timeout', |
| 575 | 'retry', |
| 576 | )); |
| 577 | }, 10_000), |
| 578 | ); |
| 579 | |
| 580 | try { |
| 581 | const exitCode = await Promise.race([proc.exited, timeout]); |
| 582 | const stdout = await new Response(proc.stdout).text(); |
| 583 | const stderr = await new Response(proc.stderr).text(); |
| 584 | |
| 585 | if (exitCode !== 0) { |
| 586 | // Distinguish denied vs not found vs other |
| 587 | const errText = stderr.trim().toLowerCase(); |
| 588 | if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) { |
| 589 | throw new CookieImportError( |
| 590 | `Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`, |
| 591 | 'keychain_denied', |
| 592 | 'retry', |
| 593 | ); |
| 594 | } |
| 595 | if (errText.includes('could not be found') || errText.includes('not found')) { |
| 596 | throw new CookieImportError( |
| 597 | `No Keychain entry for "${service}". Is this a Chromium-based browser?`, |
| 598 | 'keychain_not_found', |
| 599 | ); |
| 600 | } |
| 601 | throw new CookieImportError( |
| 602 | `Could not read Keychain: ${stderr.trim()}`, |
| 603 | 'keychain_error', |
| 604 | 'retry', |
| 605 | ); |
| 606 | } |
| 607 | |
| 608 | return stdout.trim(); |
| 609 | } catch (err) { |
| 610 | if (err instanceof CookieImportError) throw err; |
| 611 | throw new CookieImportError( |
| 612 | `Could not read Keychain: ${(err as Error).message}`, |
| 613 | 'keychain_error', |
| 614 | 'retry', |
| 615 | ); |
| 616 | } |
| 617 | } |
| 618 | |