diff --git a/packages/shorebird_cli/lib/src/archive_analysis/plist.dart b/packages/shorebird_cli/lib/src/archive_analysis/plist.dart index 4bac17a02..01b7cde5b 100644 --- a/packages/shorebird_cli/lib/src/archive_analysis/plist.dart +++ b/packages/shorebird_cli/lib/src/archive_analysis/plist.dart @@ -53,4 +53,8 @@ class Plist { ? releaseVersion : '$releaseVersion+$buildNumber'; } + + @override + String toString() => + PropertyListSerialization.stringWithPropertyList(properties); } diff --git a/packages/shorebird_cli/lib/src/doctor.dart b/packages/shorebird_cli/lib/src/doctor.dart index f092b8642..efc8962b2 100644 --- a/packages/shorebird_cli/lib/src/doctor.dart +++ b/packages/shorebird_cli/lib/src/doctor.dart @@ -26,7 +26,7 @@ class Doctor { /// Validators that verify shorebird will work on macOS. final List macosCommandValidators = [ - // TODO(bryanoltman): ensure app has network capabilities + MacosNetworkEntitlementValidator(), ]; /// Validators that should run on all commands. @@ -34,6 +34,7 @@ class Doctor { ShorebirdVersionValidator(), ShorebirdFlutterValidator(), AndroidInternetPermissionValidator(), + MacosNetworkEntitlementValidator(), ShorebirdYamlAssetValidator(), ]; @@ -46,7 +47,7 @@ class Doctor { final allIssues = []; final allFixableIssues = []; - var numIssuesFixed = 0; + var totalIssuesFixed = 0; for (final validator in validators) { if (!validator.canRunInCurrentContext()) { continue; @@ -76,13 +77,15 @@ class Doctor { // Re-run the validator to see if there are any remaining issues that // we couldn't fix. unresolvedIssues = await validator.validate(); - if (unresolvedIssues.isEmpty) { - numIssuesFixed += issues.length - unresolvedIssues.length; + final numIssuesFixed = issues.length - unresolvedIssues.length; + if (numIssuesFixed > 0) { + totalIssuesFixed += numIssuesFixed; final fixAppliedMessage = '''($numIssuesFixed fix${numIssuesFixed == 1 ? '' : 'es'} applied)'''; validatorProgress.complete( '''${validator.description} ${green.wrap(fixAppliedMessage)}''', ); + continue; } } else { @@ -125,7 +128,7 @@ class Doctor { allIssues.addAll(unresolvedIssues); } - if (numIssuesFixed > 0) { + if (totalIssuesFixed > 0) { return; } diff --git a/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart b/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart new file mode 100644 index 000000000..2831fc3b6 --- /dev/null +++ b/packages/shorebird_cli/lib/src/validators/macos_network_entitlement_validator.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/archive_analysis/plist.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; + +/// Checks that the macOS app has the network client entitlement. Without this +/// entitlement, the app will not be able to make network requests, and +/// Shorebird will not be able to check for patches. +class MacosNetworkEntitlementValidator extends Validator { + /// The plist key for the Outgoing Connections (Client) entitlement, which + /// allows macOS apps to make network requests. + static const networkClientEntitlementKey = + 'com.apple.security.network.client'; + + Directory? get _macosDirectory { + final projectRoot = shorebirdEnv.getFlutterProjectRoot(); + if (projectRoot == null) { + return null; + } + + return Directory(p.join(projectRoot.path, 'macos')); + } + + /// The entitlements plist file for the release build. This lives in + /// project_root/macos/Runner/Release.entitlements, where "Runner" may have + /// been renamed. + File? get _releaseEntitlementsPlist { + final entitlementParentCandidateDirectories = + _macosDirectory!.listSync().whereType(); + + for (final appDir in entitlementParentCandidateDirectories) { + final entitlementsPlist = File( + p.join(appDir.path, 'Release.entitlements'), + ); + + if (entitlementsPlist.existsSync()) { + return entitlementsPlist; + } + } + + return null; + } + + @override + String get description => 'macOS app has Outgoing Connections entitlement'; + + @override + bool canRunInCurrentContext() => + _macosDirectory != null && _macosDirectory!.existsSync(); + + @override + Future> validate() async { + if (_releaseEntitlementsPlist == null) { + return [ + const ValidationIssue( + severity: ValidationIssueSeverity.error, + message: 'Unable to find a Release.entitlements file', + ), + ]; + } + + if (!hasNetworkClientEntitlement(plistFile: _releaseEntitlementsPlist!)) { + return [ + ValidationIssue( + severity: ValidationIssueSeverity.error, + message: + '''${_releaseEntitlementsPlist!.path} is missing the Outgoing Connections ($networkClientEntitlementKey) entitlement.''', + fix: () => addNetworkEntitlementToPlist(_releaseEntitlementsPlist!), + ), + ]; + } + + return []; + } + + /// Whether the given entitlements plist file has the network client + /// entitlement. + @visibleForTesting + static bool hasNetworkClientEntitlement({required File plistFile}) => + Plist(file: plistFile).properties[networkClientEntitlementKey] == true; + + /// Adds the network client entitlement to the given entitlements plist file. + @visibleForTesting + static void addNetworkEntitlementToPlist(File entitlementsPlist) { + final plist = Plist(file: entitlementsPlist); + plist.properties[networkClientEntitlementKey] = true; + entitlementsPlist.writeAsStringSync(plist.toString()); + } +} diff --git a/packages/shorebird_cli/lib/src/validators/validators.dart b/packages/shorebird_cli/lib/src/validators/validators.dart index 41b18c0a7..bb7825651 100644 --- a/packages/shorebird_cli/lib/src/validators/validators.dart +++ b/packages/shorebird_cli/lib/src/validators/validators.dart @@ -6,6 +6,7 @@ import 'package:shorebird_cli/src/shorebird_process.dart'; export 'android_internet_permission_validator.dart'; export 'flavor_validator.dart'; +export 'macos_network_entitlement_validator.dart'; export 'shorebird_flutter_validator.dart'; export 'shorebird_version_validator.dart'; export 'shorebird_yaml_asset_validator.dart'; diff --git a/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart b/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart new file mode 100644 index 000000000..7468bec91 --- /dev/null +++ b/packages/shorebird_cli/test/src/validators/macos_network_entitlement_validator_test.dart @@ -0,0 +1,266 @@ +import 'dart:io'; + +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + group(MacosNetworkEntitlementValidator, () { + const entitlementsPlistWithoutEntitlement = ''' + + + + + com.apple.security.app-sandbox + + + +'''; + + const entitlementsPlistWithEntitlement = ''' + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +'''; + + late Directory projectRoot; + late ShorebirdEnv shorebirdEnv; + late MacosNetworkEntitlementValidator validator; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + }, + ); + } + + File releaseEntitlementsFile() => File( + p.join(projectRoot.path, 'macos', 'Runner', 'Release.entitlements'), + ); + + void setUpProjectRoot({String? entitlements}) { + Directory( + p.join(projectRoot.path, 'macos', 'Runner'), + ).createSync(recursive: true); + + if (entitlements != null) { + releaseEntitlementsFile() + ..createSync(recursive: true) + ..writeAsStringSync(entitlements); + } + } + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync(); + shorebirdEnv = MockShorebirdEnv(); + + when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(projectRoot); + + validator = MacosNetworkEntitlementValidator(); + }); + + group('description', () { + test('returns the correct description', () { + expect( + runWithOverrides(() => validator.description), + 'macOS app has Outgoing Connections entitlement', + ); + }); + }); + + group('canRunInCurrentContext', () { + group('when macos directory exists', () { + setUp(setUpProjectRoot); + + test('returns true', () { + expect( + runWithOverrides(() => validator.canRunInCurrentContext()), + isTrue, + ); + }); + }); + + group('when macos directory does not exist', () { + test('returns false', () { + expect( + runWithOverrides(() => validator.canRunInCurrentContext()), + isFalse, + ); + }); + }); + }); + + group('validate', () { + group('when release entitlements plist does not exist', () { + setUp(setUpProjectRoot); + + test('returns a validation issue with no fix', () async { + final issues = await runWithOverrides(() => validator.validate()); + expect(issues, hasLength(1)); + expect( + issues[0], + equals( + const ValidationIssue( + severity: ValidationIssueSeverity.error, + message: 'Unable to find a Release.entitlements file', + ), + ), + ); + }); + }); + + group('when release entitlements plist exists', () { + group('when network client entitlement is missing', () { + setUp(() { + setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement); + }); + + test('returns a validation issue with a fix', () async { + final validationResults = await runWithOverrides( + () => validator.validate(), + ); + expect(validationResults, hasLength(1)); + final issue = validationResults[0]; + expect(issue.severity, ValidationIssueSeverity.error); + expect( + issue.message, + contains( + '''is missing the Outgoing Connections (com.apple.security.network.client) entitlement.''', + ), + ); + expect(issue.fix, isNotNull); + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isFalse, + ); + runWithOverrides(() => issue.fix!()); + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isTrue, + ); + }); + }); + + group('when network client entitlement is present', () { + setUp(() { + setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement); + }); + + test('returns an empty list', () async { + expect(await runWithOverrides(() => validator.validate()), isEmpty); + }); + }); + }); + }); + + group('fix', () { + group('when the network client entitlement is missing', () { + setUp(() { + setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement); + }); + + test('adds the network client entitlement to the entitlements plist', + () { + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isFalse, + ); + + MacosNetworkEntitlementValidator.addNetworkEntitlementToPlist( + releaseEntitlementsFile(), + ); + + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isTrue, + ); + }); + }); + + group('when the network client entitlement is present', () { + setUp(() { + setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement); + }); + + test('does not modify the entitlements plist', () { + final plistContents = releaseEntitlementsFile().readAsStringSync(); + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isTrue, + ); + + MacosNetworkEntitlementValidator.addNetworkEntitlementToPlist( + releaseEntitlementsFile(), + ); + + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isTrue, + ); + + final updatedPlistContents = + releaseEntitlementsFile().readAsStringSync(); + expect(updatedPlistContents, plistContents); + }); + }); + }); + + group('plistHasNetworkClientEntitlement', () { + group('when entitlement is present', () { + setUp(() { + setUpProjectRoot(entitlements: entitlementsPlistWithEntitlement); + }); + + test('returns true', () { + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isTrue, + ); + }); + }); + + group('when entitlement is not present', () { + setUp(() { + setUpProjectRoot(entitlements: entitlementsPlistWithoutEntitlement); + }); + + test('returns false', () { + expect( + MacosNetworkEntitlementValidator.hasNetworkClientEntitlement( + plistFile: releaseEntitlementsFile(), + ), + isFalse, + ); + }); + }); + }); + }); +}