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