also looking at this
feat(packaging): register the URL schemes an app declares
#57
3 files
+177
-7
| @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test' | ||
| 2 | 2 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | import { candleArguments, codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, pkgbuildArguments, pkgbuildComponentPlist, productbuildArguments, renderWixSource, shouldRetryHdiutil, windowsArchitecture, windowsExecutableName } from './package.js' | |
| 5 | import { candleArguments, codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, pkgbuildArguments, pkgbuildComponentPlist, productbuildArguments, renderWixSource, shouldRetryHdiutil, urlTypesEntry, windowsArchitecture, windowsExecutableName } from './package.js' | |
| 6 | 6 | |
| 7 | 7 | describe('Windows MSI packaging', () => { |
| 8 | 8 | it('renders a deterministic major-upgrade installer without shell interpolation', () => { |
| @@ -179,6 +179,33 @@ describe('macOS app bundle assembly', () => { | ||
| 179 | 179 | expect(readFileSync(join(app!.outputPath!, 'Contents', 'Info.plist'), 'utf8')).toContain('LSUIElement') |
| 180 | 180 | }) |
| 181 | 181 | |
| 182 | it('writes the declared URL schemes into the bundle it produces', async () => { | |
| 183 | // The unit tests below prove `macOSInfoPlist` can render the key. This | |
| 184 | // proves `packageApp` actually asks it to: `urlSchemes` existed as a | |
| 185 | // config field that nothing read, so an app could declare a scheme, | |
| 186 | // typecheck, package, and never receive a link (#53). | |
| 187 | const dir = workDir() | |
| 188 | const binaryPath = join(dir, 'app-bin') | |
| 189 | writeFileSync(binaryPath, '#!/bin/sh\n') | |
| 190 | chmodSync(binaryPath, 0o755) | |
| 191 | ||
| 192 | const results = await packageApp({ | |
| 193 | name: 'Harness', | |
| 194 | version: '1.0.0', | |
| 195 | binaryPath, | |
| 196 | bundleId: 'org.stacksjs.harness', | |
| 197 | outDir: join(dir, 'out'), | |
| 198 | platforms: ['macos'], | |
| 199 | macos: { dmg: false, urlSchemes: ['harness'] }, | |
| 200 | }) | |
| 201 | ||
| 202 | const app = results.find(result => result.format === 'app') | |
| 203 | expect(app?.success).toBe(true) | |
| 204 | const plist = readFileSync(join(app!.outputPath!, 'Contents', 'Info.plist'), 'utf8') | |
| 205 | expect(plist).toContain('<key>CFBundleURLTypes</key>') | |
| 206 | expect(plist).toContain('<string>harness</string>') | |
| 207 | }) | |
| 208 | ||
| 182 | 209 | it('fails clearly when a helper executable is missing', async () => { |
| 183 | 210 | const dir = workDir() |
| 184 | 211 | const binaryPath = join(dir, 'app-bin') |
| @@ -235,6 +262,69 @@ describe('macOS app bundle metadata', () => { | ||
| 235 | 262 | }) |
| 236 | 263 | }) |
| 237 | 264 | |
| 265 | describe('macOS URL schemes', () => { | |
| 266 | // Craft's receive path was complete — a `kAEGetURL` AppleEvent handler, a | |
| 267 | // buffer for a URL that arrives before the page loads — and nothing could | |
| 268 | // ever cause macOS to send one, because no bundle craft produced declared a | |
| 269 | // scheme. An app could set `urlSchemes`, typecheck, package, and never | |
| 270 | // receive a link. | |
| 271 | const base = { name: 'Harness', version: '1.0.0', bundleId: 'org.stacksjs.harness' } | |
| 272 | ||
| 273 | it('declares the schemes an app handles', () => { | |
| 274 | const plist = macOSInfoPlist({ ...base, urlSchemes: ['harness'] }) | |
| 275 | expect(plist).toContain('<key>CFBundleURLTypes</key>') | |
| 276 | expect(plist).toContain('<string>harness</string>') | |
| 277 | // The URL type has to be named, and the bundle id is the one identifier | |
| 278 | // guaranteed unique to this app. | |
| 279 | expect(plist).toContain('<key>CFBundleURLName</key>\n <string>org.stacksjs.harness</string>') | |
| 280 | expect(plist).toContain('<key>CFBundleTypeRole</key>\n <string>Viewer</string>') | |
| 281 | }) | |
| 282 | ||
| 283 | it('puts several schemes in one URL type', () => { | |
| 284 | // They are alternative ways to reach the same app, not different kinds of | |
| 285 | // document — splitting them across dicts would invent a distinction | |
| 286 | // LaunchServices does not make. | |
| 287 | const plist = macOSInfoPlist({ ...base, urlSchemes: ['harness', 'harness-dev'] }) | |
| 288 | expect(plist.match(/<key>CFBundleURLSchemes<\/key>/g)).toHaveLength(1) | |
| 289 | expect(plist).toContain('<string>harness</string>') | |
| 290 | expect(plist).toContain('<string>harness-dev</string>') | |
| 291 | }) | |
| 292 | ||
| 293 | it('emits nothing at all when an app handles no schemes', () => { | |
| 294 | // Every app packaged before this key existed must keep producing the same | |
| 295 | // plist, and an empty array is a caller saying "none", not "broken". | |
| 296 | expect(macOSInfoPlist(base)).not.toContain('CFBundleURLTypes') | |
| 297 | expect(macOSInfoPlist({ ...base, urlSchemes: [] })).not.toContain('CFBundleURLTypes') | |
| 298 | }) | |
| 299 | ||
| 300 | it('collapses schemes that differ only in case', () => { | |
| 301 | // LaunchServices matches case-insensitively, so `Harness` and `harness` | |
| 302 | // are one scheme declared twice. | |
| 303 | const entry = urlTypesEntry(base.bundleId, ['harness', 'Harness'])! | |
| 304 | expect(entry.match(/<string>[Hh]arness<\/string>/g)).toHaveLength(1) | |
| 305 | expect(entry).toContain('<string>harness</string>') | |
| 306 | }) | |
| 307 | ||
| 308 | it('refuses a scheme macOS would silently ignore', () => { | |
| 309 | // The failure this prevents is invisible: LaunchServices drops a malformed | |
| 310 | // scheme, the app installs and runs, and links simply never arrive. | |
| 311 | expect(() => macOSInfoPlist({ ...base, urlSchemes: ['1harness'] })).toThrow(/Invalid URL scheme/) | |
| 312 | expect(() => macOSInfoPlist({ ...base, urlSchemes: ['my app'] })).toThrow(/Invalid URL scheme/) | |
| 313 | expect(() => macOSInfoPlist({ ...base, urlSchemes: ['harness://'] })).toThrow(/Invalid URL scheme/) | |
| 314 | expect(() => macOSInfoPlist({ ...base, urlSchemes: [''] })).toThrow(/Invalid URL scheme/) | |
| 315 | // RFC 3986 allows these three inside a scheme, and apps do use them. | |
| 316 | expect(() => macOSInfoPlist({ ...base, urlSchemes: ['x-harness.dev+beta'] })).not.toThrow() | |
| 317 | }) | |
| 318 | ||
| 319 | it('escapes a bundle id rather than emitting malformed markup', () => { | |
| 320 | expect(urlTypesEntry('com.example.a&b', ['harness'])).toContain('<string>com.example.a&b</string>') | |
| 321 | }) | |
| 322 | ||
| 323 | it('returns null rather than an empty array for no schemes', () => { | |
| 324 | expect(urlTypesEntry('com.example.app', [])).toBeNull() | |
| 325 | }) | |
| 326 | }) | |
| 327 | ||
| 238 | 328 | describe('macOS signing and delivery arguments', () => { |
| 239 | 329 | it('opts Developer ID builds into the hardened runtime and App Store builds out of it', () => { |
| 240 | 330 | expect(codesignArguments({ path: '/tmp/Craft.app', identity: 'Developer ID Application: Acme', hardenedRuntime: true })) |
| @@ -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"> |
| @@ -19,11 +19,15 @@ const keyDirectObject: u32 = 0x2D2D2D2D; // '----' | ||
| 19 | 19 | |
| 20 | 20 | /// Register an AppleEvent handler for `kAEGetURL` so the OS routes |
| 21 | 21 | /// `myapp://...` URLs into our process whenever the user opens such a |
| 22 | /// link (including the very first launch). Apps still need to declare | |
| 23 | /// their URL scheme in the bundle's `Info.plist` under | |
| 24 | /// `CFBundleURLTypes` for macOS to dispatch URLs at all — Craft can't | |
| 25 | /// do that for them, but with the handler installed the rest is | |
| 26 | /// automatic. | |
| 22 | /// link (including the very first launch). | |
| 23 | /// | |
| 24 | /// This is only half of it. macOS dispatches a URL to an app because the | |
| 25 | /// app's bundle claims the scheme under `CFBundleURLTypes`, and nothing | |
| 26 | /// craft produced used to write that key — so this handler was complete, | |
| 27 | /// correct, and could never fire. `packageApp` now emits it from the | |
| 28 | /// `urlSchemes` an app declares; a bare binary with no bundle still cannot | |
| 29 | /// be a URL handler, because there is nothing for LaunchServices to | |
| 30 | /// register. | |
| 27 | 31 | /// |
| 28 | 32 | /// macOS-only. Idempotent. |
| 29 | 33 | pub fn install() void { |