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 }))