diff --git a/.github/workflows/sync-firefox-gecko.yml b/.github/workflows/sync-firefox-gecko.yml new file mode 100644 index 0000000..ac76881 --- /dev/null +++ b/.github/workflows/sync-firefox-gecko.yml @@ -0,0 +1,53 @@ +name: Sync Firefox Gecko types + +on: + schedule: + - cron: '0 6 * * 1' + + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Gecko types + run: npm run gecko:generate + + - name: Check + run: npm run check + + - name: Create pull request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: automation/sync-firefox-gecko-types + delete-branch: true + commit-message: 'chore: sync Firefox Gecko types' + title: 'chore: sync Firefox Gecko types' + body: | + Firefox's MediaController.webidl changed. + + This PR updates the generated TypeScript declarations + from the current Firefox main branch. + + Please review the generated diff before merging. + labels: | + automated + dependencies diff --git a/.gitignore b/.gitignore index 95cc9b8..ee73310 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ docs/superpowers/ *.njsproj *.sln *.sw? +.tmp/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 017cec0..0f77417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "knip": "^6.32.2", "oxfmt": "^0.63.0", "oxlint": "^1.78.0", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "webidl2": "^24.5.0" }, "engines": { "node": ">=24" @@ -3325,6 +3326,16 @@ "node": "20 || >=22" } }, + "node_modules/webidl2": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/webidl2/-/webidl2-24.5.0.tgz", + "integrity": "sha512-fxOigKkIem1iAgQ9t4cFOP+kWEA8y6Be/uh50FpJh0FijoeeT/VMrOyJzNLUgjy0rGMEcHeReKDCqj0g9dIe9A==", + "dev": true, + "license": "W3C", + "engines": { + "node": ">= 18" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index 0da17cb..0b7d71e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "format:check": "oxfmt --check", "typecheck": "tsc --noEmit", "typecheck:watch": "tsc --noEmit --watch", - "check:deps": "knip" + "check:deps": "knip", + "gecko:generate": "tsx scripts/generate-gecko-media.ts", + "gecko:check": "npm run gecko:generate && npm run format && git diff --exit-code -- types/gecko-media.generated.d.ts" }, "devDependencies": { "@changesets/cli": "^3.0.0", @@ -38,7 +40,8 @@ "knip": "^6.32.2", "oxfmt": "^0.63.0", "oxlint": "^1.78.0", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "webidl2": "^24.5.0" }, "engines": { "node": ">=24" diff --git a/scripts/generate-gecko-media.ts b/scripts/generate-gecko-media.ts new file mode 100644 index 0000000..c9b8456 --- /dev/null +++ b/scripts/generate-gecko-media.ts @@ -0,0 +1,215 @@ +import { writeFile } from 'node:fs/promises'; +import { parse } from 'webidl2'; + +const SOURCE_URL = + 'https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/chrome-webidl/MediaController.webidl'; + +const OUTPUT_FILE = 'types/gecko-media.generated.d.ts'; + +type IDLType = { + idlType: string | IDLType[]; + generic?: string; + union?: boolean; +}; + +type Attribute = { + type: 'attribute'; + name: string; + readonly: boolean; + idlType: IDLType; +}; + +type Argument = { + name: string; + optional: boolean; + idlType: IDLType; +}; + +type Operation = { + type: 'operation'; + name: string; + arguments: Argument[]; + idlType: IDLType; +}; + +type Interface = { + type: 'interface'; + name: string; + inheritance?: string; + members: Array; +}; + +type Enum = { + type: 'enum'; + name: string; + values: Array<{ value: string }>; +}; + +type Namespace = { + type: 'namespace'; + name: string; + members: Operation[]; +}; + +type Definition = Interface | Enum | Namespace; + +export async function generateGeckoMedia(): Promise { + console.log(`Fetching ${SOURCE_URL}`); + + const response = await fetch(SOURCE_URL); + + if (!response.ok) { + throw new Error( + `Failed to fetch WebIDL: ${response.status} ${response.statusText}`, + ); + } + + const source = await response.text(); + + if (!source.includes('interface MediaController')) { + throw new Error( + 'Fetched content does not contain MediaController interface', + ); + } + + const definitions = parse(source) as unknown as Definition[]; + + console.log(`Parsed ${definitions.length} definitions`); + + function tsType(type: IDLType): string { + if (type.union) { + if (!Array.isArray(type.idlType)) { + throw new Error('Expected union IDL type to contain an array'); + } + + return type.idlType.map(tsType).join(' | '); + } + + if (type.generic === 'sequence') { + if (!Array.isArray(type.idlType) || type.idlType.length !== 1) { + throw new Error('Expected sequence'); + } + + return `readonly ${tsType(type.idlType[0])}[]`; + } + + if (Array.isArray(type.idlType)) { + throw new Error(`Unsupported IDL type: ${JSON.stringify(type)}`); + } + + const primitiveTypes: Record = { + boolean: 'boolean', + byte: 'number', + octet: 'number', + short: 'number', + 'unsigned short': 'number', + long: 'number', + 'unsigned long': 'number', + 'long long': 'number', + 'unsigned long long': 'number', + float: 'number', + 'unrestricted float': 'number', + double: 'number', + 'unrestricted double': 'number', + DOMString: 'string', + ByteString: 'string', + USVString: 'string', + undefined: 'void', + object: 'object', + symbol: 'symbol', + any: 'any', + }; + + return primitiveTypes[type.idlType] ?? type.idlType; + } + + function renderEnum(definition: Enum): string { + const values = definition.values + .map(({ value }) => ` | ${JSON.stringify(value)}`) + .join('\n'); + + return `type ${definition.name} =\n${values};`; + } + + function renderAttribute(member: Attribute): string { + const readonly = member.readonly ? 'readonly ' : ''; + + return ` ${readonly}${member.name}: ${tsType(member.idlType)};`; + } + + function renderArgument(argument: Argument): string { + const optional = argument.optional ? '?' : ''; + + return `${argument.name}${optional}: ${tsType(argument.idlType)}`; + } + + function renderOperation(member: Operation): string { + const args = member.arguments.map(renderArgument).join(', '); + + return ` ${member.name}(${args}): ${tsType(member.idlType)};`; + } + + function renderInterface(definition: Interface): string { + const inheritance = definition.inheritance + ? ` extends ${definition.inheritance}` + : ''; + + const members = definition.members + .map((member) => { + switch (member.type) { + case 'attribute': + return renderAttribute(member); + + case 'operation': + return renderOperation(member); + + default: + throw new Error(`Unsupported interface member: ${String(member)}`); + } + }) + .join('\n'); + + return `interface ${definition.name}${inheritance} {\n${members}\n}`; + } + + function renderNamespace(definition: Namespace): string { + const members = definition.members + .map((member) => { + const args = member.arguments.map(renderArgument).join(', '); + + return ` function ${member.name}(${args}): ${tsType(member.idlType)};`; + }) + .join('\n'); + + return `declare namespace ${definition.name} {\n${members}\n}`; + } + + function renderDefinition(definition: Definition): string { + switch (definition.type) { + case 'enum': + return renderEnum(definition); + + case 'interface': + return renderInterface(definition); + + case 'namespace': + return renderNamespace(definition); + + default: + throw new Error(`Unsupported WebIDL definition: ${String(definition)}`); + } + } + + return [ + '// GENERATED FILE — DO NOT EDIT', + `// Source: ${SOURCE_URL}`, + '', + ...definitions.flatMap((definition) => [renderDefinition(definition), '']), + ].join('\n'); +} + +const generated = await generateGeckoMedia(); + +await writeFile(OUTPUT_FILE, generated, 'utf8'); + +console.log(`Generated ${OUTPUT_FILE}`); diff --git a/types/gecko-media.d.ts b/types/gecko-media.d.ts index 2cb99cb..46d654d 100644 --- a/types/gecko-media.d.ts +++ b/types/gecko-media.d.ts @@ -30,67 +30,6 @@ interface PositionStateEvent extends Event { interface AudioSessionType {} -/** - * The reason a chrome caller is pausing the controller. "user" preserves the - * existing user-initiated pause; the "system-*" values represent an - * audio-focus loss, with "system-transient" being resumable and - * "system-permanent" not. - */ -type AudioFocusLossReason = 'user' | 'system-transient' | 'system-permanent'; - -interface MediaController extends EventTarget { - readonly id: number; - readonly isActive: boolean; - readonly isAudible: boolean; - readonly isMuted: boolean; - readonly isPlaying: boolean; - readonly isAnyMediaBeingControlled: boolean; - readonly playbackState: MediaSessionPlaybackState; - readonly effectiveAudioSessionType: AudioSessionType; - readonly supportedKeys: readonly MediaControlKey[]; - - getMetadata(): MediaMetadataInit; - - onactivated: EventHandler; - onaudiblechange: EventHandler; - ondeactivated: EventHandler; - oneffectiveaudiosessiontypechange: EventHandler; - onmetadatachange: EventHandler; - onplaybackstatechange: EventHandler; - onpositionstatechange: EventHandler; // Already present - onsupportedkeyschange: EventHandler; - - focus(): void; - play(): void; - pause(reason: AudioFocusLossReason): void; - resume(): void; - stop(): void; - mute(): void; - unmute(): void; - prevTrack(): void; - nextTrack(): void; - seekBackward(seekOffset: number): void; - seekForward(seekOffset: number): void; - skipAd(): void; - seekTo(seekTime: number, fastSeek?: boolean): void; -} - -type MediaControlKey = - | 'focus' - | 'play' - | 'pause' - | 'playpause' - | 'previoustrack' - | 'nexttrack' - | 'seekbackward' - | 'seekforward' - | 'skipad' - | 'seekto' - | 'stop' - | 'mute' - | 'unmute' - | 'setvolume'; - interface MediaControllerEventMap { activated: Event; audiblechange: Event; diff --git a/types/gecko-media.generated.d.ts b/types/gecko-media.generated.d.ts new file mode 100644 index 0000000..fd1477d --- /dev/null +++ b/types/gecko-media.generated.d.ts @@ -0,0 +1,63 @@ +// GENERATED FILE — DO NOT EDIT +// Source: https://raw.githubusercontent.com/mozilla-firefox/firefox/main/dom/chrome-webidl/MediaController.webidl + +type MediaControlKey = + | 'focus' + | 'play' + | 'pause' + | 'playpause' + | 'previoustrack' + | 'nexttrack' + | 'seekbackward' + | 'seekforward' + | 'skipad' + | 'seekto' + | 'stop' + | 'mute' + | 'unmute' + | 'setvolume'; + +type AudioFocusLossReason = 'user' | 'system-transient' | 'system-permanent'; + +interface MediaController extends EventTarget { + readonly id: number; + readonly isActive: boolean; + readonly isAudible: boolean; + readonly isMuted: boolean; + readonly isPlaying: boolean; + readonly isAnyMediaBeingControlled: boolean; + readonly playbackState: MediaSessionPlaybackState; + readonly effectiveAudioSessionType: AudioSessionType; + getMetadata(): MediaMetadataInit; + readonly supportedKeys: readonly MediaControlKey[]; + onactivated: EventHandler; + ondeactivated: EventHandler; + onaudiblechange: EventHandler; + oneffectiveaudiosessiontypechange: EventHandler; + onmetadatachange: EventHandler; + onplaybackstatechange: EventHandler; + onpositionstatechange: EventHandler; + onsupportedkeyschange: EventHandler; + focus(): void; + play(): void; + pause(reason: AudioFocusLossReason): void; + resume(): void; + stop(): void; + mute(): void; + unmute(): void; + prevTrack(): void; + nextTrack(): void; + seekBackward(seekOffset: number): void; + seekForward(seekOffset: number): void; + skipAd(): void; + seekTo(seekTime: number, fastSeek?: boolean): void; +} + +declare namespace MediaControlService { + function generateMediaControlKey( + aKey: MediaControlKey, + aSeekValue?: number, + ): void; + function getCurrentActiveMediaMetadata(): MediaMetadataInit; + function getCurrentMediaSessionPlaybackState(): MediaSessionPlaybackState; +}