Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/sync-firefox-gecko.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ docs/superpowers/
*.njsproj
*.sln
*.sw?
.tmp/
13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
215 changes: 215 additions & 0 deletions scripts/generate-gecko-media.ts
Original file line number Diff line number Diff line change
@@ -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<Attribute | Operation>;
};

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<string> {
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<T>');
}

return `readonly ${tsType(type.idlType[0])}[]`;
}

if (Array.isArray(type.idlType)) {
throw new Error(`Unsupported IDL type: ${JSON.stringify(type)}`);
}

const primitiveTypes: Record<string, string> = {
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}`);
61 changes: 0 additions & 61 deletions types/gecko-media.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading