ReviewOS

also looking at this

craft-native/craft

feat(packaging): register the URL schemes an app declares

#57
Merged glennmichael123 wants to merge feat/macos-url-schemes into main
3 files +177 -7
packages/typescript/src/package.test.tsmodified+91-1
Changes to packages/typescript/src/package.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test'
22import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
5import { candleArguments, codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, pkgbuildArguments, pkgbuildComponentPlist, productbuildArguments, renderWixSource, shouldRetryHdiutil, windowsArchitecture, windowsExecutableName } from './package.js'
5import { candleArguments, codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, pkgbuildArguments, pkgbuildComponentPlist, productbuildArguments, renderWixSource, shouldRetryHdiutil, urlTypesEntry, windowsArchitecture, windowsExecutableName } from './package.js'
66
77describe('Windows MSI packaging', () => {
88 it('renders a deterministic major-upgrade installer without shell interpolation', () => {
@@ -179,6 +179,33 @@ describe('macOS app bundle assembly', () => {
179179 expect(readFileSync(join(app!.outputPath!, 'Contents', 'Info.plist'), 'utf8')).toContain('LSUIElement')
180180 })
181181
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
182209 it('fails clearly when a helper executable is missing', async () => {
183210 const dir = workDir()
184211 const binaryPath = join(dir, 'app-bin')
@@ -235,6 +262,69 @@ describe('macOS app bundle metadata', () => {
235262 })
236263})
237264
265describe('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&amp;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
238328describe('macOS signing and delivery arguments', () => {
239329 it('opts Developer ID builds into the hardened runtime and App Store builds out of it', () => {
240330 expect(codesignArguments({ path: '/tmp/Craft.app', identity: 'Developer ID Application: Acme', hardenedRuntime: true }))
packages/typescript/src/package.tsmodified+77-1
Changes to packages/typescript/src/package.ts
@@ -215,6 +215,21 @@ export interface PackageConfig {
215215 * the helper lands next to `process.execPath`.
216216 */
217217 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[]
218233 }
219234
220235 /** Windows-specific options */
@@ -341,6 +356,7 @@ async function packageMacOS(config: PackageConfig, outDir: string): Promise<Pack
341356 category: opts.category,
342357 minimumSystemVersion: opts.minimumSystemVersion,
343358 menuBarOnly: opts.menuBarOnly,
359 urlSchemes: opts.urlSchemes,
344360 binaryPath: config.binaryPath,
345361 iconPath: config.iconPath,
346362 provisioningProfile: opts.provisioningProfile,
@@ -594,6 +610,60 @@ export interface MacOSBundleMetadata {
594610 minimumSystemVersion?: string
595611 /** Sets `LSUIElement` — no Dock icon, no app switcher entry */
596612 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 */
626const 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 */
636export 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>`
597667}
598668
599669/**
@@ -618,10 +688,16 @@ export function macOSInfoPlist(metadata: MacOSBundleMetadata): string {
618688 if (metadata.minimumSystemVersion) entries.push(['LSMinimumSystemVersion', metadata.minimumSystemVersion])
619689 if (metadata.menuBarOnly) entries.push(['LSUIElement', true])
620690
621 const body = entries
691 const scalars = entries
622692 .map(([key, value]) => ` <key>${xml(key)}</key>\n${value === true ? ' <true/>' : ` <string>${xml(value)}</string>`}`)
623693 .join('\n')
624694
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
625701 return `<?xml version="1.0" encoding="UTF-8"?>
626702<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
627703<plist version="1.0">
packages/zig/src/macos_deep_link.zigmodified+9-5
Changes to packages/zig/src/macos_deep_link.zig
@@ -19,11 +19,15 @@ const keyDirectObject: u32 = 0x2D2D2D2D; // '----'
1919
2020/// Register an AppleEvent handler for `kAEGetURL` so the OS routes
2121/// `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.
2731///
2832/// macOS-only. Idempotent.
2933pub fn install() void {