From e53311c58ab6d586e43d2e95a265e1742e14b620 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Tue, 11 Aug 2026 15:52:51 +0200 Subject: [PATCH 1/3] feat(bundle): .pulsar format spec + pulsar-gen codegen CLI (PR0) Foundation for custom preset bundles. Defines the .pulsar format (ZIP + manifest.json), a JSON schema, and pulsar-gen: a zero-dependency Node/TS CLI that reads a .pulsar and emits typed accessors for swift/kotlin/dart and an RN JSON sidecar (keyof-inference, nano-icons style). Includes a fixture bundle, golden outputs, and a node:test suite (7 passing). Portable emitter core is reusable from Studio's browser exporter. --- docs/bundle-format.md | 98 +++++++++++++ schema/pulsar.bundle-1.schema.json | 58 ++++++++ tools/pulsar-gen/.gitignore | 2 + tools/pulsar-gen/README.md | 42 ++++++ tools/pulsar-gen/fixtures/acme-pack.pulsar | Bin 0 -> 1414 bytes tools/pulsar-gen/fixtures/build-fixture.ts | 24 ++++ tools/pulsar-gen/fixtures/fixture.ts | 80 +++++++++++ tools/pulsar-gen/fixtures/golden/AcmePack.kt | 26 ++++ .../pulsar-gen/fixtures/golden/AcmePack.swift | 22 +++ .../fixtures/golden/acme-pack.presets.json | 19 +++ .../fixtures/golden/acme_pack.bundle.dart | 20 +++ tools/pulsar-gen/package-lock.json | 54 ++++++++ tools/pulsar-gen/package.json | 25 ++++ tools/pulsar-gen/src/cli.ts | 80 +++++++++++ tools/pulsar-gen/src/emit/dart.ts | 39 ++++++ tools/pulsar-gen/src/emit/kotlin.ts | 45 ++++++ tools/pulsar-gen/src/emit/rn.ts | 39 ++++++ tools/pulsar-gen/src/emit/shared.ts | 16 +++ tools/pulsar-gen/src/emit/swift.ts | 39 ++++++ tools/pulsar-gen/src/generate.ts | 28 ++++ tools/pulsar-gen/src/index.ts | 18 +++ tools/pulsar-gen/src/naming.ts | 54 ++++++++ tools/pulsar-gen/src/read.ts | 59 ++++++++ tools/pulsar-gen/src/types.ts | 65 +++++++++ tools/pulsar-gen/src/validate.ts | 64 +++++++++ tools/pulsar-gen/src/zip.ts | 131 ++++++++++++++++++ tools/pulsar-gen/test/pulsar-gen.test.ts | 108 +++++++++++++++ tools/pulsar-gen/tsconfig.json | 17 +++ 28 files changed, 1272 insertions(+) create mode 100644 docs/bundle-format.md create mode 100644 schema/pulsar.bundle-1.schema.json create mode 100644 tools/pulsar-gen/.gitignore create mode 100644 tools/pulsar-gen/README.md create mode 100644 tools/pulsar-gen/fixtures/acme-pack.pulsar create mode 100644 tools/pulsar-gen/fixtures/build-fixture.ts create mode 100644 tools/pulsar-gen/fixtures/fixture.ts create mode 100644 tools/pulsar-gen/fixtures/golden/AcmePack.kt create mode 100644 tools/pulsar-gen/fixtures/golden/AcmePack.swift create mode 100644 tools/pulsar-gen/fixtures/golden/acme-pack.presets.json create mode 100644 tools/pulsar-gen/fixtures/golden/acme_pack.bundle.dart create mode 100644 tools/pulsar-gen/package-lock.json create mode 100644 tools/pulsar-gen/package.json create mode 100644 tools/pulsar-gen/src/cli.ts create mode 100644 tools/pulsar-gen/src/emit/dart.ts create mode 100644 tools/pulsar-gen/src/emit/kotlin.ts create mode 100644 tools/pulsar-gen/src/emit/rn.ts create mode 100644 tools/pulsar-gen/src/emit/shared.ts create mode 100644 tools/pulsar-gen/src/emit/swift.ts create mode 100644 tools/pulsar-gen/src/generate.ts create mode 100644 tools/pulsar-gen/src/index.ts create mode 100644 tools/pulsar-gen/src/naming.ts create mode 100644 tools/pulsar-gen/src/read.ts create mode 100644 tools/pulsar-gen/src/types.ts create mode 100644 tools/pulsar-gen/src/validate.ts create mode 100644 tools/pulsar-gen/src/zip.ts create mode 100644 tools/pulsar-gen/test/pulsar-gen.test.ts create mode 100644 tools/pulsar-gen/tsconfig.json diff --git a/docs/bundle-format.md b/docs/bundle-format.md new file mode 100644 index 00000000..e7bb6964 --- /dev/null +++ b/docs/bundle-format.md @@ -0,0 +1,98 @@ +# Pulsar bundle format (`.pulsar`) — v1 + +A `.pulsar` bundle packages one or more **presets** — each a haptic pattern plus optional synced +audio and animation — into a single file that an app loads at runtime. A companion **typed view** +(generated by `pulsar-gen`) gives IDE autocomplete for the presets inside a specific bundle. + +This document is the single source of truth consumed by every SDK, the `pulsar-gen` CLI, and Studio's +exporter. + +## Container + +A `.pulsar` file is a **ZIP archive** (DEFLATE or STORE), analogous to dotLottie: + +``` +acme-pack.pulsar +├─ manifest.json # required — the index +├─ haptics/.json # required per preset — DevicePattern shape +├─ audio/ # optional — e.g. .ogg / .wav +└─ animation/ # optional — Lottie .json / .lottie +``` + +Every platform can read it: `java.util.zip` (Android), `node:zlib` (CLI), `fflate` (JS/Studio), +`archive` (Dart). iOS uses a small vendored central-directory reader over the `Compression` framework. + +## `manifest.json` + +```jsonc +{ + "schema": "pulsar.bundle/1", // required, exact + "generator": "pulsar-studio@1.4.0", // optional, informational + "id": "com.acme.haptics", // required, reverse-DNS bundle identity + "name": "Acme Pack", // required, human label; drives the generated type name + "revision": 7, // optional, monotonic + "hash": "sha256-…", // optional; content hash, embedded in generated types + "presets": [ + { + "id": "heartbeatV2", // required, code-safe identifier → bundle.presets. + "name": "Heartbeat V2", // required, human label + "duration": 1200, // optional, ms (hint) + "haptics": "haptics/heartbeatV2.json", // required, path within the zip + "audio": { "src": "audio/boom.ogg", "volume": 1.0, "offset": 0 }, // optional + "animation": { "src": "animation/pulse.lottie", "frameRate": 60, "totalFrames": 72 } // optional + } + ] +} +``` + +- **`id`** is a code-safe identifier (`^[A-Za-z_$][A-Za-z0-9_$]*$`), unique within the bundle. It + becomes a member: `bundle.presets.heartbeatV2`. `pulsar-gen` rejects ids that collide with a + target language's reserved words. +- **`name`** on the manifest drives the generated type name (PascalCase: `Acme Pack` → `AcmePack`). + +## Haptics payload (`haptics/.json`) + +The **device wire shape** — identical field naming to what the SDKs already consume (NOT Studio's +editor `intensity` naming). Times are ms; values are `0..1`. + +```jsonc +{ + "continuousPattern": { + "amplitude": [ { "time": 0, "value": 0.0 }, { "time": 10, "value": 0.8 } ], + "frequency": [ { "time": 0, "value": 0.2 }, { "time": 1000, "value": 0.2 } ] + }, + "discretePattern": [ { "time": 0, "amplitude": 0.9, "frequency": 0.2 } ] +} +``` + +## Runtime API (uniform across platforms) + +``` +loadBundle(descriptor) -> Bundle

+``` + +- The **descriptor** is emitted by `pulsar-gen` (or, for RN, inferred from a JSON sidecar). It carries + `assetName`, `bundleId`, `contentHash`, `presetIds`, and a `build(resolver) -> P` closure that + constructs the typed presets view. +- **`Bundle

`** exposes `presets: P` (the typed struct/class/object of `PresetHandle`s), + plus `id`, `revision`, `contentHash`, `get(id): PresetHandle?` (dynamic escape hatch), and `dispose()`. +- **`PresetHandle`**: `play()`, `stop()`, `duration`, and `animation?` — the Lottie bytes/metadata for + the app to render. Pulsar carries and time-aligns animation; the app's own Lottie view renders it. + +### Drift protection + +The generated descriptor embeds the manifest `contentHash`. `loadBundle(strict: true)` asserts the +loaded `.pulsar`'s hash equals the descriptor's — a renamed/removed preset fails loudly at load +instead of surfacing as a silent missing member. + +## The typed view (`pulsar-gen` targets) + +| Target | Output | How types arise | +|---|---|---| +| `swift` | `AcmePack.swift` — `enum AcmePack` + `BundleDescriptor` | codegen struct | +| `kotlin` | `AcmePack.kt` — `object AcmePack` + `BundleDescriptor` | codegen class | +| `dart` | `acme_pack.bundle.dart` — descriptor + presets class | codegen class | +| `rn` | `acme-pack.presets.json` sidecar | `keyof` inference over the imported JSON (nano-icons approach) | + +All four are derived from the same `manifest.json`, so the runtime `.pulsar` and the typed view can +never describe different presets without the hash check catching it. diff --git a/schema/pulsar.bundle-1.schema.json b/schema/pulsar.bundle-1.schema.json new file mode 100644 index 00000000..3382edae --- /dev/null +++ b/schema/pulsar.bundle-1.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://pulsar.swmansion.com/schema/pulsar.bundle-1.schema.json", + "title": "Pulsar bundle manifest v1", + "type": "object", + "required": ["schema", "id", "name", "presets"], + "additionalProperties": false, + "properties": { + "schema": { "const": "pulsar.bundle/1" }, + "generator": { "type": "string" }, + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "revision": { "type": "integer", "minimum": 0 }, + "hash": { "type": "string" }, + "presets": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/preset" } + } + }, + "definitions": { + "preset": { + "type": "object", + "required": ["id", "name", "haptics"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$", + "description": "Code-safe identifier; becomes bundle.presets." + }, + "name": { "type": "string", "minLength": 1 }, + "duration": { "type": "number", "minimum": 0 }, + "haptics": { "type": "string", "minLength": 1 }, + "audio": { + "type": "object", + "required": ["src"], + "additionalProperties": false, + "properties": { + "src": { "type": "string", "minLength": 1 }, + "volume": { "type": "number", "minimum": 0 }, + "offset": { "type": "number" } + } + }, + "animation": { + "type": "object", + "required": ["src"], + "additionalProperties": false, + "properties": { + "src": { "type": "string", "minLength": 1 }, + "frameRate": { "type": "number", "minimum": 0 }, + "totalFrames": { "type": "number", "minimum": 0 } + } + } + } + } + } +} diff --git a/tools/pulsar-gen/.gitignore b/tools/pulsar-gen/.gitignore new file mode 100644 index 00000000..ff2c5856 --- /dev/null +++ b/tools/pulsar-gen/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.tsbuildinfo diff --git a/tools/pulsar-gen/README.md b/tools/pulsar-gen/README.md new file mode 100644 index 00000000..8d55dba3 --- /dev/null +++ b/tools/pulsar-gen/README.md @@ -0,0 +1,42 @@ +# pulsar-gen + +Generates the **typed view** of a Pulsar `.pulsar` bundle so `bundle.presets.` autocompletes in +your IDE. See [`docs/bundle-format.md`](../../docs/bundle-format.md) for the bundle format itself. + +Requires Node ≥ 23.6 (runs TypeScript sources directly via type-stripping — no build step). + +## CLI + +```bash +# Emit a Swift typed accessor next to the bundle +node src/cli.ts path/to/acme-pack.pulsar --target swift --out ./Generated + +# Multiple targets at once +node src/cli.ts acme-pack.pulsar --target swift,kotlin,dart,rn --out ./gen + +# Kotlin package / print to stdout +node src/cli.ts acme-pack.pulsar --target kotlin --package com.acme.haptics --stdout +``` + +Targets: `swift` (`enum` + `BundleDescriptor`), `kotlin` (`object` + `BundleDescriptor`), +`dart` (`*.bundle.dart`), `rn` (`.presets.json` sidecar — types arise from `keyof` inference over +the imported JSON, à la nano-icons; no `.d.ts` is generated). + +## Programmatic API (portable — importable from Studio's browser bundle) + +```ts +import { validateManifest, generate, buildSidecar } from '@swmansion/pulsar-gen'; +// Node-only helpers (disk + zip): +import { readBundleFile, computeContentHash } from '@swmansion/pulsar-gen/read'; +``` + +The emitters (`src/emit/*`), `generate`, and `validateManifest` use no Node APIs, so Studio reuses +them directly for in-browser export. `read`/`zip`/`cli` are Node-only. + +## Develop + +```bash +node fixtures/build-fixture.ts # regenerate the fixture bundle + golden outputs +node --test # run the test suite +npm run typecheck # tsc --noEmit (needs `npm install` for typescript first) +``` diff --git a/tools/pulsar-gen/fixtures/acme-pack.pulsar b/tools/pulsar-gen/fixtures/acme-pack.pulsar new file mode 100644 index 0000000000000000000000000000000000000000..941dcef0f3afd22b588eebb48ec80fa2831da4b7 GIT binary patch literal 1414 zcmWIWW@Zs#fPqc{&oVI}2Zj^#GIJA4GV}BF3rcf}Q}uH4OG+|RtCh->td!C+D@sa> zQgtC>O0@wfCQUQQ%@qQg1j774oLHKYnXjLelUQ7$SDsiF0*Rv}Mm!!zaBFV7BEB z5#G6=FUaepj-Q^FuBYn{sU)K_F+9P6O@{^ay55*E+j{HGh}{^oHHHD^dx-l}BlI#C z0Nt;_#K0f{q+#yQNKGs%NlHyD2{WRC54s&89yqef|9lh2T(^p~ESEP5h++s^)_^yCA}tthn=)-4w< z+=pWSCmPHBcI%OUruB4p_LeS--_{S8zOVnB>yY`LneW~Ahi89T{WBL` zacbIXu8cW3hb{O%nJ8`Pu=HQlqM*zDr02QHsV<|FlX}_&`8iS*&qoM+T6Dts)MSem zo3ov}E%WUd1H2iTL>O@AcVMg_0qnUS-3;`cjxfLr$VAKc=$g^Q|5f}XMwro;ksFiJWP@MdKLNwWcA6VQrx Jz|6qF000*xzm5O^ literal 0 HcmV?d00001 diff --git a/tools/pulsar-gen/fixtures/build-fixture.ts b/tools/pulsar-gen/fixtures/build-fixture.ts new file mode 100644 index 00000000..747fd33b --- /dev/null +++ b/tools/pulsar-gen/fixtures/build-fixture.ts @@ -0,0 +1,24 @@ +// Regenerates the committed fixture bundle and its golden generated outputs. +// node fixtures/build-fixture.ts + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { buildFixtureBundle } from './fixture.ts'; +import { readBundleBytes } from '../src/read.ts'; +import { generate, TARGETS } from '../src/generate.ts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const goldenDir = join(here, 'golden'); +mkdirSync(goldenDir, { recursive: true }); + +const bytes = buildFixtureBundle(); +writeFileSync(join(here, 'acme-pack.pulsar'), bytes); + +const { manifest } = readBundleBytes(bytes); +for (const target of TARGETS) { + const file = generate(manifest, target, { assetName: 'acme-pack' }); + writeFileSync(join(goldenDir, file.filename), file.content); + process.stderr.write(`built golden ${target}: ${file.filename}\n`); +} +process.stderr.write('fixture + goldens updated\n'); diff --git a/tools/pulsar-gen/fixtures/fixture.ts b/tools/pulsar-gen/fixtures/fixture.ts new file mode 100644 index 00000000..df086ae7 --- /dev/null +++ b/tools/pulsar-gen/fixtures/fixture.ts @@ -0,0 +1,80 @@ +// Builds the canonical test fixture bundle in-memory. Shared by build-fixture.ts and the tests. + +import { writeZip, type ZipEntries } from '../src/zip.ts'; +import { computeContentHash } from '../src/read.ts'; +import type { BundleManifest, DevicePattern } from '../src/types.ts'; + +const enc = (s: string) => new TextEncoder().encode(s); +const jsonBytes = (v: unknown) => enc(JSON.stringify(v, null, 2) + '\n'); + +const heartbeat: DevicePattern = { + continuousPattern: { + amplitude: [ + { time: 0, value: 0 }, + { time: 10, value: 0.8 }, + { time: 120, value: 0 }, + ], + frequency: [ + { time: 0, value: 0.2 }, + { time: 1000, value: 0.2 }, + ], + }, + discretePattern: [ + { time: 0, amplitude: 0.9, frequency: 0.2 }, + { time: 120, amplitude: 0.6, frequency: 0.2 }, + ], +}; + +const explosion: DevicePattern = { + continuousPattern: { + amplitude: [ + { time: 0, value: 1 }, + { time: 200, value: 0 }, + ], + frequency: [{ time: 0, value: 0.8 }], + }, + discretePattern: [{ time: 0, amplitude: 1, frequency: 0.9 }], +}; + +export function buildFixtureBundle(): Uint8Array { + const manifest: BundleManifest = { + schema: 'pulsar.bundle/1', + generator: 'pulsar-gen-fixture', + id: 'com.acme.haptics', + name: 'Acme Pack', + revision: 7, + presets: [ + { + id: 'heartbeatV2', + name: 'Heartbeat V2', + duration: 1200, + haptics: 'haptics/heartbeatV2.json', + audio: { src: 'audio/boom.ogg', volume: 1, offset: 0 }, + animation: { src: 'animation/pulse.lottie', frameRate: 60, totalFrames: 72 }, + }, + { + id: 'explosion', + name: 'Explosion', + duration: 800, + haptics: 'haptics/explosion.json', + audio: { src: 'audio/blast.wav' }, + }, + ], + }; + + // Placeholder media payloads (real bundles carry actual audio / Lottie bytes). + const entries: ZipEntries = { + 'haptics/heartbeatV2.json': jsonBytes(heartbeat), + 'haptics/explosion.json': jsonBytes(explosion), + 'audio/boom.ogg': enc('OggS-fixture-audio'), + 'audio/blast.wav': enc('RIFF-fixture-audio'), + 'animation/pulse.lottie': enc('{"v":"fixture-lottie"}'), + }; + + // Compute the content hash over everything EXCEPT the manifest's own hash, then embed it. + const withoutHash: ZipEntries = { ...entries, 'manifest.json': jsonBytes(manifest) }; + manifest.hash = computeContentHash(withoutHash); + entries['manifest.json'] = jsonBytes(manifest); + + return writeZip(entries); +} diff --git a/tools/pulsar-gen/fixtures/golden/AcmePack.kt b/tools/pulsar-gen/fixtures/golden/AcmePack.kt new file mode 100644 index 00000000..effa46fa --- /dev/null +++ b/tools/pulsar-gen/fixtures/golden/AcmePack.kt @@ -0,0 +1,26 @@ +// Code generated by pulsar-gen. DO NOT EDIT. +// Bundle: com.acme.haptics (2 presets) +package com.swmansion.pulsar.bundles + +import com.swmansion.pulsar.bundle.BundleDescriptor +import com.swmansion.pulsar.bundle.BundleResolver +import com.swmansion.pulsar.bundle.PresetHandle + +object AcmePack { + const val assetName = "acme-pack.pulsar" + const val bundleId = "com.acme.haptics" + const val contentHash = "sha256-355197402b5ff6460e2dbefcf9048bd6868234f102c3f8c13fbb932f653a9790" + + class Presets(r: BundleResolver) { + val heartbeatV2: PresetHandle = r["heartbeatV2"] + val explosion: PresetHandle = r["explosion"] + } + + val descriptor = BundleDescriptor( + assetName = assetName, + bundleId = bundleId, + contentHash = contentHash, + presetIds = listOf("heartbeatV2", "explosion"), + build = ::Presets, + ) +} diff --git a/tools/pulsar-gen/fixtures/golden/AcmePack.swift b/tools/pulsar-gen/fixtures/golden/AcmePack.swift new file mode 100644 index 00000000..e31eb114 --- /dev/null +++ b/tools/pulsar-gen/fixtures/golden/AcmePack.swift @@ -0,0 +1,22 @@ +// Code generated by pulsar-gen. DO NOT EDIT. +// Bundle: com.acme.haptics (2 presets) +import Pulsar + +public enum AcmePack { + public static let assetName = "acme-pack" + public static let bundleId = "com.acme.haptics" + public static let contentHash = "sha256-355197402b5ff6460e2dbefcf9048bd6868234f102c3f8c13fbb932f653a9790" + + public struct Presets { + public let heartbeatV2: PresetHandle + public let explosion: PresetHandle + } + + public static let descriptor = BundleDescriptor( + assetName: assetName, + bundleId: bundleId, + contentHash: contentHash, + presetIds: ["heartbeatV2", "explosion"], + build: { r in Presets(heartbeatV2: r["heartbeatV2"], explosion: r["explosion"]) } + ) +} diff --git a/tools/pulsar-gen/fixtures/golden/acme-pack.presets.json b/tools/pulsar-gen/fixtures/golden/acme-pack.presets.json new file mode 100644 index 00000000..62b6a15f --- /dev/null +++ b/tools/pulsar-gen/fixtures/golden/acme-pack.presets.json @@ -0,0 +1,19 @@ +{ + "id": "com.acme.haptics", + "contentHash": "sha256-355197402b5ff6460e2dbefcf9048bd6868234f102c3f8c13fbb932f653a9790", + "presets": { + "heartbeatV2": { + "name": "Heartbeat V2", + "audio": true, + "animation": true, + "duration": 1200 + }, + "explosion": { + "name": "Explosion", + "audio": true, + "animation": false, + "duration": 800 + } + }, + "revision": 7 +} diff --git a/tools/pulsar-gen/fixtures/golden/acme_pack.bundle.dart b/tools/pulsar-gen/fixtures/golden/acme_pack.bundle.dart new file mode 100644 index 00000000..cfd13921 --- /dev/null +++ b/tools/pulsar-gen/fixtures/golden/acme_pack.bundle.dart @@ -0,0 +1,20 @@ +// Code generated by pulsar-gen. DO NOT EDIT. +// Bundle: com.acme.haptics (2 presets) +import 'package:pulsar_haptics/pulsar_haptics.dart'; + +class AcmePackPresets { + AcmePackPresets(BundleResolver r) + : heartbeatV2 = r['heartbeatV2'], + explosion = r['explosion']; + + final PresetHandle heartbeatV2; + final PresetHandle explosion; +} + +final acmePack = BundleDescriptor( + assetName: 'assets/pulsar/acme-pack.pulsar', + bundleId: 'com.acme.haptics', + contentHash: 'sha256-355197402b5ff6460e2dbefcf9048bd6868234f102c3f8c13fbb932f653a9790', + presetIds: const ['heartbeatV2', 'explosion'], + build: AcmePackPresets.new, +); diff --git a/tools/pulsar-gen/package-lock.json b/tools/pulsar-gen/package-lock.json new file mode 100644 index 00000000..a19a437c --- /dev/null +++ b/tools/pulsar-gen/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "@swmansion/pulsar-gen", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@swmansion/pulsar-gen", + "version": "0.1.0", + "license": "MIT", + "bin": { + "pulsar-gen": "src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=23.6" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tools/pulsar-gen/package.json b/tools/pulsar-gen/package.json new file mode 100644 index 00000000..633e45cd --- /dev/null +++ b/tools/pulsar-gen/package.json @@ -0,0 +1,25 @@ +{ + "name": "@swmansion/pulsar-gen", + "version": "0.1.0", + "description": "Generate typed accessors (Swift/Kotlin/Dart) and RN sidecars for Pulsar .pulsar bundles.", + "type": "module", + "engines": { "node": ">=23.6" }, + "bin": { "pulsar-gen": "src/cli.ts" }, + "exports": { + ".": "./src/index.ts", + "./read": "./src/read.ts", + "./zip": "./src/zip.ts" + }, + "scripts": { + "build:fixture": "node fixtures/build-fixture.ts", + "test": "node --test", + "typecheck": "tsc --noEmit" + }, + "files": ["src", "README.md"], + "keywords": ["pulsar", "haptics", "codegen", "bundle"], + "license": "MIT", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/tools/pulsar-gen/src/cli.ts b/tools/pulsar-gen/src/cli.ts new file mode 100644 index 00000000..7f50018c --- /dev/null +++ b/tools/pulsar-gen/src/cli.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// pulsar-gen — generate typed accessors for a .pulsar bundle. +// +// pulsar-gen --target swift,kotlin,dart,rn [--out DIR] [--package PKG] [--asset NAME] +// +// Emits one file per target. Targets: swift | kotlin | dart | rn (comma-separated or repeated). + +import { parseArgs } from 'node:util'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { readBundleFile } from './read.ts'; +import { generate, TARGETS } from './generate.ts'; +import type { Target, GenerateOptions } from './types.ts'; + +function usage(): never { + process.stderr.write( + 'Usage: pulsar-gen --target [--out DIR] ' + + '[--package PKG] [--asset NAME] [--stdout]\n', + ); + process.exit(2); +} + +function main(argv: string[]): void { + let parsed; + try { + parsed = parseArgs({ + args: argv, + allowPositionals: true, + options: { + target: { type: 'string', multiple: true, short: 't' }, + out: { type: 'string', short: 'o' }, + package: { type: 'string', short: 'p' }, + asset: { type: 'string' }, + stdout: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + }, + }); + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return usage(); + } + + if (parsed.values.help || parsed.positionals.length !== 1) return usage(); + + const bundlePath = parsed.positionals[0]; + const rawTargets = (parsed.values.target ?? []).flatMap((t) => t.split(',')); + if (rawTargets.length === 0) { + process.stderr.write('error: at least one --target is required\n'); + return usage(); + } + const targets = rawTargets.map((t) => t.trim()) as Target[]; + for (const t of targets) { + if (!TARGETS.includes(t)) { + process.stderr.write(`error: unknown target "${t}" (expected ${TARGETS.join(', ')})\n`); + process.exit(2); + } + } + + const { manifest } = readBundleFile(bundlePath); + const opts: GenerateOptions = { + assetName: parsed.values.asset ?? basename(bundlePath).replace(/\.pulsar$/i, ''), + packageName: parsed.values.package, + }; + + const outDir = parsed.values.out ?? '.'; + if (!parsed.values.stdout) mkdirSync(outDir, { recursive: true }); + + for (const target of targets) { + const file = generate(manifest, target, opts); + if (parsed.values.stdout) { + process.stdout.write(file.content); + } else { + const dest = join(outDir, file.filename); + writeFileSync(dest, file.content); + process.stderr.write(`pulsar-gen: wrote ${dest}\n`); + } + } +} + +main(process.argv.slice(2)); diff --git a/tools/pulsar-gen/src/emit/dart.ts b/tools/pulsar-gen/src/emit/dart.ts new file mode 100644 index 00000000..b038ee65 --- /dev/null +++ b/tools/pulsar-gen/src/emit/dart.ts @@ -0,0 +1,39 @@ +// Dart emitter — a typed BundleDescriptor + presets class. Portable (no Node APIs). + +import type { BundleManifest, GenerateOptions, GeneratedFile } from '../types.ts'; +import { pascalCase, lowerCamel, snakeCase } from '../naming.ts'; +import { DO_NOT_EDIT, resolveAssetName, contentHash } from './shared.ts'; + +export function emitDart(manifest: BundleManifest, opts: GenerateOptions = {}): GeneratedFile { + const typeName = pascalCase(manifest.name); + const varName = lowerCamel(manifest.name); + const asset = `assets/pulsar/${resolveAssetName(manifest, opts)}.pulsar`; + const ids = manifest.presets.map((p) => p.id); + + const ctorInit = ids + .map((id, i) => `${i === 0 ? ' : ' : ' '}${id} = r['${id}']`) + .join(',\n'); + const fields = ids.map((id) => ` final PresetHandle ${id};`).join('\n'); + const idList = ids.map((id) => `'${id}'`).join(', '); + + const content = `// ${DO_NOT_EDIT} +// Bundle: ${manifest.id} (${manifest.presets.length} preset${manifest.presets.length === 1 ? '' : 's'}) +import 'package:pulsar_haptics/pulsar_haptics.dart'; + +class ${typeName}Presets { + ${typeName}Presets(BundleResolver r) +${ctorInit}; + +${fields} +} + +final ${varName} = BundleDescriptor<${typeName}Presets>( + assetName: '${asset}', + bundleId: '${manifest.id}', + contentHash: '${contentHash(manifest)}', + presetIds: const [${idList}], + build: ${typeName}Presets.new, +); +`; + return { filename: `${snakeCase(manifest.name)}.bundle.dart`, content }; +} diff --git a/tools/pulsar-gen/src/emit/kotlin.ts b/tools/pulsar-gen/src/emit/kotlin.ts new file mode 100644 index 00000000..4401f331 --- /dev/null +++ b/tools/pulsar-gen/src/emit/kotlin.ts @@ -0,0 +1,45 @@ +// Kotlin emitter — `object ` exposing a typed BundleDescriptor. Portable (no Node APIs). + +import type { BundleManifest, GenerateOptions, GeneratedFile } from '../types.ts'; +import { pascalCase } from '../naming.ts'; +import { DO_NOT_EDIT, resolveAssetName, contentHash } from './shared.ts'; + +export function emitKotlin(manifest: BundleManifest, opts: GenerateOptions = {}): GeneratedFile { + const typeName = pascalCase(manifest.name); + const asset = `${resolveAssetName(manifest, opts)}.pulsar`; + const pkg = opts.packageName ?? 'com.swmansion.pulsar.bundles'; + const ids = manifest.presets.map((p) => p.id); + + const presetFields = ids + .map((id) => ` val ${id}: PresetHandle = r["${id}"]`) + .join('\n'); + const idList = ids.map((id) => `"${id}"`).join(', '); + + const content = `// ${DO_NOT_EDIT} +// Bundle: ${manifest.id} (${manifest.presets.length} preset${manifest.presets.length === 1 ? '' : 's'}) +package ${pkg} + +import com.swmansion.pulsar.bundle.BundleDescriptor +import com.swmansion.pulsar.bundle.BundleResolver +import com.swmansion.pulsar.bundle.PresetHandle + +object ${typeName} { + const val assetName = "${asset}" + const val bundleId = "${manifest.id}" + const val contentHash = "${contentHash(manifest)}" + + class Presets(r: BundleResolver) { +${presetFields} + } + + val descriptor = BundleDescriptor( + assetName = assetName, + bundleId = bundleId, + contentHash = contentHash, + presetIds = listOf(${idList}), + build = ::Presets, + ) +} +`; + return { filename: `${typeName}.kt`, content }; +} diff --git a/tools/pulsar-gen/src/emit/rn.ts b/tools/pulsar-gen/src/emit/rn.ts new file mode 100644 index 00000000..e12b2d16 --- /dev/null +++ b/tools/pulsar-gen/src/emit/rn.ts @@ -0,0 +1,39 @@ +// React Native emitter — a JSON sidecar. Types arise from `keyof` inference over this file +// (the nano-icons approach), so there is NO .d.ts / code generation here. Portable (no Node APIs). + +import type { BundleManifest, GenerateOptions, GeneratedFile } from '../types.ts'; +import { resolveAssetName } from './shared.ts'; + +export interface PresetSidecarEntry { + name: string; + duration?: number; + audio: boolean; + animation: boolean; +} + +export interface BundleSidecar { + id: string; + contentHash: string; + revision?: number; + presets: Record; +} + +export function buildSidecar(manifest: BundleManifest): BundleSidecar { + const presets: Record = {}; + for (const p of manifest.presets) { + const entry: PresetSidecarEntry = { name: p.name, audio: !!p.audio, animation: !!p.animation }; + if (p.duration !== undefined) entry.duration = p.duration; + presets[p.id] = entry; + } + const sidecar: BundleSidecar = { id: manifest.id, contentHash: manifest.hash ?? '', presets }; + if (manifest.revision !== undefined) sidecar.revision = manifest.revision; + return sidecar; +} + +export function emitRn(manifest: BundleManifest, opts: GenerateOptions = {}): GeneratedFile { + const asset = resolveAssetName(manifest, opts); + return { + filename: `${asset}.presets.json`, + content: JSON.stringify(buildSidecar(manifest), null, 2) + '\n', + }; +} diff --git a/tools/pulsar-gen/src/emit/shared.ts b/tools/pulsar-gen/src/emit/shared.ts new file mode 100644 index 00000000..24418e1e --- /dev/null +++ b/tools/pulsar-gen/src/emit/shared.ts @@ -0,0 +1,16 @@ +// Shared emitter helpers (portable — no Node APIs). + +import type { BundleManifest, GenerateOptions } from '../types.ts'; + +export const DO_NOT_EDIT = 'Code generated by pulsar-gen. DO NOT EDIT.'; + +/** Asset base name (no extension), from options or the manifest id's last segment. */ +export function resolveAssetName(manifest: BundleManifest, opts: GenerateOptions): string { + if (opts.assetName) return opts.assetName; + const seg = manifest.id.split('.').pop() || manifest.id; + return seg.replace(/[^A-Za-z0-9._-]/g, '-'); +} + +export function contentHash(manifest: BundleManifest): string { + return manifest.hash ?? ''; +} diff --git a/tools/pulsar-gen/src/emit/swift.ts b/tools/pulsar-gen/src/emit/swift.ts new file mode 100644 index 00000000..52b2f1d7 --- /dev/null +++ b/tools/pulsar-gen/src/emit/swift.ts @@ -0,0 +1,39 @@ +// Swift emitter — `enum ` exposing a typed BundleDescriptor. Portable (no Node APIs). + +import type { BundleManifest, GenerateOptions, GeneratedFile } from '../types.ts'; +import { pascalCase } from '../naming.ts'; +import { DO_NOT_EDIT, resolveAssetName, contentHash } from './shared.ts'; + +export function emitSwift(manifest: BundleManifest, opts: GenerateOptions = {}): GeneratedFile { + const typeName = pascalCase(manifest.name); + const asset = resolveAssetName(manifest, opts); + const ids = manifest.presets.map((p) => p.id); + + const presetFields = ids.map((id) => ` public let ${id}: PresetHandle`).join('\n'); + const resolverArgs = ids.map((id) => `${id}: r["${id}"]`).join(', '); + const idList = ids.map((id) => `"${id}"`).join(', '); + + const content = `// ${DO_NOT_EDIT} +// Bundle: ${manifest.id} (${manifest.presets.length} preset${manifest.presets.length === 1 ? '' : 's'}) +import Pulsar + +public enum ${typeName} { + public static let assetName = "${asset}" + public static let bundleId = "${manifest.id}" + public static let contentHash = "${contentHash(manifest)}" + + public struct Presets { +${presetFields} + } + + public static let descriptor = BundleDescriptor( + assetName: assetName, + bundleId: bundleId, + contentHash: contentHash, + presetIds: [${idList}], + build: { r in Presets(${resolverArgs}) } + ) +} +`; + return { filename: `${typeName}.swift`, content }; +} diff --git a/tools/pulsar-gen/src/generate.ts b/tools/pulsar-gen/src/generate.ts new file mode 100644 index 00000000..492e448c --- /dev/null +++ b/tools/pulsar-gen/src/generate.ts @@ -0,0 +1,28 @@ +// Target dispatch (portable — no Node APIs). Reused by the CLI and by Studio's browser exporter. + +import type { BundleManifest, GenerateOptions, GeneratedFile, Target } from './types.ts'; +import { emitSwift } from './emit/swift.ts'; +import { emitKotlin } from './emit/kotlin.ts'; +import { emitDart } from './emit/dart.ts'; +import { emitRn } from './emit/rn.ts'; + +export const TARGETS: Target[] = ['swift', 'kotlin', 'dart', 'rn']; + +export function generate( + manifest: BundleManifest, + target: Target, + opts: GenerateOptions = {}, +): GeneratedFile { + switch (target) { + case 'swift': + return emitSwift(manifest, opts); + case 'kotlin': + return emitKotlin(manifest, opts); + case 'dart': + return emitDart(manifest, opts); + case 'rn': + return emitRn(manifest, opts); + default: + throw new Error(`Unknown target "${target as string}". Expected one of: ${TARGETS.join(', ')}`); + } +} diff --git a/tools/pulsar-gen/src/index.ts b/tools/pulsar-gen/src/index.ts new file mode 100644 index 00000000..83c25c8d --- /dev/null +++ b/tools/pulsar-gen/src/index.ts @@ -0,0 +1,18 @@ +// Portable programmatic API (no Node APIs) — safe to import from Studio's browser bundle. +// For reading .pulsar files from disk, import from './read.ts' (Node-only) instead. + +export * from './types.ts'; +export { validateManifest } from './validate.ts'; +export { generate, TARGETS } from './generate.ts'; +export { emitSwift } from './emit/swift.ts'; +export { emitKotlin } from './emit/kotlin.ts'; +export { emitDart } from './emit/dart.ts'; +export { emitRn, buildSidecar } from './emit/rn.ts'; +export type { BundleSidecar, PresetSidecarEntry } from './emit/rn.ts'; +export { + isValidPresetId, + assertValidPresetId, + pascalCase, + lowerCamel, + snakeCase, +} from './naming.ts'; diff --git a/tools/pulsar-gen/src/naming.ts b/tools/pulsar-gen/src/naming.ts new file mode 100644 index 00000000..5ada7a12 --- /dev/null +++ b/tools/pulsar-gen/src/naming.ts @@ -0,0 +1,54 @@ +// Identifier + naming helpers (portable — no Node APIs). + +const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** Reserved words that would collide as a preset member across our target languages. */ +const RESERVED = new Set([ + // shared / cross-language hazards + 'class', 'enum', 'struct', 'object', 'val', 'var', 'let', 'const', 'func', 'fun', 'def', + 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'when', 'case', 'default', 'break', + 'continue', 'import', 'export', 'public', 'private', 'internal', 'protected', 'static', 'final', + 'init', 'self', 'this', 'super', 'null', 'nil', 'true', 'false', 'void', 'new', 'delete', + 'try', 'catch', 'throw', 'throws', 'async', 'await', 'extends', 'implements', 'interface', + 'typealias', 'typeof', 'in', 'is', 'as', 'operator', 'where', + // Bundle surface members a preset id must not shadow + 'presets', 'id', 'revision', 'contentHash', 'dispose', 'get', +]); + +export function isValidPresetId(id: string): boolean { + return IDENT_RE.test(id) && !RESERVED.has(id); +} + +export function assertValidPresetId(id: string): void { + if (!IDENT_RE.test(id)) { + throw new Error( + `Invalid preset id "${id}": must match ${IDENT_RE} (letters, digits, _ or $; not starting with a digit).`, + ); + } + if (RESERVED.has(id)) { + throw new Error(`Invalid preset id "${id}": reserved word / conflicts with a Bundle member.`); + } +} + +/** "Acme Pack" | "acme-pack" | "acme_pack" -> "AcmePack". */ +export function pascalCase(input: string): string { + const parts = input.split(/[^A-Za-z0-9]+/).filter(Boolean); + const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(''); + return /^[A-Za-z_$]/.test(pascal) ? pascal : `Bundle${pascal}`; +} + +/** "AcmePack" -> "acmePack". */ +export function lowerCamel(input: string): string { + const p = pascalCase(input); + return p.charAt(0).toLowerCase() + p.slice(1); +} + +/** "Acme Pack" -> "acme_pack" (for Dart file names). */ +export function snakeCase(input: string): string { + return input + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .join('_') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); +} diff --git a/tools/pulsar-gen/src/read.ts b/tools/pulsar-gen/src/read.ts new file mode 100644 index 00000000..f95cb945 --- /dev/null +++ b/tools/pulsar-gen/src/read.ts @@ -0,0 +1,59 @@ +// Node-only: read a .pulsar file from disk and pull out its validated manifest. + +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { readZip, type ZipEntries } from './zip.ts'; +import { validateManifest } from './validate.ts'; +import type { BundleManifest } from './types.ts'; + +const MANIFEST = 'manifest.json'; + +export interface ReadBundleResult { + manifest: BundleManifest; + entries: ZipEntries; +} + +export function readBundleBytes(data: Uint8Array): ReadBundleResult { + const entries = readZip(data); + const manifestBytes = entries[MANIFEST]; + if (!manifestBytes) throw new Error(`Bundle is missing ${MANIFEST}`); + const manifest = validateManifest(JSON.parse(Buffer.from(manifestBytes).toString('utf8'))); + return { manifest, entries }; +} + +export function readBundleFile(path: string): ReadBundleResult { + return readBundleBytes(readFileSync(path)); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** Recursively key-sorted JSON — deterministic across generators. */ +export function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + const keys = Object.keys(value as Record).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record)[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +/** + * Deterministic content hash over a bundle's entries. The `hash` field of manifest.json is + * excluded (it is what we are computing). Documented in docs/bundle-format.md so Studio and the + * SDKs can reproduce it. Returns e.g. "sha256-ab12…". + */ +export function computeContentHash(entries: ZipEntries): string { + const lines: string[] = []; + for (const name of Object.keys(entries).sort()) { + let bytes = entries[name]; + if (name === MANIFEST) { + const obj = JSON.parse(Buffer.from(bytes).toString('utf8')) as Record; + delete obj.hash; + bytes = new TextEncoder().encode(stableStringify(obj)); + } + lines.push(`${name}\n${sha256Hex(bytes)}`); + } + return 'sha256-' + sha256Hex(new TextEncoder().encode(lines.join('\n'))); +} diff --git a/tools/pulsar-gen/src/types.ts b/tools/pulsar-gen/src/types.ts new file mode 100644 index 00000000..582b3cf5 --- /dev/null +++ b/tools/pulsar-gen/src/types.ts @@ -0,0 +1,65 @@ +// Canonical Pulsar bundle manifest types (portable — no Node APIs). +// Mirrors schema/pulsar.bundle-1.schema.json and docs/bundle-format.md. + +export const SCHEMA_ID = 'pulsar.bundle/1'; + +export interface AudioRef { + src: string; + volume?: number; + offset?: number; +} + +export interface AnimationRef { + src: string; + frameRate?: number; + totalFrames?: number; +} + +export interface PresetEntry { + /** Code-safe identifier; becomes `bundle.presets.`. */ + id: string; + /** Human label. */ + name: string; + /** Optional duration hint, ms. */ + duration?: number; + /** Path within the zip to the DevicePattern JSON. */ + haptics: string; + audio?: AudioRef; + animation?: AnimationRef; +} + +export interface BundleManifest { + schema: string; + generator?: string; + /** Reverse-DNS bundle identity. */ + id: string; + /** Human label; drives the generated type name. */ + name: string; + revision?: number; + /** Content hash (e.g. "sha256-…"); embedded in generated descriptors for drift protection. */ + hash?: string; + presets: PresetEntry[]; +} + +/** A single haptics payload (device wire shape). */ +export interface DevicePattern { + continuousPattern: { + amplitude: Array<{ time: number; value: number }>; + frequency: Array<{ time: number; value: number }>; + }; + discretePattern: Array<{ time: number; amplitude: number; frequency: number }>; +} + +export type Target = 'swift' | 'kotlin' | 'dart' | 'rn'; + +export interface GenerateOptions { + /** Bundle file name without extension, e.g. "acme-pack". Defaults from manifest.id. */ + assetName?: string; + /** Kotlin package / Dart notice; ignored by other targets. */ + packageName?: string; +} + +export interface GeneratedFile { + filename: string; + content: string; +} diff --git a/tools/pulsar-gen/src/validate.ts b/tools/pulsar-gen/src/validate.ts new file mode 100644 index 00000000..d2dd8488 --- /dev/null +++ b/tools/pulsar-gen/src/validate.ts @@ -0,0 +1,64 @@ +// Manifest validation (portable — no Node APIs). + +import { SCHEMA_ID, type BundleManifest, type PresetEntry } from './types.ts'; +import { assertValidPresetId } from './naming.ts'; + +function fail(msg: string): never { + throw new Error(`Invalid Pulsar manifest: ${msg}`); +} + +function asString(v: unknown, path: string): string { + if (typeof v !== 'string' || v.length === 0) fail(`${path} must be a non-empty string`); + return v as string; +} + +export function validateManifest(input: unknown): BundleManifest { + if (typeof input !== 'object' || input === null) fail('manifest must be an object'); + const m = input as Record; + + if (m.schema !== SCHEMA_ID) fail(`schema must be "${SCHEMA_ID}" (got ${JSON.stringify(m.schema)})`); + const id = asString(m.id, 'id'); + const name = asString(m.name, 'name'); + + if (!Array.isArray(m.presets) || m.presets.length === 0) fail('presets must be a non-empty array'); + + const seen = new Set(); + const presets: PresetEntry[] = m.presets.map((raw, i) => { + if (typeof raw !== 'object' || raw === null) fail(`presets[${i}] must be an object`); + const p = raw as Record; + const pid = asString(p.id, `presets[${i}].id`); + try { + assertValidPresetId(pid); + } catch (e) { + fail(`presets[${i}].id — ${(e as Error).message}`); + } + if (seen.has(pid)) fail(`duplicate preset id "${pid}"`); + seen.add(pid); + + const entry: PresetEntry = { + id: pid, + name: asString(p.name, `presets[${i}].name`), + haptics: asString(p.haptics, `presets[${i}].haptics`), + }; + if (p.duration !== undefined) entry.duration = Number(p.duration); + if (p.audio !== undefined) { + const a = p.audio as Record; + entry.audio = { src: asString(a.src, `presets[${i}].audio.src`) }; + if (a.volume !== undefined) entry.audio.volume = Number(a.volume); + if (a.offset !== undefined) entry.audio.offset = Number(a.offset); + } + if (p.animation !== undefined) { + const a = p.animation as Record; + entry.animation = { src: asString(a.src, `presets[${i}].animation.src`) }; + if (a.frameRate !== undefined) entry.animation.frameRate = Number(a.frameRate); + if (a.totalFrames !== undefined) entry.animation.totalFrames = Number(a.totalFrames); + } + return entry; + }); + + const manifest: BundleManifest = { schema: SCHEMA_ID, id, name, presets }; + if (typeof m.generator === 'string') manifest.generator = m.generator; + if (m.revision !== undefined) manifest.revision = Number(m.revision); + if (typeof m.hash === 'string') manifest.hash = m.hash; + return manifest; +} diff --git a/tools/pulsar-gen/src/zip.ts b/tools/pulsar-gen/src/zip.ts new file mode 100644 index 00000000..e1d05404 --- /dev/null +++ b/tools/pulsar-gen/src/zip.ts @@ -0,0 +1,131 @@ +// Minimal ZIP reader/writer over node:zlib (Node-only). Enough for .pulsar bundles: +// STORE (method 0) and DEFLATE (method 8), central-directory based, no encryption. + +import { deflateRawSync, inflateRawSync } from 'node:zlib'; + +const LOCAL_SIG = 0x04034b50; +const CEN_SIG = 0x02014b50; +const EOCD_SIG = 0x06054b50; + +let CRC_TABLE: Uint32Array | null = null; +function crcTable(): Uint32Array { + if (CRC_TABLE) return CRC_TABLE; + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + CRC_TABLE = t; + return t; +} + +function crc32(buf: Uint8Array): number { + const t = crcTable(); + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +export type ZipEntries = Record; + +export function readZip(data: Uint8Array): ZipEntries { + const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + // Find End Of Central Directory (search backward; comment is usually empty). + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) { + if (buf.readUInt32LE(i) === EOCD_SIG) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error('Not a zip: End Of Central Directory not found'); + + const count = buf.readUInt16LE(eocd + 10); + let ptr = buf.readUInt32LE(eocd + 16); // central directory offset + + const entries: ZipEntries = {}; + for (let n = 0; n < count; n++) { + if (buf.readUInt32LE(ptr) !== CEN_SIG) throw new Error('Corrupt zip: bad central directory signature'); + const method = buf.readUInt16LE(ptr + 10); + const compSize = buf.readUInt32LE(ptr + 20); + const nameLen = buf.readUInt16LE(ptr + 28); + const extraLen = buf.readUInt16LE(ptr + 30); + const commentLen = buf.readUInt16LE(ptr + 32); + const localOff = buf.readUInt32LE(ptr + 42); + const name = buf.toString('utf8', ptr + 46, ptr + 46 + nameLen); + + if (buf.readUInt32LE(localOff) !== LOCAL_SIG) throw new Error('Corrupt zip: bad local header signature'); + const lNameLen = buf.readUInt16LE(localOff + 26); + const lExtraLen = buf.readUInt16LE(localOff + 28); + const dataStart = localOff + 30 + lNameLen + lExtraLen; + const raw = buf.subarray(dataStart, dataStart + compSize); + + if (!name.endsWith('/')) { + entries[name] = method === 0 ? new Uint8Array(raw) : new Uint8Array(inflateRawSync(raw)); + } + ptr += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +export function writeZip(entries: ZipEntries): Uint8Array { + const files = Object.keys(entries).sort(); + const localParts: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + + for (const name of files) { + const nameBuf = Buffer.from(name, 'utf8'); + const content = Buffer.from(entries[name]); + const crc = crc32(content); + const deflated = deflateRawSync(content); + const useStore = deflated.length >= content.length; + const method = useStore ? 0 : 8; + const payload = useStore ? content : deflated; + + const local = Buffer.alloc(30); + local.writeUInt32LE(LOCAL_SIG, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(method, 8); + local.writeUInt16LE(0, 10); // time + local.writeUInt16LE(0, 12); // date + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(payload.length, 18); + local.writeUInt32LE(content.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); // extra len + localParts.push(local, nameBuf, payload); + + const cen = Buffer.alloc(46); + cen.writeUInt32LE(CEN_SIG, 0); + cen.writeUInt16LE(20, 4); // version made by + cen.writeUInt16LE(20, 6); // version needed + cen.writeUInt16LE(0, 8); // flags + cen.writeUInt16LE(method, 10); + cen.writeUInt16LE(0, 12); // time + cen.writeUInt16LE(0, 14); // date + cen.writeUInt32LE(crc, 16); + cen.writeUInt32LE(payload.length, 20); + cen.writeUInt32LE(content.length, 24); + cen.writeUInt16LE(nameBuf.length, 28); + cen.writeUInt32LE(offset, 42); // local header offset + central.push(cen, nameBuf); + + offset += local.length + nameBuf.length + payload.length; + } + + const centralBuf = Buffer.concat(central); + const localBuf = Buffer.concat(localParts); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(EOCD_SIG, 0); + eocd.writeUInt16LE(files.length, 8); + eocd.writeUInt16LE(files.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(localBuf.length, 16); // central dir offset + return new Uint8Array(Buffer.concat([localBuf, centralBuf, eocd])); +} + +export { crc32 }; diff --git a/tools/pulsar-gen/test/pulsar-gen.test.ts b/tools/pulsar-gen/test/pulsar-gen.test.ts new file mode 100644 index 00000000..c98adeb8 --- /dev/null +++ b/tools/pulsar-gen/test/pulsar-gen.test.ts @@ -0,0 +1,108 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { readZip, writeZip } from '../src/zip.ts'; +import { readBundleBytes, computeContentHash } from '../src/read.ts'; +import { validateManifest } from '../src/validate.ts'; +import { generate } from '../src/generate.ts'; +import { buildSidecar } from '../src/emit/rn.ts'; +import { buildFixtureBundle } from '../fixtures/fixture.ts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const goldenDir = join(here, '..', 'fixtures', 'golden'); +const golden = (name: string) => readFileSync(join(goldenDir, name), 'utf8'); + +test('zip round-trips STORE and DEFLATE entries', () => { + const entries = { + 'a.txt': new TextEncoder().encode('hi'), + 'nested/big.json': new TextEncoder().encode('x'.repeat(5000)), // compressible → DEFLATE + }; + const round = readZip(writeZip(entries)); + assert.equal(new TextDecoder().decode(round['a.txt']), 'hi'); + assert.equal(new TextDecoder().decode(round['nested/big.json']), 'x'.repeat(5000)); +}); + +test('readBundleBytes parses + validates the fixture manifest', () => { + const { manifest, entries } = readBundleBytes(buildFixtureBundle()); + assert.equal(manifest.id, 'com.acme.haptics'); + assert.equal(manifest.name, 'Acme Pack'); + assert.deepEqual( + manifest.presets.map((p) => p.id), + ['heartbeatV2', 'explosion'], + ); + assert.ok(manifest.hash?.startsWith('sha256-')); + assert.ok(entries['haptics/heartbeatV2.json']); + assert.ok(entries['audio/boom.ogg']); +}); + +test('content hash is deterministic and excludes the manifest hash field', () => { + const a = readBundleBytes(buildFixtureBundle()); + const b = readBundleBytes(buildFixtureBundle()); + assert.equal(a.manifest.hash, b.manifest.hash); + + // Recomputing over the same entries (hash field stripped internally) reproduces it. + assert.equal(computeContentHash(a.entries), a.manifest.hash); +}); + +test('validateManifest rejects malformed manifests', () => { + assert.throws(() => validateManifest({ schema: 'wrong', id: 'x', name: 'y', presets: [] }), /schema/); + assert.throws( + () => + validateManifest({ + schema: 'pulsar.bundle/1', + id: 'x', + name: 'y', + presets: [{ id: 'ok', name: 'A', haptics: 'a.json' }, { id: 'ok', name: 'B', haptics: 'b.json' }], + }), + /duplicate/, + ); + assert.throws( + () => + validateManifest({ + schema: 'pulsar.bundle/1', + id: 'x', + name: 'y', + presets: [{ id: '1bad', name: 'A', haptics: 'a.json' }], + }), + /Invalid preset id/, + ); + assert.throws( + () => + validateManifest({ + schema: 'pulsar.bundle/1', + id: 'x', + name: 'y', + presets: [{ id: 'class', name: 'A', haptics: 'a.json' }], + }), + /reserved/, + ); +}); + +test('emitters match committed goldens', () => { + const { manifest } = readBundleBytes(buildFixtureBundle()); + assert.equal(generate(manifest, 'swift', { assetName: 'acme-pack' }).content, golden('AcmePack.swift')); + assert.equal(generate(manifest, 'kotlin', { assetName: 'acme-pack' }).content, golden('AcmePack.kt')); + assert.equal(generate(manifest, 'dart', { assetName: 'acme-pack' }).content, golden('acme_pack.bundle.dart')); + assert.equal(generate(manifest, 'rn', { assetName: 'acme-pack' }).content, golden('acme-pack.presets.json')); +}); + +test('swift/kotlin/dart typed views expose both preset ids', () => { + const { manifest } = readBundleBytes(buildFixtureBundle()); + for (const target of ['swift', 'kotlin', 'dart'] as const) { + const out = generate(manifest, target, { assetName: 'acme-pack' }).content; + assert.match(out, /heartbeatV2/); + assert.match(out, /explosion/); + assert.match(out, /com\.acme\.haptics/); + } +}); + +test('rn sidecar keys are the preset ids (source of keyof inference)', () => { + const { manifest } = readBundleBytes(buildFixtureBundle()); + const sidecar = buildSidecar(manifest); + assert.deepEqual(Object.keys(sidecar.presets), ['heartbeatV2', 'explosion']); + assert.equal(sidecar.presets.heartbeatV2.animation, true); + assert.equal(sidecar.presets.explosion.animation, false); +}); diff --git a/tools/pulsar-gen/tsconfig.json b/tools/pulsar-gen/tsconfig.json new file mode 100644 index 00000000..aa09e63f --- /dev/null +++ b/tools/pulsar-gen/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"], + "lib": ["ES2023"] + }, + "include": ["src", "test", "fixtures"] +} From 561718b322efed2bef42774a89d51273d652783c Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Tue, 11 Aug 2026 16:01:27 +0200 Subject: [PATCH 2/3] feat(ios): runtime .pulsar bundle loader + SwiftPM codegen plugin (PR1) Adds runtime bundle loading to the standalone iOS SDK (PulsarHaptics), the artifact RN + Flutter link against: - Bundle/PulsarUnzip.swift: minimal STORE+DEFLATE zip reader over the Compression framework (no new dependency). - Bundle/PulsarBundle.swift: Codable manifest, PresetHandle (lazy parse, synced audio via parsePatternWithSound, animation bytes for the app to render), LoadedBundle (untyped string-keyed surface for the RN/Flutter bridges), and the typed PulsarBundle

/BundleDescriptor

/BundleResolver. - Bundle/BundleLoader.swift: Pulsar.loadBundle(data:/path:) + typed loadBundle(_:strict:); haptics JSON decodes straight into PatternData; audio extracted to caches for Core Haptics. - pulsar-gen-swift host tool + PulsarGenPlugin build-tool plugin: regenerate the typed accessor on every build, no Node/network in the sandbox. - BundleTests: unzip + load + typed-view + schema-rejection (Swift Testing). The loaded bundle owns its handles, so no change to the compiled preset registry. Note: native/plugin code is not compiled in this environment (iOS/UIKit); verify via xcodebuild on a simulator. --- iOS/Pulsar/Package.swift | 13 ++ .../PulsarGenPlugin/PulsarGenPlugin.swift | 25 +++ .../Sources/Pulsar/Bundle/BundleLoader.swift | 89 ++++++++ .../Sources/Pulsar/Bundle/PulsarBundle.swift | 200 ++++++++++++++++++ .../Sources/Pulsar/Bundle/PulsarUnzip.swift | 99 +++++++++ iOS/Pulsar/Sources/Pulsar/Bundle/README.md | 51 +++++ .../Sources/pulsar-gen-swift/main.swift | 135 ++++++++++++ .../Tests/PulsarTests/BundleTests.swift | 107 ++++++++++ 8 files changed, 719 insertions(+) create mode 100644 iOS/Pulsar/Plugins/PulsarGenPlugin/PulsarGenPlugin.swift create mode 100644 iOS/Pulsar/Sources/Pulsar/Bundle/BundleLoader.swift create mode 100644 iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift create mode 100644 iOS/Pulsar/Sources/Pulsar/Bundle/PulsarUnzip.swift create mode 100644 iOS/Pulsar/Sources/Pulsar/Bundle/README.md create mode 100644 iOS/Pulsar/Sources/pulsar-gen-swift/main.swift create mode 100644 iOS/Pulsar/Tests/PulsarTests/BundleTests.swift diff --git a/iOS/Pulsar/Package.swift b/iOS/Pulsar/Package.swift index 2e192e3b..e518e3ea 100644 --- a/iOS/Pulsar/Package.swift +++ b/iOS/Pulsar/Package.swift @@ -13,6 +13,10 @@ let package = Package( name: "Pulsar", targets: ["Pulsar"], ), + .plugin( + name: "PulsarGenPlugin", + targets: ["PulsarGenPlugin"] + ), ], targets: [ .target( @@ -22,6 +26,15 @@ let package = Package( name: "PulsarTests", dependencies: ["Pulsar"] ), + // Host tool + build-tool plugin that generate typed accessors for .pulsar bundles. + .executableTarget( + name: "pulsar-gen-swift" + ), + .plugin( + name: "PulsarGenPlugin", + capability: .buildTool(), + dependencies: ["pulsar-gen-swift"] + ), ], swiftLanguageModes: [.v6] ) diff --git a/iOS/Pulsar/Plugins/PulsarGenPlugin/PulsarGenPlugin.swift b/iOS/Pulsar/Plugins/PulsarGenPlugin/PulsarGenPlugin.swift new file mode 100644 index 00000000..af4fc28e --- /dev/null +++ b/iOS/Pulsar/Plugins/PulsarGenPlugin/PulsarGenPlugin.swift @@ -0,0 +1,25 @@ +import PackagePlugin +import Foundation + +/// SwiftPM build-tool plugin: for every `*.pulsar` file in a target, generates a typed Swift +/// accessor (`.pulsar.swift`) at build time via the self-contained `pulsar-gen-swift` tool. +/// Zero manual regeneration — dropping an updated `.pulsar` in and rebuilding refreshes the types. +@main +struct PulsarGenPlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + guard let sourceTarget = target as? SourceModuleTarget else { return [] } + let tool = try context.tool(named: "pulsar-gen-swift") + + return sourceTarget.sourceFiles(withSuffix: "pulsar").map { file in + let base = file.path.stem // filename without extension + let output = context.pluginWorkDirectory.appending("\(base).pulsar.swift") + return .buildCommand( + displayName: "pulsar-gen \(base).pulsar", + executable: tool.path, + arguments: [file.path.string, output.string], + inputFiles: [file.path], + outputFiles: [output] + ) + } + } +} diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/BundleLoader.swift b/iOS/Pulsar/Sources/Pulsar/Bundle/BundleLoader.swift new file mode 100644 index 00000000..72887c15 --- /dev/null +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/BundleLoader.swift @@ -0,0 +1,89 @@ +import Foundation + +extension Pulsar { + /// Load a `.pulsar` bundle from raw bytes (used by the React Native / Flutter bridges). + @objc public func loadBundle(data: Data) throws -> LoadedBundle { + let files = try PulsarUnzip.read(data) + guard let manifestData = files["manifest.json"] else { throw PulsarBundleError.missingManifest } + let manifest = try JSONDecoder().decode(BundleManifest.self, from: manifestData) + guard manifest.schema == "pulsar.bundle/1" else { + throw PulsarBundleError.unsupportedSchema(manifest.schema) + } + + let mediaDir = try Self.bundleMediaDir(for: manifest.id) + var handles: [String: PresetHandle] = [:] + + for preset in manifest.presets { + guard let hapticsData = files[preset.haptics] else { + throw PulsarBundleError.missingEntry(preset.haptics) + } + // The haptics payload uses the device wire shape, which decodes directly into PatternData. + let pattern = try JSONDecoder().decode(PatternData.self, from: hapticsData) + + var sound: ResolvedSound? + if let audio = preset.audio, let audioBytes = files[audio.src] { + // Core Haptics needs a file URL for audio resources — extract to the caches dir. + let dest = mediaDir.appendingPathComponent((audio.src as NSString).lastPathComponent) + try audioBytes.write(to: dest, options: .atomic) + sound = ResolvedSound(uri: dest.path, volume: audio.volume ?? 1, offset: audio.offset ?? 0) + } + + var animation: BundleAnimation? + if let anim = preset.animation, let animBytes = files[anim.src] { + animation = BundleAnimation(data: animBytes, frameRate: anim.frameRate ?? 0, totalFrames: anim.totalFrames ?? 0) + } + + handles[preset.id] = PresetHandle( + id: preset.id, + duration: preset.duration ?? 0, + pulsar: self, + pattern: pattern, + sound: sound, + animation: animation + ) + } + + return LoadedBundle( + id: manifest.id, + contentHash: manifest.hash ?? "", + revision: manifest.revision ?? 0, + handles: handles + ) + } + + /// Load a `.pulsar` bundle from a file path (used by the React Native / Flutter bridges). + @objc public func loadBundle(path: String) throws -> LoadedBundle { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try loadBundle(data: data) + } + + /// Typed load for native Swift consumers, using a `pulsar-gen`-generated descriptor. + /// Resolves `.pulsar` from the app's main bundle. + /// + /// let bundle = try pulsar.loadBundle(AcmePack.descriptor) + /// bundle.presets.heartbeatV2.play() + public func loadBundle

(_ descriptor: BundleDescriptor

, strict: Bool = false) throws -> PulsarBundle

{ + guard let url = Foundation.Bundle.main.url(forResource: descriptor.assetName, withExtension: "pulsar") else { + throw PulsarBundleError.resourceNotFound(descriptor.assetName) + } + let loaded = try loadBundle(path: url.path) + + if strict, !descriptor.contentHash.isEmpty, loaded.contentHash != descriptor.contentHash { + throw PulsarBundleError.hashMismatch(expected: descriptor.contentHash, actual: loaded.contentHash) + } + + let missing = descriptor.presetIds.filter { loaded.handle($0) == nil } + guard missing.isEmpty else { throw PulsarBundleError.missingPresets(missing) } + + let presets = descriptor.build(BundleResolver(loaded)) + return PulsarBundle(loaded: loaded, presets: presets) + } + + private static func bundleMediaDir(for bundleId: String) throws -> URL { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("PulsarBundles", isDirectory: true) + .appendingPathComponent(bundleId, isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base + } +} diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift new file mode 100644 index 00000000..fae3be6b --- /dev/null +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift @@ -0,0 +1,200 @@ +import Foundation + +// MARK: - Manifest (Codable mirror of manifest.json — see docs/bundle-format.md) + +struct BundleManifest: Codable { + let schema: String + let generator: String? + let id: String + let name: String + let revision: Int? + let hash: String? + let presets: [BundlePresetEntry] +} + +struct BundlePresetEntry: Codable { + let id: String + let name: String + let duration: Double? + let haptics: String + let audio: BundleAudioRef? + let animation: BundleAnimationRef? +} + +struct BundleAudioRef: Codable { + let src: String + let volume: Float? + let offset: Double? +} + +struct BundleAnimationRef: Codable { + let src: String + let frameRate: Double? + let totalFrames: Int? +} + +// MARK: - Runtime handles + +/// Lottie bytes + timing for a preset's animation. Pulsar carries and time-aligns it; +/// the host app's own Lottie view renders it. +@objc public final class BundleAnimation: NSObject { + @objc public let data: Data + @objc public let frameRate: Double + @objc public let totalFrames: Int + init(data: Data, frameRate: Double, totalFrames: Int) { + self.data = data + self.frameRate = frameRate + self.totalFrames = totalFrames + } +} + +struct ResolvedSound { + let uri: String + let volume: Float + let offset: Double +} + +/// A single playable preset from a loaded bundle. Parses its pattern lazily on first play. +@objc public final class PresetHandle: NSObject { + @objc public let id: String + @objc public let duration: Double + @objc public let animation: BundleAnimation? + + private weak var pulsar: Pulsar? + private let pattern: PatternData + private let sound: ResolvedSound? + private var composer: PatternComposer? + + init(id: String, duration: Double, pulsar: Pulsar, pattern: PatternData, sound: ResolvedSound?, animation: BundleAnimation?) { + self.id = id + self.duration = duration + self.pulsar = pulsar + self.pattern = pattern + self.sound = sound + self.animation = animation + } + + private func ensureParsed() { + guard composer == nil, let pulsar = pulsar else { return } + let c = pulsar.getPatternComposer() + if let s = sound { + c.parsePatternWithSound(hapticsData: pattern, uri: s.uri, volume: s.volume, offset: s.offset) + } else { + c.parsePattern(hapticsData: pattern) + } + composer = c + } + + @objc public func play() { + ensureParsed() + composer?.play() + } + + @objc public func stop() { + composer?.stop() + } + + func dispose() { + composer?.dispose() + composer = nil + } +} + +/// Untyped loaded bundle — the surface used by the React Native / Flutter bridges (string ids). +@objc public final class LoadedBundle: NSObject { + @objc public let id: String + @objc public let contentHash: String + @objc public let revision: Int + private let handles: [String: PresetHandle] + + init(id: String, contentHash: String, revision: Int, handles: [String: PresetHandle]) { + self.id = id + self.contentHash = contentHash + self.revision = revision + self.handles = handles + } + + @objc public func handle(_ id: String) -> PresetHandle? { handles[id] } + @objc public var presetIds: [String] { Array(handles.keys) } + @objc public func play(_ id: String) -> Bool { + guard let h = handles[id] else { return false } + h.play() + return true + } + @objc public func dispose() { handles.values.forEach { $0.dispose() } } +} + +// MARK: - Typed view (native Swift consumers; produced by pulsar-gen) + +/// Looks up preset handles by id when a generated descriptor builds its typed `Presets` struct. +/// `loadBundle` guarantees every id in the descriptor exists before this is used. +public final class BundleResolver { + private let loaded: LoadedBundle + init(_ loaded: LoadedBundle) { self.loaded = loaded } + public subscript(_ id: String) -> PresetHandle { loaded.handle(id)! } +} + +/// Emitted by pulsar-gen: binds a bundle asset + hash to a typed `Presets` builder. +public struct BundleDescriptor { + public let assetName: String + public let bundleId: String + public let contentHash: String + public let presetIds: [String] + public let build: (BundleResolver) -> Presets + + public init( + assetName: String, + bundleId: String, + contentHash: String, + presetIds: [String], + build: @escaping (BundleResolver) -> Presets + ) { + self.assetName = assetName + self.bundleId = bundleId + self.contentHash = contentHash + self.presetIds = presetIds + self.build = build + } +} + +/// The typed bundle returned by `pulsar.loadBundle(SomeBundle.descriptor)`. +/// (Named `PulsarBundle` to avoid colliding with `Foundation.Bundle`.) +public final class PulsarBundle { + public let presets: Presets + public let id: String + public let revision: Int + public let contentHash: String + private let loaded: LoadedBundle + + init(loaded: LoadedBundle, presets: Presets) { + self.loaded = loaded + self.presets = presets + self.id = loaded.id + self.revision = loaded.revision + self.contentHash = loaded.contentHash + } + + /// Dynamic escape hatch for ids not known at compile time. + public func get(_ id: String) -> PresetHandle? { loaded.handle(id) } + public func dispose() { loaded.dispose() } +} + +public enum PulsarBundleError: Error, CustomStringConvertible { + case missingManifest + case unsupportedSchema(String) + case missingEntry(String) + case resourceNotFound(String) + case missingPresets([String]) + case hashMismatch(expected: String, actual: String) + + public var description: String { + switch self { + case .missingManifest: return "Bundle is missing manifest.json" + case .unsupportedSchema(let s): return "Unsupported bundle schema \"\(s)\" (expected pulsar.bundle/1)" + case .missingEntry(let p): return "Bundle is missing referenced entry \"\(p)\"" + case .resourceNotFound(let n): return "Bundle resource \"\(n).pulsar\" not found in the app bundle" + case .missingPresets(let ids): return "Bundle is missing preset(s) \(ids) — regenerate types with pulsar-gen" + case .hashMismatch(let e, let a): return "Bundle content hash mismatch: generated types expect \(e) but the loaded bundle is \(a). Re-export the bundle or regenerate the types." + } + } +} diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarUnzip.swift b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarUnzip.swift new file mode 100644 index 00000000..2911503d --- /dev/null +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarUnzip.swift @@ -0,0 +1,99 @@ +import Foundation +import Compression + +/// Minimal, dependency-free ZIP reader for `.pulsar` bundles. Supports STORE (method 0) and +/// DEFLATE (method 8, via the `Compression` framework's raw-zlib codec). Central-directory based. +enum PulsarUnzip { + enum ZipError: Error, CustomStringConvertible { + case notAZip + case corrupt(String) + case inflateFailed(String) + var description: String { + switch self { + case .notAZip: return "Not a .pulsar/zip archive (End Of Central Directory not found)" + case .corrupt(let m): return "Corrupt .pulsar archive: \(m)" + case .inflateFailed(let m): return "Failed to inflate zip entry: \(m)" + } + } + } + + private static let localSig: UInt32 = 0x0403_4b50 + private static let cenSig: UInt32 = 0x0201_4b50 + private static let eocdSig: UInt32 = 0x0605_4b50 + + /// Returns a map of entry path -> uncompressed bytes. + static func read(_ data: Data) throws -> [String: Data] { + let bytes = [UInt8](data) + let count = bytes.count + + func u16(_ off: Int) -> Int { Int(bytes[off]) | (Int(bytes[off + 1]) << 8) } + func u32(_ off: Int) -> UInt32 { + UInt32(bytes[off]) | (UInt32(bytes[off + 1]) << 8) | (UInt32(bytes[off + 2]) << 16) | (UInt32(bytes[off + 3]) << 24) + } + + // Locate EOCD by scanning backward. + var eocd = -1 + var i = count - 22 + while i >= 0 { + if u32(i) == eocdSig { eocd = i; break } + i -= 1 + } + guard eocd >= 0 else { throw ZipError.notAZip } + + let entryCount = u16(eocd + 10) + var ptr = Int(u32(eocd + 16)) // central directory offset + + var result: [String: Data] = [:] + for _ in 0.. Data { + if uncompressedSize == 0 { return Data() } + var dst = Data(count: uncompressedSize) + let written = dst.withUnsafeMutableBytes { (dstPtr: UnsafeMutableRawBufferPointer) -> Int in + data.withUnsafeBytes { (srcPtr: UnsafeRawBufferPointer) -> Int in + compression_decode_buffer( + dstPtr.bindMemory(to: UInt8.self).baseAddress!, uncompressedSize, + srcPtr.bindMemory(to: UInt8.self).baseAddress!, data.count, + nil, COMPRESSION_ZLIB + ) + } + } + guard written == uncompressedSize else { + throw ZipError.inflateFailed("expected \(uncompressedSize) bytes, got \(written)") + } + return dst + } +} diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/README.md b/iOS/Pulsar/Sources/Pulsar/Bundle/README.md new file mode 100644 index 00000000..888b040e --- /dev/null +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/README.md @@ -0,0 +1,51 @@ +# Pulsar bundles (iOS) + +Load a `.pulsar` bundle authored in Pulsar Studio at runtime and play its presets with full +autocomplete. See [`docs/bundle-format.md`](../../../../../docs/bundle-format.md) for the format. + +## Native Swift usage + +1. Add the `.pulsar` file to your app target (Copy Bundle Resources). +2. Generate the typed accessor — either apply the build plugin (below) or run the CLI once and + commit the output. +3. Load and play: + +```swift +let pulsar = Pulsar() +let bundle = try pulsar.loadBundle(AcmePack.descriptor) // AcmePack is generated +bundle.presets.heartbeatV2.play() // ← autocompletes +bundle.presets.explosion.stop() + +// Animation bytes for the app's own Lottie view (Pulsar times, the app renders): +if let anim = bundle.presets.heartbeatV2.animation { + myLottieView.load(data: anim.data) +} +``` + +`loadBundle(_:strict:)` — pass `strict: true` to assert the loaded bundle's content hash matches +the generated types (fails loudly on a stale bundle/types mismatch instead of a silent surprise). + +## Zero-manual codegen (build plugin) + +Apply the plugin to your target and drop `.pulsar` files into its sources — the typed accessor +regenerates on every build (like Xcode 15 asset symbols): + +```swift +.target( + name: "MyApp", + plugins: [.plugin(name: "PulsarGenPlugin", package: "Pulsar")] +) +``` + +The plugin runs the self-contained `pulsar-gen-swift` host tool — no Node or network in the build. +CocoaPods consumers (React Native / Flutter) instead run `@swmansion/pulsar-gen` in a script phase. + +## Bridge surface (React Native / Flutter) + +The wrappers use the untyped, string-keyed surface: + +```swift +let loaded = try pulsar.loadBundle(path: bundlePath) // or loadBundle(data:) +loaded.presetIds // -> [String] +loaded.play("heartbeatV2") // -> Bool +``` diff --git a/iOS/Pulsar/Sources/pulsar-gen-swift/main.swift b/iOS/Pulsar/Sources/pulsar-gen-swift/main.swift new file mode 100644 index 00000000..e2574a26 --- /dev/null +++ b/iOS/Pulsar/Sources/pulsar-gen-swift/main.swift @@ -0,0 +1,135 @@ +// pulsar-gen-swift — self-contained host tool used by the SwiftPM build-tool plugin. +// Reads a .pulsar bundle and emits a typed Swift accessor. Foundation + Compression only +// (no UIKit), so it builds for the host (macOS) inside the plugin sandbox. +// +// pulsar-gen-swift + +import Foundation +import Compression + +// MARK: - Minimal zip reader (STORE + DEFLATE) + +enum Zip { + static func read(_ data: Data) throws -> [String: Data] { + let bytes = [UInt8](data) + let count = bytes.count + func u16(_ o: Int) -> Int { Int(bytes[o]) | (Int(bytes[o + 1]) << 8) } + func u32(_ o: Int) -> Int { + Int(bytes[o]) | (Int(bytes[o + 1]) << 8) | (Int(bytes[o + 2]) << 16) | (Int(bytes[o + 3]) << 24) + } + var eocd = -1 + var i = count - 22 + while i >= 0 { if u32(i) == 0x0605_4b50 { eocd = i; break }; i -= 1 } + guard eocd >= 0 else { throw Err("not a zip") } + let entryCount = u16(eocd + 10) + var ptr = u32(eocd + 16) + var out: [String: Data] = [:] + for _ in 0.. Data { + if size == 0 { return Data() } + var dst = Data(count: size) + let n = dst.withUnsafeMutableBytes { d in + data.withUnsafeBytes { s in + compression_decode_buffer( + d.bindMemory(to: UInt8.self).baseAddress!, size, + s.bindMemory(to: UInt8.self).baseAddress!, data.count, nil, COMPRESSION_ZLIB) + } + } + guard n == size else { throw Err("inflate failed") } + return dst + } +} + +struct Err: Error, CustomStringConvertible { let m: String; init(_ m: String) { self.m = m }; var description: String { m } } + +// MARK: - Manifest + +struct Manifest: Codable { + let schema: String + let id: String + let name: String + let hash: String? + let presets: [Preset] + struct Preset: Codable { let id: String; let name: String } +} + +// MARK: - Emit (matches @swmansion/pulsar-gen swift target) + +func pascalCase(_ s: String) -> String { + let parts = s.split { !$0.isLetter && !$0.isNumber } + let p = parts.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined() + return (p.first?.isLetter == true || p.first == "_") ? p : "Bundle" + p +} + +func emitSwift(_ manifest: Manifest, assetName: String) -> String { + let type = pascalCase(manifest.name) + let ids = manifest.presets.map { $0.id } + let fields = ids.map { " public let \($0): PresetHandle" }.joined(separator: "\n") + let args = ids.map { "\($0): r[\"\($0)\"]" }.joined(separator: ", ") + let idList = ids.map { "\"\($0)\"" }.joined(separator: ", ") + return """ + // Code generated by pulsar-gen. DO NOT EDIT. + // Bundle: \(manifest.id) (\(ids.count) preset\(ids.count == 1 ? "" : "s")) + import Pulsar + + public enum \(type) { + public static let assetName = "\(assetName)" + public static let bundleId = "\(manifest.id)" + public static let contentHash = "\(manifest.hash ?? "")" + + public struct Presets { + \(fields) + } + + public static let descriptor = BundleDescriptor( + assetName: assetName, + bundleId: bundleId, + contentHash: contentHash, + presetIds: [\(idList)], + build: { r in Presets(\(args)) } + ) + } + + """ +} + +// MARK: - Entry + +let args = CommandLine.arguments +guard args.count == 3 else { + FileHandle.standardError.write(Data("usage: pulsar-gen-swift \n".utf8)) + exit(2) +} +do { + let input = URL(fileURLWithPath: args[1]) + let output = URL(fileURLWithPath: args[2]) + let files = try Zip.read(try Data(contentsOf: input)) + guard let manifestData = files["manifest.json"] else { throw Err("missing manifest.json") } + let manifest = try JSONDecoder().decode(Manifest.self, from: manifestData) + let assetName = input.deletingPathExtension().lastPathComponent + try emitSwift(manifest, assetName: assetName).write(to: output, atomically: true, encoding: .utf8) +} catch { + FileHandle.standardError.write(Data("pulsar-gen-swift: \(error)\n".utf8)) + exit(1) +} diff --git a/iOS/Pulsar/Tests/PulsarTests/BundleTests.swift b/iOS/Pulsar/Tests/PulsarTests/BundleTests.swift new file mode 100644 index 00000000..8efd7e1a --- /dev/null +++ b/iOS/Pulsar/Tests/PulsarTests/BundleTests.swift @@ -0,0 +1,107 @@ +import Testing +import Foundation +@testable import Pulsar + +/// Loading a `.pulsar` bundle: unzip → manifest decode → PatternData decode → typed view. +/// Uses a STORE-only in-memory zip so the test needs no zip writer from the SDK. +@Suite struct BundleTests { + + // MARK: - Minimal STORE-only zip writer (test helper) + + private static func crc32(_ bytes: [UInt8]) -> UInt32 { + var table = [UInt32](repeating: 0, count: 256) + for n in 0..<256 { + var c = UInt32(n) + for _ in 0..<8 { c = (c & 1) != 0 ? 0xEDB8_8320 ^ (c >> 1) : c >> 1 } + table[n] = c + } + var c: UInt32 = 0xFFFF_FFFF + for b in bytes { c = table[Int((c ^ UInt32(b)) & 0xFF)] ^ (c >> 8) } + return c ^ 0xFFFF_FFFF + } + + private static func le16(_ v: Int) -> [UInt8] { [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF)] } + private static func le32(_ v: UInt32) -> [UInt8] { + [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8((v >> 24) & 0xFF)] + } + + private static func makeZip(_ entries: [(String, Data)]) -> Data { + var local: [UInt8] = [] + var central: [UInt8] = [] + var offset = 0 + for (name, content) in entries { + let nameBytes = Array(name.utf8) + let data = [UInt8](content) + let crc = crc32(data) + var lh: [UInt8] = le32(0x0403_4b50) + le16(20) + le16(0) + le16(0) + le16(0) + le16(0) + lh += le32(crc) + le32(UInt32(data.count)) + le32(UInt32(data.count)) + le16(nameBytes.count) + le16(0) + lh += nameBytes + data + var ch: [UInt8] = le32(0x0201_4b50) + le16(20) + le16(20) + le16(0) + le16(0) + le16(0) + le16(0) + ch += le32(crc) + le32(UInt32(data.count)) + le32(UInt32(data.count)) + ch += le16(nameBytes.count) + le16(0) + le16(0) + le16(0) + le16(0) + le32(0) + le32(UInt32(offset)) + ch += nameBytes + offset += lh.count + local += lh + central += ch + } + var eocd: [UInt8] = le32(0x0605_4b50) + le16(0) + le16(0) + eocd += le16(entries.count) + le16(entries.count) + le32(UInt32(central.count)) + le32(UInt32(local.count)) + le16(0) + return Data(local + central + eocd) + } + + private static func fixture() -> Data { + let manifest = """ + {"schema":"pulsar.bundle/1","id":"com.acme.haptics","name":"Acme Pack","revision":7, + "hash":"sha256-test","presets":[ + {"id":"heartbeatV2","name":"Heartbeat V2","duration":1200,"haptics":"haptics/heartbeatV2.json"}, + {"id":"explosion","name":"Explosion","duration":800,"haptics":"haptics/explosion.json"}]} + """ + let heartbeat = """ + {"continuousPattern":{"amplitude":[{"time":0,"value":0},{"time":10,"value":0.8}], + "frequency":[{"time":0,"value":0.2}]},"discretePattern":[{"time":0,"amplitude":0.9,"frequency":0.2}]} + """ + let explosion = """ + {"continuousPattern":{"amplitude":[{"time":0,"value":1}],"frequency":[{"time":0,"value":0.8}]}, + "discretePattern":[{"time":0,"amplitude":1,"frequency":0.9}]} + """ + return makeZip([ + ("manifest.json", Data(manifest.utf8)), + ("haptics/heartbeatV2.json", Data(heartbeat.utf8)), + ("haptics/explosion.json", Data(explosion.utf8)), + ]) + } + + // MARK: - Tests + + @Test func unzipReadsAllEntries() throws { + let files = try PulsarUnzip.read(Self.fixture()) + #expect(files["manifest.json"] != nil) + #expect(files["haptics/heartbeatV2.json"] != nil) + #expect(files["haptics/explosion.json"] != nil) + } + + @Test func loadBundleExposesEveryPreset() throws { + let loaded = try Pulsar().loadBundle(data: Self.fixture()) + #expect(loaded.id == "com.acme.haptics") + #expect(loaded.contentHash == "sha256-test") + #expect(loaded.revision == 7) + #expect(Set(loaded.presetIds) == ["heartbeatV2", "explosion"]) + #expect(loaded.handle("heartbeatV2") != nil) + #expect(loaded.handle("heartbeatV2")?.duration == 1200) + #expect(loaded.handle("missing") == nil) + } + + @Test func typedDescriptorBuildsPresetsView() throws { + struct Presets { let heartbeatV2: PresetHandle; let explosion: PresetHandle } + let loaded = try Pulsar().loadBundle(data: Self.fixture()) + let resolver = BundleResolver(loaded) + let presets = Presets(heartbeatV2: resolver["heartbeatV2"], explosion: resolver["explosion"]) + #expect(presets.heartbeatV2.id == "heartbeatV2") + #expect(presets.explosion.id == "explosion") + } + + @Test func rejectsUnsupportedSchema() { + let bad = Self.makeZip([("manifest.json", Data(#"{"schema":"pulsar.bundle/2","id":"x","name":"y","presets":[]}"#.utf8))]) + #expect(throws: PulsarBundleError.self) { _ = try Pulsar().loadBundle(data: bad) } + } +} From 05b871983bf3ce180e310b5988f16acbeacdb243 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Tue, 11 Aug 2026 16:15:22 +0200 Subject: [PATCH 3/3] feat(android): runtime .pulsar bundle loader + Gradle codegen plugin (PR2) Adds runtime bundle loading to the standalone Android SDK (com.swmansion:pulsar), the artifact RN + Flutter link against: - bundle/Unzip.kt: java.util.zip reader (STORE+DEFLATE). - bundle/BundleManifest.kt: @Serializable manifest + device-wire haptics DTOs (kotlinx.serialization, already on the SDK), mapped into the SDK PatternData. - bundle/PulsarBundle.kt: PresetHandle (lazy parse, synced audio via parsePatternWithSound with hapticChannels=false so music + haptics play together, animation bytes for the app to render), LoadedBundle (untyped string-keyed surface for bridges), typed PulsarBundle

/BundleDescriptor

. - bundle/BundleLoader.kt + Pulsar.loadBundle(bytes/path/asset/descriptor). - tools/pulsar-gen-gradle: Gradle plugin (id com.swmansion.pulsar.gen) that generates typed Kotlin per .pulsar in src/pulsarBundles/ and packages bundles into assets before compile (FlutterGen/Compose-Resources model). - BundleUnitTest: JVM decode/unzip/mapping tests. Loaded bundle owns its handles; no change to the compiled preset registry. Note: Android/Gradle code not compiled in this environment; verify via gradle. --- .../main/java/com/swmansion/pulsar/Pulsar.kt | 42 +++++++ .../swmansion/pulsar/bundle/BundleLoader.kt | 68 ++++++++++ .../swmansion/pulsar/bundle/BundleManifest.kt | 61 +++++++++ .../swmansion/pulsar/bundle/PulsarBundle.kt | 95 ++++++++++++++ .../com/swmansion/pulsar/bundle/README.md | 44 +++++++ .../java/com/swmansion/pulsar/bundle/Unzip.kt | 22 ++++ .../swmansion/pulsar/bundle/BundleUnitTest.kt | 67 ++++++++++ tools/pulsar-gen-gradle/build.gradle.kts | 28 +++++ tools/pulsar-gen-gradle/settings.gradle.kts | 1 + .../gradle/GeneratePulsarBundlesTask.kt | 116 ++++++++++++++++++ .../pulsar/gradle/PulsarGenPlugin.kt | 56 +++++++++ 11 files changed, 600 insertions(+) create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt create mode 100644 Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt create mode 100644 tools/pulsar-gen-gradle/build.gradle.kts create mode 100644 tools/pulsar-gen-gradle/settings.gradle.kts create mode 100644 tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt create mode 100644 tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt index 6074a8ef..90b41f1a 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt @@ -3,12 +3,19 @@ package com.swmansion.pulsar import android.app.Activity import android.content.Context import com.swmansion.pulsar.audio.AudioSimulator +import com.swmansion.pulsar.bundle.BundleDescriptor +import com.swmansion.pulsar.bundle.BundleLoaderImpl +import com.swmansion.pulsar.bundle.BundleResolver +import com.swmansion.pulsar.bundle.LoadedBundle +import com.swmansion.pulsar.bundle.PulsarBundle +import com.swmansion.pulsar.bundle.PulsarBundleException import com.swmansion.pulsar.composers.PatternComposer import com.swmansion.pulsar.composers.RealtimeComposer import com.swmansion.pulsar.haptics.HapticEngineWrapper import com.swmansion.pulsar.presets.PresetsWrapper import com.swmansion.pulsar.types.CompatibilityMode import com.swmansion.pulsar.types.RealtimeComposerStrategy +import java.io.File open class Pulsar(protected var context: Context) { protected val engine = HapticEngineWrapper(context) @@ -87,4 +94,39 @@ open class Pulsar(protected var context: Context) { fun enableImpulseCompositionMode(state: Boolean) { engine.enableImpulseCompositionMode(state) } + + // region: preset bundles + + /** Load a `.pulsar` bundle from raw bytes (used by the React Native / Flutter bridges). */ + fun loadBundle(bytes: ByteArray): LoadedBundle = BundleLoaderImpl.load(this, context, bytes) + + /** Load a `.pulsar` bundle from a file path. */ + fun loadBundle(path: String): LoadedBundle = loadBundle(File(path).readBytes()) + + /** Load a `.pulsar` bundle bundled under `src/main/assets/`. */ + fun loadBundleFromAsset(assetName: String): LoadedBundle = + context.assets.open(assetName).use { loadBundle(it.readBytes()) } + + /** + * Typed load for Kotlin consumers, using a `pulsar-gen`-generated descriptor. + * + * val bundle = pulsar.loadBundle(AcmePack.descriptor) + * bundle.presets.heartbeatV2.play() + */ + fun

loadBundle(descriptor: BundleDescriptor

, strict: Boolean = false): PulsarBundle

{ + val loaded = loadBundleFromAsset(descriptor.assetName) + if (strict && descriptor.contentHash.isNotEmpty() && loaded.contentHash != descriptor.contentHash) { + throw PulsarBundleException( + "Bundle content hash mismatch: generated types expect ${descriptor.contentHash} " + + "but the loaded bundle is ${loaded.contentHash}. Re-export the bundle or regenerate the types.", + ) + } + val missing = descriptor.presetIds.filter { loaded.handle(it) == null } + if (missing.isNotEmpty()) { + throw PulsarBundleException("Bundle is missing preset(s) $missing — regenerate types with pulsar-gen") + } + return PulsarBundle(loaded, descriptor.build(BundleResolver(loaded))) + } + + // endregion } diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt new file mode 100644 index 00000000..7cb299d6 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt @@ -0,0 +1,68 @@ +package com.swmansion.pulsar.bundle + +import android.content.Context +import com.swmansion.pulsar.Pulsar +import com.swmansion.pulsar.types.SoundData +import kotlinx.serialization.json.Json +import java.io.File + +/** Decodes a `.pulsar` archive into a [LoadedBundle]. Invoked by the `Pulsar.loadBundle*` members. */ +internal object BundleLoaderImpl { + private val json = Json { ignoreUnknownKeys = true } + private const val SCHEMA = "pulsar.bundle/1" + + fun load(haptics: Pulsar, context: Context, bytes: ByteArray): LoadedBundle { + val files = Unzip.read(bytes) + val manifestBytes = files["manifest.json"] + ?: throw PulsarBundleException("Bundle is missing manifest.json") + val manifest = json.decodeFromString(BundleManifest.serializer(), manifestBytes.decodeToString()) + if (manifest.schema != SCHEMA) { + throw PulsarBundleException("Unsupported bundle schema \"${manifest.schema}\" (expected $SCHEMA)") + } + + val mediaDir = File(context.cacheDir, "PulsarBundles/${manifest.id}").apply { mkdirs() } + val handles = LinkedHashMap() + + for (preset in manifest.presets) { + val hapticsBytes = files[preset.haptics] + ?: throw PulsarBundleException("Bundle is missing referenced entry \"${preset.haptics}\"") + // Device wire shape decodes directly, then maps into the SDK's PatternData. + val pattern = json.decodeFromString(DevicePatternDto.serializer(), hapticsBytes.decodeToString()) + .toPatternData() + + val sound = preset.audio?.let { audio -> + files[audio.src]?.let { data -> + val dest = File(mediaDir, audio.src.substringAfterLast('/')) + dest.writeBytes(data) + SoundData( + uri = dest.absolutePath, + volume = audio.volume ?: 1f, + offset = (audio.offset ?: 0.0).toLong(), + // Bundle audio is plain music: always play Pulsar's own haptics alongside it. + hapticChannels = false, + ) + } + } + + val animation = preset.animation?.let { anim -> + files[anim.src]?.let { BundleAnimation(it, anim.frameRate ?: 0.0, anim.totalFrames ?: 0) } + } + + handles[preset.id] = PresetHandle( + id = preset.id, + duration = (preset.duration ?: 0.0).toLong(), + animation = animation, + haptics = haptics, + pattern = pattern, + sound = sound, + ) + } + + return LoadedBundle( + id = manifest.id, + contentHash = manifest.hash ?: "", + revision = manifest.revision ?: 0, + handles = handles, + ) + } +} diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt new file mode 100644 index 00000000..0671a86a --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt @@ -0,0 +1,61 @@ +package com.swmansion.pulsar.bundle + +import com.swmansion.pulsar.types.ConfigPoint +import com.swmansion.pulsar.types.ContinuousPattern +import com.swmansion.pulsar.types.PatternData +import com.swmansion.pulsar.types.ValuePoint +import kotlinx.serialization.Serializable + +// Codable mirror of manifest.json — see docs/bundle-format.md. + +@Serializable +internal data class BundleManifest( + val schema: String, + val generator: String? = null, + val id: String, + val name: String, + val revision: Int? = null, + val hash: String? = null, + val presets: List, +) + +@Serializable +internal data class PresetEntry( + val id: String, + val name: String, + val duration: Double? = null, + val haptics: String, + val audio: AudioRef? = null, + val animation: AnimationRef? = null, +) + +@Serializable +internal data class AudioRef(val src: String, val volume: Float? = null, val offset: Double? = null) + +@Serializable +internal data class AnimationRef(val src: String, val frameRate: Double? = null, val totalFrames: Int? = null) + +// Device wire shape of a haptics payload; decodes directly, then maps into the SDK's PatternData. + +@Serializable +internal data class ValuePointDto(val time: Double, val value: Float) + +@Serializable +internal data class ConfigPointDto(val time: Double, val amplitude: Float, val frequency: Float) + +@Serializable +internal data class ContinuousDto(val amplitude: List, val frequency: List) + +@Serializable +internal data class DevicePatternDto( + val continuousPattern: ContinuousDto, + val discretePattern: List, +) { + fun toPatternData(): PatternData = PatternData( + continuousPattern = ContinuousPattern( + amplitude = continuousPattern.amplitude.map { ValuePoint(it.time.toLong(), it.value) }, + frequency = continuousPattern.frequency.map { ValuePoint(it.time.toLong(), it.value) }, + ), + discretePattern = discretePattern.map { ConfigPoint(it.time.toLong(), it.amplitude, it.frequency) }, + ) +} diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt new file mode 100644 index 00000000..3fe1e393 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt @@ -0,0 +1,95 @@ +package com.swmansion.pulsar.bundle + +import com.swmansion.pulsar.Pulsar +import com.swmansion.pulsar.composers.PatternComposer +import com.swmansion.pulsar.types.PatternData +import com.swmansion.pulsar.types.SoundData + +/** Lottie bytes + timing for a preset's animation; the host app's own Lottie view renders it. */ +class BundleAnimation internal constructor( + val data: ByteArray, + val frameRate: Double, + val totalFrames: Int, +) + +/** A single playable preset from a loaded bundle. Parses its pattern lazily on first play. */ +class PresetHandle internal constructor( + val id: String, + val duration: Long, + val animation: BundleAnimation?, + private val haptics: Pulsar, + private val pattern: PatternData, + private val sound: SoundData?, +) { + private var composer: PatternComposer? = null + + private fun ensureParsed() { + if (composer == null) { + val c = haptics.getPatternComposer() + if (sound != null) c.parsePatternWithSound(pattern, sound) else c.parsePattern(pattern) + composer = c + } + } + + fun play() { + ensureParsed() + composer?.play() + } + + fun stop() { + composer?.stop() + } + + internal fun dispose() { + composer?.release() + composer = null + } +} + +/** Untyped loaded bundle — the surface the React Native / Flutter bridges use (string ids). */ +class LoadedBundle internal constructor( + val id: String, + val contentHash: String, + val revision: Int, + private val handles: Map, +) { + fun handle(id: String): PresetHandle? = handles[id] + val presetIds: List get() = handles.keys.toList() + fun play(id: String): Boolean { + val h = handles[id] ?: return false + h.play() + return true + } + fun dispose() = handles.values.forEach { it.dispose() } +} + +/** + * Looks up preset handles by id when a generated descriptor builds its typed presets view. + * `loadBundle` guarantees every id in the descriptor exists before this is used. + */ +class BundleResolver internal constructor(private val loaded: LoadedBundle) { + operator fun get(id: String): PresetHandle = loaded.handle(id)!! +} + +/** Emitted by pulsar-gen: binds a bundle asset + hash to a typed presets builder. */ +class BundleDescriptor

( + val assetName: String, + val bundleId: String, + val contentHash: String, + val presetIds: List, + val build: (BundleResolver) -> P, +) + +/** The typed bundle returned by `pulsar.loadBundle(SomeBundle.descriptor)`. */ +class PulsarBundle

internal constructor( + private val loaded: LoadedBundle, + val presets: P, +) { + val id: String get() = loaded.id + val revision: Int get() = loaded.revision + val contentHash: String get() = loaded.contentHash + fun get(id: String): PresetHandle? = loaded.handle(id) + fun dispose() = loaded.dispose() +} + +class PulsarBundleException(message: String) : Exception(message) diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md new file mode 100644 index 00000000..5a9f1232 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md @@ -0,0 +1,44 @@ +# Pulsar bundles (Android) + +Load a `.pulsar` bundle authored in Pulsar Studio at runtime and play its presets with full +autocomplete. See [`docs/bundle-format.md`](../../../../../../../docs/bundle-format.md) for the format. + +## Kotlin usage + +```kotlin +val pulsar = Pulsar(context) +val bundle = pulsar.loadBundle(AcmePack.descriptor) // AcmePack is generated +bundle.presets.heartbeatV2.play() // ← autocompletes +bundle.presets.explosion.stop() + +// Animation bytes for the app's own Lottie view (Pulsar times, the app renders): +bundle.presets.heartbeatV2.animation?.let { myLottieView.setAnimation(it.data.inputStream(), null) } +``` + +`loadBundle(descriptor, strict = true)` asserts the loaded bundle's content hash matches the +generated types, failing loudly on a stale bundle/types mismatch. + +## Zero-manual codegen (Gradle plugin) + +```kotlin +plugins { id("com.swmansion.pulsar.gen") } +``` + +Drop `.pulsar` files into `src/pulsarBundles/`. On every build the plugin generates the typed +`object` per bundle and packages the bundle into the APK assets (under `assets/pulsar/`) — the +FlutterGen / Compose-Resources model, no manual step. Configure via: + +```kotlin +pulsarBundles { + // bundlesDir.set(layout.projectDirectory.dir("src/pulsarBundles")) // default + packageName.set("com.acme.haptics") +} +``` + +## Bridge surface (React Native / Flutter) + +```kotlin +val loaded = pulsar.loadBundle(bytes) // or loadBundle(path) / loadBundleFromAsset("pulsar/acme-pack.pulsar") +loaded.presetIds // -> List +loaded.play("heartbeatV2") // -> Boolean +``` diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt new file mode 100644 index 00000000..ffd2b830 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt @@ -0,0 +1,22 @@ +package com.swmansion.pulsar.bundle + +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream + +/** Reads a `.pulsar` (zip) into a map of entry path -> bytes using the JDK's zip support. */ +internal object Unzip { + fun read(bytes: ByteArray): Map { + val out = LinkedHashMap() + ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + out[entry.name] = zis.readBytes() + } + zis.closeEntry() + entry = zis.nextEntry + } + } + return out + } +} diff --git a/Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt b/Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt new file mode 100644 index 00000000..97c1e531 --- /dev/null +++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt @@ -0,0 +1,67 @@ +package com.swmansion.pulsar.bundle + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** JVM-level checks for the bundle decode path (no Android Context needed). */ +class BundleUnitTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun zip(entries: Map): ByteArray { + val bos = ByteArrayOutputStream() + ZipOutputStream(bos).use { z -> + entries.forEach { (name, content) -> + z.putNextEntry(ZipEntry(name)) + z.write(content.toByteArray()) + z.closeEntry() + } + } + return bos.toByteArray() + } + + private val manifestJson = """ + {"schema":"pulsar.bundle/1","id":"com.acme.haptics","name":"Acme Pack","revision":7, + "hash":"sha256-test","presets":[ + {"id":"heartbeatV2","name":"Heartbeat V2","duration":1200,"haptics":"haptics/heartbeatV2.json", + "audio":{"src":"audio/boom.ogg","volume":1.0,"offset":0}}, + {"id":"explosion","name":"Explosion","duration":800,"haptics":"haptics/explosion.json"}]} + """.trimIndent() + + private val hapticsJson = """ + {"continuousPattern":{"amplitude":[{"time":0,"value":0.0},{"time":10,"value":0.8}], + "frequency":[{"time":0,"value":0.2}]},"discretePattern":[{"time":0,"amplitude":0.9,"frequency":0.2}]} + """.trimIndent() + + @Test + fun unzipReadsAllEntries() { + val files = Unzip.read(zip(mapOf("manifest.json" to manifestJson, "haptics/heartbeatV2.json" to hapticsJson))) + assertNotNull(files["manifest.json"]) + assertNotNull(files["haptics/heartbeatV2.json"]) + } + + @Test + fun manifestDecodesWithOptionalFields() { + val manifest = json.decodeFromString(BundleManifest.serializer(), manifestJson) + assertEquals("com.acme.haptics", manifest.id) + assertEquals("sha256-test", manifest.hash) + assertEquals(listOf("heartbeatV2", "explosion"), manifest.presets.map { it.id }) + assertEquals("audio/boom.ogg", manifest.presets[0].audio?.src) + assertTrue(manifest.presets[1].audio == null) + } + + @Test + fun devicePatternMapsIntoPatternData() { + val pattern = json.decodeFromString(DevicePatternDto.serializer(), hapticsJson).toPatternData() + assertEquals(listOf(0L, 10L), pattern.continuousPattern.amplitude.map { it.time }) + assertEquals(0.8f, pattern.continuousPattern.amplitude[1].value) + assertEquals(1, pattern.discretePattern.size) + assertEquals(0.9f, pattern.discretePattern[0].amplitude) + assertEquals(0.2f, pattern.discretePattern[0].frequency) + } +} diff --git a/tools/pulsar-gen-gradle/build.gradle.kts b/tools/pulsar-gen-gradle/build.gradle.kts new file mode 100644 index 00000000..97d1fbff --- /dev/null +++ b/tools/pulsar-gen-gradle/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + `kotlin-dsl` + `java-gradle-plugin` +} + +group = "com.swmansion.pulsar" +version = "0.1.0" + +gradlePlugin { + plugins { + create("pulsarGen") { + id = "com.swmansion.pulsar.gen" + implementationClass = "com.swmansion.pulsar.gradle.PulsarGenPlugin" + displayName = "Pulsar bundle codegen" + description = "Generates typed Kotlin accessors for .pulsar bundles and packages them into assets." + } + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + // AGP types for wiring generated sources/assets into the Android build (not shipped). + compileOnly("com.android.tools.build:gradle:8.7.2") +} diff --git a/tools/pulsar-gen-gradle/settings.gradle.kts b/tools/pulsar-gen-gradle/settings.gradle.kts new file mode 100644 index 00000000..c6c3dfe6 --- /dev/null +++ b/tools/pulsar-gen-gradle/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "pulsar-gen-gradle" diff --git a/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt new file mode 100644 index 00000000..57092522 --- /dev/null +++ b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt @@ -0,0 +1,116 @@ +package com.swmansion.pulsar.gradle + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.ByteArrayInputStream +import java.io.File +import java.util.zip.ZipInputStream + +/** + * Reads every `*.pulsar` bundle in [bundlesDir], emits a typed Kotlin accessor per bundle into + * [generatedSrcDir], and copies the bundle into `[generatedAssetsDir]/pulsar/` so it ships in the + * APK's assets. Runs before Kotlin compilation, so dropping in an updated bundle refreshes the types. + */ +abstract class GeneratePulsarBundlesTask : DefaultTask() { + @get:InputDirectory @get:Optional + abstract val bundlesDir: DirectoryProperty + + @get:OutputDirectory + abstract val generatedSrcDir: DirectoryProperty + + @get:OutputDirectory + abstract val generatedAssetsDir: DirectoryProperty + + @get:Input + abstract val packageName: Property + + @TaskAction + fun generate() { + val dir = bundlesDir.orNull?.asFile + val srcOut = generatedSrcDir.get().asFile.also { it.mkdirs() } + val assetsOut = generatedAssetsDir.get().asFile.resolve("pulsar").also { it.mkdirs() } + if (dir == null || !dir.exists()) return + + dir.listFiles { f -> f.isFile && f.extension == "pulsar" }?.sortedBy { it.name }?.forEach { file -> + val entries = unzip(file.readBytes()) + val manifestBytes = entries["manifest.json"] + ?: error("Pulsar: ${file.name} is missing manifest.json") + + @Suppress("UNCHECKED_CAST") + val manifest = JsonSlurper().parseText(String(manifestBytes)) as Map + val name = manifest["name"] as? String ?: error("Pulsar: ${file.name} manifest missing 'name'") + val typeName = pascalCase(name) + + val code = emitKotlin(manifest, assetName = "pulsar/${file.name}", packageName = packageName.get()) + File(srcOut, "$typeName.kt").writeText(code) + file.copyTo(File(assetsOut, file.name), overwrite = true) + logger.lifecycle("pulsar-gen: ${file.name} -> $typeName.kt") + } + } + + private fun unzip(bytes: ByteArray): Map { + val out = LinkedHashMap() + ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory) out[entry.name] = zis.readBytes() + zis.closeEntry() + entry = zis.nextEntry + } + } + return out + } +} + +internal fun pascalCase(input: String): String { + val parts = input.split(Regex("[^A-Za-z0-9]+")).filter { it.isNotEmpty() } + val p = parts.joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } } + return if (p.firstOrNull()?.isLetter() == true || p.firstOrNull() == '_') p else "Bundle$p" +} + +@Suppress("UNCHECKED_CAST") +internal fun emitKotlin(manifest: Map, assetName: String, packageName: String): String { + val bundleId = manifest["id"] as? String ?: "" + val hash = manifest["hash"] as? String ?: "" + val typeName = pascalCase(manifest["name"] as String) + val presets = (manifest["presets"] as? List>).orEmpty() + val ids = presets.map { it["id"] as String } + + val fields = ids.joinToString("\n") { " val $it: PresetHandle = r[\"$it\"]" } + val idList = ids.joinToString(", ") { "\"$it\"" } + + return buildString { + appendLine("// Code generated by pulsar-gen. DO NOT EDIT.") + appendLine("// Bundle: $bundleId (${ids.size} preset${if (ids.size == 1) "" else "s"})") + appendLine("package $packageName") + appendLine() + appendLine("import com.swmansion.pulsar.bundle.BundleDescriptor") + appendLine("import com.swmansion.pulsar.bundle.BundleResolver") + appendLine("import com.swmansion.pulsar.bundle.PresetHandle") + appendLine() + appendLine("object $typeName {") + appendLine(" const val assetName = \"$assetName\"") + appendLine(" const val bundleId = \"$bundleId\"") + appendLine(" const val contentHash = \"$hash\"") + appendLine() + appendLine(" class Presets(r: BundleResolver) {") + appendLine(fields) + appendLine(" }") + appendLine() + appendLine(" val descriptor = BundleDescriptor(") + appendLine(" assetName = assetName,") + appendLine(" bundleId = bundleId,") + appendLine(" contentHash = contentHash,") + appendLine(" presetIds = listOf($idList),") + appendLine(" build = ::Presets,") + appendLine(" )") + appendLine("}") + } +} diff --git a/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt new file mode 100644 index 00000000..47b87b67 --- /dev/null +++ b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt @@ -0,0 +1,56 @@ +package com.swmansion.pulsar.gradle + +import com.android.build.gradle.BaseExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +abstract class PulsarGenExtension { + /** Directory scanned for `*.pulsar` bundles. Defaults to `src/pulsarBundles`. */ + abstract val bundlesDir: DirectoryProperty + + /** Package for the generated accessor objects. Defaults to `com.swmansion.pulsar.bundles`. */ + abstract val packageName: Property +} + +/** + * Apply with `id("com.swmansion.pulsar.gen")`. Generates typed Kotlin accessors for every + * `.pulsar` bundle in `src/pulsarBundles/` and packages the bundles into the APK assets, wired to + * run before compilation — the FlutterGen / Compose-Resources model for Android. + */ +class PulsarGenPlugin : Plugin { + override fun apply(project: Project) { + val ext = project.extensions.create("pulsarBundles", PulsarGenExtension::class.java) + + val genSrc: Provider = project.layout.buildDirectory.dir("generated/source/pulsar/main") + val genAssets: Provider = project.layout.buildDirectory.dir("generated/assets/pulsar") + + val task = project.tasks.register("generatePulsarBundles", GeneratePulsarBundlesTask::class.java) { t -> + t.bundlesDir.convention( + ext.bundlesDir.orElse(project.layout.projectDirectory.dir("src/pulsarBundles")), + ) + t.generatedSrcDir.set(genSrc) + t.generatedAssetsDir.set(genAssets) + t.packageName.convention(ext.packageName.orElse("com.swmansion.pulsar.bundles")) + } + + project.plugins.withId("com.android.application") { wireAndroid(project, task, genSrc, genAssets) } + project.plugins.withId("com.android.library") { wireAndroid(project, task, genSrc, genAssets) } + } + + private fun wireAndroid( + project: Project, + task: TaskProvider, + genSrc: Provider, + genAssets: Provider, + ) { + val android = project.extensions.findByType(BaseExtension::class.java) ?: return + android.sourceSets.getByName("main").java.srcDir(genSrc) + android.sourceSets.getByName("main").assets.srcDir(genAssets) + project.tasks.named("preBuild").configure { it.dependsOn(task) } + } +}