├── .gitignore ├── analysis_options.yaml ├── lib ├── appstream.dart └── src │ ├── utils.dart │ ├── language.dart │ ├── url.dart │ ├── screenshot.dart │ ├── launchable.dart │ ├── release.dart │ ├── icon.dart │ ├── pool.dart │ ├── component.dart │ ├── provides.dart │ └── collection.dart ├── CONTRIBUTING.md ├── pubspec.yaml ├── .github └── workflows │ ├── analyze.yml │ ├── format.yml │ └── test.yml ├── README.md ├── CHANGELOG.md ├── example └── example.dart ├── LICENSE └── test └── appstream_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool/ 2 | .packages 3 | pubspec.lock 4 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | 3 | linter: 4 | rules: 5 | - always_declare_return_types 6 | - prefer_single_quotes 7 | - sort_child_properties_last 8 | - unawaited_futures 9 | - unsafe_html 10 | - use_full_hex_values_for_flutter_colors 11 | -------------------------------------------------------------------------------- /lib/appstream.dart: -------------------------------------------------------------------------------- 1 | export 'src/collection.dart'; 2 | export 'src/component.dart'; 3 | export 'src/icon.dart'; 4 | export 'src/language.dart'; 5 | export 'src/launchable.dart'; 6 | export 'src/pool.dart'; 7 | export 'src/provides.dart'; 8 | export 'src/release.dart'; 9 | export 'src/screenshot.dart'; 10 | export 'src/url.dart'; 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # packagekit.dart Contribution Guide 2 | 3 | If you have a problem, please [file an issue](https://github.com/canonical/appstream.dart/issues/new). 4 | 5 | If you have a solution, then we accept contributions via [pull requests](https://github.com/canonical/appstream.dart/pulls). 6 | All contributions require the author(s) to sign the [contributor license agreement](http://www.ubuntu.com/legal/contributors/). 7 | 8 | Thanks for your help! 9 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: appstream 2 | version: 0.2.10 3 | 4 | description: 5 | A parser for Appstream data. 6 | This package allows Dart applications to access package metadata on Linux systems. 7 | 8 | homepage: https://github.com/canonical/appstream.dart 9 | 10 | environment: 11 | sdk: '>=2.14.0 <3.0.0' 12 | 13 | platforms: 14 | linux: 15 | 16 | dependencies: 17 | xml: ^6.1.0 18 | yaml: ^3.1.0 19 | 20 | dev_dependencies: 21 | lints: ^2.0.0 22 | test: ^1.16.8 23 | -------------------------------------------------------------------------------- /.github/workflows/analyze.yml: -------------------------------------------------------------------------------- 1 | name: Analyze 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | container: 14 | image: dart:stable 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Install dependencies 20 | run: dart pub get 21 | 22 | - name: Analyze project source 23 | run: dart analyze --fatal-infos --fatal-warnings . 24 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | container: 14 | image: dart:stable 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Install dependencies 20 | run: dart pub get 21 | 22 | - name: Verify formatting 23 | run: dart format --output=none --set-exit-if-changed . 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | container: 14 | image: dart:stable 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Print Dart SDK version 20 | run: dart --version 21 | 22 | - name: Install dependencies 23 | run: dart pub get 24 | 25 | - name: Run regression tests 26 | run: dart test 27 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | bool listsEqual(List a, List b) { 2 | if (a.length != b.length) { 3 | return false; 4 | } 5 | 6 | for (var i = 0; i < a.length; i++) { 7 | if (a[i] != b[i]) { 8 | return false; 9 | } 10 | } 11 | 12 | return true; 13 | } 14 | 15 | bool mapsEqual(Map a, Map b) { 16 | if (a.length != b.length) { 17 | return false; 18 | } 19 | 20 | for (var key in a.keys) { 21 | if (a[key] != b[key]) { 22 | return false; 23 | } 24 | } 25 | 26 | return true; 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Pub Package](https://img.shields.io/pub/v/appstream.svg)](https://pub.dev/packages/appstream) 2 | 3 | A parser for [Appstream](https://www.freedesktop.org/software/appstream) data. 4 | This package allows Dart applications to access package metadata on Linux systems. 5 | 6 | ```dart 7 | import 'package:appstream/appstream.dart'; 8 | 9 | var pool = AppstreamPool(); 10 | await pool.load(); 11 | for (var component in pool.components) { 12 | print(component); 13 | } 14 | ``` 15 | 16 | ## Contributing to appstream.dart 17 | 18 | We welcome contributions! See the [contribution guide](CONTRIBUTING.md) for more details. 19 | -------------------------------------------------------------------------------- /lib/src/language.dart: -------------------------------------------------------------------------------- 1 | /// Metadata about language support for a component. 2 | class AppstreamLanguage { 3 | /// The locale this language is for, e.g. 'en' 4 | final String locale; 5 | 6 | /// The percentage of translated text available for this language. 7 | final int? percentage; 8 | 9 | const AppstreamLanguage(this.locale, {this.percentage}); 10 | 11 | @override 12 | bool operator ==(other) => 13 | other is AppstreamLanguage && 14 | other.locale == locale && 15 | other.percentage == percentage; 16 | 17 | @override 18 | int get hashCode => Object.hash(locale, percentage); 19 | 20 | @override 21 | String toString() => '$runtimeType($locale, percentage: $percentage)'; 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/url.dart: -------------------------------------------------------------------------------- 1 | /// Types of URLs for components. 2 | enum AppstreamUrlType { 3 | homepage, 4 | bugtracker, 5 | faq, 6 | help, 7 | donation, 8 | translate, 9 | contact, 10 | vcsBrowser, 11 | contribute 12 | } 13 | 14 | /// A URL for more information about an Appstream component. 15 | class AppstreamUrl { 16 | /// The type of URL. 17 | final AppstreamUrlType type; 18 | 19 | /// The URL, e.g. 'https://example.com/help'. 20 | final String url; 21 | 22 | const AppstreamUrl(this.url, {required this.type}); 23 | 24 | @override 25 | bool operator ==(other) => 26 | other is AppstreamUrl && other.type == type && other.url == url; 27 | 28 | @override 29 | int get hashCode => Object.hash(type, url); 30 | 31 | @override 32 | String toString() => '$runtimeType($url, type: $type)'; 33 | } 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.10 4 | 5 | * Fixed YAML parsing to support appstream releases with missing version information 6 | 7 | ## 0.2.9 8 | 9 | * Add `vcsBrowser` and `contribute` URL types 10 | * Replace deprecated `text` property of `XmlElement` with `innerText` 11 | 12 | ## 0.2.8 13 | 14 | * Load catalogs from the new 'swcatalog' dir. 15 | * Load catalogs from `/usr/share` and `/var/cache` as well as `/var/lib`. 16 | * Handle missing catalog dirs. 17 | 18 | ## 0.2.7 19 | 20 | * Read and parse XML/YAML files using isolates to avoid blocking the main event loop. 21 | 22 | ## 0.2.6 23 | 24 | * Filter out null keywords. 25 | * Fix the simple code example in the README. 26 | * Fix a typo in error messages (s/Invaid/Invalid/g). 27 | * Filter out invalid YAML documents (because of duplicate mapping keys). 28 | 29 | ## 0.2.5 30 | 31 | * Update xml dependency to version 6.1.x. 32 | * Update lints package to version 2. 33 | 34 | ## 0.2.4 35 | 36 | * Only list as supporting Linux. 37 | 38 | ## 0.2.3 39 | 40 | * Handle empty URLs. 41 | * Handle versions being encoded as Yaml doubles. 42 | 43 | ## 0.2.2 44 | 45 | * Correctly parse release URLs. 46 | * Package is optional for components. 47 | * Fix inputmethod type name incorrect. 48 | 49 | ## 0.2.1 50 | 51 | * Add missing documentation on AppstreamFirmwareType and AppstreamDBusType. 52 | * Update package description. 53 | 54 | ## 0.2.0 55 | 56 | * Use enums in provides. 57 | * Decode more YAML provides. 58 | * Fix parsing of developer name. 59 | * Add documentation. 60 | 61 | ## 0.1.2 62 | 63 | * Fix error decoding YAML collection priority. 64 | * Fix XML collection version/origin attributes. 65 | * Decode XML collection architecture. 66 | 67 | ## 0.1.1 68 | 69 | * Load releases, languages and content ratings. 70 | 71 | ## 0.1.0 72 | 73 | * Initial release 74 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | import 'package:appstream/appstream.dart'; 2 | 3 | void main() async { 4 | var pool = AppstreamPool(); 5 | await pool.load(); 6 | for (var component in pool.components) { 7 | var type = { 8 | AppstreamComponentType.unknown: 'unknown', 9 | AppstreamComponentType.generic: 'generic', 10 | AppstreamComponentType.desktopApplication: 'desktop-application', 11 | AppstreamComponentType.consoleApplication: 'console-application', 12 | AppstreamComponentType.webApplication: 'web-application', 13 | AppstreamComponentType.addon: 'addon', 14 | AppstreamComponentType.font: 'font', 15 | AppstreamComponentType.codec: 'codec', 16 | AppstreamComponentType.inputMethod: 'input-method', 17 | AppstreamComponentType.firmware: 'firmware', 18 | AppstreamComponentType.driver: 'driver', 19 | AppstreamComponentType.localization: 'localization', 20 | AppstreamComponentType.service: 'service', 21 | AppstreamComponentType.repository: 'repository', 22 | AppstreamComponentType.operatingSystem: 'operating-system', 23 | AppstreamComponentType.iconTheme: 'icon-theme', 24 | AppstreamComponentType.runtime: 'runtime', 25 | }[component.type] ?? 26 | 'unknown'; 27 | var name = component.name['C'] ?? ''; 28 | var summary = component.summary['C'] ?? ''; 29 | String? homepage; 30 | for (var url in component.urls) { 31 | if (url.type == AppstreamUrlType.homepage) { 32 | homepage = url.url; 33 | break; 34 | } 35 | } 36 | 37 | print('---'); 38 | print('Identifier: ${component.id} [$type]'); 39 | print('Name: $name'); 40 | print('Summary: $summary'); 41 | if (component.package != null) { 42 | print('Package: ${component.package}'); 43 | } 44 | if (homepage != null) { 45 | print('Homepage: $homepage'); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/src/screenshot.dart: -------------------------------------------------------------------------------- 1 | import 'utils.dart'; 2 | 3 | /// Types of screenshot image. 4 | enum AppstreamImageType { source, thumbnail } 5 | 6 | /// Metadata about an image. 7 | class AppstreamImage { 8 | /// Type of image. 9 | final AppstreamImageType type; 10 | 11 | /// The URL where this image can be obtained by. 12 | final String url; 13 | 14 | /// The width of this image in pixels. 15 | final int? width; 16 | 17 | /// The height of this image in pixels. 18 | final int? height; 19 | 20 | /// The language this image is intended for. 21 | final String? lang; 22 | 23 | const AppstreamImage( 24 | {required this.type, 25 | required this.url, 26 | this.width, 27 | this.height, 28 | this.lang}); 29 | 30 | @override 31 | bool operator ==(other) => 32 | other is AppstreamImage && 33 | other.type == type && 34 | other.url == url && 35 | other.width == width && 36 | other.height == height && 37 | other.lang == lang; 38 | 39 | @override 40 | int get hashCode => Object.hash(type, url, width, height, lang); 41 | 42 | @override 43 | String toString() => 44 | "$runtimeType(type: $type, url: '$url', width: $width, height: $height, lang: $lang)"; 45 | } 46 | 47 | /// Metadata for a screenshot of a component. 48 | class AppstreamScreenshot { 49 | /// Images available for this screenshot. 50 | final List images; 51 | 52 | /// A caption for this screenshot, keyed by language. 53 | final Map caption; 54 | 55 | /// True if this is the default screenshot for this component. 56 | final bool isDefault; 57 | 58 | const AppstreamScreenshot( 59 | {this.images = const [], 60 | this.caption = const {}, 61 | this.isDefault = false}); 62 | 63 | @override 64 | bool operator ==(other) => 65 | other is AppstreamScreenshot && 66 | listsEqual(other.images, images) && 67 | mapsEqual(other.caption, caption) && 68 | other.isDefault == isDefault; 69 | 70 | @override 71 | int get hashCode => Object.hash(images, caption, isDefault); 72 | 73 | @override 74 | String toString() => 75 | '$runtimeType(images: $images, caption: $caption, isDefault: $isDefault)'; 76 | } 77 | -------------------------------------------------------------------------------- /lib/src/launchable.dart: -------------------------------------------------------------------------------- 1 | /// Metadata about something that can be launched from a component. 2 | class AppstreamLaunchable { 3 | const AppstreamLaunchable(); 4 | } 5 | 6 | /// Metadata about an application that can be launched via a desktop file. 7 | class AppstreamLaunchableDesktopId extends AppstreamLaunchable { 8 | /// The ID of a desktop file, e.g. 'myapp.desktop'. 9 | final String desktopId; 10 | 11 | const AppstreamLaunchableDesktopId(this.desktopId); 12 | 13 | @override 14 | bool operator ==(other) => 15 | other is AppstreamLaunchableDesktopId && other.desktopId == desktopId; 16 | 17 | @override 18 | int get hashCode => desktopId.hashCode; 19 | 20 | @override 21 | String toString() => '$runtimeType($desktopId)'; 22 | } 23 | 24 | /// Metadata about a service that can be launched from a component. 25 | class AppstreamLaunchableService extends AppstreamLaunchable { 26 | /// The name of the service, e.g. 'myservice'. 27 | final String serviceName; 28 | 29 | const AppstreamLaunchableService(this.serviceName); 30 | 31 | @override 32 | bool operator ==(other) => 33 | other is AppstreamLaunchableService && other.serviceName == serviceName; 34 | 35 | @override 36 | int get hashCode => serviceName.hashCode; 37 | 38 | @override 39 | String toString() => '$runtimeType($serviceName)'; 40 | } 41 | 42 | /// Metadata about a [Cockpit package](https://cockpit-project.org/guide/latest/packages.html) that can be launched from a component. 43 | class AppstreamLaunchableCockpitManifest extends AppstreamLaunchable { 44 | /// A [Cockpit package name](https://cockpit-project.org/guide/latest/packages.html). 45 | final String packageName; 46 | 47 | const AppstreamLaunchableCockpitManifest(this.packageName); 48 | 49 | @override 50 | bool operator ==(other) => 51 | other is AppstreamLaunchableCockpitManifest && 52 | other.packageName == packageName; 53 | 54 | @override 55 | int get hashCode => packageName.hashCode; 56 | 57 | @override 58 | String toString() => '$runtimeType($packageName)'; 59 | } 60 | 61 | /// Metadata for components that are web applications. 62 | class AppstreamLaunchableUrl extends AppstreamLaunchable { 63 | /// A URL for this application, e.g. 'https://example.com/myapp'. 64 | final String url; 65 | 66 | const AppstreamLaunchableUrl(this.url); 67 | 68 | @override 69 | bool operator ==(other) => 70 | other is AppstreamLaunchableUrl && other.url == url; 71 | 72 | @override 73 | int get hashCode => url.hashCode; 74 | 75 | @override 76 | String toString() => '$runtimeType($url)'; 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/release.dart: -------------------------------------------------------------------------------- 1 | import 'utils.dart'; 2 | 3 | /// Types of release. 4 | enum AppstreamReleaseType { stable, development } 5 | 6 | /// How important this release is to be installed. 7 | enum AppstreamReleaseUrgency { low, medium, high, critical } 8 | 9 | /// Types of issues. 10 | enum AppstreamIssueType { generic, cve } 11 | 12 | /// Metadata about issue in an issue tracker. 13 | class AppstreamIssue { 14 | /// The type of issue this is. 15 | final AppstreamIssueType type; 16 | 17 | /// The ID for this issue, the form of which depends on the issue [type]. 18 | final String id; 19 | 20 | /// URL to more information about this issue. 21 | final String? url; 22 | 23 | const AppstreamIssue(this.id, 24 | {this.type = AppstreamIssueType.generic, this.url}); 25 | 26 | @override 27 | bool operator ==(other) => 28 | other is AppstreamIssue && 29 | other.type == type && 30 | other.id == id && 31 | other.url == url; 32 | 33 | @override 34 | int get hashCode => Object.hash(type, id, url); 35 | 36 | @override 37 | String toString() => "$runtimeType('$id', type: $type, url: $url)"; 38 | } 39 | 40 | /// Metadata about an available release for a component. 41 | class AppstreamRelease { 42 | /// The version of this release. 43 | final String? version; 44 | 45 | /// When this release occurred. 46 | final DateTime? date; 47 | 48 | /// The type of release this is. 49 | final AppstreamReleaseType type; 50 | 51 | /// How important this release is to be installed. 52 | final AppstreamReleaseUrgency urgency; 53 | 54 | /// Description of release, keyed by language. 55 | final Map description; 56 | 57 | /// Link to more information about this release. 58 | final String? url; 59 | 60 | /// Issues resolved by this release. 61 | final List issues; 62 | 63 | const AppstreamRelease( 64 | {this.version, 65 | this.date, 66 | this.type = AppstreamReleaseType.stable, 67 | this.urgency = AppstreamReleaseUrgency.medium, 68 | this.description = const {}, 69 | this.url, 70 | this.issues = const []}); 71 | 72 | @override 73 | bool operator ==(other) => 74 | other is AppstreamRelease && 75 | other.version == version && 76 | other.date == date && 77 | other.type == type && 78 | other.urgency == urgency && 79 | mapsEqual(other.description, description) && 80 | other.url == url && 81 | listsEqual(other.issues, issues); 82 | 83 | @override 84 | int get hashCode => 85 | Object.hash(version, date, type, urgency, description, url, issues); 86 | 87 | @override 88 | String toString() => 89 | '$runtimeType(version: $version, date: $date, type: $type, urgency: $urgency, description: $description, url: $url, issues: $issues)'; 90 | } 91 | -------------------------------------------------------------------------------- /lib/src/icon.dart: -------------------------------------------------------------------------------- 1 | /// Metadata about an icon. 2 | class AppstreamIcon { 3 | const AppstreamIcon(); 4 | } 5 | 6 | /// Metadata for an icon in the stock set. 7 | class AppstreamStockIcon extends AppstreamIcon { 8 | /// The name of the icon, e.g. 'firefox'. 9 | final String name; 10 | 11 | const AppstreamStockIcon(this.name); 12 | 13 | @override 14 | bool operator ==(other) => other is AppstreamStockIcon && other.name == name; 15 | 16 | @override 17 | int get hashCode => name.hashCode; 18 | 19 | @override 20 | String toString() => "$runtimeType('$name')"; 21 | } 22 | 23 | /// Metadata for an icon installed in the icon cache. 24 | class AppstreamCachedIcon extends AppstreamIcon { 25 | /// Name of the icon, e.g. 'firefox.png'. 26 | final String name; 27 | 28 | /// Width of the icon in pixels. 29 | final int? width; 30 | 31 | /// Height of the icon in pixels. 32 | final int? height; 33 | 34 | const AppstreamCachedIcon(this.name, {this.width, this.height}); 35 | 36 | @override 37 | bool operator ==(other) => 38 | other is AppstreamCachedIcon && 39 | other.name == name && 40 | other.width == width && 41 | other.height == height; 42 | 43 | @override 44 | int get hashCode => Object.hash(name, width, height); 45 | 46 | @override 47 | String toString() => "$runtimeType('$name', width: $width, height: $height)"; 48 | } 49 | 50 | /// Metadata for an icon installed on the local system. 51 | class AppstreamLocalIcon extends AppstreamIcon { 52 | /// The file containing the icon, e.g. '/usr/share/my_app/my_icon.png'. 53 | final String filename; 54 | 55 | /// Width of the icon in pixels. 56 | final int? width; 57 | 58 | /// Height of the icon in pixels. 59 | final int? height; 60 | 61 | const AppstreamLocalIcon(this.filename, {this.width, this.height}); 62 | 63 | @override 64 | bool operator ==(other) => 65 | other is AppstreamLocalIcon && 66 | other.filename == filename && 67 | other.width == width && 68 | other.height == height; 69 | 70 | @override 71 | int get hashCode => Object.hash(filename, width, height); 72 | 73 | @override 74 | String toString() => 75 | "$runtimeType('$filename', width: $width, height: $height)"; 76 | } 77 | 78 | /// Metadata for an icon accessed via a URL. 79 | class AppstreamRemoteIcon extends AppstreamIcon { 80 | /// The URL for the icon file. e.g. 'https://example.com/my_icon.png' 81 | final String url; 82 | 83 | /// Width of the icon in pixels. 84 | final int? width; 85 | 86 | /// Height of the icon in pixels. 87 | final int? height; 88 | 89 | const AppstreamRemoteIcon(this.url, {this.width, this.height}); 90 | 91 | @override 92 | bool operator ==(other) => 93 | other is AppstreamRemoteIcon && 94 | other.url == url && 95 | other.width == width && 96 | other.height == height; 97 | 98 | @override 99 | int get hashCode => Object.hash(url, width, height); 100 | 101 | @override 102 | String toString() => "$runtimeType('$url', width: $width, height: $height)"; 103 | } 104 | -------------------------------------------------------------------------------- /lib/src/pool.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | import 'dart:isolate'; 4 | 5 | import 'collection.dart'; 6 | import 'component.dart'; 7 | 8 | class _LoadCollectionArguments { 9 | const _LoadCollectionArguments(this.port, this.path); 10 | final SendPort port; 11 | final String path; 12 | } 13 | 14 | /// Metadata for all the components known about on this system. 15 | class AppstreamPool { 16 | /// The components in this pool. 17 | final components = []; 18 | 19 | /// Load the pool. 20 | Future load() async { 21 | final catalogDirPrefixes = ['/usr/share', '/var/lib', '/var/cache']; 22 | 23 | var catalogDirs = []; 24 | for (var prefix in catalogDirPrefixes) { 25 | var catalogPath = '$prefix/swcatalog'; 26 | var catalogLegacyPath = '$prefix/app-info'; 27 | 28 | // Only use the legacy path if it's not a symlink to the current path. 29 | var ignoreLegacyPath = false; 30 | var legacyLink = Link(catalogLegacyPath); 31 | ignoreLegacyPath = 32 | await legacyLink.exists() && await legacyLink.target() == catalogPath; 33 | 34 | catalogDirs.add(catalogPath); 35 | if (!ignoreLegacyPath) { 36 | catalogDirs.add(catalogLegacyPath); 37 | } 38 | } 39 | 40 | var collectionFutures = >[]; 41 | for (var dir in catalogDirs) { 42 | var xmlPaths = await _listFiles(dir, ['.xml', '.xml.gz']); 43 | for (var path in xmlPaths) { 44 | collectionFutures.add(_loadXmlCollection(path)); 45 | } 46 | var yamlPaths = await _listFiles('$dir/yaml', ['.yml', '.yml.gz']); 47 | for (var path in yamlPaths) { 48 | collectionFutures.add(_loadYamlCollection(path)); 49 | } 50 | } 51 | var collections = await Future.wait(collectionFutures); 52 | for (var collection in collections) { 53 | components.addAll(collection.components); 54 | } 55 | } 56 | 57 | static Future> _listFiles( 58 | String path, Iterable suffixes) async { 59 | var dir = Directory(path); 60 | try { 61 | return await dir 62 | .list() 63 | .where((e) => e is File) 64 | .map((e) => (e as File).path) 65 | .toList(); 66 | } on FileSystemException { 67 | return []; 68 | } 69 | } 70 | 71 | static Future _loadCollection( 72 | void Function(_LoadCollectionArguments args) entryPoint, 73 | String path) async { 74 | ReceivePort port = ReceivePort(); 75 | final isolate = await Isolate.spawn<_LoadCollectionArguments>( 76 | entryPoint, _LoadCollectionArguments(port.sendPort, path)); 77 | final collection = await port.first; 78 | isolate.kill(priority: Isolate.immediate); 79 | return collection; 80 | } 81 | 82 | static Future _loadXmlCollection(String path) => 83 | _loadCollection(_loadXmlCollectionInIsolate, path); 84 | 85 | static void _loadXmlCollectionInIsolate(_LoadCollectionArguments args) async { 86 | args.port.send(AppstreamCollection.fromXml(await _loadFile(args.path))); 87 | } 88 | 89 | static Future _loadYamlCollection(String path) => 90 | _loadCollection(_loadYamlCollectionInIsolate, path); 91 | 92 | static void _loadYamlCollectionInIsolate( 93 | _LoadCollectionArguments args) async { 94 | args.port.send(AppstreamCollection.fromYaml(await _loadFile(args.path))); 95 | } 96 | 97 | static Future _loadFile(String path) async { 98 | var stream = File(path).openRead(); 99 | if (path.endsWith('.gz')) { 100 | stream = gzip.decoder.bind(stream); 101 | } 102 | 103 | return await utf8.decoder.bind(stream).join(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lib/src/component.dart: -------------------------------------------------------------------------------- 1 | import 'icon.dart'; 2 | import 'language.dart'; 3 | import 'launchable.dart'; 4 | import 'provides.dart'; 5 | import 'release.dart'; 6 | import 'screenshot.dart'; 7 | import 'url.dart'; 8 | 9 | /// Types of Appstream component. 10 | enum AppstreamComponentType { 11 | unknown, 12 | generic, 13 | desktopApplication, 14 | consoleApplication, 15 | webApplication, 16 | addon, 17 | font, 18 | codec, 19 | inputMethod, 20 | firmware, 21 | driver, 22 | localization, 23 | service, 24 | repository, 25 | operatingSystem, 26 | iconTheme, 27 | runtime 28 | } 29 | 30 | /// Rating applied to an aspect of a component, e.g. the language used within it. 31 | enum AppstreamContentRating { none, mild, moderate, intense } 32 | 33 | /// Metadata about a component (application, font etc). 34 | class AppstreamComponent { 35 | /// Unique ID for this component. 36 | final String id; 37 | 38 | /// Type of component. 39 | final AppstreamComponentType type; 40 | 41 | /// The name of the package this component is provided by. 42 | final String? package; 43 | 44 | /// Human readable name of the component, keyed by language. 45 | final Map name; 46 | 47 | /// Short summary of the component, keyed by language. 48 | final Map summary; 49 | 50 | /// Long description of the component, keyed by language. 51 | final Map description; 52 | 53 | /// The developer or project responsible for this project, keyed by langauge. 54 | final Map developerName; 55 | 56 | /// The license this project is under 57 | final String? projectLicense; 58 | 59 | /// Umbrella project this component is part of, e.g. 'GNOME'. 60 | final String? projectGroup; 61 | 62 | /// Icons for this component. 63 | final List icons; 64 | 65 | /// Web links for this component. 66 | final List urls; 67 | 68 | /// Categories this component fits. 69 | final List categories; 70 | 71 | /// Search keywords for this component, keyed by language. 72 | final Map> keywords; 73 | 74 | /// Screenshots of this component. 75 | final List screenshots; 76 | 77 | /// Desktops that require this component, e.g. 'GNOME' 78 | final List compulsoryForDesktops; 79 | 80 | /// Releases for this component. 81 | final List releases; 82 | 83 | /// Things this component provides. 84 | final List provides; 85 | 86 | /// Things that can be launched from this component. 87 | final List launchables; 88 | 89 | /// Languages this component is available in. 90 | final List languages; 91 | 92 | /// Content ratings for this package, keyed by content rating system name. e.g. {'oars-1.0': {'drugs-alcohol': AppstreamContentRating.moderate, 'language-humor': AppstreamContentRating.mild}} 93 | final Map> contentRatings; 94 | 95 | /// Creates a new Appstream component. 96 | const AppstreamComponent( 97 | {required this.id, 98 | required this.type, 99 | required this.package, 100 | required this.name, 101 | required this.summary, 102 | this.description = const {}, 103 | this.developerName = const {}, 104 | this.projectLicense, 105 | this.projectGroup, 106 | this.icons = const [], 107 | this.urls = const [], 108 | this.categories = const [], 109 | this.keywords = const {}, 110 | this.screenshots = const [], 111 | this.compulsoryForDesktops = const [], 112 | this.releases = const [], 113 | this.provides = const [], 114 | this.launchables = const [], 115 | this.languages = const [], 116 | this.contentRatings = const {}}); 117 | 118 | @override 119 | String toString() => 120 | "$runtimeType(id: $id, type: $type, package: $package, name: $name, summary: $summary, description: $description, developerName: '$developerName', projectLicense: $projectLicense, projectGroup: $projectGroup, icons: $icons, urls: $urls, categories: $categories, keywords: $keywords, screenshots: $screenshots, compulsoryForDesktops: $compulsoryForDesktops, release: $releases, provides: $provides, launchables: $launchables, languages: $languages, contentRatings: $contentRatings)"; 121 | } 122 | -------------------------------------------------------------------------------- /lib/src/provides.dart: -------------------------------------------------------------------------------- 1 | /// A firmware type. 2 | enum AppstreamFirmwareType { runtime, flashed } 3 | 4 | /// A DBus bus. 5 | enum AppstreamDBusType { user, system } 6 | 7 | /// Metadata about a thing an Appstream component provides. 8 | class AppstreamProvides { 9 | const AppstreamProvides(); 10 | } 11 | 12 | /// Metadata about an media type this component can handle. 13 | class AppstreamProvidesMediatype extends AppstreamProvides { 14 | /// The media type, e.g. 'image/png'. 15 | final String mediaType; 16 | 17 | const AppstreamProvidesMediatype(this.mediaType); 18 | 19 | @override 20 | bool operator ==(other) => 21 | other is AppstreamProvidesMediatype && other.mediaType == mediaType; 22 | 23 | @override 24 | int get hashCode => mediaType.hashCode; 25 | 26 | @override 27 | String toString() => '$runtimeType($mediaType)'; 28 | } 29 | 30 | /// Metadata about a library an Appstream component provides. 31 | class AppstreamProvidesLibrary extends AppstreamProvides { 32 | /// The name of the library, e.g. 'libawesome.so.1' 33 | final String libraryName; 34 | 35 | const AppstreamProvidesLibrary(this.libraryName); 36 | 37 | @override 38 | bool operator ==(other) => 39 | other is AppstreamProvidesLibrary && other.libraryName == libraryName; 40 | 41 | @override 42 | int get hashCode => libraryName.hashCode; 43 | 44 | @override 45 | String toString() => '$runtimeType($libraryName)'; 46 | } 47 | 48 | /// Metadata about a binary an Appstream component provides. 49 | class AppstreamProvidesBinary extends AppstreamProvides { 50 | /// The name of the binary, e.g. 'my_app'. 51 | final String binaryName; 52 | 53 | const AppstreamProvidesBinary(this.binaryName); 54 | 55 | @override 56 | bool operator ==(other) => 57 | other is AppstreamProvidesBinary && other.binaryName == binaryName; 58 | 59 | @override 60 | int get hashCode => binaryName.hashCode; 61 | 62 | @override 63 | String toString() => '$runtimeType($binaryName)'; 64 | } 65 | 66 | /// Metadata about a font an Appstream component provides. 67 | class AppstreamProvidesFont extends AppstreamProvides { 68 | /// The name of the font, e.g. 'Ubuntu Bold'. 69 | final String fontName; 70 | 71 | const AppstreamProvidesFont(this.fontName); 72 | 73 | @override 74 | bool operator ==(other) => 75 | other is AppstreamProvidesFont && other.fontName == fontName; 76 | 77 | @override 78 | int get hashCode => fontName.hashCode; 79 | 80 | @override 81 | String toString() => '$runtimeType($fontName)'; 82 | } 83 | 84 | /// Metadata about hardware an Appstream component can handle. 85 | class AppstreamProvidesModalias extends AppstreamProvides { 86 | /// A modalias glob, e.g. 'usb:v25FBp0160d*' 87 | final String modalias; 88 | 89 | const AppstreamProvidesModalias(this.modalias); 90 | 91 | @override 92 | bool operator ==(other) => 93 | other is AppstreamProvidesModalias && other.modalias == modalias; 94 | 95 | @override 96 | int get hashCode => modalias.hashCode; 97 | 98 | @override 99 | String toString() => '$runtimeType($modalias)'; 100 | } 101 | 102 | /// Metadata about firmware an Appstream component provides. 103 | class AppstreamProvidesFirmware extends AppstreamProvides { 104 | /// The type of firmware. 105 | final AppstreamFirmwareType type; 106 | 107 | /// The name of the firmware. 108 | final String name; 109 | 110 | const AppstreamProvidesFirmware(this.type, this.name); 111 | 112 | @override 113 | bool operator ==(other) => 114 | other is AppstreamProvidesFirmware && 115 | other.type == type && 116 | other.name == name; 117 | 118 | @override 119 | int get hashCode => Object.hash(type, name); 120 | 121 | @override 122 | String toString() => "$runtimeType($type, '$name')"; 123 | } 124 | 125 | /// Metadata about a Python 2 module an Appstream component provides. 126 | class AppstreamProvidesPython2 extends AppstreamProvides { 127 | /// Name of a Python 2 module, e.g. 'mymodule'. 128 | final String moduleName; 129 | 130 | const AppstreamProvidesPython2(this.moduleName); 131 | 132 | @override 133 | bool operator ==(other) => 134 | other is AppstreamProvidesPython2 && other.moduleName == moduleName; 135 | 136 | @override 137 | int get hashCode => moduleName.hashCode; 138 | 139 | @override 140 | String toString() => '$runtimeType($moduleName)'; 141 | } 142 | 143 | /// Metadata about a Python 3 module an Appstream component provides. 144 | class AppstreamProvidesPython3 extends AppstreamProvides { 145 | /// Name of a Python 3 module, e.g. 'mymodule3'. 146 | final String moduleName; 147 | 148 | const AppstreamProvidesPython3(this.moduleName); 149 | 150 | @override 151 | bool operator ==(other) => 152 | other is AppstreamProvidesPython3 && other.moduleName == moduleName; 153 | 154 | @override 155 | int get hashCode => moduleName.hashCode; 156 | 157 | @override 158 | String toString() => '$runtimeType($moduleName)'; 159 | } 160 | 161 | /// Metadata about a D-Bus name an Appstream component provides. 162 | class AppstreamProvidesDBus extends AppstreamProvides { 163 | /// The bus this name is on. 164 | final AppstreamDBusType busType; 165 | 166 | /// The name used on the bus, e.g. 'com.example.MyService'. 167 | final String busName; 168 | 169 | const AppstreamProvidesDBus(this.busType, this.busName); 170 | 171 | @override 172 | bool operator ==(other) => 173 | other is AppstreamProvidesDBus && 174 | other.busType == busType && 175 | other.busName == busName; 176 | 177 | @override 178 | int get hashCode => Object.hash(busType, busName); 179 | 180 | @override 181 | String toString() => '$runtimeType($busType, $busName)'; 182 | } 183 | 184 | /// Metadata about another Appstream component that can be relaced. 185 | class AppstreamProvidesId extends AppstreamProvides { 186 | /// The ID of the component that can be replaced. 187 | final String id; 188 | 189 | const AppstreamProvidesId(this.id); 190 | 191 | @override 192 | bool operator ==(other) => other is AppstreamProvidesId && other.id == id; 193 | 194 | @override 195 | int get hashCode => id.hashCode; 196 | 197 | @override 198 | String toString() => '$runtimeType($id)'; 199 | } 200 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /test/appstream_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:appstream/appstream.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('collection - empty xml', () async { 6 | expect(() => AppstreamCollection.fromXml(''), throwsFormatException); 7 | }); 8 | 9 | test('collection - invalid xml', () async { 10 | expect(() => AppstreamCollection.fromXml(''), 11 | throwsFormatException); 12 | }); 13 | 14 | test('collection - empty - xml', () async { 15 | var collection = AppstreamCollection.fromXml( 16 | ''); 17 | expect(collection.version, equals('0.12')); 18 | expect(collection.origin, equals('ubuntu-hirsute-main')); 19 | expect(collection.architecture, isNull); 20 | expect(collection.priority, isNull); 21 | expect(collection.components, isEmpty); 22 | }); 23 | 24 | test('collection - architecture - xml', () async { 25 | var collection = AppstreamCollection.fromXml( 26 | ''); 27 | expect(collection.architecture, equals('arm64')); 28 | }); 29 | 30 | test('collection - single - xml', () async { 31 | var collection = AppstreamCollection.fromXml( 32 | ''' 33 | 34 | com.example.Hello 35 | hello 36 | Hello World 37 | A simple example application 38 | 39 | 40 | '''); 41 | expect(collection.components, hasLength(1)); 42 | var component = collection.components[0]; 43 | expect(component.id, equals('com.example.Hello')); 44 | expect(component.type, equals(AppstreamComponentType.consoleApplication)); 45 | expect(component.package, equals('hello')); 46 | expect(component.name, equals({'C': 'Hello World'})); 47 | expect(component.summary, equals({'C': 'A simple example application'})); 48 | expect(component.description, isEmpty); 49 | expect(component.developerName, isEmpty); 50 | expect(component.projectLicense, isNull); 51 | expect(component.projectGroup, isNull); 52 | expect(component.icons, isEmpty); 53 | expect(component.urls, isEmpty); 54 | expect(component.categories, isEmpty); 55 | expect(component.keywords, isEmpty); 56 | expect(component.screenshots, isEmpty); 57 | expect(component.compulsoryForDesktops, isEmpty); 58 | expect(component.provides, isEmpty); 59 | expect(component.releases, isEmpty); 60 | expect(component.languages, isEmpty); 61 | expect(component.contentRatings, isEmpty); 62 | }); 63 | 64 | test('collection - optional fields - xml', () async { 65 | var collection = AppstreamCollection.fromXml( 66 | ''' 67 | 68 | com.example.Hello 69 | hello 70 | Hello World 71 | A simple example application 72 |

The best thing since sliced bread

73 | The Developer 74 | GPL-3 75 | GNOME 76 | GNOME 77 | KDE 78 |
79 |
80 | '''); 81 | expect(collection.components, hasLength(1)); 82 | var component = collection.components[0]; 83 | expect(component.description, 84 | equals({'C': '

The best thing since sliced bread

'})); 85 | expect(component.developerName, equals({'C': 'The Developer'})); 86 | expect(component.projectLicense, equals('GPL-3')); 87 | expect(component.projectGroup, equals('GNOME')); 88 | expect(component.compulsoryForDesktops, equals(['GNOME', 'KDE'])); 89 | }); 90 | 91 | test('collection - icons - xml', () async { 92 | var collection = AppstreamCollection.fromXml( 93 | ''' 94 | 95 | com.example.Hello 96 | hello 97 | Hello World 98 | A simple example application 99 | stock-name 100 | icon.png 101 | /path/to/icon.png 102 | https://example.com/icon.png 103 | 104 | 105 | '''); 106 | expect(collection.components, hasLength(1)); 107 | var component = collection.components[0]; 108 | expect(component.icons, hasLength(4)); 109 | expect( 110 | component.icons, 111 | equals([ 112 | AppstreamStockIcon('stock-name'), 113 | AppstreamCachedIcon('icon.png', width: 8, height: 16), 114 | AppstreamLocalIcon('/path/to/icon.png', width: 32, height: 48), 115 | AppstreamRemoteIcon('https://example.com/icon.png', 116 | width: 64, height: 128) 117 | ])); 118 | }); 119 | 120 | test('collection - urls - xml', () async { 121 | var collection = AppstreamCollection.fromXml( 122 | ''' 123 | 124 | com.example.Hello 125 | hello 126 | Hello World 127 | A simple example application 128 | https://example.com 129 | https://example.com/help 130 | 131 | 132 | 133 | '''); 134 | expect(collection.components, hasLength(1)); 135 | var component = collection.components[0]; 136 | expect( 137 | component.urls, 138 | equals([ 139 | AppstreamUrl('https://example.com', type: AppstreamUrlType.homepage), 140 | AppstreamUrl('https://example.com/help', type: AppstreamUrlType.help), 141 | AppstreamUrl('', type: AppstreamUrlType.contact) 142 | ])); 143 | }); 144 | 145 | test('collection - launchables - xml', () async { 146 | var collection = AppstreamCollection.fromXml( 147 | ''' 148 | 149 | com.example.Hello 150 | hello 151 | Hello World 152 | A simple example application 153 | com.example.Hello1 154 | com.example.Hello2 155 | https://example.com/launch 156 | 157 | 158 | '''); 159 | expect(collection.components, hasLength(1)); 160 | var component = collection.components[0]; 161 | expect( 162 | component.launchables, 163 | equals([ 164 | AppstreamLaunchableDesktopId('com.example.Hello1'), 165 | AppstreamLaunchableDesktopId('com.example.Hello2'), 166 | AppstreamLaunchableUrl('https://example.com/launch') 167 | ])); 168 | }); 169 | 170 | test('collection - categories - xml', () async { 171 | var collection = AppstreamCollection.fromXml( 172 | ''' 173 | 174 | com.example.Hello 175 | hello 176 | Hello World 177 | A simple example application 178 | 179 | Game 180 | ArcadeGame 181 | 182 | 183 | 184 | '''); 185 | expect(collection.components, hasLength(1)); 186 | var component = collection.components[0]; 187 | expect(component.categories, equals(['Game', 'ArcadeGame'])); 188 | }); 189 | 190 | test('collection - keywords - xml', () async { 191 | var collection = AppstreamCollection.fromXml( 192 | ''' 193 | 194 | com.example.Hello 195 | hello 196 | Hello World 197 | A simple example application 198 | 199 | Hello 200 | Welcome 201 | 202 | 203 | Hallo 204 | Wilkommen 205 | 206 | 207 | 208 | '''); 209 | expect(collection.components, hasLength(1)); 210 | var component = collection.components[0]; 211 | expect( 212 | component.keywords, 213 | equals({ 214 | 'C': ['Hello', 'Welcome'], 215 | 'de_DE': ['Hallo', 'Wilkommen'] 216 | })); 217 | }); 218 | 219 | test('collection - screenshot - xml', () async { 220 | var collection = AppstreamCollection.fromXml( 221 | ''' 222 | 223 | com.example.Hello 224 | hello 225 | Hello World 226 | A simple example application 227 | 228 | A screenshot 229 | https://example.com/thumbnail-big.jpg 230 | https://example.com/thumbnail-small.jpg 231 | https://example.com/screenshot.jpg 232 | 233 | 234 | 235 | '''); 236 | expect(collection.components, hasLength(1)); 237 | var component = collection.components[0]; 238 | expect( 239 | component.screenshots, 240 | equals([ 241 | AppstreamScreenshot(images: [ 242 | AppstreamImage( 243 | type: AppstreamImageType.thumbnail, 244 | url: 'https://example.com/thumbnail-big.jpg', 245 | width: 512, 246 | height: 384), 247 | AppstreamImage( 248 | type: AppstreamImageType.thumbnail, 249 | url: 'https://example.com/thumbnail-small.jpg', 250 | width: 256, 251 | height: 192, 252 | lang: 'en_NZ'), 253 | AppstreamImage( 254 | type: AppstreamImageType.source, 255 | url: 'https://example.com/screenshot.jpg', 256 | width: 1024, 257 | height: 768) 258 | ], caption: { 259 | 'C': 'A screenshot' 260 | }) 261 | ])); 262 | }); 263 | 264 | test('collection - screenshots - xml', () async { 265 | var collection = AppstreamCollection.fromXml( 266 | ''' 267 | 268 | com.example.Hello 269 | hello 270 | Hello World 271 | A simple example application 272 | 273 | https://example.com/screenshot1.jpg 274 | 275 | 276 | https://example.com/screenshot2.jpg 277 | 278 | 279 | 280 | '''); 281 | expect(collection.components, hasLength(1)); 282 | var component = collection.components[0]; 283 | expect( 284 | component.screenshots, 285 | equals([ 286 | AppstreamScreenshot(images: [ 287 | AppstreamImage( 288 | type: AppstreamImageType.source, 289 | url: 'https://example.com/screenshot1.jpg', 290 | ) 291 | ]), 292 | AppstreamScreenshot(images: [ 293 | AppstreamImage( 294 | type: AppstreamImageType.source, 295 | url: 'https://example.com/screenshot2.jpg') 296 | ], isDefault: true) 297 | ])); 298 | }); 299 | 300 | test('collection - releases - xml', () async { 301 | var collection = AppstreamCollection.fromXml( 302 | ''' 303 | 304 | com.example.Hello 305 | hello 306 | Hello World 307 | A simple example application 308 | 309 | 310 | This stable release fixes bugs. 311 | https://example.com/releases/version-1.2.html 312 | 313 | #123 314 | CVE-2019-123456 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | '''); 323 | expect(collection.components, hasLength(1)); 324 | var component = collection.components[0]; 325 | expect( 326 | component.releases, 327 | equals([ 328 | AppstreamRelease( 329 | version: '1.2', 330 | date: DateTime(2014, 4, 12), 331 | urgency: AppstreamReleaseUrgency.high, 332 | description: {'C': 'This stable release fixes bugs.'}, 333 | url: 'https://example.com/releases/version-1.2.html', 334 | issues: [ 335 | AppstreamIssue('#123', 336 | url: 'https://github.com/example/example/issues/123'), 337 | AppstreamIssue('CVE-2019-123456', type: AppstreamIssueType.cve) 338 | ]), 339 | AppstreamRelease( 340 | version: '1.1', 341 | type: AppstreamReleaseType.development, 342 | date: DateTime(2013, 10, 20)), 343 | AppstreamRelease(version: '1.0', date: DateTime.utc(2012, 8, 26)) 344 | ])); 345 | }); 346 | 347 | test('collection - provides - xml', () async { 348 | var collection = AppstreamCollection.fromXml( 349 | ''' 350 | 351 | com.example.Hello 352 | hello 353 | Hello World 354 | A simple example application 355 | 356 | text/html 357 | image/png 358 | libhello.so.1 359 | hello 360 | Hello 361 | usb:* 362 | hello.fw 363 | modhello 364 | modhello3 365 | com.example.Service 366 | com.example.SimpleHello 367 | 368 | 369 | 370 | '''); 371 | expect(collection.components, hasLength(1)); 372 | var component = collection.components[0]; 373 | expect( 374 | component.provides, 375 | equals([ 376 | AppstreamProvidesMediatype('text/html'), 377 | AppstreamProvidesMediatype('image/png'), 378 | AppstreamProvidesLibrary('libhello.so.1'), 379 | AppstreamProvidesBinary('hello'), 380 | AppstreamProvidesFont('Hello'), 381 | AppstreamProvidesModalias('usb:*'), 382 | AppstreamProvidesFirmware(AppstreamFirmwareType.runtime, 'hello.fw'), 383 | AppstreamProvidesPython2('modhello'), 384 | AppstreamProvidesPython3('modhello3'), 385 | AppstreamProvidesDBus( 386 | AppstreamDBusType.system, 'com.example.Service'), 387 | AppstreamProvidesId('com.example.SimpleHello') 388 | ])); 389 | }); 390 | 391 | test('collection - languages - xml', () async { 392 | var collection = AppstreamCollection.fromXml( 393 | ''' 394 | 395 | com.example.Hello 396 | hello 397 | Hello World 398 | A simple example application 399 | 400 | en 401 | de 402 | 403 | 404 | 405 | '''); 406 | expect(collection.components, hasLength(1)); 407 | var component = collection.components[0]; 408 | expect( 409 | component.languages, 410 | equals([ 411 | AppstreamLanguage('en'), 412 | AppstreamLanguage('de', percentage: 42) 413 | ])); 414 | }); 415 | 416 | test('collection - content-rating - xml', () async { 417 | var collection = AppstreamCollection.fromXml( 418 | ''' 419 | 420 | com.example.Hello 421 | hello 422 | Hello World 423 | A simple example application 424 | 425 | moderate 426 | mild 427 | 428 | 429 | 430 | '''); 431 | expect(collection.components, hasLength(1)); 432 | var component = collection.components[0]; 433 | expect( 434 | component.contentRatings, 435 | equals({ 436 | 'oars-1.0': { 437 | 'drugs-alcohol': AppstreamContentRating.moderate, 438 | 'language-humor': AppstreamContentRating.mild 439 | } 440 | })); 441 | }); 442 | 443 | test('collection - empty yaml', () async { 444 | expect(() => AppstreamCollection.fromYaml(''), throwsFormatException); 445 | }); 446 | 447 | test('collection - invalid yaml', () async { 448 | expect(() => AppstreamCollection.fromYaml('---\nFile: NotTheRightThing\n'), 449 | throwsFormatException); 450 | }); 451 | 452 | test('collection - yaml with duplicate mapping keys', () async { 453 | var collection = AppstreamCollection.fromYaml("""--- 454 | File: DEP-11 455 | Version: '0.12' 456 | Origin: ubuntu-hirsute-main 457 | --- 458 | Type: console-application 459 | ID: com.example.Hello 460 | Package: hello 461 | Name: 462 | C: Hello World 463 | Summary: 464 | C: A simple example application 465 | ContentRating: 466 | oars-1.1: {} 467 | oars-1.1: {} 468 | """); 469 | expect(collection.components, isEmpty); 470 | }); 471 | 472 | test('collection - empty - yaml', () async { 473 | var collection = AppstreamCollection.fromYaml("""--- 474 | File: DEP-11 475 | Version: '0.12' 476 | Origin: ubuntu-hirsute-main 477 | """); 478 | expect(collection.version, equals('0.12')); 479 | expect(collection.origin, equals('ubuntu-hirsute-main')); 480 | expect(collection.architecture, isNull); 481 | expect(collection.priority, isNull); 482 | expect(collection.components, isEmpty); 483 | }); 484 | 485 | test('collection - architecture - yaml', () async { 486 | var collection = AppstreamCollection.fromYaml("""--- 487 | File: DEP-11 488 | Version: '0.12' 489 | Origin: ubuntu-hirsute-main 490 | Architecture: arm64 491 | """); 492 | expect(collection.architecture, equals('arm64')); 493 | }); 494 | 495 | test('collection - priority - yaml', () async { 496 | var collection = AppstreamCollection.fromYaml("""--- 497 | File: DEP-11 498 | Version: '0.12' 499 | Origin: ubuntu-hirsute-main 500 | Priority: 42 501 | """); 502 | expect(collection.priority, equals(42)); 503 | }); 504 | 505 | test('collection - single - yaml', () async { 506 | var collection = AppstreamCollection.fromYaml("""--- 507 | File: DEP-11 508 | Version: '0.12' 509 | Origin: ubuntu-hirsute-main 510 | --- 511 | Type: console-application 512 | ID: com.example.Hello 513 | Package: hello 514 | Name: 515 | C: Hello World 516 | Summary: 517 | C: A simple example application 518 | """); 519 | expect(collection.components, hasLength(1)); 520 | var component = collection.components[0]; 521 | expect(component.id, equals('com.example.Hello')); 522 | expect(component.type, equals(AppstreamComponentType.consoleApplication)); 523 | expect(component.package, equals('hello')); 524 | expect(component.name, equals({'C': 'Hello World'})); 525 | expect(component.summary, equals({'C': 'A simple example application'})); 526 | expect(component.description, isEmpty); 527 | expect(component.developerName, isEmpty); 528 | expect(component.projectLicense, isNull); 529 | expect(component.projectGroup, isNull); 530 | expect(component.icons, isEmpty); 531 | expect(component.urls, isEmpty); 532 | expect(component.categories, isEmpty); 533 | expect(component.keywords, isEmpty); 534 | expect(component.screenshots, isEmpty); 535 | expect(component.compulsoryForDesktops, isEmpty); 536 | expect(component.provides, isEmpty); 537 | expect(component.releases, isEmpty); 538 | expect(component.languages, isEmpty); 539 | expect(component.contentRatings, isEmpty); 540 | }); 541 | 542 | test('collection - optional fields - yaml', () async { 543 | var collection = AppstreamCollection.fromYaml("""--- 544 | File: DEP-11 545 | Version: '0.12' 546 | Origin: ubuntu-hirsute-main 547 | --- 548 | Type: console-application 549 | ID: com.example.Hello 550 | Package: hello 551 | Name: 552 | C: Hello World 553 | Summary: 554 | C: A simple example application 555 | Description: 556 | C: >- 557 |

The best thing since sliced bread

558 | DeveloperName: 559 | C: The Developer 560 | ProjectLicense: GPL-3 561 | ProjectGroup: GNOME 562 | CompulsoryForDesktops: 563 | - GNOME 564 | - KDE 565 | """); 566 | expect(collection.components, hasLength(1)); 567 | var component = collection.components[0]; 568 | expect(component.description, 569 | equals({'C': '

The best thing since sliced bread

'})); 570 | expect(component.developerName, equals({'C': 'The Developer'})); 571 | expect(component.projectLicense, equals('GPL-3')); 572 | expect(component.projectGroup, equals('GNOME')); 573 | expect(component.compulsoryForDesktops, equals(['GNOME', 'KDE'])); 574 | }); 575 | 576 | test('collection - icons - yaml', () async { 577 | var collection = AppstreamCollection.fromYaml("""--- 578 | File: DEP-11 579 | Version: '0.12' 580 | Origin: ubuntu-hirsute-main 581 | MediaBaseUrl: https://example.com/images 582 | --- 583 | Type: console-application 584 | ID: com.example.Hello 585 | Package: hello 586 | Name: 587 | C: Hello World 588 | Summary: 589 | C: A simple example application 590 | Icon: 591 | stock: stock-name 592 | cached: 593 | - name: icon.png 594 | width: 8 595 | height: 16 596 | local: 597 | - name: /path/to/icon.png 598 | width: 32 599 | height: 48 600 | remote: 601 | - url: https://example.com/icon.png 602 | width: 64 603 | height: 128 604 | - url: icon-big.png 605 | width: 256 606 | height: 256 607 | """); 608 | expect(collection.components, hasLength(1)); 609 | var component = collection.components[0]; 610 | expect(component.id, equals('com.example.Hello')); 611 | expect(component.type, equals(AppstreamComponentType.consoleApplication)); 612 | expect(component.package, equals('hello')); 613 | expect(component.name, equals({'C': 'Hello World'})); 614 | expect(component.summary, equals({'C': 'A simple example application'})); 615 | expect( 616 | component.icons, 617 | equals([ 618 | AppstreamStockIcon('stock-name'), 619 | AppstreamCachedIcon('icon.png', width: 8, height: 16), 620 | AppstreamLocalIcon('/path/to/icon.png', width: 32, height: 48), 621 | AppstreamRemoteIcon('https://example.com/icon.png', 622 | width: 64, height: 128), 623 | AppstreamRemoteIcon('https://example.com/images/icon-big.png', 624 | width: 256, height: 256) 625 | ])); 626 | }); 627 | 628 | test('collection - urls - yaml', () async { 629 | var collection = AppstreamCollection.fromYaml("""--- 630 | File: DEP-11 631 | Version: '0.12' 632 | Origin: ubuntu-hirsute-main 633 | --- 634 | Type: console-application 635 | ID: com.example.Hello 636 | Package: hello 637 | Name: 638 | C: Hello World 639 | Summary: 640 | C: A simple example application 641 | Url: 642 | homepage: https://example.com 643 | help: https://example.com/help 644 | contact: 645 | """); 646 | expect(collection.components, hasLength(1)); 647 | var component = collection.components[0]; 648 | expect( 649 | component.urls, 650 | equals([ 651 | AppstreamUrl('https://example.com', type: AppstreamUrlType.homepage), 652 | AppstreamUrl('https://example.com/help', type: AppstreamUrlType.help), 653 | AppstreamUrl('', type: AppstreamUrlType.contact) 654 | ])); 655 | }); 656 | 657 | test('collection - launchables - yaml', () async { 658 | var collection = AppstreamCollection.fromYaml("""--- 659 | File: DEP-11 660 | Version: '0.12' 661 | Origin: ubuntu-hirsute-main 662 | --- 663 | Type: console-application 664 | ID: com.example.Hello 665 | Package: hello 666 | Name: 667 | C: Hello World 668 | Summary: 669 | C: A simple example application 670 | Launchable: 671 | desktop-id: 672 | - com.example.Hello1 673 | - com.example.Hello2 674 | url: 675 | - https://example.com/launch 676 | """); 677 | expect(collection.components, hasLength(1)); 678 | var component = collection.components[0]; 679 | expect( 680 | component.launchables, 681 | equals([ 682 | AppstreamLaunchableDesktopId('com.example.Hello1'), 683 | AppstreamLaunchableDesktopId('com.example.Hello2'), 684 | AppstreamLaunchableUrl('https://example.com/launch') 685 | ])); 686 | }); 687 | 688 | test('collection - categories - yaml', () async { 689 | var collection = AppstreamCollection.fromYaml("""--- 690 | File: DEP-11 691 | Version: '0.12' 692 | Origin: ubuntu-hirsute-main 693 | --- 694 | Type: console-application 695 | ID: com.example.Hello 696 | Package: hello 697 | Name: 698 | C: Hello World 699 | Summary: 700 | C: A simple example application 701 | Categories: 702 | - Game 703 | - ArcadeGame 704 | """); 705 | expect(collection.components, hasLength(1)); 706 | var component = collection.components[0]; 707 | expect(component.categories, equals(['Game', 'ArcadeGame'])); 708 | }); 709 | 710 | test('collection - keywords - yaml', () async { 711 | var collection = AppstreamCollection.fromYaml("""--- 712 | File: DEP-11 713 | Version: '0.12' 714 | Origin: ubuntu-hirsute-main 715 | --- 716 | Type: console-application 717 | ID: com.example.Hello 718 | Package: hello 719 | Name: 720 | C: Hello World 721 | Summary: 722 | C: A simple example application 723 | Keywords: 724 | C: 725 | - Hello 726 | - Welcome 727 | de_DE: 728 | - Hallo 729 | - Wilkommen 730 | """); 731 | expect(collection.components, hasLength(1)); 732 | var component = collection.components[0]; 733 | expect( 734 | component.keywords, 735 | equals({ 736 | 'C': ['Hello', 'Welcome'], 737 | 'de_DE': ['Hallo', 'Wilkommen'] 738 | })); 739 | }); 740 | 741 | test('collection - keywords (null values) - yaml', () async { 742 | // Test for https://github.com/canonical/appstream.dart/issues/22 743 | var collection = AppstreamCollection.fromYaml("""--- 744 | File: DEP-11 745 | Version: '0.12' 746 | Origin: ubuntu-hirsute-main 747 | --- 748 | Type: console-application 749 | ID: com.example.Hello 750 | Package: hello 751 | Name: 752 | C: Hello World 753 | Summary: 754 | C: A simple example application 755 | Keywords: 756 | C: 757 | - Hello 758 | - Welcome 759 | de_DE: 760 | - Hallo 761 | - 762 | - Wilkommen 763 | """); 764 | expect(collection.components, hasLength(1)); 765 | var component = collection.components[0]; 766 | expect( 767 | component.keywords, 768 | equals({ 769 | 'C': ['Hello', 'Welcome'], 770 | 'de_DE': ['Hallo', 'Wilkommen'] 771 | })); 772 | }); 773 | 774 | test('collection - screenshot - yaml', () async { 775 | var collection = AppstreamCollection.fromYaml("""--- 776 | File: DEP-11 777 | Version: '0.12' 778 | Origin: ubuntu-hirsute-main 779 | MediaBaseUrl: https://example.com/images 780 | --- 781 | Type: console-application 782 | ID: com.example.Hello 783 | Package: hello 784 | Name: 785 | C: Hello World 786 | Summary: 787 | C: A simple example application 788 | Screenshots: 789 | - caption: 790 | C: A screenshot 791 | thumbnails: 792 | - url: https://example.com/thumbnail-big.jpg 793 | width: 512 794 | height: 384 795 | - url: thumbnail-small.jpg 796 | width: 256 797 | height: 192 798 | lang: en_NZ 799 | source-image: 800 | url: screenshot.jpg 801 | width: 1024 802 | height: 768 803 | """); 804 | expect(collection.components, hasLength(1)); 805 | var component = collection.components[0]; 806 | expect( 807 | component.screenshots, 808 | equals([ 809 | AppstreamScreenshot(images: [ 810 | AppstreamImage( 811 | type: AppstreamImageType.thumbnail, 812 | url: 'https://example.com/thumbnail-big.jpg', 813 | width: 512, 814 | height: 384), 815 | AppstreamImage( 816 | type: AppstreamImageType.thumbnail, 817 | url: 'https://example.com/images/thumbnail-small.jpg', 818 | width: 256, 819 | height: 192, 820 | lang: 'en_NZ'), 821 | AppstreamImage( 822 | type: AppstreamImageType.source, 823 | url: 'https://example.com/images/screenshot.jpg', 824 | width: 1024, 825 | height: 768) 826 | ], caption: { 827 | 'C': 'A screenshot' 828 | }) 829 | ])); 830 | }); 831 | 832 | test('collection - screenshots - yaml', () async { 833 | var collection = AppstreamCollection.fromYaml("""--- 834 | File: DEP-11 835 | Version: '0.12' 836 | Origin: ubuntu-hirsute-main 837 | --- 838 | Type: console-application 839 | ID: com.example.Hello 840 | Package: hello 841 | Name: 842 | C: Hello World 843 | Summary: 844 | C: A simple example application 845 | Screenshots: 846 | - source-image: 847 | url: https://example.com/screenshot1.jpg 848 | - default: true 849 | source-image: 850 | url: https://example.com/screenshot2.jpg 851 | """); 852 | expect(collection.components, hasLength(1)); 853 | var component = collection.components[0]; 854 | expect( 855 | component.screenshots, 856 | equals([ 857 | AppstreamScreenshot(images: [ 858 | AppstreamImage( 859 | type: AppstreamImageType.source, 860 | url: 'https://example.com/screenshot1.jpg', 861 | ) 862 | ]), 863 | AppstreamScreenshot(images: [ 864 | AppstreamImage( 865 | type: AppstreamImageType.source, 866 | url: 'https://example.com/screenshot2.jpg') 867 | ], isDefault: true) 868 | ])); 869 | }); 870 | 871 | test('collection - releases - yaml', () async { 872 | var collection = AppstreamCollection.fromYaml("""--- 873 | File: DEP-11 874 | Version: '0.12' 875 | Origin: ubuntu-hirsute-main 876 | --- 877 | Type: console-application 878 | ID: com.example.Hello 879 | Package: hello 880 | Name: 881 | C: Hello World 882 | Summary: 883 | C: A simple example application 884 | Releases: 885 | - version: '1.2' 886 | date: 2014-04-12 887 | urgency: high 888 | description: 889 | C: This stable release fixes bugs. 890 | url: 891 | details: https://example.com/releases/version-1.2.html 892 | issues: 893 | - id: '#123' 894 | url: https://github.com/example/example/issues/123 895 | - id: CVE-2019-123456 896 | type: cve 897 | - version: '1.1' 898 | type: development 899 | date: 2013-10-20 900 | - version: 1.0 901 | unix-timestamp: 1345939200 902 | - unix-timestamp: 1234567890 903 | """); 904 | expect(collection.components, hasLength(1)); 905 | var component = collection.components[0]; 906 | expect( 907 | component.releases, 908 | equals([ 909 | AppstreamRelease( 910 | version: '1.2', 911 | date: DateTime(2014, 4, 12), 912 | urgency: AppstreamReleaseUrgency.high, 913 | description: {'C': 'This stable release fixes bugs.'}, 914 | url: 'https://example.com/releases/version-1.2.html', 915 | issues: [ 916 | AppstreamIssue('#123', 917 | url: 'https://github.com/example/example/issues/123'), 918 | AppstreamIssue('CVE-2019-123456', type: AppstreamIssueType.cve) 919 | ]), 920 | AppstreamRelease( 921 | version: '1.1', 922 | type: AppstreamReleaseType.development, 923 | date: DateTime(2013, 10, 20)), 924 | AppstreamRelease(version: '1.0', date: DateTime.utc(2012, 8, 26)), 925 | AppstreamRelease(date: DateTime.utc(2009, 2, 13, 23, 31, 30)), 926 | ])); 927 | }); 928 | 929 | test('collection - provides - yaml', () async { 930 | var collection = AppstreamCollection.fromYaml("""--- 931 | File: DEP-11 932 | Version: '0.12' 933 | Origin: ubuntu-hirsute-main 934 | --- 935 | Type: console-application 936 | ID: com.example.Hello 937 | Package: hello 938 | Name: 939 | C: Hello World 940 | Summary: 941 | C: A simple example application 942 | Provides: 943 | mediatypes: 944 | - text/html 945 | - image/png 946 | libraries: 947 | - libhello.so.1 948 | binaries: 949 | - hello 950 | fonts: 951 | - name: Hello 952 | modaliases: 953 | - usb:* 954 | firmware: 955 | - type: runtime 956 | file: hello.fw 957 | python2: 958 | - modhello 959 | python3: 960 | - modhello3 961 | dbus: 962 | - type: system 963 | service: com.example.Service 964 | ids: 965 | - com.example.SimpleHello 966 | """); 967 | expect(collection.components, hasLength(1)); 968 | var component = collection.components[0]; 969 | expect( 970 | component.provides, 971 | equals([ 972 | AppstreamProvidesMediatype('text/html'), 973 | AppstreamProvidesMediatype('image/png'), 974 | AppstreamProvidesLibrary('libhello.so.1'), 975 | AppstreamProvidesBinary('hello'), 976 | AppstreamProvidesFont('Hello'), 977 | AppstreamProvidesModalias('usb:*'), 978 | AppstreamProvidesFirmware(AppstreamFirmwareType.runtime, 'hello.fw'), 979 | AppstreamProvidesPython2('modhello'), 980 | AppstreamProvidesPython3('modhello3'), 981 | AppstreamProvidesDBus( 982 | AppstreamDBusType.system, 'com.example.Service'), 983 | AppstreamProvidesId('com.example.SimpleHello') 984 | ])); 985 | }); 986 | 987 | test('collection - languages - yaml', () async { 988 | var collection = AppstreamCollection.fromYaml("""--- 989 | File: DEP-11 990 | Version: '0.12' 991 | Origin: ubuntu-hirsute-main 992 | --- 993 | Type: console-application 994 | ID: com.example.Hello 995 | Package: hello 996 | Name: 997 | C: Hello World 998 | Summary: 999 | C: A simple example application 1000 | Languages: 1001 | - locale: en 1002 | - locale: de 1003 | percentage: 42 1004 | """); 1005 | expect(collection.components, hasLength(1)); 1006 | var component = collection.components[0]; 1007 | expect( 1008 | component.languages, 1009 | equals([ 1010 | AppstreamLanguage('en'), 1011 | AppstreamLanguage('de', percentage: 42) 1012 | ])); 1013 | }); 1014 | 1015 | test('collection - content-rating - yaml', () async { 1016 | var collection = AppstreamCollection.fromYaml("""--- 1017 | File: DEP-11 1018 | Version: '0.12' 1019 | Origin: ubuntu-hirsute-main 1020 | --- 1021 | Type: console-application 1022 | ID: com.example.Hello 1023 | Package: hello 1024 | Name: 1025 | C: Hello World 1026 | Summary: 1027 | C: A simple example application 1028 | ContentRating: 1029 | oars-1.0: 1030 | drugs-alcohol: moderate 1031 | language-humor: mild 1032 | """); 1033 | expect(collection.components, hasLength(1)); 1034 | var component = collection.components[0]; 1035 | expect( 1036 | component.contentRatings, 1037 | equals({ 1038 | 'oars-1.0': { 1039 | 'drugs-alcohol': AppstreamContentRating.moderate, 1040 | 'language-humor': AppstreamContentRating.mild 1041 | } 1042 | })); 1043 | }); 1044 | } 1045 | -------------------------------------------------------------------------------- /lib/src/collection.dart: -------------------------------------------------------------------------------- 1 | import 'package:xml/xml.dart'; 2 | import 'package:yaml/yaml.dart'; 3 | 4 | import 'component.dart'; 5 | import 'icon.dart'; 6 | import 'language.dart'; 7 | import 'launchable.dart'; 8 | import 'provides.dart'; 9 | import 'release.dart'; 10 | import 'screenshot.dart'; 11 | import 'url.dart'; 12 | 13 | /// A collection of Appstream components. 14 | class AppstreamCollection { 15 | /// The Appstream version these components comply with. 16 | final String version; 17 | 18 | /// The repository these components come from, e.g. 'ubuntu-hirsute-main' 19 | final String origin; 20 | 21 | /// The architecture these components are for, e.g. 'arm64'. 22 | final String? architecture; 23 | 24 | /// The priorization of this metadata file over other metadata. 25 | final int? priority; 26 | 27 | /// The components in this collection. 28 | final List components; 29 | 30 | /// Creates a new Appstream collection. 31 | AppstreamCollection( 32 | {this.version = '0.14', 33 | required this.origin, 34 | this.architecture, 35 | this.priority, 36 | Iterable components = const []}) 37 | : components = List.from(components); 38 | 39 | /// Decodes an Appstream collection in XML format. 40 | factory AppstreamCollection.fromXml(String xml) { 41 | var document = XmlDocument.parse(xml); 42 | 43 | var root = document.getElement('components'); 44 | if (root == null) { 45 | throw FormatException("XML document doesn't contain components tag"); 46 | } 47 | 48 | var version = root.getAttribute('version'); 49 | if (version == null) { 50 | throw FormatException('Missing AppStream version'); 51 | } 52 | var origin = root.getAttribute('origin'); 53 | if (origin == null) { 54 | throw FormatException('Missing repository origin'); 55 | } 56 | var architecture = root.getAttribute('architecture'); 57 | 58 | var components = []; 59 | for (var component in root.children 60 | .whereType() 61 | .where((e) => e.name.local == 'component')) { 62 | var typeName = component.getAttribute('type'); 63 | if (typeName == null) { 64 | throw FormatException('Missing component type'); 65 | } 66 | var type = _parseComponentType(typeName); 67 | 68 | var id = component.getElement('id'); 69 | if (id == null) { 70 | throw FormatException('Missing component ID'); 71 | } 72 | var package = component.getElement('pkgname'); 73 | if (package == null) { 74 | throw FormatException('Missing component package'); 75 | } 76 | var name = _getXmlTranslatedString(component, 'name'); 77 | var summary = _getXmlTranslatedString(component, 'summary'); 78 | var description = _getXmlTranslatedString(component, 'description'); 79 | var developerName = _getXmlTranslatedString(component, 'developer_name'); 80 | var projectLicense = component.getElement('project_license')?.innerText; 81 | var projectGroup = component.getElement('project_group')?.innerText; 82 | 83 | var elements = component.children.whereType(); 84 | 85 | var icons = []; 86 | for (var icon in elements.where((e) => e.name.local == 'icon')) { 87 | var type = icon.getAttribute('type'); 88 | if (type == null) { 89 | throw FormatException('Missing icon type'); 90 | } 91 | var w = icon.getAttribute('width'); 92 | var width = w != null ? int.parse(w) : null; 93 | var h = icon.getAttribute('height'); 94 | var height = h != null ? int.parse(h) : null; 95 | switch (type) { 96 | case 'stock': 97 | icons.add(AppstreamStockIcon(icon.innerText)); 98 | break; 99 | case 'cached': 100 | icons.add(AppstreamCachedIcon(icon.innerText, 101 | width: width, height: height)); 102 | break; 103 | case 'local': 104 | icons.add(AppstreamLocalIcon(icon.innerText, 105 | width: width, height: height)); 106 | break; 107 | case 'remote': 108 | icons.add(AppstreamRemoteIcon(icon.innerText, 109 | width: width, height: height)); 110 | break; 111 | } 112 | } 113 | 114 | var urls = []; 115 | for (var url in elements.where((e) => e.name.local == 'url')) { 116 | var typeName = url.getAttribute('type'); 117 | if (typeName == null) { 118 | throw FormatException('Missing Url type'); 119 | } 120 | urls.add(AppstreamUrl(url.innerText, type: _parseUrlType(typeName))); 121 | } 122 | 123 | var launchables = []; 124 | for (var launchable 125 | in elements.where((e) => e.name.local == 'launchable')) { 126 | switch (launchable.getAttribute('type')) { 127 | case 'desktop-id': 128 | launchables.add(AppstreamLaunchableDesktopId(launchable.innerText)); 129 | break; 130 | case 'service': 131 | launchables.add(AppstreamLaunchableService(launchable.innerText)); 132 | break; 133 | case 'cockpit-manifest': 134 | launchables 135 | .add(AppstreamLaunchableCockpitManifest(launchable.innerText)); 136 | break; 137 | case 'url': 138 | launchables.add(AppstreamLaunchableUrl(launchable.innerText)); 139 | break; 140 | } 141 | } 142 | 143 | var categories = []; 144 | var categoriesElement = component.getElement('categories'); 145 | if (categoriesElement != null) { 146 | categories = categoriesElement.children 147 | .whereType() 148 | .where((e) => e.name.local == 'category') 149 | .map((e) => e.innerText) 150 | .toList(); 151 | } 152 | 153 | var keywords = >{}; 154 | for (var keywordsElement 155 | in elements.where((e) => e.name.local == 'keywords')) { 156 | var lang = keywordsElement.getAttribute('xml:lang') ?? 'C'; 157 | keywords[lang] = keywordsElement.children 158 | .whereType() 159 | .where((e) => e.name.local == 'keyword') 160 | .map((e) => e.innerText) 161 | .toList(); 162 | } 163 | 164 | var screenshots = []; 165 | for (var screenshot 166 | in elements.where((e) => e.name.local == 'screenshot')) { 167 | var isDefault = screenshot.getAttribute('type') == 'default'; 168 | var caption = _getXmlTranslatedString(screenshot, 'caption'); 169 | var images = []; 170 | for (var imageElement in screenshot.children 171 | .whereType() 172 | .where((e) => e.name.local == 'image')) { 173 | var typeName = imageElement.getAttribute('type'); 174 | if (typeName == null) { 175 | throw FormatException('Missing image type'); 176 | } 177 | var type = { 178 | 'source': AppstreamImageType.source, 179 | 'thumbnail': AppstreamImageType.thumbnail 180 | }[typeName]; 181 | if (type == null) { 182 | throw FormatException('Unknown image type'); 183 | } 184 | var w = imageElement.getAttribute('width'); 185 | var width = w != null ? int.parse(w) : null; 186 | var h = imageElement.getAttribute('height'); 187 | var height = h != null ? int.parse(h) : null; 188 | var lang = imageElement.getAttribute('xml:lang'); 189 | images.add(AppstreamImage( 190 | type: type, 191 | url: imageElement.innerText, 192 | width: width, 193 | height: height, 194 | lang: lang)); 195 | } 196 | screenshots.add(AppstreamScreenshot( 197 | images: images, caption: caption, isDefault: isDefault)); 198 | } 199 | 200 | var compulsoryForDesktops = elements 201 | .where((e) => e.name.local == 'compulsory_for_desktop') 202 | .map((e) => e.innerText) 203 | .toList(); 204 | 205 | var releases = []; 206 | var releasesElement = component.getElement('releases'); 207 | if (releasesElement != null) { 208 | for (var release in releasesElement.children 209 | .whereType() 210 | .where((e) => e.name.local == 'release')) { 211 | var version = release.getAttribute('version'); 212 | DateTime? date; 213 | var dateAttribute = release.getAttribute('date'); 214 | var unixTimestamp = release.getAttribute('timestamp'); 215 | if (unixTimestamp != null) { 216 | date = DateTime.fromMillisecondsSinceEpoch( 217 | int.parse(unixTimestamp) * 1000, 218 | isUtc: true); 219 | } else if (dateAttribute != null) { 220 | date = DateTime.parse(dateAttribute); 221 | } 222 | AppstreamReleaseType? type; 223 | var typeName = release.getAttribute('type'); 224 | if (typeName != null) { 225 | type = _parseReleaseType(typeName); 226 | } 227 | AppstreamReleaseUrgency? urgency; 228 | var urgencyName = release.getAttribute('urgency'); 229 | if (urgencyName != null) { 230 | urgency = _parseReleaseUrgency(urgencyName); 231 | } 232 | var description = _getXmlTranslatedString(release, 'description'); 233 | var urlElement = release.getElement('url'); 234 | var url = urlElement?.innerText; 235 | 236 | var issues = []; 237 | var issuesElement = release.getElement('issues'); 238 | if (issuesElement != null) { 239 | for (var issue in issuesElement.children 240 | .whereType() 241 | .where((e) => e.name.local == 'issue')) { 242 | AppstreamIssueType? type; 243 | var typeName = issue.getAttribute('type'); 244 | if (typeName != null) { 245 | type = _parseIssueType(typeName); 246 | } 247 | var url = issue.getAttribute('url'); 248 | issues.add(AppstreamIssue(issue.innerText, 249 | type: type ?? AppstreamIssueType.generic, url: url)); 250 | } 251 | } 252 | 253 | releases.add(AppstreamRelease( 254 | version: version, 255 | date: date, 256 | type: type ?? AppstreamReleaseType.stable, 257 | urgency: urgency ?? AppstreamReleaseUrgency.medium, 258 | description: description, 259 | url: url, 260 | issues: issues)); 261 | } 262 | } 263 | 264 | var provides = []; 265 | var providesElement = component.getElement('provides'); 266 | if (providesElement != null) { 267 | for (var element in providesElement.children.whereType()) { 268 | switch (element.name.local) { 269 | case 'mediatype': 270 | provides.add(AppstreamProvidesMediatype(element.innerText)); 271 | break; 272 | case 'library': 273 | provides.add(AppstreamProvidesLibrary(element.innerText)); 274 | break; 275 | case 'binary': 276 | provides.add(AppstreamProvidesBinary(element.innerText)); 277 | break; 278 | case 'font': 279 | provides.add(AppstreamProvidesFont(element.innerText)); 280 | break; 281 | case 'modalias': 282 | provides.add(AppstreamProvidesModalias(element.innerText)); 283 | break; 284 | case 'firmware': 285 | var typeName = element.getAttribute('type'); 286 | if (typeName == null) { 287 | throw FormatException('Missing firmware type'); 288 | } 289 | var type = { 290 | 'runtime': AppstreamFirmwareType.runtime, 291 | 'flashed': AppstreamFirmwareType.flashed 292 | }[typeName]; 293 | if (type == null) { 294 | throw FormatException('Unknown firmware type $typeName'); 295 | } 296 | provides.add(AppstreamProvidesFirmware(type, element.innerText)); 297 | break; 298 | case 'python2': 299 | provides.add(AppstreamProvidesPython2(element.innerText)); 300 | break; 301 | case 'python3': 302 | provides.add(AppstreamProvidesPython3(element.innerText)); 303 | break; 304 | case 'dbus': 305 | var type = element.getAttribute('type'); 306 | if (type == null) { 307 | throw FormatException('Missing DBus bus type'); 308 | } 309 | provides.add(AppstreamProvidesDBus( 310 | _parseDBusType(type), element.innerText)); 311 | break; 312 | case 'id': 313 | provides.add(AppstreamProvidesId(element.innerText)); 314 | break; 315 | } 316 | } 317 | } 318 | 319 | var languages = []; 320 | var languagesElement = component.getElement('languages'); 321 | if (languagesElement != null) { 322 | for (var language in languagesElement.children 323 | .whereType() 324 | .where((e) => e.name.local == 'lang')) { 325 | var percentage = language.getAttribute('percentage'); 326 | languages.add(AppstreamLanguage(language.innerText, 327 | percentage: percentage != null ? int.parse(percentage) : null)); 328 | } 329 | } 330 | 331 | var contentRatings = >{}; 332 | for (var contentRating 333 | in elements.where((e) => e.name.local == 'content_rating')) { 334 | var type = contentRating.getAttribute('type'); 335 | if (type == null) { 336 | throw FormatException('Missing content rating type'); 337 | } 338 | var ratings = {}; 339 | for (var contentAttribute in contentRating.children 340 | .whereType() 341 | .where((e) => e.name.local == 'content_attribute')) { 342 | var id = contentAttribute.getAttribute('id'); 343 | if (id == null) { 344 | throw FormatException('Missing content attribute id'); 345 | } 346 | ratings[id] = _parseContentRating(contentAttribute.innerText); 347 | } 348 | contentRatings[type] = ratings; 349 | } 350 | 351 | components.add(AppstreamComponent( 352 | id: id.innerText, 353 | type: type, 354 | package: package.innerText, 355 | name: name, 356 | summary: summary, 357 | description: description, 358 | developerName: developerName, 359 | projectLicense: projectLicense, 360 | projectGroup: projectGroup, 361 | icons: icons, 362 | urls: urls, 363 | launchables: launchables, 364 | categories: categories, 365 | keywords: keywords, 366 | screenshots: screenshots, 367 | compulsoryForDesktops: compulsoryForDesktops, 368 | releases: releases, 369 | provides: provides, 370 | languages: languages, 371 | contentRatings: contentRatings)); 372 | } 373 | 374 | return AppstreamCollection( 375 | version: version, 376 | origin: origin, 377 | architecture: architecture, 378 | components: components); 379 | } 380 | 381 | // Very dumb removal of invalid YAML documents. 382 | // See https://github.com/canonical/appstream.dart/issues/15. 383 | // Fixing these documents would be much costlier and error-prone, 384 | // hence this simplistic approach to just filter out invalid documents. 385 | static String _removeInvalidDocuments(String yaml) { 386 | String processNode(String document) { 387 | try { 388 | loadYamlDocument(document); 389 | return document; 390 | } on YamlException { 391 | return ''; 392 | } 393 | } 394 | 395 | final documentSeparator = '\n---\n'; 396 | final documents = yaml.split(documentSeparator); 397 | for (var i = 0; i < documents.length; ++i) { 398 | documents[i] = processNode(documents[i]); 399 | } 400 | return documents.where((e) => e.isNotEmpty).join(documentSeparator); 401 | } 402 | 403 | /// Decodes an Appstream collection in YAML format. 404 | factory AppstreamCollection.fromYaml(String yaml) { 405 | var yamlDocuments = loadYamlDocuments(_removeInvalidDocuments(yaml)); 406 | if (yamlDocuments.isEmpty) { 407 | throw FormatException('Empty YAML file'); 408 | } 409 | var header = yamlDocuments[0]; 410 | if (header.contents is! YamlMap) { 411 | throw FormatException('Invalid DEP-11 header'); 412 | } 413 | var headerMap = (header.contents as YamlMap); 414 | var file = headerMap['File']; 415 | if (file != 'DEP-11') { 416 | throw FormatException('Not a DEP-11 file'); 417 | } 418 | var version = headerMap['Version']; 419 | if (version == null) { 420 | throw FormatException('Missing AppStream version'); 421 | } 422 | var origin = headerMap['Origin']; 423 | if (origin == null) { 424 | throw FormatException('Missing repository origin'); 425 | } 426 | var priority = headerMap['Priority']; 427 | var mediaBaseUrl = headerMap['MediaBaseUrl']; 428 | var architecture = headerMap['Architecture']; 429 | var components = []; 430 | for (var doc in yamlDocuments.skip(1)) { 431 | var component = doc.contents as YamlMap; 432 | var id = component['ID']; 433 | if (id == null) { 434 | throw FormatException('Missing component ID'); 435 | } 436 | var typeName = component['Type']; 437 | if (typeName == null) { 438 | throw FormatException('Missing component type'); 439 | } 440 | var type = _parseComponentType(typeName); 441 | var package = component['Package']; 442 | var name = component['Name']; 443 | if (name == null) { 444 | throw FormatException('Missing component name'); 445 | } 446 | var summary = component['Summary']; 447 | if (summary == null) { 448 | throw FormatException('Missing component summary'); 449 | } 450 | var description = component['Description']; 451 | var developerName = component['DeveloperName']; 452 | var projectLicense = component['ProjectLicense']; 453 | var projectGroup = component['ProjectGroup']; 454 | 455 | var icons = []; 456 | var icon = component['Icon']; 457 | if (icon != null) { 458 | for (var type in icon.keys) { 459 | switch (type) { 460 | case 'stock': 461 | icons.add(AppstreamStockIcon(icon[type])); 462 | break; 463 | case 'cached': 464 | for (var i in icon[type]) { 465 | icons.add(AppstreamCachedIcon(i['name'], 466 | width: i['width'], height: i['height'])); 467 | } 468 | break; 469 | case 'local': 470 | for (var i in icon[type]) { 471 | icons.add(AppstreamLocalIcon(i['name'], 472 | width: i['width'], height: i['height'])); 473 | } 474 | break; 475 | case 'remote': 476 | for (var i in icon[type]) { 477 | icons.add(AppstreamRemoteIcon(_makeUrl(mediaBaseUrl, i['url']), 478 | width: i['width'], height: i['height'])); 479 | } 480 | break; 481 | } 482 | } 483 | } 484 | 485 | var urls = []; 486 | var url = component['Url']; 487 | if (url != null) { 488 | for (var typeName in url.keys) { 489 | urls.add( 490 | AppstreamUrl(url[typeName] ?? '', type: _parseUrlType(typeName))); 491 | } 492 | } 493 | 494 | var launchables = []; 495 | var launchable = component['Launchable']; 496 | if (launchable != null) { 497 | if (launchable is! YamlMap) { 498 | throw FormatException('Invalid Launchable type'); 499 | } 500 | for (var typeName in launchable.keys) { 501 | var launchableList = launchable[typeName]; 502 | if (launchableList is! YamlList) { 503 | throw FormatException('Invalid Launchable type'); 504 | } 505 | switch (typeName) { 506 | case 'desktop-id': 507 | launchables.addAll( 508 | launchableList.map((l) => AppstreamLaunchableDesktopId(l))); 509 | break; 510 | case 'service': 511 | launchables.addAll( 512 | launchableList.map((l) => AppstreamLaunchableService(l))); 513 | break; 514 | case 'cockpit-manifest': 515 | launchables.addAll(launchableList 516 | .map((l) => AppstreamLaunchableCockpitManifest(l))); 517 | break; 518 | case 'url': 519 | launchables 520 | .addAll(launchableList.map((l) => AppstreamLaunchableUrl(l))); 521 | break; 522 | } 523 | } 524 | } 525 | 526 | var categories = []; 527 | var categoriesComponent = component['Categories']; 528 | if (categoriesComponent != null) { 529 | if (categoriesComponent is! YamlList) { 530 | throw FormatException('Invalid Categories type'); 531 | } 532 | categories.addAll(categoriesComponent.cast()); 533 | } 534 | 535 | var keywords = >{}; 536 | var keywordsComponent = component['Keywords']; 537 | if (keywordsComponent != null) { 538 | if (keywordsComponent is! YamlMap) { 539 | throw FormatException('Invalid Keywords type'); 540 | } 541 | keywords = keywordsComponent.map( 542 | (lang, keywordList) => MapEntry( 543 | lang, 544 | keywordList.nodes 545 | .where((e) => e.value != null) 546 | .map((e) => e.value.toString()) 547 | .toList(), 548 | ), 549 | ); 550 | } 551 | 552 | var screenshots = []; 553 | var screenshotsComponent = component['Screenshots']; 554 | if (screenshotsComponent != null) { 555 | if (screenshotsComponent is! YamlList) { 556 | throw FormatException('Invalid Screenshots type'); 557 | } 558 | for (var screenshot in screenshotsComponent) { 559 | var isDefault = screenshot['default'] ?? 'false' == 'true'; 560 | var caption = screenshot['caption']; 561 | var images = []; 562 | var thumbnails = screenshot['thumbnails']; 563 | if (thumbnails != null) { 564 | if (thumbnails is! YamlList) { 565 | throw FormatException('Invalid thumbnails type'); 566 | } 567 | for (var thumbnail in thumbnails) { 568 | var url = thumbnail['url']; 569 | if (url == null) { 570 | throw FormatException('Image missing Url'); 571 | } 572 | var width = thumbnail['width']; 573 | var height = thumbnail['height']; 574 | var lang = thumbnail['lang']; 575 | images.add(AppstreamImage( 576 | type: AppstreamImageType.thumbnail, 577 | url: _makeUrl(mediaBaseUrl, url), 578 | width: width, 579 | height: height, 580 | lang: lang)); 581 | } 582 | } 583 | var sourceImage = screenshot['source-image']; 584 | if (sourceImage != null) { 585 | var url = sourceImage['url']; 586 | if (url == null) { 587 | throw FormatException('Image missing Url'); 588 | } 589 | var width = sourceImage['width']; 590 | var height = sourceImage['height']; 591 | var lang = sourceImage['lang']; 592 | images.add(AppstreamImage( 593 | type: AppstreamImageType.source, 594 | url: _makeUrl(mediaBaseUrl, url), 595 | width: width, 596 | height: height, 597 | lang: lang)); 598 | } 599 | screenshots.add(AppstreamScreenshot( 600 | images: images, 601 | caption: caption != null 602 | ? _parseYamlTranslatedString(caption) 603 | : const {}, 604 | isDefault: isDefault)); 605 | } 606 | } 607 | 608 | var compulsoryForDesktops = []; 609 | var compulsoryForDesktopsComponent = component['CompulsoryForDesktops']; 610 | if (compulsoryForDesktopsComponent != null) { 611 | if (compulsoryForDesktopsComponent is! YamlList) { 612 | throw FormatException('Invalid CompulsoryForDesktops type'); 613 | } 614 | compulsoryForDesktops 615 | .addAll(compulsoryForDesktopsComponent.cast()); 616 | } 617 | 618 | var releases = []; 619 | var releasesComponent = component['Releases']; 620 | if (releasesComponent != null) { 621 | if (releasesComponent is! YamlList) { 622 | throw FormatException('Invalid Releases type'); 623 | } 624 | for (var release in releasesComponent) { 625 | if (release is! YamlMap) { 626 | throw FormatException('Invalid release type'); 627 | } 628 | var version = release['version']; 629 | DateTime? date; 630 | var dateAttribute = release['date']; 631 | var unixTimestamp = release['unix-timestamp']; 632 | if (unixTimestamp != null) { 633 | date = DateTime.fromMillisecondsSinceEpoch(unixTimestamp * 1000, 634 | isUtc: true); 635 | } else if (dateAttribute != null) { 636 | date = DateTime.parse(dateAttribute); 637 | } 638 | AppstreamReleaseType? type; 639 | var typeName = release['type']; 640 | if (typeName != null) { 641 | type = _parseReleaseType(typeName); 642 | } 643 | AppstreamReleaseUrgency? urgency; 644 | var urgencyName = release['urgency']; 645 | if (urgencyName != null) { 646 | urgency = _parseReleaseUrgency(urgencyName); 647 | } 648 | var description = release['description']; 649 | var url = release['url']?['details']; 650 | var issues = []; 651 | var issuesComponent = release['issues']; 652 | if (issuesComponent != null) { 653 | if (issuesComponent is! YamlList) { 654 | throw FormatException('Invalid issues type'); 655 | } 656 | for (var issue in issuesComponent) { 657 | if (issue is! YamlMap) { 658 | throw FormatException('Invalid issue type'); 659 | } 660 | var id = issue['id']; 661 | if (id == null) { 662 | throw FormatException('Issue missing id'); 663 | } 664 | AppstreamIssueType? type; 665 | var typeName = issue['type']; 666 | if (typeName != null) { 667 | type = _parseIssueType(typeName); 668 | } 669 | var url = issue['url']; 670 | issues.add(AppstreamIssue(id, 671 | type: type ?? AppstreamIssueType.generic, url: url)); 672 | } 673 | } 674 | releases.add(AppstreamRelease( 675 | version: _parseYamlVersion(version), 676 | date: date, 677 | type: type ?? AppstreamReleaseType.stable, 678 | urgency: urgency ?? AppstreamReleaseUrgency.medium, 679 | description: description != null 680 | ? _parseYamlTranslatedString(description) 681 | : const {}, 682 | url: url, 683 | issues: issues)); 684 | } 685 | } 686 | 687 | var provides = []; 688 | var providesComponent = component['Provides']; 689 | if (providesComponent != null) { 690 | if (providesComponent is! YamlMap) { 691 | throw FormatException('Invalid Provides type'); 692 | } 693 | for (var type in providesComponent.keys) { 694 | var values = providesComponent[type]; 695 | if (values is! YamlList) { 696 | throw FormatException('Invalid $type provides'); 697 | } 698 | switch (type) { 699 | case 'mediatypes': 700 | case 'mimetypes': 701 | provides.addAll(values.map((e) => AppstreamProvidesMediatype(e))); 702 | break; 703 | case 'libraries': 704 | provides.addAll(values.map((e) => AppstreamProvidesLibrary(e))); 705 | break; 706 | case 'binaries': 707 | provides.addAll(values.map((e) => AppstreamProvidesBinary(e))); 708 | break; 709 | case 'fonts': 710 | for (var fontComponent in values) { 711 | if (fontComponent is! YamlMap) { 712 | throw FormatException('Invalid font provides'); 713 | } 714 | var name = fontComponent['name']; 715 | if (name == null) { 716 | throw FormatException('Missing font name'); 717 | } 718 | provides.add(AppstreamProvidesFont(name)); 719 | } 720 | break; 721 | case 'firmware': 722 | for (var firmwareComponent in values) { 723 | if (firmwareComponent is! YamlMap) { 724 | throw FormatException('Invalid firmware provides'); 725 | } 726 | var type = firmwareComponent['type']; 727 | switch (type) { 728 | case 'runtime': 729 | var file = firmwareComponent['file']; 730 | if (file == null) { 731 | throw FormatException('Missing firmware file'); 732 | } 733 | provides.add(AppstreamProvidesFirmware( 734 | AppstreamFirmwareType.runtime, file)); 735 | break; 736 | case 'flashed': 737 | var guid = firmwareComponent['guid']; 738 | if (guid == null) { 739 | throw FormatException('Missing firmware guid'); 740 | } 741 | provides.add(AppstreamProvidesFirmware( 742 | AppstreamFirmwareType.flashed, guid)); 743 | break; 744 | } 745 | } 746 | break; 747 | case 'python2': 748 | for (var moduleName in values) { 749 | provides.add(AppstreamProvidesPython2(moduleName)); 750 | } 751 | break; 752 | case 'python3': 753 | for (var moduleName in values) { 754 | provides.add(AppstreamProvidesPython3(moduleName)); 755 | } 756 | break; 757 | case 'modaliases': 758 | for (var modalias in values) { 759 | provides.add(AppstreamProvidesModalias(modalias)); 760 | } 761 | break; 762 | case 'dbus': 763 | for (var dbusComponent in values) { 764 | if (dbusComponent is! YamlMap) { 765 | throw FormatException('Invalid dbus provides'); 766 | } 767 | var type = dbusComponent['type']; 768 | if (type == null) { 769 | throw FormatException('Missing DBus bus type'); 770 | } 771 | var service = dbusComponent['service']; 772 | if (service == null) { 773 | throw FormatException('Missing DBus service name'); 774 | } 775 | provides 776 | .add(AppstreamProvidesDBus(_parseDBusType(type), service)); 777 | } 778 | break; 779 | case 'ids': 780 | provides.addAll(values.map((e) => AppstreamProvidesId(e))); 781 | break; 782 | } 783 | } 784 | } 785 | 786 | var languages = []; 787 | var languagesComponent = component['Languages']; 788 | if (languagesComponent != null) { 789 | if (languagesComponent is! YamlList) { 790 | throw FormatException('Invalid Languages type'); 791 | } 792 | 793 | for (var language in languagesComponent) { 794 | if (language is! YamlMap) { 795 | throw FormatException('Invalid language type'); 796 | } 797 | var locale = language['locale']; 798 | if (locale == null) { 799 | throw FormatException('Missing language locale'); 800 | } 801 | var percentage = language['percentage']; 802 | languages.add(AppstreamLanguage(locale, percentage: percentage)); 803 | } 804 | } 805 | 806 | var contentRatings = >{}; 807 | var contentRatingComponent = component['ContentRating']; 808 | if (contentRatingComponent != null) { 809 | if (contentRatingComponent is! YamlMap) { 810 | throw FormatException('Invalid ContentRating type'); 811 | } 812 | for (var type in contentRatingComponent.keys) { 813 | contentRatings[type] = contentRatingComponent[type] 814 | .map((key, value) => 815 | MapEntry(key as String, _parseContentRating(value))); 816 | } 817 | } 818 | 819 | components.add(AppstreamComponent( 820 | id: id, 821 | type: type, 822 | package: package, 823 | name: _parseYamlTranslatedString(name), 824 | summary: _parseYamlTranslatedString(summary), 825 | description: description != null 826 | ? _parseYamlTranslatedString(description) 827 | : const {}, 828 | developerName: developerName != null 829 | ? _parseYamlTranslatedString(developerName) 830 | : const {}, 831 | projectLicense: projectLicense, 832 | projectGroup: projectGroup, 833 | icons: icons, 834 | urls: urls, 835 | launchables: launchables, 836 | categories: categories, 837 | keywords: keywords, 838 | screenshots: screenshots, 839 | compulsoryForDesktops: compulsoryForDesktops, 840 | releases: releases, 841 | provides: provides, 842 | languages: languages, 843 | contentRatings: contentRatings)); 844 | } 845 | 846 | return AppstreamCollection( 847 | version: _parseYamlVersion(version)!, 848 | origin: origin, 849 | architecture: architecture, 850 | priority: priority, 851 | components: components); 852 | } 853 | 854 | @override 855 | String toString() => "$runtimeType(version: $version, origin: '$origin')"; 856 | } 857 | 858 | String? _parseYamlVersion(dynamic value) { 859 | if (value is double) { 860 | return value.toString(); 861 | } else { 862 | return value as String?; 863 | } 864 | } 865 | 866 | Map _parseYamlTranslatedString(dynamic value) { 867 | if (value is YamlMap) { 868 | return value.cast(); 869 | } else { 870 | throw FormatException('Invalid type for translated string'); 871 | } 872 | } 873 | 874 | Map _getXmlTranslatedString(XmlElement parent, String name) { 875 | var value = {}; 876 | for (var element in parent.children 877 | .whereType() 878 | .where((e) => e.name.local == name)) { 879 | var lang = element.getAttribute('lang') ?? 'C'; 880 | value[lang] = element.innerXml; 881 | } 882 | 883 | return value; 884 | } 885 | 886 | String _makeUrl(String? mediaBaseUrl, String url) { 887 | if (mediaBaseUrl == null) { 888 | return url; 889 | } 890 | 891 | if (url.startsWith('http:') || url.startsWith('https:')) { 892 | return url; 893 | } 894 | 895 | return '$mediaBaseUrl/$url'; 896 | } 897 | 898 | AppstreamComponentType _parseComponentType(String typeName) { 899 | return { 900 | 'generic': AppstreamComponentType.generic, 901 | 'desktop-application': AppstreamComponentType.desktopApplication, 902 | 'console-application': AppstreamComponentType.consoleApplication, 903 | 'web-application': AppstreamComponentType.webApplication, 904 | 'addon': AppstreamComponentType.addon, 905 | 'font': AppstreamComponentType.font, 906 | 'codec': AppstreamComponentType.codec, 907 | 'inputmethod': AppstreamComponentType.inputMethod, 908 | 'firmware': AppstreamComponentType.firmware, 909 | 'driver': AppstreamComponentType.driver, 910 | 'localization': AppstreamComponentType.localization, 911 | 'service': AppstreamComponentType.service, 912 | 'repository': AppstreamComponentType.repository, 913 | 'operating-system': AppstreamComponentType.operatingSystem, 914 | 'icon-theme': AppstreamComponentType.iconTheme, 915 | 'runtime': AppstreamComponentType.runtime 916 | }[typeName] ?? 917 | AppstreamComponentType.unknown; 918 | } 919 | 920 | AppstreamUrlType _parseUrlType(String typeName) { 921 | var type = { 922 | 'homepage': AppstreamUrlType.homepage, 923 | 'bugtracker': AppstreamUrlType.bugtracker, 924 | 'faq': AppstreamUrlType.faq, 925 | 'help': AppstreamUrlType.help, 926 | 'donation': AppstreamUrlType.donation, 927 | 'translate': AppstreamUrlType.translate, 928 | 'contact': AppstreamUrlType.contact, 929 | 'vcs-browser': AppstreamUrlType.vcsBrowser, 930 | 'contribute': AppstreamUrlType.contribute 931 | }[typeName]; 932 | if (type == null) { 933 | throw FormatException("Unknown url type '$typeName'"); 934 | } 935 | return type; 936 | } 937 | 938 | AppstreamReleaseType _parseReleaseType(String typeName) { 939 | var type = { 940 | 'stable': AppstreamReleaseType.stable, 941 | 'development': AppstreamReleaseType.development 942 | }[typeName]; 943 | if (type == null) { 944 | throw FormatException("Unknown release type '$typeName'"); 945 | } 946 | return type; 947 | } 948 | 949 | AppstreamReleaseUrgency _parseReleaseUrgency(String urgencyName) { 950 | var urgency = { 951 | 'low': AppstreamReleaseUrgency.low, 952 | 'medium': AppstreamReleaseUrgency.medium, 953 | 'high': AppstreamReleaseUrgency.high, 954 | 'critical': AppstreamReleaseUrgency.critical 955 | }[urgencyName]; 956 | if (urgency == null) { 957 | throw FormatException("Unknown release urgency '$urgencyName'"); 958 | } 959 | return urgency; 960 | } 961 | 962 | AppstreamIssueType _parseIssueType(String typeName) { 963 | var type = { 964 | 'generic': AppstreamIssueType.generic, 965 | 'cve': AppstreamIssueType.cve 966 | }[typeName]; 967 | if (type == null) { 968 | throw FormatException("Unknown issue type '$typeName'"); 969 | } 970 | return type; 971 | } 972 | 973 | AppstreamDBusType _parseDBusType(String typeName) { 974 | var type = { 975 | 'user': AppstreamDBusType.user, 976 | 'system': AppstreamDBusType.system 977 | }[typeName]; 978 | if (type == null) { 979 | throw FormatException("Unknown DBus type '$typeName'"); 980 | } 981 | return type; 982 | } 983 | 984 | AppstreamContentRating _parseContentRating(String ratingName) { 985 | var rating = { 986 | 'none': AppstreamContentRating.none, 987 | 'mild': AppstreamContentRating.mild, 988 | 'moderate': AppstreamContentRating.moderate, 989 | 'intense': AppstreamContentRating.intense 990 | }[ratingName]; 991 | if (rating == null) { 992 | throw FormatException("Unknown content rating '$ratingName'"); 993 | } 994 | return rating; 995 | } 996 | --------------------------------------------------------------------------------