Skip to content

Commit

Permalink
feat: add packagekit service
Browse files Browse the repository at this point in the history
  • Loading branch information
d-loose committed Sep 12, 2023
1 parent a0f3e2b commit 601b074
Show file tree
Hide file tree
Showing 7 changed files with 1,387 additions and 139 deletions.
1 change: 1 addition & 0 deletions lib/packagekit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'src/packagekit/packagekit_service.dart';
129 changes: 129 additions & 0 deletions lib/src/packagekit/packagekit_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import 'dart:async';

import 'package:dbus/dbus.dart';
import 'package:flutter/material.dart';
import 'package:packagekit/packagekit.dart';
import 'package:ubuntu_service/ubuntu_service.dart';

typedef PackageKitPackageInfo = PackageKitPackageEvent;

class PackageKitService {
PackageKitService({
@visibleForTesting PackageKitClient? client,
@visibleForTesting DBusClient? dbus,
}) : _client = client ?? getService<PackageKitClient>(),
_dbus = dbus ?? DBusClient.system();
final PackageKitClient _client;
final DBusClient _dbus;

bool get isAvailable => _isAvailable;
bool _isAvailable = false;

int _nextId = 0;
final Map<int, PackageKitTransaction> _transactions = {};

// To be used to access the transaction properties (e.g. progress) of transactions
// that run in the background for longer (e.g. installing a package).
// The respective methods will return a transaction ID, similar to how methods
// in the snap client return change IDs.
PackageKitTransaction? getTransaction(int id) => _transactions[id];

/// Explicitly activates the PackageKit service in case it is not running.
/// Prevents AppArmor denials when trying to call a well-known method while
/// the daemon is inactive.
/// See https://github.com/ubuntu/app-center/issues/1215
/// and https://forum.snapcraft.io/t/apparmor-denial-in-new-snap-store-despite-connected-packagekit-control-interface/35290
Future<void> activateService() async {
if (_isAvailable) return;

final object = DBusRemoteObject(
_dbus,
name: 'org.freedesktop.DBus',
path: DBusObjectPath('/org/freedesktop/DBus'),
);
await object.callMethod(
'org.freedesktop.DBus',
'StartServiceByName',
const [DBusString('org.freedesktop.PackageKit'), DBusUint32(0)],
);
try {
await _client.connect();
_isAvailable = true;
} on DBusServiceUnknownException catch (_) {}
}

/// Creates a new `PackageKitTransaction` and invokes `action` on it, if
/// provided. If a `listener` is provided it will receive the `PackageKitEvent`s
/// from the transaction.
/// Returns an internal transaction id.
Future<int> _createTransaction({
Future<void> Function(PackageKitTransaction transaction)? action,
void Function(PackageKitEvent event)? listener,
}) async {
final transaction = await _client.createTransaction();
final id = _nextId++;
_transactions[id] = transaction;

late final StreamSubscription subscription;
subscription = transaction.events.listen((event) {
listener?.call(event);
if (event is PackageKitFinishedEvent || event is PackageKitDestroyEvent) {
_transactions.remove(id);
subscription.cancel();
}
});
await action?.call(transaction);
return id;
}

/// Waits until the transaction specified by the internal `id` has finished.
Future<void> waitTransaction(int id) async {
if (!_transactions.keys.contains(id)) return;

final completer = Completer();
final subscription = _transactions[id]!.events.listen(
(event) {
if (event is PackageKitFinishedEvent ||
event is PackageKitDestroyEvent) {
completer.complete();
}
},
onDone: completer.complete,
);
await completer.future;
await subscription.cancel();
}

/// Creates a transaction that installs the package given by `packageId` and
/// returns the transaction ID.
Future<int> install(PackageKitPackageId packageId) async =>
_createTransaction(
action: (transaction) => transaction.installPackages([packageId]),
);

/// Creates a transaction that removes the package given by `packageId` and
/// returns the transaction ID.
// TODO: Decide how to handle dependencies. Autoremove? Ask the user?
Future<int> remove(PackageKitPackageId packageId) async => _createTransaction(
action: (transaction) => transaction.removePackages([packageId]),
);

/// Resolves a single package name provided by `name`.
Future<PackageKitPackageInfo?> resolve(String name) async {
PackageKitPackageInfo? info;
await _createTransaction(
action: (transaction) => transaction.resolve([name]),
listener: (event) {
if (event is PackageKitPackageEvent) {
info = event;
}
},
).then(waitTransaction);
return info;
}

Future<void> dispose() async {
await _dbus.close();
await _client.close();
}
}
2 changes: 2 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies:
args: ^2.4.2
cached_network_image: ^3.2.3
collection: ^1.17.0
dbus: ^0.7.8
file: ^6.1.0
flutter:
sdk: flutter
Expand All @@ -27,6 +28,7 @@ dependencies:
intl: ^0.18.0
meta: ^1.9.1
package_info_plus: ^4.0.2
packagekit: ^0.2.6
path: ^1.8.3
shimmer: ^3.0.0
snapcraft_launcher: ^0.1.0
Expand Down
132 changes: 132 additions & 0 deletions test/packagekit_service_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'dart:async';

import 'package:app_center/src/packagekit/packagekit_service.dart';
import 'package:dbus/dbus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:packagekit/packagekit.dart';

import 'packagekit_service_test.mocks.dart';
import 'test_utils.dart';

void main() {
group('activate service', () {
test('service available', () async {
final dbus = createMockDbusClient();
final packageKit =
PackageKitService(dbus: dbus, client: createMockPackageKitClient());
expect(packageKit.isAvailable, isFalse);
await packageKit.activateService();
verify(dbus.callMethod(
path: DBusObjectPath('/org/freedesktop/DBus'),
destination: 'org.freedesktop.DBus',
name: 'StartServiceByName',
interface: 'org.freedesktop.DBus',
values: const [DBusString('org.freedesktop.PackageKit'), DBusUint32(0)],
)).called(1);
expect(packageKit.isAvailable, isTrue);

await packageKit.activateService();
verifyNever(dbus.callMethod(
path: DBusObjectPath('/org/freedesktop/DBus'),
destination: 'org.freedesktop.DBus',
name: 'StartServiceByName',
interface: 'org.freedesktop.DBus',
values: const [DBusString('org.freedesktop.PackageKit'), DBusUint32(0)],
));
});

test('service unavailable', () async {
final dbus = createMockDbusClient();
final client = createMockPackageKitClient();
when(client.connect()).thenThrow(
DBusServiceUnknownException(
DBusMethodErrorResponse('org.freedesktop.DBus.Error.ServiceUnknown'),
),
);
final packageKit = PackageKitService(dbus: dbus, client: client);
expect(packageKit.isAvailable, isFalse);
await packageKit.activateService();
verify(dbus.callMethod(
path: DBusObjectPath('/org/freedesktop/DBus'),
destination: 'org.freedesktop.DBus',
name: 'StartServiceByName',
interface: 'org.freedesktop.DBus',
values: const [DBusString('org.freedesktop.PackageKit'), DBusUint32(0)],
)).called(1);
expect(packageKit.isAvailable, isFalse);
});
});

test('install', () async {
final completer = Completer();
final mockTransaction = createMockPackageKitTransaction(
start: completer.future,
);
final mockClient = createMockPackageKitClient(transaction: mockTransaction);
final packageKit =
PackageKitService(dbus: createMockDbusClient(), client: mockClient);
await packageKit.activateService();
final id = await packageKit
.install(const PackageKitPackageId(name: 'foo', version: '1.0'));
verify(mockTransaction.installPackages(
[const PackageKitPackageId(name: 'foo', version: '1.0')])).called(1);
final transaction = packageKit.getTransaction(id);
expect(transaction, isNotNull);
completer.complete();
await packageKit.waitTransaction(id);
expect(packageKit.getTransaction(id), isNull);
});

test('remove', () async {
final completer = Completer();
final mockTransaction = createMockPackageKitTransaction(
start: completer.future,
);
final mockClient = createMockPackageKitClient(transaction: mockTransaction);
final packageKit =
PackageKitService(dbus: createMockDbusClient(), client: mockClient);
await packageKit.activateService();
final id = await packageKit
.remove(const PackageKitPackageId(name: 'foo', version: '1.0'));
verify(mockTransaction.removePackages(
[const PackageKitPackageId(name: 'foo', version: '1.0')])).called(1);
final transaction = packageKit.getTransaction(id);
expect(transaction, isNotNull);
completer.complete();
await packageKit.waitTransaction(id);
expect(packageKit.getTransaction(id), isNull);
});

test('resolve', () async {
const mockInfo = PackageKitPackageEvent(
info: PackageKitInfo.available,
packageId: PackageKitPackageId(name: 'foo', version: '1.0'),
summary: 'summary',
);
final mockTransaction = createMockPackageKitTransaction(
events: [mockInfo],
);
final mockClient = createMockPackageKitClient(transaction: mockTransaction);
final packageKit =
PackageKitService(dbus: createMockDbusClient(), client: mockClient);
await packageKit.activateService();
final info = await packageKit.resolve('foo');
verify(mockTransaction.resolve(['foo'])).called(1);
expect(info, equals(mockInfo));
});
}

@GenerateMocks([DBusClient])
MockDBusClient createMockDbusClient() {
final dbus = MockDBusClient();
when(dbus.callMethod(
path: DBusObjectPath('/org/freedesktop/DBus'),
destination: 'org.freedesktop.DBus',
name: 'StartServiceByName',
interface: 'org.freedesktop.DBus',
values: const [DBusString('org.freedesktop.PackageKit'), DBusUint32(0)],
)).thenAnswer((_) async => DBusMethodSuccessResponse());
return dbus;
}
Loading

0 comments on commit 601b074

Please sign in to comment.