diff --git a/flatpak/README b/flatpak/README deleted file mode 100644 index 42b64942..00000000 --- a/flatpak/README +++ /dev/null @@ -1,8 +0,0 @@ -To generate a release: - -1. Build the Linux release using Flutter. -2. Add the new release version and date to the start of the "releases" list in the "spec.json" file (and adjust other parameters in the file if needed). -3. Run "dart flatpak_generator.dart spec.json" in this folder. -4. Upload the generated tar.gz file as a Github release, using the app's version name for the tag (e.g. "1.0.0"). -5. Test the Flatpak using the guide at https://docs.flatpak.org/en/latest/first-build.html, using the generated json manifest as your Flatpak manifest. -6. Update your Flathub manifest file in your Flathub Github repo. (If the "flathub.json" file is not there yet, upload that too.) diff --git a/flatpak/flatpak_generator.dart b/flatpak/flatpak_generator.dart deleted file mode 100644 index c78fa84b..00000000 --- a/flatpak/flatpak_generator.dart +++ /dev/null @@ -1,300 +0,0 @@ -// ignore_for_file: avoid_classes_with_only_static_members, avoid_print - -import 'dart:convert'; -import 'dart:io'; - -void main(List arguments) { - if (arguments.length != 1) { - throw Exception('Must have only one argument: the path to the JSON specification.'); - } - if (!Platform.isLinux) { - throw Exception('Must be run under x86_64 Linux'); - } - final jsonFile = File(arguments[0]); - if (!jsonFile.existsSync()) { - throw Exception('The provided JSON file does not exist.'); - } - final specJson = SpecJson.fromJson(jsonFile); - - final outputDir = Directory.current; - - final packageGenerator = PackageGenerator(inputDir: jsonFile.parent, specJson: specJson); - packageGenerator.generatePackage(Directory.current, _Platform.x86_64); - - if (specJson.linuxArmReleaseBundleDirPath != null) { - packageGenerator.generatePackage(Directory.current, _Platform.aarch64); - } - - final sha256x64 = packageGenerator.sha256x64; - - if (sha256x64 == null) { - throw Exception('Could not generate SHA256 for the created package'); - } - - final sha256aarch64 = packageGenerator.sha256aarch64; - - final manifestContent = - FlatpakManifestGenerator(specJson).getFlatpakManifest(sha256x64, sha256aarch64); - final manifestPath = '${outputDir.path}/${specJson.appId}.json'; - final manifestFile = File(manifestPath); - manifestFile.writeAsStringSync(manifestContent); - print('Generated $manifestPath'); - - if (specJson.linuxArmReleaseBundleDirPath == null) { - final flathubJsonPath = '${outputDir.path}/${FlathubJsonGenerator.filename}'; - final flathubJsonFile = File(flathubJsonPath); - flathubJsonFile.writeAsStringSync(FlathubJsonGenerator.generate()); - print('Generated $flathubJsonPath'); - } -} - -enum _Platform { x86_64, aarch64 } - -class PackageGenerator { - final Directory inputDir; - final SpecJson specJson; - String? sha256x64; - String? sha256aarch64; - - PackageGenerator({required this.inputDir, required this.specJson}); - - void generatePackage(Directory outputDir, _Platform platform) { - final tempDir = outputDir.createTempSync('flutter_package_generator'); - final appId = specJson.appId; - - // desktop file - final desktopFile = File('${inputDir.path}/${specJson.desktopPath}'); - - if (!desktopFile.existsSync()) { - throw Exception( - 'The desktop file does not exist under the specified path: ${desktopFile.path}'); - } - - desktopFile.copySync('${tempDir.path}/$appId.desktop'); - - // icons - final iconTempDir = Directory('${tempDir.path}/icons'); - - for (final icon in specJson.icons) { - final iconFile = File('${inputDir.path}/${icon.path}'); - if (!iconFile.existsSync()) { - throw Exception('The icon file ${iconFile.path} does not exist.'); - } - final iconSubdir = Directory('${iconTempDir.path}/${icon.type}'); - iconSubdir.createSync(recursive: true); - iconFile.copySync('${iconSubdir.path}/$appId.${icon.fileExtension}'); - } - - // appdata file - final origAppDataFile = File('${inputDir.path}/${specJson.appDataPath}'); - if (!origAppDataFile.existsSync()) { - throw Exception( - 'The app data file does not exist under the specified path: ${origAppDataFile.path}'); - } - - final editedAppDataContent = - AppDataModifier.replaceVersions(origAppDataFile.readAsStringSync(), specJson.releases); - - final editedAppDataFile = File('${tempDir.path}/$appId.appdata.xml'); - editedAppDataFile.writeAsStringSync(editedAppDataContent); - - // build files - final bundlePath = platform == _Platform.aarch64 - ? specJson.linuxArmReleaseBundleDirPath - : specJson.linuxReleaseBundleDirPath; - final buildDir = Directory(bundlePath!); - if (!buildDir.existsSync()) { - throw Exception( - 'The linux build directory does not exist under the specified path: ${buildDir.path}'); - } - final destDir = Directory('${tempDir.path}/bin'); - destDir.createSync(); - - final platformSuffix = platform == _Platform.aarch64 ? 'aarch64' : 'x86_64'; - final packagePath = - '${outputDir.absolute.path}/${specJson.lowercaseAppName}-linux-$platformSuffix.tar.gz'; - - Process.runSync('cp', ['-r', '${buildDir.absolute.path}/.', destDir.absolute.path]); - Process.runSync('tar', ['-czvf', packagePath, '.'], workingDirectory: tempDir.absolute.path); - - print('Generated $packagePath'); - - final preShasum = Process.runSync('shasum', ['-a', '256', packagePath]); - - if (platform == _Platform.aarch64) { - sha256aarch64 = preShasum.stdout.toString().split(' ').first; - } else { - sha256x64 = preShasum.stdout.toString().split(' ').first; - } - - tempDir.deleteSync(recursive: true); - } -} - -// updates releases in ${appName}.appdata.xml -class AppDataModifier { - static String replaceVersions(String origAppDataContent, List versions) { - final joinedReleases = - versions.map((v) => '\t\t').join('\n'); - final releasesSection = '\n$joinedReleases\n\t'; - if (origAppDataContent.contains('') - .replaceFirst(RegExp(''), releasesSection) - .replaceAll('<~>', '\n'); - } else { - return origAppDataContent.replaceFirst('', '\n\t$releasesSection\n'); - } - } -} - -// ${appId}.json -class FlatpakManifestGenerator { - final SpecJson specJson; - - FlatpakManifestGenerator(this.specJson); - - String getFlatpakManifest(String sha256x64, String? sha256aarch64) { - final appName = specJson.lowercaseAppName; - final appId = specJson.appId; - const encoder = JsonEncoder.withIndent(' '); - return encoder.convert({ - 'app-id': appId, - 'runtime': 'org.freedesktop.Platform', - 'runtime-version': specJson.runtimeVersion, - 'sdk': 'org.freedesktop.Sdk', - 'command': appName, - 'separate-locales': false, - 'finish-args': specJson.finishArgs, - 'modules': [ - ...specJson.extraModules ?? [], - { - 'name': appName, - 'buildsystem': 'simple', - 'build-commands': [ - 'cp -R $appName/bin/ /app/$appName', - 'chmod +x /app/$appName/$appName', - 'mkdir /app/bin/', - 'mkdir /app/lib/', - 'ln -s /app/$appName/$appName /app/bin/$appName', - ...specJson.flatpakCommandsAfterUnpack ?? [], - ...specJson.icons.map((icon) => - 'install -Dm644 $appName/icons/${icon.type}/$appId.${icon.fileExtension} /app/share/icons/hicolor/${icon.type}/apps/$appId.${icon.fileExtension}'), - 'install -Dm644 $appName/$appId.desktop /app/share/applications/$appId.desktop', - 'install -Dm644 $appName/$appId.appdata.xml /app/share/applications/$appId.appdata.xml' - ], - 'sources': [ - { - 'type': 'archive', - 'only-arches': ['x86_64'], - 'url': - 'https://github.com/${specJson.githubReleaseOrganization}/${specJson.githubReleaseProject}/releases/download/${specJson.releases.first.version}/${specJson.lowercaseAppName}-linux-x86_64.tar.gz', - 'sha256': sha256x64, - 'dest': specJson.lowercaseAppName - }, - if (specJson.linuxArmReleaseBundleDirPath != null) - { - 'type': 'archive', - 'only-arches': ['aarch64'], - 'url': - 'https://github.com/${specJson.githubReleaseOrganization}/${specJson.githubReleaseProject}/releases/download/${specJson.releases.first.version}/${specJson.lowercaseAppName}-linux-aarch64.tar.gz', - 'sha256': sha256aarch64, - 'dest': specJson.lowercaseAppName - } - ] - } - ] - }); - } -} - -// flathub.json -class FlathubJsonGenerator { - static const String filename = 'flathub.json'; - - static String generate() { - const encoder = JsonEncoder.withIndent(' '); - return encoder.convert({ - 'only-arches': ['x86_64'] - }); - } -} - -class Release { - final String version; - final String date; - - Release({required this.version, required this.date}); -} - -class Icon { - final String type; - final String path; - late final String fileExtension; - - Icon({required this.type, required this.path}) { - fileExtension = path.split('.').last; - } -} - -class SpecJson { - final String appId; - final String lowercaseAppName; - final List releases; - final String runtimeVersion; - final String linuxReleaseBundleDirPath; - final String? linuxArmReleaseBundleDirPath; - final String appDataPath; - final String desktopPath; - final List icons; - final List? flatpakCommandsAfterUnpack; - final List? extraModules; - final List finishArgs; - final String githubReleaseOrganization; - final String githubReleaseProject; - - SpecJson( - {required this.appId, - required this.lowercaseAppName, - required this.releases, - required this.runtimeVersion, - required this.linuxReleaseBundleDirPath, - this.linuxArmReleaseBundleDirPath, - required this.appDataPath, - required this.desktopPath, - required this.icons, - required this.flatpakCommandsAfterUnpack, - this.extraModules, - required this.finishArgs, - required this.githubReleaseOrganization, - required this.githubReleaseProject}); - - static SpecJson fromJson(File jsonFile) { - try { - final json = jsonDecode(jsonFile.readAsStringSync()); - return SpecJson( - appId: json['appId'], - lowercaseAppName: json['lowercaseAppName'], - releases: (json['releases'] as List).map((r) { - final rMap = r as Map; - return Release(version: rMap['version'], date: rMap['date']); - }).toList(), - runtimeVersion: json['runtimeVersion'], - linuxReleaseBundleDirPath: json['linuxReleaseBundleDirPath'], - appDataPath: json['appDataPath'], - desktopPath: json['desktopPath'], - icons: (json['icons'] as Map).entries.map((mapEntry) { - return Icon(type: mapEntry.key as String, path: mapEntry.value as String); - }).toList(), - flatpakCommandsAfterUnpack: - (json['buildCommandsAfterUnpack'] as List?)?.map((bc) => bc as String).toList(), - linuxArmReleaseBundleDirPath: json['linuxArmReleaseBundleDirPath'] as String?, - extraModules: json['extraModules'] as List?, - finishArgs: (json['finishArgs'] as List).map((fa) => fa as String).toList(), - githubReleaseOrganization: json['githubReleaseOrganization'], - githubReleaseProject: json['githubReleaseProject']); - } catch (e) { - throw Exception('Could not parse JSON file, due to this error:\n$e'); - } - } -} diff --git a/flatpak/spec.json b/flatpak/flatpak_meta.json similarity index 64% rename from flatpak/spec.json rename to flatpak/flatpak_meta.json index d897e6aa..43736066 100644 --- a/flatpak/spec.json +++ b/flatpak/flatpak_meta.json @@ -1,14 +1,21 @@ { "appId": "de.wger.flutter", - "releases": [ + "lowercaseAppName": "wger", + "githubReleaseOrganization": "wger-project", + "githubReleaseProject": "flutter", + "localReleases": [ { "version": "1.5.3", "date": "2023-03-16" } ], - "lowercaseAppName": "wger", - "runtimeVersion": "22.08", - "linuxReleaseBundleDirPath": "../build/linux/x64/release/bundle", + "localReleaseAssets": [ + { + "arch": "x86_64", + "tarballPath": "./scripts/flatpak_generator exports/wger-linux-x86_64.tar.gz" + } + ], + "localLinuxBuildDir": "../build/linux", "appDataPath": "de.wger.flutter.appdata.xml", "desktopPath": "de.wger.flutter.desktop", "icons": { @@ -16,13 +23,12 @@ "128x126": "logo128.png", "512x512": "logo512.png" }, + "freedesktopRuntime": "22.08", "finishArgs": [ "--share=ipc", "--share=network", "--socket=fallback-x11", "--socket=wayland", "--device=dri" - ], - "githubReleaseOrganization": "wger-project", - "githubReleaseProject": "flutter" + ] } \ No newline at end of file diff --git a/flatpak/scripts/README.md b/flatpak/scripts/README.md new file mode 100644 index 00000000..8d387451 --- /dev/null +++ b/flatpak/scripts/README.md @@ -0,0 +1,29 @@ +# Prerequisites +## Setting up the flathub_meta.json file +The dart scripts here require a metadata file as input. This file is already prepared for this repository — see ../flatpak_meta.json. + +## Publishing on Flathub +If uploading to Flathub for the first time, follow the official contributing guidelines: https://github.com/flathub/flathub/blob/master/CONTRIBUTING.md . + +# Local builds + +To run a local build: + +1. Build the Linux release using Flutter. +2. Add the new release version and date to the start of the "releases" list in the "spec.json" file (and adjust other parameters in the file if needed). +3. Run "dart flatpak_packager.dart --meta ../flatpak_meta.json -n pubspec.yaml" in this folder. +4. Run "dart manifest_generator.dart --meta ../flatpak_meta.json -n pubspec.yaml" in this folder. +5. Test the Flatpak using the guide at https://docs.flatpak.org/en/latest/first-build.html, using the generated json manifest as your Flatpak manifest. + +# Builds based on data fetched from Github + +To generate and publish a release on Github: + +1. Create a new release on Github using the app's version name for the tag (e.g. "1.0.0"), without any assets for now. +2. Build the Linux release using Flutter. +3. Run "dart flatpak_packager.dart --meta ../flatpak_meta.json --github -n pubspec.yaml" in this folder. +4. Upload the generated tar.gz file as a Github release. +5. Run "dart manifest_generator.dart --meta ../flatpak_meta.json --github -n pubspec.yaml" in this folder. +6. Upload your Flathub manifest file to your Flathub Github repo, overwriting any old manifest file you may have there. If you're building for only certain architectures and the "flathub.json" file is not in your Flathub Github repo yet, upload that too. + +All of this can be automated via Github Actions. \ No newline at end of file diff --git a/flatpak/scripts/flatpak_packager.dart b/flatpak/scripts/flatpak_packager.dart new file mode 100644 index 00000000..9cb60a8a --- /dev/null +++ b/flatpak/scripts/flatpak_packager.dart @@ -0,0 +1,141 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'flatpak_shared.dart'; + +/// arguments: +/// --meta [file] +/// --github +void main(List arguments) async { + if (Platform.isWindows) { + throw Exception('Must be run under a UNIX-like operating system.'); + } + + final metaIndex = arguments.indexOf('--meta'); + if (metaIndex == -1) { + throw Exception( + 'You must run this script with a metadata file argument, using the --meta flag.'); + } + if (arguments.length == metaIndex + 1) { + throw Exception('The --meta flag must be followed by the path to the metadata file.'); + } + + final metaFile = File(arguments[metaIndex + 1]); + if (!metaFile.existsSync()) { + throw Exception('The provided metadata file does not exist.'); + } + + final meta = FlatpakMeta.fromJson(metaFile); + + final fetchFromGithub = arguments.contains('--github'); + + final outputDir = Directory('${Directory.current.path}/flatpak_generator exports'); + outputDir.createSync(); + + final packageGenerator = PackageGenerator(inputDir: metaFile.parent, meta: meta); + + if (PackageGenerator.runningOnARM()) { + await packageGenerator.generatePackage(outputDir, CPUArchitecture.aarch64, fetchFromGithub); + } else { + await packageGenerator.generatePackage(outputDir, CPUArchitecture.x86_64, fetchFromGithub); + } +} + +class PackageGenerator { + final Directory inputDir; + final FlatpakMeta meta; + final Map shaByArch = {}; + + PackageGenerator({required this.inputDir, required this.meta}); + + Future generatePackage( + Directory outputDir, CPUArchitecture arch, bool fetchReleasesFromGithub) async { + final tempDir = outputDir.createTempSync('flutter_generator_temp'); + final appId = meta.appId; + + // desktop file + final desktopFile = File('${inputDir.path}/${meta.desktopPath}'); + + if (!desktopFile.existsSync()) { + throw Exception( + 'The desktop file does not exist under the specified path: ${desktopFile.path}'); + } + + desktopFile.copySync('${tempDir.path}/$appId.desktop'); + + // icons + final iconTempDir = Directory('${tempDir.path}/icons'); + + for (final icon in meta.icons) { + final iconFile = File('${inputDir.path}/${icon.path}'); + if (!iconFile.existsSync()) { + throw Exception('The icon file ${iconFile.path} does not exist.'); + } + final iconSubdir = Directory('${iconTempDir.path}/${icon.type}'); + iconSubdir.createSync(recursive: true); + iconFile.copySync('${iconSubdir.path}/${icon.getFilename(appId)}'); + } + + // appdata file + final origAppDataFile = File('${inputDir.path}/${meta.appDataPath}'); + if (!origAppDataFile.existsSync()) { + throw Exception( + 'The app data file does not exist under the specified path: ${origAppDataFile.path}'); + } + + final editedAppDataContent = AppDataModifier.replaceVersions( + origAppDataFile.readAsStringSync(), await meta.getReleases(fetchReleasesFromGithub)); + + final editedAppDataFile = File('${tempDir.path}/$appId.appdata.xml'); + editedAppDataFile.writeAsStringSync(editedAppDataContent); + + // build files + final bundlePath = + '${inputDir.path}/${meta.localLinuxBuildDir}/${arch.flutterDirName}/release/bundle'; + final buildDir = Directory(bundlePath); + if (!buildDir.existsSync()) { + throw Exception( + 'The linux build directory does not exist under the specified path: ${buildDir.path}'); + } + final destDir = Directory('${tempDir.path}/bin'); + destDir.createSync(); + + final packagePath = + '${outputDir.absolute.path}/${meta.lowercaseAppName}-linux-${arch.flatpakArchCode}.tar.gz'; + + Process.runSync('cp', ['-r', '${buildDir.absolute.path}/.', destDir.absolute.path]); + Process.runSync('tar', ['-czvf', packagePath, '.'], workingDirectory: tempDir.absolute.path); + + print('Generated $packagePath'); + + final preShasum = Process.runSync('shasum', ['-a', '256', packagePath]); + + shaByArch.putIfAbsent(arch, () => preShasum.stdout.toString().split(' ').first); + + tempDir.deleteSync(recursive: true); + } + + static bool runningOnARM() { + final unameRes = Process.runSync('uname', ['-m']); + final unameString = unameRes.stdout.toString().trimLeft(); + return unameString.startsWith('arm') || unameString.startsWith('aarch'); + } +} + +// updates releases in ${appName}.appdata.xml +class AppDataModifier { + static String replaceVersions(String origAppDataContent, List versions) { + final joinedReleases = + versions.map((v) => '\t\t').join('\n'); + final releasesSection = '\n$joinedReleases\n\t'; //todo check this + if (origAppDataContent.contains('') + .replaceFirst(RegExp(''), releasesSection) + .replaceAll('<~>', '\n'); + } else { + return origAppDataContent.replaceFirst('', '\n\t$releasesSection\n'); + } + } +} diff --git a/flatpak/scripts/flatpak_shared.dart b/flatpak/scripts/flatpak_shared.dart new file mode 100644 index 00000000..a83e2c6a --- /dev/null +++ b/flatpak/scripts/flatpak_shared.dart @@ -0,0 +1,310 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +class Release { + final String version; + final String date; //todo add resources + + Release({required this.version, required this.date}); +} + +enum CPUArchitecture { + x86_64('x86_64', 'x64'), + aarch64('aarch64', 'aarch64'); + + final String flatpakArchCode; + final String flutterDirName; + + const CPUArchitecture(this.flatpakArchCode, this.flutterDirName); +} + +class ReleaseAsset { + final CPUArchitecture arch; + final String tarballUrlOrPath; + final bool isRelativeLocalPath; + final String tarballSha256; + + ReleaseAsset( + {required this.arch, + required this.tarballUrlOrPath, + required this.isRelativeLocalPath, + required this.tarballSha256}); +} + +class Icon { + static const _symbolicType = 'symbolic'; + final String type; + final String path; + late final String _fileExtension; + + Icon({required this.type, required this.path}) { + _fileExtension = path.split('.').last; + } + + String getFilename(String appId) => + (type == _symbolicType) ? '$appId-symbolic.$_fileExtension' : '$appId.$_fileExtension'; +} + +class GithubReleases { + final String githubReleaseOrganization; + final String githubReleaseProject; + List? _releases; + List? _latestReleaseAssets; + + GithubReleases(this.githubReleaseOrganization, this.githubReleaseProject) {} + + Future> getReleases() async { + if (_releases == null) { + await _fetchReleasesAndAssets(); + } + return _releases!; + } + + Future?> getLatestReleaseAssets() async { + if (_releases == null) { + await _fetchReleasesAndAssets(); + } + return _latestReleaseAssets; + } + + Future _fetchReleasesAndAssets() async { + final releaseJsonContent = (await http.get(Uri( + scheme: 'https', + host: 'api.github.com', + path: '/repos/$githubReleaseOrganization/$githubReleaseProject/releases'))) + .body; + final decodedJson = jsonDecode(releaseJsonContent) as List; + + DateTime? latestReleaseAssetDate = null; + + final releases = List.empty(growable: true); + + await Future.forEach(decodedJson, (dynamic releaseDynamic) async { + final releaseMap = releaseDynamic as Map; + + final releaseDateAndTime = DateTime.parse((releaseMap['published_at'] as String)); + final releaseDateString = releaseDateAndTime.toIso8601String().split('T').first; + + if (latestReleaseAssetDate == null || + (latestReleaseAssetDate?.compareTo(releaseDateAndTime) == -1)) { + final assets = await _parseReleaseAssets(releaseMap['assets'] as List); + if (assets != null) { + _latestReleaseAssets = assets; + latestReleaseAssetDate = releaseDateAndTime; + } + } + + releases.add(Release(version: releaseMap['name'] as String, date: releaseDateString)); + }); + + if (releases.isNotEmpty) { + _releases = releases; + } + } + + Future?> _parseReleaseAssets(List assetMaps) async { + String? x64TarballUrl; + String? x64Sha; + String? aarch64TarballUrl; + String? aarch64Sha; + for (final am in assetMaps) { + final amMap = am as Map; + + final downloadUrl = amMap['browser_download_url'] as String; + final filename = amMap['name'] as String; + final fileExtension = filename.substring(filename.indexOf('.') + 1); + final filenameWithoutExtension = filename.substring(0, filename.indexOf('.')); + + final arch = filenameWithoutExtension.endsWith('aarch64') + ? CPUArchitecture.aarch64 + : CPUArchitecture.x86_64; + + switch (fileExtension) { + case 'sha256': + if (arch == CPUArchitecture.aarch64) { + aarch64Sha = await _readSha(downloadUrl); + } else { + x64Sha = await _readSha(downloadUrl); + } + break; + case 'tar': + case 'tar.gz': + case 'tgz': + case 'tar.xz': + case 'txz': + case 'tar.bz2': + case 'tbz2': + case 'zip': + case '7z': + if (arch == CPUArchitecture.aarch64) { + aarch64TarballUrl = downloadUrl; + } else { + x64TarballUrl = downloadUrl; + } + break; + default: + break; + } + } + final res = List.empty(growable: true); + if (x64TarballUrl != null && x64Sha != null) { + res.add(ReleaseAsset( + arch: CPUArchitecture.x86_64, + tarballUrlOrPath: x64TarballUrl, + isRelativeLocalPath: false, + tarballSha256: x64Sha)); + } + if (aarch64TarballUrl != null && aarch64Sha != null) { + res.add(ReleaseAsset( + arch: CPUArchitecture.aarch64, + tarballUrlOrPath: aarch64TarballUrl, + isRelativeLocalPath: false, + tarballSha256: aarch64Sha)); + } + return res.isEmpty ? null : res; + } + + Future _readSha(String shaUrl) async { + final urlSplitByScheme = shaUrl.split('://'); + final urlWithoutScheme = urlSplitByScheme.last; + final firstSlashIndex = urlWithoutScheme.indexOf('/'); + return (await http.get(Uri( + scheme: urlSplitByScheme.first, + host: urlWithoutScheme.substring(0, firstSlashIndex), + path: urlWithoutScheme.substring(firstSlashIndex)))) + .body + .split(' ') + .first; + } +} + +class FlatpakMeta { + final String appId; + final String lowercaseAppName; + final String appDataPath; + final String desktopPath; + final List icons; + + // Flatpak manifest releated properties + final String freedesktopRuntime; + final List? buildCommandsAfterUnpack; + final List? extraModules; + final List finishArgs; + + // Properties relevant only for local releases + final List? _localReleases; + final List? _localReleaseAssets; + final String localLinuxBuildDir; + + // Properties relevant only for releases fetched from Github + final String? githubReleaseOrganization; + final String? githubReleaseProject; + late final GithubReleases? _githubReleases; + + FlatpakMeta( + {required this.appId, + required this.lowercaseAppName, + required this.githubReleaseOrganization, + required this.githubReleaseProject, + required List? localReleases, + required List? localReleaseAssets, + required this.localLinuxBuildDir, + required this.appDataPath, + required this.desktopPath, + required this.icons, + required this.freedesktopRuntime, + required this.buildCommandsAfterUnpack, + required this.extraModules, + required this.finishArgs}) + : _localReleases = localReleases, + _localReleaseAssets = localReleaseAssets { + if (githubReleaseOrganization != null && githubReleaseProject != null) { + _githubReleases = GithubReleases(githubReleaseOrganization!, githubReleaseProject!); + } + } + + Future> getReleases(bool fetchReleasesFromGithub) async { + if (fetchReleasesFromGithub) { + if (_githubReleases == null) { + throw Exception( + 'Metadata must include Github repository info if fetching releases from Github.'); + } + return await _githubReleases!.getReleases(); + } else { + if (_localReleases == null) { + throw Exception('Metadata must include releases if not fetching releases from Github.'); + } + return _localReleases!; + } + } + + Future?> getReleaseAssets(bool fetchReleasesFromGithub) async { + if (fetchReleasesFromGithub) { + if (_githubReleases == null) { + throw Exception( + 'Metadata must include Github repository info if fetching releases from Github.'); + } + return await _githubReleases!.getLatestReleaseAssets(); + } else { + if (_localReleases == null) { + throw Exception('Metadata must include releases if not fetching releases from Github.'); + } + return _localReleaseAssets; + } + } + + static FlatpakMeta fromJson(File jsonFile) { + try { + final dynamic json = jsonDecode(jsonFile.readAsStringSync()); + return FlatpakMeta( + appId: json['appId'] as String, + lowercaseAppName: json['lowercaseAppName'] as String, + githubReleaseOrganization: json['githubReleaseOrganization'] as String?, + githubReleaseProject: json['githubReleaseProject'] as String?, + localReleases: (json['localReleases'] as List?)?.map((dynamic r) { + final rMap = r as Map; + return Release(version: rMap['version'] as String, date: rMap['date'] as String); + }).toList(), + localReleaseAssets: (json['localReleaseAssets'] as List?)?.map((dynamic ra) { + final raMap = ra as Map; + final archString = raMap['arch'] as String; + final arch = (archString == CPUArchitecture.x86_64.flatpakArchCode) + ? CPUArchitecture.x86_64 + : (archString == CPUArchitecture.aarch64.flatpakArchCode) + ? CPUArchitecture.aarch64 + : null; + if (arch == null) { + throw Exception( + 'Architecture must be either "${CPUArchitecture.x86_64.flatpakArchCode}" or "${CPUArchitecture.aarch64.flatpakArchCode}"'); + } + final tarballPath = '${jsonFile.parent.path}/${raMap['tarballPath'] as String}'; + final preShasum = Process.runSync('shasum', ['-a', '256', tarballPath]); + final shasum = preShasum.stdout.toString().split(' ').first; + if (preShasum.exitCode != 0) { + throw Exception(preShasum.stderr); + } + return ReleaseAsset( + arch: arch, + tarballUrlOrPath: tarballPath, + isRelativeLocalPath: true, + tarballSha256: shasum); + }).toList(), + localLinuxBuildDir: json['localLinuxBuildDir'] as String, + appDataPath: json['appDataPath'] as String, + desktopPath: json['desktopPath'] as String, + icons: (json['icons'] as Map).entries.map((mapEntry) { + return Icon(type: mapEntry.key as String, path: mapEntry.value as String); + }).toList(), + freedesktopRuntime: json['freedesktopRuntime'] as String, + buildCommandsAfterUnpack: (json['buildCommandsAfterUnpack'] as List?) + ?.map((dynamic bc) => bc as String) + .toList(), + extraModules: json['extraModules'] as List?, + finishArgs: (json['finishArgs'] as List).map((dynamic fa) => fa as String).toList()); + } catch (e) { + throw Exception('Could not parse JSON file, due to this error:\n$e'); + } + } +} diff --git a/flatpak/scripts/manifest_generator.dart b/flatpak/scripts/manifest_generator.dart new file mode 100644 index 00000000..cd523b67 --- /dev/null +++ b/flatpak/scripts/manifest_generator.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; +import 'dart:io'; +import 'flatpak_shared.dart'; + +void main(List arguments) async { + if (Platform.isWindows) { + throw Exception('Must be run under a UNIX-like operating system.'); + } + + final metaIndex = arguments.indexOf('--meta'); + if (metaIndex == -1) { + throw Exception( + 'You must run this script with a metadata file argument, using the --meta flag.'); + } + if (arguments.length == metaIndex + 1) { + throw Exception( + 'The --meta flag must be followed by the path to the metadata file.'); + } + + final metaFile = File(arguments[metaIndex + 1]); + if (!metaFile.existsSync()) { + throw Exception('The provided metadata file does not exist.'); + } + + final meta = FlatpakMeta.fromJson(metaFile); + + final fetchFromGithub = arguments.contains('--github'); + + final outputDir = + Directory('${Directory.current.path}/flatpak_generator exports'); + outputDir.createSync(); + + final manifestGenerator = FlatpakManifestGenerator(meta); + final manifestContent = + await manifestGenerator.generateFlatpakManifest(fetchFromGithub); + final manifestPath = '${outputDir.path}/${meta.appId}.json'; + final manifestFile = File(manifestPath); + manifestFile.writeAsStringSync(manifestContent); + print('Generated $manifestPath'); + + final flathubJsonContent = + await manifestGenerator.generateFlathubJson(fetchFromGithub); + if (flathubJsonContent != null) { + final flathubJsonPath = '${outputDir.path}/flathub.json}'; + final flathubJsonFile = File(flathubJsonPath); + flathubJsonFile.writeAsStringSync(flathubJsonContent); + print('Generated $flathubJsonPath'); + } +} + +// ${appId}.json +class FlatpakManifestGenerator { + final FlatpakMeta meta; + Map? _githubArchSupport; + Map? _localArchSupport; + + FlatpakManifestGenerator(this.meta); + + Future generateFlatpakManifest(bool fetchFromGithub) async { + final appName = meta.lowercaseAppName; + final appId = meta.appId; + final assets = await meta.getReleaseAssets(fetchFromGithub); + + if (assets == null) { + throw Exception('There are no associated assets.'); + } + + _lazyGenerateArchSupportMap(fetchFromGithub, assets); + + const encoder = JsonEncoder.withIndent(' '); + return encoder.convert({ + 'app-id': appId, + 'runtime': 'org.freedesktop.Platform', + 'runtime-version': meta.freedesktopRuntime, + 'sdk': 'org.freedesktop.Sdk', + 'command': appName, + 'separate-locales': false, + 'finish-args': meta.finishArgs, + 'modules': [ + ...meta.extraModules ?? [], + { + 'name': appName, + 'buildsystem': 'simple', + 'build-commands': [ + 'cp -R $appName/bin/ /app/$appName', + 'chmod +x /app/$appName/$appName', + 'mkdir -p /app/bin/', + 'mkdir -p /app/lib/', + 'ln -s /app/$appName/$appName /app/bin/$appName', + ...meta.buildCommandsAfterUnpack ?? [], + ...meta.icons.map((icon) => + 'install -Dm644 $appName/icons/${icon.type}/${icon.getFilename(appId)} /app/share/icons/hicolor/${icon.type}/apps/${icon.getFilename(appId)}'), + 'install -Dm644 $appName/$appId.desktop /app/share/applications/$appId.desktop', + 'install -Dm644 $appName/$appId.appdata.xml /app/share/applications/$appId.appdata.xml' + ], + 'sources': assets + .map((a) => { + 'type': 'archive', + 'only-arches': [a.arch.flatpakArchCode], + (fetchFromGithub ? 'url' : 'path'): a.tarballUrlOrPath, + 'sha256': a.tarballSha256, + 'dest': meta.lowercaseAppName + }) + .toList() + } + ] + }); + } + + Future generateFlathubJson(bool fetchFromGithub) async { + final assets = await meta.getReleaseAssets(fetchFromGithub); + + if (assets == null) { + throw Exception('There are no associated assets.'); + } + + _lazyGenerateArchSupportMap(fetchFromGithub, assets); + + const encoder = JsonEncoder.withIndent(' '); + + final onlyArchListInput = + fetchFromGithub ? _githubArchSupport! : _localArchSupport!; + + final onlyArchList = List.empty(growable: true); + for (final e in onlyArchListInput.entries) { + if (e.value == true) { + onlyArchList.add(e.key.flatpakArchCode); + } + } + + if (onlyArchList.length == CPUArchitecture.values.length) { + return null; + } else { + return encoder.convert({'only-arches': onlyArchList}); + } + } + + void _lazyGenerateArchSupportMap( + bool fetchFromGithub, List assets) { + if (fetchFromGithub) { + if (_githubArchSupport == null) { + _githubArchSupport = { + for (final arch in CPUArchitecture.values) arch: false + }; + for (final a in assets) { + _githubArchSupport![a.arch] = true; + } + } + } else { + if (_localArchSupport == null) { + _localArchSupport = { + for (final arch in CPUArchitecture.values) arch: false + }; + for (final a in assets) { + _localArchSupport![a.arch] = true; + } + } + } + } +} diff --git a/flatpak/scripts/pubspec.lock b/flatpak/scripts/pubspec.lock new file mode 100644 index 00000000..4ab45d80 --- /dev/null +++ b/flatpak/scripts/pubspec.lock @@ -0,0 +1,85 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + url: "https://pub.dev" + source: hosted + version: "1.17.1" + http: + dependency: "direct main" + description: + name: http + sha256: "6aa2946395183537c8b880962d935877325d6a09a2867c3970c05c0fed6ac482" + url: "https://pub.dev" + source: hosted + version: "0.13.5" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + url: "https://pub.dev" + source: hosted + version: "1.3.1" +sdks: + dart: ">=2.18.5 <3.0.0" diff --git a/flatpak/scripts/pubspec.yaml b/flatpak/scripts/pubspec.yaml new file mode 100644 index 00000000..d014b6ec --- /dev/null +++ b/flatpak/scripts/pubspec.yaml @@ -0,0 +1,8 @@ +name: flatpak_generator +version: 1.0.0 + +environment: + sdk: '>=2.18.5' + +dependencies: + http: ^0.13.5 \ No newline at end of file diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 688c810b..8ac0f707 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -44,4 +44,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.0