chore(scripts): group scripts into checks, codegen, maestro and release subdirectories

scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
This commit is contained in:
edde746
2026-08-17 01:40:54 +02:00
parent e47b4e0a73
commit f622ba8efe
86 changed files with 137 additions and 131 deletions
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Generate Android notification and monochrome icons from assets/plezy.svg.
set -e
SVG_SOURCE="assets/plezy.svg"
ANDROID_RES="android/app/src/main/res"
TEMP_DIR="/tmp/android_icons_$$"
if [ ! -f "$SVG_SOURCE" ]; then
echo "Error: $SVG_SOURCE not found"
exit 1
fi
if ! command -v rsvg-convert &> /dev/null; then
echo "Error: rsvg-convert not found. Install with: brew install librsvg"
exit 1
fi
if command -v magick &> /dev/null; then
IMAGEMAGICK=magick
elif command -v convert &> /dev/null; then
IMAGEMAGICK=convert
else
echo "Error: ImageMagick not found. Install with: brew install imagemagick"
exit 1
fi
if ! command -v bc &> /dev/null; then
echo "Error: bc not found. Install with: brew install bc"
exit 1
fi
mkdir -p "$TEMP_DIR"
echo "🎨 Generating Android icons from $SVG_SOURCE..."
generate_white_icon() {
local size=$1
local output=$2
# Read dimensions so non-square artwork can be padded.
local svg_viewbox=$(grep -o 'viewBox="[^"]*"' "$SVG_SOURCE" | sed 's/viewBox="//;s/"//')
local svg_width=$(echo "$svg_viewbox" | awk '{print $3}')
local svg_height=$(echo "$svg_viewbox" | awk '{print $4}')
# Preserve the source aspect ratio.
if (( $(echo "$svg_width != $svg_height" | bc -l) )); then
# Center non-square artwork on a transparent square canvas.
rsvg-convert --keep-aspect-ratio --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp_unpadded.png"
"$IMAGEMAGICK" "$TEMP_DIR/temp_unpadded.png" \
-resize "${size}x${size}" \
-gravity center \
-background transparent \
-extent "${size}x${size}" \
"$TEMP_DIR/temp.png"
else
rsvg-convert -w "$size" -h "$size" --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp.png"
fi
# Convert the source alpha channel to a white silhouette.
"$IMAGEMAGICK" "$TEMP_DIR/temp.png" \
-alpha extract \
"$TEMP_DIR/alpha_mask.png"
"$IMAGEMAGICK" -size "${size}x${size}" xc:white \
"$TEMP_DIR/alpha_mask.png" \
-alpha off \
-compose copy-opacity \
-composite \
-define png:color-type=6 \
"$output"
echo " ✓ Generated $(basename $output) (${size}x${size}px)"
}
echo ""
echo "📱 Generating notification icons (ic_stat_notification.png)..."
declare -A NOTIF_SIZES=(
["mdpi"]=24
["hdpi"]=36
["xhdpi"]=48
["xxhdpi"]=72
["xxxhdpi"]=96
)
for density in "${!NOTIF_SIZES[@]}"; do
size=${NOTIF_SIZES[$density]}
output_dir="$ANDROID_RES/drawable-$density"
mkdir -p "$output_dir"
generate_white_icon "$size" "$output_dir/ic_stat_notification.png"
done
echo ""
echo "🚀 Generating monochrome launcher icons (ic_launcher_monochrome.png)..."
declare -A MONO_SIZES=(
["mdpi"]=108
["hdpi"]=162
["xhdpi"]=216
["xxhdpi"]=324
["xxxhdpi"]=432
)
for density in "${!MONO_SIZES[@]}"; do
size=${MONO_SIZES[$density]}
output_dir="$ANDROID_RES/mipmap-$density"
mkdir -p "$output_dir"
generate_white_icon "$size" "$output_dir/ic_launcher_monochrome.png"
done
rm -rf "$TEMP_DIR"
echo ""
echo "✅ All icons generated successfully!"
echo ""
echo "Icon locations:"
echo " • Notification icons: $ANDROID_RES/drawable-*/ic_stat_notification.png"
echo " • Monochrome icons: $ANDROID_RES/mipmap-*/ic_launcher_monochrome.png"
echo ""
echo "To apply changes, rebuild the app: flutter clean && flutter build apk"
+504
View File
@@ -0,0 +1,504 @@
/// Generates lib/data/ducet_order.dart from pinned Unicode and CLDR data.
///
/// Usage:
/// dart run scripts/codegen/generate_ducet_ranks.dart [allkeys.txt] [FractionalUCA.txt]
///
/// Default generation uses tracked deterministic gzip copies of the pinned source bytes.
/// Unicode data is redistributed unmodified under the Unicode License v3.
/// Explicit/cache/download inputs remain available for integrity and HTTP failure testing.
library;
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
final class SourceDescriptor {
const SourceDescriptor({
required this.name,
required this.url,
required this.bundledFileName,
required this.cacheFileName,
required this.sha256Digest,
required this.licenseUrl,
});
final String name;
final String url;
final String bundledFileName;
final String cacheFileName;
final String sha256Digest;
final String licenseUrl;
}
const allKeysSource = SourceDescriptor(
name: 'Unicode 13.0 allkeys',
url: 'https://www.unicode.org/Public/UCA/13.0.0/allkeys.txt',
bundledFileName: 'allkeys-13.0.0.txt.gz',
cacheFileName: 'plezy-allkeys-13.0.0-a3255d45b7af97f4dc14fb8364d7573b434425e5c58cacf00d16901ce081c78d.txt',
sha256Digest: 'a3255d45b7af97f4dc14fb8364d7573b434425e5c58cacf00d16901ce081c78d',
licenseUrl: 'https://www.unicode.org/license.txt',
);
const fractionalUcaSource = SourceDescriptor(
name: 'CLDR FractionalUCA at 651afecf9ccf1541a49306993e8210fa2209aa0b',
url:
'https://raw.githubusercontent.com/unicode-org/cldr/651afecf9ccf1541a49306993e8210fa2209aa0b/common/uca/FractionalUCA.txt',
bundledFileName: 'FractionalUCA-651afecf9ccf1541a49306993e8210fa2209aa0b.txt.gz',
cacheFileName:
'plezy-FractionalUCA-651afecf9ccf1541a49306993e8210fa2209aa0b-'
'a6144d0c8c19cc899a5d2f48fbc14e3e31e819049a73aa86b67111f1f3f81637.txt',
sha256Digest: 'a6144d0c8c19cc899a5d2f48fbc14e3e31e819049a73aa86b67111f1f3f81637',
licenseUrl: 'https://github.com/unicode-org/cldr/blob/651afecf9ccf1541a49306993e8210fa2209aa0b/LICENSE',
);
final class SourceDownloadResponse {
const SourceDownloadResponse({required this.statusCode, required this.bytes, this.close});
final int statusCode;
final Stream<List<int>> bytes;
final void Function()? close;
}
typedef SourceDownloader = Future<SourceDownloadResponse> Function(Uri uri);
typedef GeneratedFileWriter = void Function(File output, String contents);
final class SourceIntegrityException implements Exception {
const SourceIntegrityException(this.message);
final String message;
@override
String toString() => 'SourceIntegrityException: $message';
}
final class VerifiedSourceBundle {
VerifiedSourceBundle({
required this.allKeysFile,
required this.fractionalUcaFile,
required this._stagingDirectory,
required this._stagedCacheFiles,
});
final File allKeysFile;
final File fractionalUcaFile;
final Directory _stagingDirectory;
final Map<File, File> _stagedCacheFiles;
void commitCaches() {
for (final entry in _stagedCacheFiles.entries) {
final staged = entry.key;
final cache = entry.value;
cache.parent.createSync(recursive: true);
staged.renameSync(cache.path);
}
_stagedCacheFiles.clear();
}
void dispose() {
if (_stagingDirectory.existsSync()) {
_stagingDirectory.deleteSync(recursive: true);
}
}
}
Future<SourceDownloadResponse> downloadSource(Uri uri) async {
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.headers.set(HttpHeaders.userAgentHeader, 'Plezy DUCET generator');
final response = await request.close();
return SourceDownloadResponse(
statusCode: response.statusCode,
bytes: response,
close: () => client.close(force: true),
);
} catch (_) {
client.close(force: true);
rethrow;
}
}
Future<String> sha256ForFile(File file) async => (await sha256.bind(file.openRead()).first).toString();
Future<void> verifySourceFile(File file, SourceDescriptor descriptor) async {
if (!await file.exists()) {
throw SourceIntegrityException('${descriptor.name} is missing: ${file.path}');
}
final actualDigest = await sha256ForFile(file);
if (actualDigest != descriptor.sha256Digest) {
throw SourceIntegrityException(
'${descriptor.name} SHA-256 mismatch: expected ${descriptor.sha256Digest}, got $actualDigest',
);
}
}
Future<VerifiedSourceBundle> loadBundledSources({
required Directory bundledSourceDirectory,
SourceDescriptor allKeysDescriptor = allKeysSource,
SourceDescriptor fractionalUcaDescriptor = fractionalUcaSource,
}) async {
final staging = await Directory.systemTemp.createTemp('plezy_ducet_bundled_');
Future<File> decompressAndVerify(SourceDescriptor descriptor) async {
final compressed = File.fromUri(bundledSourceDirectory.uri.resolve(descriptor.bundledFileName));
if (!await compressed.exists()) {
throw SourceIntegrityException('${descriptor.name} bundled source is missing: ${compressed.path}');
}
final decompressed = File.fromUri(staging.uri.resolve('${descriptor.bundledFileName}.raw'));
try {
await compressed.openRead().transform(gzip.decoder).pipe(decompressed.openWrite());
} catch (error) {
throw SourceIntegrityException('${descriptor.name} bundled gzip is invalid: $error');
}
await verifySourceFile(decompressed, descriptor);
return decompressed;
}
try {
final allKeys = await decompressAndVerify(allKeysDescriptor);
final fractionalUca = await decompressAndVerify(fractionalUcaDescriptor);
return VerifiedSourceBundle(
allKeysFile: allKeys,
fractionalUcaFile: fractionalUca,
stagingDirectory: staging,
stagedCacheFiles: <File, File>{},
);
} catch (_) {
if (await staging.exists()) {
await staging.delete(recursive: true);
}
rethrow;
}
}
Future<VerifiedSourceBundle> loadVerifiedSources({
File? explicitAllKeys,
File? explicitFractionalUca,
Directory? cacheDirectory,
SourceDownloader downloader = downloadSource,
SourceDescriptor allKeysDescriptor = allKeysSource,
SourceDescriptor fractionalUcaDescriptor = fractionalUcaSource,
}) async {
final cacheRoot = cacheDirectory ?? Directory.systemTemp;
final staging = await Directory.systemTemp.createTemp('plezy_ducet_sources_');
final stagedCacheFiles = <File, File>{};
Future<File> resolve(SourceDescriptor descriptor, File? explicit) async {
if (explicit != null) {
await verifySourceFile(explicit, descriptor);
return explicit;
}
final cache = File.fromUri(cacheRoot.uri.resolve(descriptor.cacheFileName));
if (await cache.exists()) {
await verifySourceFile(cache, descriptor);
return cache;
}
final staged = File.fromUri(staging.uri.resolve(descriptor.cacheFileName));
final response = await downloader(Uri.parse(descriptor.url));
try {
if (response.statusCode != HttpStatus.ok) {
throw SourceIntegrityException('${descriptor.name} download returned HTTP ${response.statusCode}');
}
await response.bytes.pipe(staged.openWrite());
} finally {
response.close?.call();
}
await verifySourceFile(staged, descriptor);
stagedCacheFiles[staged] = cache;
return staged;
}
try {
final allKeys = await resolve(allKeysDescriptor, explicitAllKeys);
final fractionalUca = await resolve(fractionalUcaDescriptor, explicitFractionalUca);
return VerifiedSourceBundle(
allKeysFile: allKeys,
fractionalUcaFile: fractionalUca,
stagingDirectory: staging,
stagedCacheFiles: stagedCacheFiles,
);
} catch (_) {
if (await staging.exists()) {
await staging.delete(recursive: true);
}
rethrow;
}
}
class Weight implements Comparable<Weight> {
final List<(int, int, int)> levels;
final int codepoint;
const Weight(this.levels, this.codepoint);
@override
int compareTo(Weight other) {
final len = levels.length < other.levels.length ? levels.length : other.levels.length;
for (var i = 0; i < len; i++) {
final cmp = levels[i].$1.compareTo(other.levels[i].$1);
if (cmp != 0) return cmp;
}
if (levels.length != other.levels.length) {
return levels.length.compareTo(other.levels.length);
}
for (var i = 0; i < len; i++) {
final cmp = levels[i].$2.compareTo(other.levels[i].$2);
if (cmp != 0) return cmp;
}
for (var i = 0; i < len; i++) {
final cmp = levels[i].$3.compareTo(other.levels[i].$3);
if (cmp != 0) return cmp;
}
return codepoint.compareTo(other.codepoint);
}
}
bool _isKatakana(int cp) =>
(cp >= 0x30A0 && cp <= 0x30FF) || (cp >= 0x31F0 && cp <= 0x31FF) || (cp >= 0xFF65 && cp <= 0xFF9F);
Map<int, Weight> parseAllKeys(String text) {
final result = <int, Weight>{};
final weightRe = RegExp(r'\[([.*])([0-9A-Fa-f]{4})\.([0-9A-Fa-f]{4})\.([0-9A-Fa-f]{4})\]');
for (final line in text.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#') || trimmed.startsWith('@')) continue;
final semiIdx = trimmed.indexOf(';');
if (semiIdx < 0) continue;
final cpPart = trimmed.substring(0, semiIdx).trim();
if (cpPart.contains(' ')) continue;
final cp = int.tryParse(cpPart, radix: 16);
if (cp == null || cp > 0xFFFF) continue;
final matches = weightRe.allMatches(trimmed.substring(semiIdx + 1));
if (matches.isEmpty) continue;
final levels = <(int, int, int)>[];
for (final match in matches) {
final primary = int.parse(match.group(2)!, radix: 16);
final secondary = int.parse(match.group(3)!, radix: 16);
var tertiary = int.parse(match.group(4)!, radix: 16);
if (_isKatakana(cp) && tertiary >= 0x000F) tertiary -= 6;
levels.add((primary, secondary, tertiary));
}
if (levels.every((level) => level.$1 == 0 && level.$2 == 0 && level.$3 == 0)) continue;
if (result.containsKey(cp)) {
throw FormatException('Duplicate allkeys entry for U+${cp.toRadixString(16).padLeft(4, '0')}');
}
result[cp] = Weight(levels, cp);
}
return result;
}
(List<int>, Map<int, int>) parseRadicals(String text) {
final order = <int>[];
final kangxiDecomp = <int, int>{};
final radicalRe = RegExp(r'^\[radical \d+');
for (final line in text.split('\n')) {
if (!radicalRe.hasMatch(line)) continue;
final eqIdx = line.indexOf('=');
final colonIdx = line.indexOf(':');
if (eqIdx < 0 || colonIdx < 0 || colonIdx <= eqIdx) continue;
final closeBracket = line.lastIndexOf(']');
if (closeBracket < 0) continue;
final headerChars = line.substring(eqIdx + 1, colonIdx).runes.toList();
if (headerChars.length >= 2) {
final kangxi = headerChars.first;
final cjk = headerChars[1];
if (kangxi >= 0x2F00 && kangxi <= 0x2FD5 && cjk <= 0xFFFF) {
kangxiDecomp[kangxi] = cjk;
}
}
final runes = line.substring(colonIdx + 1, closeBracket).runes.toList();
var i = 0;
while (i < runes.length) {
final cp = runes[i];
if (cp == 0x20) {
i++;
continue;
}
if (i + 2 < runes.length && runes[i + 1] == 0x2D) {
final endCp = runes[i + 2];
for (var value = cp; value <= endCp; value++) {
if (value <= 0xFFFF) order.add(value);
}
i += 3;
continue;
}
if (cp <= 0xFFFF) order.add(cp);
i++;
}
}
final seen = <int>{};
return (order.where(seen.add).toList(), kangxiDecomp);
}
List<int> buildOrder(Map<int, Weight> allKeys, List<int> cjkRadicalOrder) {
final entries = allKeys.entries.toList()..sort((a, b) => a.value.compareTo(b.value));
final ordered = <int>[];
final cjkSet = cjkRadicalOrder.toSet();
var cjkInserted = false;
for (final entry in entries) {
if (cjkSet.contains(entry.key)) continue;
if (!cjkInserted && entry.value.levels.isNotEmpty && entry.value.levels.first.$1 >= 0xFB00) {
ordered.addAll(cjkRadicalOrder);
cjkInserted = true;
}
ordered.add(entry.key);
}
if (!cjkInserted) ordered.addAll(cjkRadicalOrder);
return ordered;
}
String buildDenseRankLookup(List<int> ordered) {
if (ordered.length > 0xFFFF) {
throw StateError('Rank table overflow: ${ordered.length} entries exceed 65535');
}
final ranks = List<int>.filled(0x10000, 0);
for (var rank = 0; rank < ordered.length; rank++) {
final codepoint = ordered[rank];
if (codepoint < 0 || codepoint > 0xFFFF) {
throw RangeError.range(codepoint, 0, 0xFFFF, 'codepoint');
}
if (ranks[codepoint] != 0) {
throw StateError('Duplicate codepoint U+${codepoint.toRadixString(16).padLeft(4, '0')}');
}
ranks[codepoint] = rank + 1;
}
return String.fromCharCodes(ranks);
}
String _escapeCodeUnit(int codeUnit) {
if (codeUnit == 0x27) return r"\'";
if (codeUnit == 0x5C) return r'\\';
if (codeUnit == 0x24) return r'\$';
return '\\u${codeUnit.toRadixString(16).padLeft(4, '0')}';
}
String renderDucetOrder(List<int> ordered, Map<int, int> kangxiDecomp) {
final denseRanks = buildDenseRankLookup(ordered);
final buffer = StringBuffer()
..writeln('/// Dense BMP ranks per DUCET (Unicode 13.0) + pinned CLDR CJK radical-stroke.')
..writeln('/// Each code unit stores rank + 1; zero means absent.')
..writeln('/// Katakana sorts before hiragana (CLDR root tailoring).')
..writeln('/// Generated by scripts/codegen/generate_ducet_ranks.dart — do not edit.')
..writeln('library;')
..writeln()
..writeln('const String _ducetRanks =');
const unitsPerLine = 128;
for (var start = 0; start < denseRanks.length; start += unitsPerLine) {
final end = start + unitsPerLine < denseRanks.length ? start + unitsPerLine : denseRanks.length;
buffer.write(" '");
for (var i = start; i < end; i++) {
buffer.write(_escapeCodeUnit(denseRanks.codeUnitAt(i)));
}
buffer.writeln(start + unitsPerLine >= denseRanks.length ? "';" : "'");
}
buffer
..writeln()
..writeln('/// Kangxi Radicals (U+2F00-U+2FD5) → CJK Unified equivalents.')
..writeln('/// ICU decomposes these via NFD before collation.')
..writeln('const Map<int, int> _kangxiDecomp = {');
final sortedKangxi = kangxiDecomp.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
for (final entry in sortedKangxi) {
buffer.writeln(
' 0x${entry.key.toRadixString(16).toUpperCase()}: '
'0x${entry.value.toRadixString(16).toUpperCase()},',
);
}
buffer
..writeln('};')
..writeln()
..writeln('/// Compare two characters using DUCET + CLDR ordering.')
..writeln('/// Decomposes Kangxi radicals to CJK equivalents before lookup.')
..writeln('/// Falls back to codepoint order for characters not in the table.')
..writeln('int ducetCompare(String a, String b) {')
..writeln(' var cpA = a.runes.first;')
..writeln(' var cpB = b.runes.first;')
..writeln(' cpA = _kangxiDecomp[cpA] ?? cpA;')
..writeln(' cpB = _kangxiDecomp[cpB] ?? cpB;')
..writeln(' final storedRankA = cpA <= 0xFFFF ? _ducetRanks.codeUnitAt(cpA) : 0;')
..writeln(' final storedRankB = cpB <= 0xFFFF ? _ducetRanks.codeUnitAt(cpB) : 0;')
..writeln(' final rankA = storedRankA == 0 ? 0x100000 + cpA : storedRankA - 1;')
..writeln(' final rankB = storedRankB == 0 ? 0x100000 + cpB : storedRankB - 1;')
..writeln(' return rankA.compareTo(rankB);')
..writeln('}');
return buffer.toString();
}
void writeGeneratedFile(File output, String contents) {
output.parent.createSync(recursive: true);
final staged = File('${output.path}.$pid.tmp');
try {
staged.writeAsStringSync(contents, flush: true);
staged.renameSync(output.path);
} finally {
if (staged.existsSync()) staged.deleteSync();
}
}
Future<void> generateDucetRanks({
bool useBundledSources = true,
Directory? bundledSourceDirectory,
File? explicitAllKeys,
File? explicitFractionalUca,
Directory? cacheDirectory,
File? output,
SourceDownloader downloader = downloadSource,
GeneratedFileWriter writer = writeGeneratedFile,
SourceDescriptor allKeysDescriptor = allKeysSource,
SourceDescriptor fractionalUcaDescriptor = fractionalUcaSource,
}) async {
final sources = useBundledSources
? await loadBundledSources(
bundledSourceDirectory:
bundledSourceDirectory ?? Directory.fromUri(Directory.current.uri.resolve('scripts/codegen/data/')),
allKeysDescriptor: allKeysDescriptor,
fractionalUcaDescriptor: fractionalUcaDescriptor,
)
: await loadVerifiedSources(
explicitAllKeys: explicitAllKeys,
explicitFractionalUca: explicitFractionalUca,
cacheDirectory: cacheDirectory,
downloader: downloader,
allKeysDescriptor: allKeysDescriptor,
fractionalUcaDescriptor: fractionalUcaDescriptor,
);
try {
final allKeys = parseAllKeys(await sources.allKeysFile.readAsString());
final (cjkOrder, kangxiDecomp) = parseRadicals(await sources.fractionalUcaFile.readAsString());
final ordered = buildOrder(allKeys, cjkOrder);
final rendered = renderDucetOrder(ordered, kangxiDecomp);
sources.commitCaches();
writer(output ?? File.fromUri(Directory.current.uri.resolve('lib/data/ducet_order.dart')), rendered);
} finally {
sources.dispose();
}
}
Future<void> main(List<String> args) async {
if (args.length > 2) {
stderr.writeln('Usage: dart run scripts/codegen/generate_ducet_ranks.dart [allkeys.txt] [FractionalUCA.txt]');
exitCode = 64;
return;
}
try {
await generateDucetRanks(
explicitAllKeys: args.isNotEmpty ? File(args.first) : null,
explicitFractionalUca: args.length > 1 ? File(args[1]) : null,
useBundledSources: args.isEmpty,
);
stderr.writeln('Written to ${File.fromUri(Directory.current.uri.resolve('lib/data/ducet_order.dart')).path}');
} catch (error) {
stderr.writeln(error);
exitCode = 1;
}
}
@@ -0,0 +1,208 @@
/// Generates lib/data/hid_key_labels.dart from the curated offline HID catalog.
///
/// Usage:
/// dart run scripts/codegen/generate_hid_key_labels.dart [input.json] [output.dart]
library;
import 'dart:convert';
import 'dart:io';
const defaultHidKeyLabelsInput = 'scripts/codegen/data/hid_key_labels.json';
const defaultHidKeyLabelsOutput = 'lib/data/hid_key_labels.dart';
final class HidKeyLabel {
const HidKeyLabel({required this.id, required this.label});
final String id;
final String label;
int get numericId => int.parse(id, radix: 16);
}
final class HidKeyGroup {
const HidKeyGroup({required this.name, required this.keys});
final String name;
final List<HidKeyLabel> keys;
}
final class HidKeyCatalog {
const HidKeyCatalog({required this.groups});
final List<HidKeyGroup> groups;
}
typedef AtomicFileWriter = Future<void> Function(String path, String contents);
HidKeyCatalog parseHidKeyLabelsCatalog(String source) {
final Object? decoded;
try {
decoded = jsonDecode(source);
} on FormatException catch (error) {
throw FormatException('Invalid HID catalog JSON: ${error.message}');
}
final root = _expectMap(decoded, 'catalog');
if (root['schemaVersion'] != 1) {
throw const FormatException('HID catalog schemaVersion must be 1');
}
final rawGroups = _expectList(root['groups'], 'groups');
if (rawGroups.isEmpty) {
throw const FormatException('HID catalog groups must not be empty');
}
final groups = <HidKeyGroup>[];
final groupNames = <String>{};
final ids = <String>{};
var previousId = -1;
for (var groupIndex = 0; groupIndex < rawGroups.length; groupIndex++) {
final rawGroup = _expectMap(rawGroups[groupIndex], 'groups[$groupIndex]');
final name = _expectNonemptyString(rawGroup['name'], 'groups[$groupIndex].name');
if (!groupNames.add(name)) {
throw FormatException('Duplicate HID group name: $name');
}
final rawKeys = _expectList(rawGroup['keys'], 'groups[$groupIndex].keys');
if (rawKeys.isEmpty) {
throw FormatException('HID group "$name" must contain at least one key');
}
final keys = <HidKeyLabel>[];
for (var keyIndex = 0; keyIndex < rawKeys.length; keyIndex++) {
final path = 'groups[$groupIndex].keys[$keyIndex]';
final rawKey = _expectMap(rawKeys[keyIndex], path);
final id = _expectString(rawKey['id'], '$path.id');
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(id)) {
throw FormatException('$path.id must be exactly eight lowercase hexadecimal digits');
}
if (!ids.add(id)) {
throw FormatException('Duplicate HID key ID: $id');
}
final numericId = int.parse(id, radix: 16);
if (numericId <= previousId) {
throw FormatException('HID key IDs must be in strictly increasing numeric order: $id');
}
previousId = numericId;
final label = _expectNonemptyString(rawKey['label'], '$path.label');
keys.add(HidKeyLabel(id: id, label: label));
}
groups.add(HidKeyGroup(name: name, keys: List.unmodifiable(keys)));
}
return HidKeyCatalog(groups: List.unmodifiable(groups));
}
String renderHidKeyLabels(HidKeyCatalog catalog) {
final output = StringBuffer()
..writeln(
'// Generated by dart run scripts/codegen/generate_hid_key_labels.dart from '
'scripts/codegen/data/hid_key_labels.json; do not edit by hand.',
)
..writeln()
..writeln('/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.')
..writeln('const hidKeyLabels = <int, String>{');
for (final group in catalog.groups) {
output.writeln(' // ${group.name}');
for (final key in group.keys) {
output.writeln(' 0x${key.id}: ${_dartString(key.label)},');
}
}
output.writeln('};');
return output.toString();
}
Future<void> writeFileAtomically(String path, String contents) async {
final output = File(path);
await output.parent.create(recursive: true);
final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}');
try {
await temporary.writeAsString(contents, flush: true);
await temporary.rename(path);
} finally {
if (await temporary.exists()) {
await temporary.delete();
}
}
}
Future<void> generateHidKeyLabels(
String inputPath,
String outputPath, {
AtomicFileWriter writer = writeFileAtomically,
}) async {
final source = await File(inputPath).readAsString();
final catalog = parseHidKeyLabelsCatalog(source);
final rendered = renderHidKeyLabels(catalog);
await writer(outputPath, rendered);
}
Future<void> main(List<String> arguments) async {
if (arguments.length > 2) {
stderr.writeln('Usage: dart run scripts/codegen/generate_hid_key_labels.dart [input.json] [output.dart]');
exitCode = 64;
return;
}
final inputPath = arguments.isEmpty ? defaultHidKeyLabelsInput : arguments[0];
final outputPath = arguments.length < 2 ? defaultHidKeyLabelsOutput : arguments[1];
await generateHidKeyLabels(inputPath, outputPath);
}
Map<String, Object?> _expectMap(Object? value, String path) {
if (value is! Map<String, Object?>) {
throw FormatException('$path must be a JSON object');
}
return value;
}
List<Object?> _expectList(Object? value, String path) {
if (value is! List<Object?>) {
throw FormatException('$path must be a JSON array');
}
return value;
}
String _expectString(Object? value, String path) {
if (value is! String) {
throw FormatException('$path must be a string');
}
return value;
}
String _expectNonemptyString(Object? value, String path) {
final string = _expectString(value, path);
if (string.trim().isEmpty) {
throw FormatException('$path must not be empty');
}
return string;
}
String _dartString(String value) {
if (value == r'\') return r"r'\'";
if (value.contains("'")) {
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll('"', r'\"')
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return '"$escaped"';
}
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll("'", r"\'")
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return "'$escaped'";
}
+239
View File
@@ -0,0 +1,239 @@
/// Generates lib/data/iso_639_data.dart from the curated offline ISO 639 catalog.
///
/// Usage:
/// dart run scripts/codegen/generate_iso_639_data.dart [input.json] [output.dart]
library;
import 'dart:convert';
import 'dart:io';
const defaultIso639Input = 'scripts/codegen/data/iso_639_codes.json';
const defaultIso639Output = 'lib/data/iso_639_data.dart';
final class Iso639CatalogEntry {
const Iso639CatalogEntry({
required this.primary,
required this.terminology,
required this.bibliographic,
required this.name,
});
final String primary;
final String terminology;
final String? bibliographic;
final String name;
}
final class Iso639Catalog {
const Iso639Catalog({required this.entries});
final List<Iso639CatalogEntry> entries;
}
typedef AtomicFileWriter = Future<void> Function(String path, String contents);
Iso639Catalog parseIso639Catalog(String source) {
final Object? decoded;
try {
decoded = jsonDecode(source);
} on FormatException catch (error) {
throw FormatException('Invalid ISO 639 catalog JSON: ${error.message}');
}
final root = _expectMap(decoded, 'catalog');
if (root['schemaVersion'] != 1) {
throw const FormatException('ISO 639 catalog schemaVersion must be 1');
}
final languages = _expectMap(root['languages'], 'languages');
if (languages.isEmpty) {
throw const FormatException('ISO 639 catalog languages must not be empty');
}
final entries = <Iso639CatalogEntry>[];
final allCodes = <String>{};
String? previousPrimary;
for (final catalogEntry in languages.entries) {
final key = catalogEntry.key;
if (!_isPrimaryCode(key)) {
throw FormatException('Language key must be exactly two lowercase letters: $key');
}
if (previousPrimary != null && key.compareTo(previousPrimary) <= 0) {
throw FormatException('Language keys must be in strictly increasing order: $key');
}
previousPrimary = key;
final rawEntry = _expectMap(catalogEntry.value, 'languages.$key');
final primary = _expectString(rawEntry['primary'], 'languages.$key.primary');
if (primary != key) {
throw FormatException('Language key $key does not match primary code $primary');
}
if (!_isPrimaryCode(primary)) {
throw FormatException('languages.$key.primary must be exactly two lowercase letters');
}
if (!allCodes.add(primary)) {
throw FormatException('Duplicate ISO 639 code: $primary');
}
final terminology = _expectString(rawEntry['terminology'], 'languages.$key.terminology');
if (!_isAliasCode(terminology)) {
throw FormatException('languages.$key.terminology must be exactly three lowercase letters');
}
if (!allCodes.add(terminology)) {
throw FormatException('Duplicate ISO 639 code: $terminology');
}
final rawBibliographic = rawEntry['bibliographic'];
final String? bibliographic;
if (rawBibliographic == null) {
bibliographic = null;
} else {
bibliographic = _expectString(rawBibliographic, 'languages.$key.bibliographic');
if (!_isAliasCode(bibliographic)) {
throw FormatException('languages.$key.bibliographic must be null or exactly three lowercase letters');
}
if (!allCodes.add(bibliographic)) {
throw FormatException('Duplicate ISO 639 code: $bibliographic');
}
}
final name = _expectNonemptyString(rawEntry['name'], 'languages.$key.name');
entries.add(
Iso639CatalogEntry(primary: primary, terminology: terminology, bibliographic: bibliographic, name: name),
);
}
return Iso639Catalog(entries: List.unmodifiable(entries));
}
String renderIso639Data(Iso639Catalog catalog) {
final output = StringBuffer()
..writeln(
'// Generated by dart run scripts/codegen/generate_iso_639_data.dart from '
'scripts/codegen/data/iso_639_codes.json; do not edit by hand.',
)
..writeln()
..writeln('class LanguageEntry {')
..writeln(' final String code1;')
..writeln(' final String code2;')
..writeln(' final String? code2B;')
..writeln(' final String name;')
..writeln(' const LanguageEntry(this.code1, this.code2, this.code2B, this.name);')
..writeln('}')
..writeln()
..writeln('const languageEntries = <String, LanguageEntry>{');
for (final entry in catalog.entries) {
final bibliographic = entry.bibliographic == null ? 'null' : _dartString(entry.bibliographic!);
output.writeln(
' ${_dartString(entry.primary)}: LanguageEntry('
'${_dartString(entry.primary)}, ${_dartString(entry.terminology)}, $bibliographic, ${_dartString(entry.name)}),',
);
}
output
..writeln('};')
..writeln()
..writeln('const code2ToCode1 = <String, String>{');
for (final entry in catalog.entries) {
output.writeln(' ${_dartString(entry.terminology)}: ${_dartString(entry.primary)},');
}
output
..writeln('};')
..writeln()
..writeln('const code2BToCode1 = <String, String>{');
for (final entry in catalog.entries) {
final bibliographic = entry.bibliographic;
if (bibliographic != null) {
output.writeln(' ${_dartString(bibliographic)}: ${_dartString(entry.primary)},');
}
}
output.writeln('};');
return output.toString();
}
Future<void> writeFileAtomically(String path, String contents) async {
final output = File(path);
await output.parent.create(recursive: true);
final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}');
try {
await temporary.writeAsString(contents, flush: true);
await temporary.rename(path);
} finally {
if (await temporary.exists()) {
await temporary.delete();
}
}
}
Future<void> generateIso639Data(
String inputPath,
String outputPath, {
AtomicFileWriter writer = writeFileAtomically,
}) async {
final source = await File(inputPath).readAsString();
final catalog = parseIso639Catalog(source);
final rendered = renderIso639Data(catalog);
await writer(outputPath, rendered);
}
Future<void> main(List<String> arguments) async {
if (arguments.length > 2) {
stderr.writeln('Usage: dart run scripts/codegen/generate_iso_639_data.dart [input.json] [output.dart]');
exitCode = 64;
return;
}
final inputPath = arguments.isEmpty ? defaultIso639Input : arguments[0];
final outputPath = arguments.length < 2 ? defaultIso639Output : arguments[1];
await generateIso639Data(inputPath, outputPath);
}
Map<String, Object?> _expectMap(Object? value, String path) {
if (value is! Map<String, Object?>) {
throw FormatException('$path must be a JSON object');
}
return value;
}
String _expectString(Object? value, String path) {
if (value is! String) {
throw FormatException('$path must be a string');
}
return value;
}
String _expectNonemptyString(Object? value, String path) {
final string = _expectString(value, path);
if (string.trim().isEmpty) {
throw FormatException('$path must not be empty');
}
return string;
}
bool _isPrimaryCode(String code) => RegExp(r'^[a-z]{2}$').hasMatch(code);
bool _isAliasCode(String code) => RegExp(r'^[a-z]{3}$').hasMatch(code);
String _dartString(String value) {
if (value.contains("'")) {
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll('"', r'\"')
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return '"$escaped"';
}
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll("'", r"\'")
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return "'$escaped'";
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Generate Dart and Go relay protocol constants from relay_protocol.json."""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SPEC_PATH = ROOT / "relay_protocol.json"
DART_PATH = ROOT / "lib/watch_together/services/relay_protocol.g.dart"
GO_PATH = ROOT / "server/relay_protocol_gen.go"
SUPPORTED_ID_PATTERN = r"^[A-Za-z0-9_-]+$"
def camel_to_pascal(value: str) -> str:
return value[:1].upper() + value[1:]
def validated_id_pattern(spec: dict) -> str:
try:
pattern = spec["idPattern"]
except KeyError:
raise ValueError("idPattern is required") from None
if not isinstance(pattern, str):
raise ValueError("idPattern must be a string")
if pattern != SUPPORTED_ID_PATTERN:
raise ValueError(
f"unsupported idPattern {pattern!r}; expected {SUPPORTED_ID_PATTERN!r}"
)
return pattern
def dart_source(spec: dict) -> str:
id_pattern = validated_id_pattern(spec)
lines = [
"// Generated by scripts/codegen/generate_relay_protocol.py. Do not edit.",
"",
"abstract final class RelayProtocol {",
]
lines.extend(
[
f" static const int protocolVersion = {spec['protocolVersion']};",
f" static const int legacyProtocolVersion = {spec['legacyProtocolVersion']};",
"",
]
)
for group in ("clientMessageTypes", "serverMessageTypes"):
for name, value in spec[group].items():
lines.append(f" static const String {name} = {value!r};")
for name, value in spec["errorCodes"].items():
lines.append(f" static const String {name}Code = {value!r};")
lines.append("")
for name, value in spec["limits"].items():
lines.append(f" static const int {name} = {value};")
lines.extend(
[
"",
f" static final RegExp _idPattern = RegExp(r{id_pattern!r});",
"",
" static bool isValidSessionId(String value) =>",
" value.isNotEmpty && value.length <= maxSessionIdLength && _idPattern.hasMatch(value);",
"",
" static bool isValidPeerId(String value) =>",
" value.isNotEmpty && value.length <= maxPeerIdLength && _idPattern.hasMatch(value);",
"}",
"",
]
)
return "\n".join(lines)
def go_source(spec: dict) -> str:
validated_id_pattern(spec)
lines = [
"// Code generated by scripts/codegen/generate_relay_protocol.py. DO NOT EDIT.",
"",
"package main",
"",
"const (",
]
lines.extend(
[
f"\trelayProtocolVersion = {spec['protocolVersion']}",
f"\tlegacyRelayProtocolVersion = {spec['legacyProtocolVersion']}",
"",
]
)
protocol_constants = []
for group in ("clientMessageTypes", "serverMessageTypes"):
protocol_constants.extend(
(f"relayType{camel_to_pascal(name)}", f'"{value}"')
for name, value in spec[group].items()
)
protocol_constants.extend(
(f"relayError{camel_to_pascal(name)}", f'"{value}"')
for name, value in spec["errorCodes"].items()
)
protocol_name_width = max(len(name) for name, _ in protocol_constants)
lines.extend(
f"\t{name:<{protocol_name_width}} = {value}"
for name, value in protocol_constants
)
lines.append("")
go_limit_names = {
"maxRoomSize": "maxRoomSize",
"maxMessageSize": "maxMessageSize",
"maxSessionIdLength": "maxSessionIDLength",
"maxPeerIdLength": "maxPeerIDLength",
}
limit_constants = [
(go_limit_names[name], str(value)) for name, value in spec["limits"].items()
]
limit_name_width = max(len(name) for name, _ in limit_constants)
lines.extend(
f"\t{name:<{limit_name_width}} = {value}"
for name, value in limit_constants
)
lines.extend(
[
")",
"",
"func validRelayID(value string, maxLength int) bool {",
"\tif len(value) == 0 || len(value) > maxLength {",
"\t\treturn false",
"\t}",
"\tfor _, ch := range value {",
"\t\tif (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') &&",
"\t\t\t(ch < '0' || ch > '9') && ch != '_' && ch != '-' {",
"\t\t\treturn false",
"\t\t}",
"\t}",
"\treturn true",
"}",
"",
]
)
return "\n".join(lines)
def main() -> None:
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
dart_output = dart_source(spec)
go_output = go_source(spec)
DART_PATH.write_text(dart_output, encoding="utf-8", newline="\n")
GO_PATH.write_text(go_output, encoding="utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,91 @@
import copy
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import generate_relay_protocol as generator
class RelayProtocolGeneratorTest(unittest.TestCase):
def setUp(self) -> None:
self.spec = json.loads(generator.SPEC_PATH.read_text(encoding="utf-8"))
def test_supported_pattern_renders_both_targets(self) -> None:
dart_output = generator.dart_source(copy.deepcopy(self.spec))
go_output = generator.go_source(copy.deepcopy(self.spec))
self.assertIn(
f"RegExp(r{generator.SUPPORTED_ID_PATTERN!r})",
dart_output,
)
self.assertIn("func validRelayID(value string, maxLength int) bool", go_output)
def test_main_writes_canonical_lf_dart_output(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
spec_path = root / "relay_protocol.json"
dart_path = root / "relay_protocol.g.dart"
go_path = root / "relay_protocol_gen.go"
spec_path.write_text(json.dumps(self.spec), encoding="utf-8")
with (
mock.patch.object(generator, "SPEC_PATH", spec_path),
mock.patch.object(generator, "DART_PATH", dart_path),
mock.patch.object(generator, "GO_PATH", go_path),
):
generator.main()
dart_bytes = dart_path.read_bytes()
self.assertIn(b"\n", dart_bytes)
self.assertNotIn(b"\r\n", dart_bytes)
def test_changed_pattern_fails_before_writing_either_target(self) -> None:
changed_spec = copy.deepcopy(self.spec)
changed_spec["idPattern"] = r"^[A-Za-z0-9_.-]+$"
for renderer in (generator.dart_source, generator.go_source):
with self.subTest(renderer=renderer.__name__):
with self.assertRaisesRegex(ValueError, "idPattern"):
renderer(changed_spec)
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
spec_path = root / "relay_protocol.json"
dart_path = root / "relay_protocol.g.dart"
go_path = root / "relay_protocol_gen.go"
spec_path.write_text(json.dumps(changed_spec), encoding="utf-8")
dart_path.write_text("dart sentinel\n", encoding="utf-8")
go_path.write_text("go sentinel\n", encoding="utf-8")
with (
mock.patch.object(generator, "SPEC_PATH", spec_path),
mock.patch.object(generator, "DART_PATH", dart_path),
mock.patch.object(generator, "GO_PATH", go_path),
):
with self.assertRaisesRegex(ValueError, "idPattern"):
generator.main()
self.assertEqual(dart_path.read_text(encoding="utf-8"), "dart sentinel\n")
self.assertEqual(go_path.read_text(encoding="utf-8"), "go sentinel\n")
def test_missing_pattern_is_rejected(self) -> None:
spec = copy.deepcopy(self.spec)
del spec["idPattern"]
with self.assertRaisesRegex(ValueError, "idPattern is required"):
generator.validated_id_pattern(spec)
def test_non_string_pattern_is_rejected(self) -> None:
for value in (None, 42, [generator.SUPPORTED_ID_PATTERN]):
with self.subTest(value=value):
spec = copy.deepcopy(self.spec)
spec["idPattern"] = value
with self.assertRaisesRegex(ValueError, "idPattern must be a string"):
generator.validated_id_pattern(spec)
if __name__ == "__main__":
unittest.main()