also looking at this
feat(packaging): register the URL schemes an app declares
#57
3 files
+177
-7
| @@ -215,6 +215,21 @@ export interface PackageConfig { | ||
| 215 | 215 | * the helper lands next to `process.execPath`. |
| 216 | 216 | */ |
| 217 | 217 | additionalExecutables?: string[] |
| 218 | ||
| 219 | /** | |
| 220 | * URL schemes the app registers as a handler for — `['myapp']` makes | |
| 221 | * `myapp://…` open it. | |
| 222 | * | |
| 223 | * Emitted as `CFBundleURLTypes`. Without this key macOS never dispatches | |
| 224 | * a URL to the app at all, so Craft's receive path (the `kAEGetURL` | |
| 225 | * AppleEvent handler behind `craft.deepLink`) can be fully working and | |
| 226 | * still never hear anything. | |
| 227 | * | |
| 228 | * Schemes are matched case-insensitively by LaunchServices and are | |
| 229 | * global to the machine: pick something specific to the app, not `app` | |
| 230 | * or `open`. | |
| 231 | */ | |
| 232 | urlSchemes?: string[] | |
| 218 | 233 | } |
| 219 | 234 | |
| 220 | 235 | /** Windows-specific options */ |
| @@ -341,6 +356,7 @@ async function packageMacOS(config: PackageConfig, outDir: string): Promise<Pack | ||
| 341 | 356 | category: opts.category, |
| 342 | 357 | minimumSystemVersion: opts.minimumSystemVersion, |
| 343 | 358 | menuBarOnly: opts.menuBarOnly, |
| 359 | urlSchemes: opts.urlSchemes, | |
| 344 | 360 | binaryPath: config.binaryPath, |
| 345 | 361 | iconPath: config.iconPath, |
| 346 | 362 | provisioningProfile: opts.provisioningProfile, |
| @@ -594,6 +610,60 @@ export interface MacOSBundleMetadata { | ||
| 594 | 610 | minimumSystemVersion?: string |
| 595 | 611 | /** Sets `LSUIElement` — no Dock icon, no app switcher entry */ |
| 596 | 612 | menuBarOnly?: boolean |
| 613 | /** URL schemes the app handles, emitted as `CFBundleURLTypes` */ | |
| 614 | urlSchemes?: string[] | |
| 615 | } | |
| 616 | ||
| 617 | /** | |
| 618 | * A URL scheme, as RFC 3986 defines one: a letter, then letters, digits, `+`, | |
| 619 | * `-` and `.`. | |
| 620 | * | |
| 621 | * Worth rejecting at package time rather than passing through, because the | |
| 622 | * failure is otherwise invisible: LaunchServices ignores a malformed scheme, | |
| 623 | * the app installs and runs, and links simply never arrive — which is the | |
| 624 | * exact shape of bug this key exists to fix. | |
| 625 | */ | |
| 626 | const URL_SCHEME = /^[a-z][a-z0-9+.-]*$/i | |
| 627 | ||
| 628 | /** | |
| 629 | * `CFBundleURLTypes` for the schemes an app handles, or `null` if it handles | |
| 630 | * none. | |
| 631 | * | |
| 632 | * All the schemes go in one URL type. They are alternatives for reaching the | |
| 633 | * same app, not different kinds of document, so splitting them across several | |
| 634 | * dicts would only invent distinctions LaunchServices does not care about. | |
| 635 | */ | |
| 636 | export function urlTypesEntry(bundleId: string, schemes: readonly string[]): string | null { | |
| 637 | const seen = new Set<string>() | |
| 638 | const unique: string[] = [] | |
| 639 | ||
| 640 | for (const scheme of schemes) { | |
| 641 | if (!URL_SCHEME.test(scheme)) | |
| 642 | throw new Error(`Invalid URL scheme ${JSON.stringify(scheme)}: must start with a letter and contain only letters, digits, "+", "-" or "."`) | |
| 643 | // LaunchServices matches case-insensitively, so `MyApp` and `myapp` are | |
| 644 | // one scheme declared twice. | |
| 645 | const key = scheme.toLowerCase() | |
| 646 | if (seen.has(key)) continue | |
| 647 | seen.add(key) | |
| 648 | unique.push(scheme) | |
| 649 | } | |
| 650 | ||
| 651 | if (unique.length === 0) return null | |
| 652 | ||
| 653 | const list = unique.map(scheme => ` <string>${xml(scheme)}</string>`).join('\n') | |
| 654 | return ` <key>CFBundleURLTypes</key> | |
| 655 | <array> | |
| 656 | <dict> | |
| 657 | <key>CFBundleURLName</key> | |
| 658 | <string>${xml(bundleId)}</string> | |
| 659 | <key>CFBundleTypeRole</key> | |
| 660 | <string>Viewer</string> | |
| 661 | <key>CFBundleURLSchemes</key> | |
| 662 | <array> | |
| 663 | ${list} | |
| 664 | </array> | |
| 665 | </dict> | |
| 666 | </array>` | |
| 597 | 667 | } |
| 598 | 668 | |
| 599 | 669 | /** |
| @@ -618,10 +688,16 @@ export function macOSInfoPlist(metadata: MacOSBundleMetadata): string { | ||
| 618 | 688 | if (metadata.minimumSystemVersion) entries.push(['LSMinimumSystemVersion', metadata.minimumSystemVersion]) |
| 619 | 689 | if (metadata.menuBarOnly) entries.push(['LSUIElement', true]) |
| 620 | 690 | |
| 621 | const body = entries | |
| 691 | const scalars = entries | |
| 622 | 692 | .map(([key, value]) => ` <key>${xml(key)}</key>\n${value === true ? ' <true/>' : ` <string>${xml(value)}</string>`}`) |
| 623 | 693 | .join('\n') |
| 624 | 694 | |
| 695 | // Appended after the scalars rather than mixed in: it is the one entry whose | |
| 696 | // value is not a string or a boolean, and keeping the simple mapping above | |
| 697 | // simple is worth the extra line here. | |
| 698 | const urlTypes = urlTypesEntry(metadata.bundleId, metadata.urlSchemes || []) | |
| 699 | const body = urlTypes ? `${scalars}\n${urlTypes}` : scalars | |
| 700 | ||
| 625 | 701 | return `<?xml version="1.0" encoding="UTF-8"?> |
| 626 | 702 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 627 | 703 | <plist version="1.0"> |