commit 24dcfcf70df9fa31821b1d5a577e7c68aaadaba9 Author: Philip Peterson <1326208+philip-peterson@users.noreply.github.com> Date: Sat Oct 10 20:12:15 2026 -0800 Taproom: a Flutter desktop frontend for Homebrew Browse installed packages, upgrade outdated ones, search formulae and casks, and explore taps, all by driving the brew CLI. Includes a Taskfile for running, testing and building. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5f0c86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ + +# go-task checksum cache +.task/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..edae6a4 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "abaf9c523780a608bd46686fd5e53740a07077f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + - platform: macos + create_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + base_revision: abaf9c523780a608bd46686fd5e53740a07077f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2e69eb --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Taproom + +A Flutter desktop frontend for Homebrew: browse what's installed, see and +apply upgrades, search formulae and casks, and explore your taps. + +It drives the `brew` CLI directly (JSON output for queries, streamed output +for installs), so it needs Homebrew at `/opt/homebrew` or `/usr/local` and +runs **without the macOS App Sandbox** (see `macos/Runner/*.entitlements`), +since a sandboxed app can't launch `brew`. + +Casks whose installers need `sudo` prompt for your password via a native +dialog (a `SUDO_ASKPASS` helper written to the temp dir at launch). + +## Develop + +Tasks are defined in `Taskfile.yml` ([go-task](https://taskfile.dev), +`brew install go-task`); run `task` to list them. + + task run # debug build; needs full Xcode, not just the Command Line Tools + task check # format check + analyze + tests (tests include read-only + # checks against your real brew) + task install # release build copied to /Applications diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..95a5163 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,82 @@ +# https://taskfile.dev — run `task` to list tasks. +version: '3' + +vars: + APP: build/macos/Build/Products/Release/taproom.app + +tasks: + default: + silent: true + cmds: + - task --list + + deps: + desc: Fetch Dart/Flutter packages + sources: [pubspec.yaml, pubspec.lock] + cmds: + - flutter pub get + + run: + desc: Run the app in debug mode (needs full Xcode) + deps: [deps] + cmds: + - flutter run -d macos + + test: + desc: Run all tests (includes read-only checks against your real brew) + deps: [deps] + cmds: + - flutter test {{.CLI_ARGS}} + + analyze: + desc: Static analysis + deps: [deps] + cmds: + - flutter analyze + + fmt: + desc: Format all Dart code + cmds: + - dart format lib test + + fmt:check: + desc: Fail if any Dart code is unformatted + cmds: + - dart format --output=none --set-exit-if-changed lib test + + check: + desc: Formatting, analysis and tests — run before committing + cmds: + - task: fmt:check + - task: analyze + - task: test + + build: + desc: Build the release .app (needs full Xcode) + deps: [deps] + cmds: + - flutter build macos --release + - echo "Built {{.APP}}" + + open: + desc: Launch the release build + deps: [build] + cmds: + - open "{{.APP}}" + + install: + desc: Copy the release build to /Applications + deps: [build] + cmds: + - rm -rf /Applications/Taproom.app + - cp -R "{{.APP}}" /Applications/Taproom.app + + doctor: + desc: Check the Flutter/Xcode toolchain + cmds: + - flutter doctor + + clean: + desc: Remove build outputs + cmds: + - flutter clean diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..ff5b08e --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - macos/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/lib/brew/brew_client.dart b/lib/brew/brew_client.dart new file mode 100644 index 0000000..7d21b09 --- /dev/null +++ b/lib/brew/brew_client.dart @@ -0,0 +1,240 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'models.dart'; + +typedef ProcessRunner = Future Function( + String executable, + List args, + Map environment, +); + +typedef ProcessStarter = Future Function( + String executable, + List args, + Map environment, +); + +class BrewException implements Exception { + BrewException(this.args, this.exitCode, this.stderr); + + final List args; + final int exitCode; + final String stderr; + + @override + String toString() => + 'brew ${args.join(' ')} exited $exitCode${stderr.isEmpty ? '' : ':\n$stderr'}'; +} + +/// Thin wrapper over the `brew` CLI. Queries go through [runner] and are +/// parsed from brew's JSON output; mutations (install, tap, ...) are +/// returned as [BrewTask]s that stream their output. +class BrewClient { + BrewClient({ + required this.brewPath, + ProcessRunner? runner, + ProcessStarter? starter, + this.askpassPath, + }) : _runner = runner ?? _defaultRunner, + _starter = starter ?? _defaultStarter; + + final String brewPath; + + /// A SUDO_ASKPASS helper, so casks that run `sudo` (pkg installers) can + /// prompt for a password without a terminal. + final String? askpassPath; + final ProcessRunner _runner; + final ProcessStarter _starter; + + static const _candidatePaths = [ + '/opt/homebrew/bin/brew', // Apple Silicon + '/usr/local/bin/brew', // Intel + '/home/linuxbrew/.linuxbrew/bin/brew', + ]; + + /// GUI apps don't inherit the shell's PATH, so look in brew's standard + /// install locations rather than relying on `which`. + static String? locate() { + for (final p in _candidatePaths) { + if (File(p).existsSync()) return p; + } + return null; + } + + static Future _defaultRunner( + String exe, + List args, + Map env, + ) => Process.run(exe, args, environment: env); + + static Future _defaultStarter( + String exe, + List args, + Map env, + ) => Process.start(exe, args, environment: env); + + Map _environment({required bool readOnly}) { + final inherited = Platform.environment; + final brewBin = File(brewPath).parent.path; + return { + ...inherited, + 'PATH': + '$brewBin:/usr/bin:/bin:/usr/sbin:/sbin' + '${inherited['PATH'] == null ? '' : ':${inherited['PATH']}'}', + 'HOMEBREW_NO_COLOR': '1', + 'HOMEBREW_NO_EMOJI': '1', + 'HOMEBREW_NO_ENV_HINTS': '1', + 'NONINTERACTIVE': '1', + // Keep queries snappy; an explicit "brew update" is one click away. + if (readOnly) 'HOMEBREW_NO_AUTO_UPDATE': '1', + 'SUDO_ASKPASS': ?askpassPath, + }; + } + + Future _run(List args, {bool allowFailure = false}) async { + final r = await _runner(brewPath, args, _environment(readOnly: true)); + if (r.exitCode != 0 && !allowFailure) { + throw BrewException(args, r.exitCode, (r.stderr as String).trim()); + } + return r.stdout as String; + } + + Future> _runJson(List args) async => + jsonDecode(await _run(args)) as Map; + + Future> installed() async => Package.listFromInfoJson( + await _runJson(['info', '--json=v2', '--installed']), + ); + + Future> outdated({bool greedy = false}) async => + OutdatedEntry.listFromJson( + await _runJson(['outdated', '--json=v2', if (greedy) '--greedy']), + ); + + Future> taps() async { + final out = await _run(['tap-info', '--json', '--installed']); + return (jsonDecode(out) as List) + .map((t) => Tap.fromJson(t as Map)) + .toList(); + } + + Future info(String name, PackageKind kind) async { + final list = Package.listFromInfoJson( + await _runJson(['info', '--json=v2', kind.flag, name]), + ); + return list.isEmpty ? null : list.first; + } + + /// Searches formulae and casks by name. Like `brew search`, a query + /// wrapped in slashes is treated as a regex. + Future> search(String query) async { + final results = await Future.wait([ + for (final kind in PackageKind.values) + // brew exits non-zero when nothing matches; that's just no results. + _run([ + 'search', + kind.flag, + query, + ], allowFailure: true).then((out) => parseSearchOutput(out, kind)), + ]); + return results.expand((r) => r).toList(); + } + + @visibleForTesting + static List parseSearchOutput(String out, PackageKind kind) => [ + for (final line in const LineSplitter().convert(out)) + if (line.trim().isNotEmpty && + !line.startsWith('==>') && + !line.contains(' ')) + SearchResult(line.trim(), kind), + ]; + + BrewTask install(String name, PackageKind kind) => + task('Install $name', ['install', kind.flag, name]); + + BrewTask uninstall(String name, PackageKind kind) => + task('Uninstall $name', ['uninstall', kind.flag, name]); + + BrewTask reinstall(String name, PackageKind kind) => + task('Reinstall $name', ['reinstall', kind.flag, name]); + + /// Upgrades one package, or everything outdated when [name] is null. + BrewTask upgrade({String? name, PackageKind? kind, bool greedy = false}) => + task(name == null ? 'Upgrade all' : 'Upgrade $name', [ + 'upgrade', + if (kind != null) kind.flag, + if (greedy) '--greedy', + ?name, + ]); + + BrewTask pin(String name, {required bool pinned}) => task( + '${pinned ? 'Pin' : 'Unpin'} $name', + [pinned ? 'pin' : 'unpin', name], + ); + + BrewTask update() => task('Update Homebrew', ['update']); + + BrewTask cleanup() => task('Clean up', ['cleanup']); + + BrewTask addTap(String name, {String? url}) => + task('Tap $name', ['tap', name, if (url != null && url.isNotEmpty) url]); + + BrewTask removeTap(String name) => task('Untap $name', ['untap', name]); + + BrewTask task(String title, List args) => BrewTask._( + title: title, + args: args, + start: () => _starter(brewPath, args, _environment(readOnly: false)), + ); +} + +/// A running (or finished) brew command whose output is streamed into +/// [lines]. Listen to it to rebuild as output arrives. +class BrewTask extends ChangeNotifier { + BrewTask._({required this.title, required this.args, required this.start}); + + final String title; + final List args; + final Future Function() start; + + final List lines = []; + int? exitCode; + Process? _process; + bool _started = false; + + bool get running => _started && exitCode == null; + bool get succeeded => exitCode == 0; + String get command => 'brew ${args.join(' ')}'; + + /// Starts the command; completes with its exit code. + Future run() async { + if (_started) throw StateError('BrewTask already started'); + _started = true; + lines.add('\$ $command'); + notifyListeners(); + try { + final p = _process = await start(); + await p.stdin.close(); + Future pump(Stream> s) => s + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((l) { + lines.add(l); + notifyListeners(); + }); + await Future.wait([pump(p.stdout), pump(p.stderr)]); + exitCode = await p.exitCode; + } catch (e) { + lines.add('Failed to run brew: $e'); + exitCode = -1; + } + notifyListeners(); + return exitCode!; + } + + void cancel() => _process?.kill(); +} diff --git a/lib/brew/models.dart b/lib/brew/models.dart new file mode 100644 index 0000000..ae36463 --- /dev/null +++ b/lib/brew/models.dart @@ -0,0 +1,195 @@ +enum PackageKind { + formula, + cask; + + /// The flag brew uses to disambiguate a name, e.g. `brew info --cask foo`. + String get flag => this == formula ? '--formula' : '--cask'; + + String get label => this == formula ? 'Formula' : 'Cask'; +} + +/// A formula or cask, as described by `brew info --json=v2`. +class Package { + const Package({ + required this.name, + required this.fullName, + required this.kind, + this.displayName, + this.tap, + this.desc, + this.homepage, + this.latestVersion, + this.installedVersion, + this.outdated = false, + this.pinned = false, + this.installedOnRequest = false, + this.dependencies = const [], + this.caveats, + this.deprecated = false, + }); + + /// Formula name or cask token; what you pass to `brew install`. + final String name; + final String fullName; + final PackageKind kind; + + /// Casks have a human name ("Firefox") separate from their token. + final String? displayName; + final String? tap; + final String? desc; + final String? homepage; + final String? latestVersion; + final String? installedVersion; + final bool outdated; + final bool pinned; + + /// False for formulae only present as a dependency of something else. + final bool installedOnRequest; + final List dependencies; + final String? caveats; + final bool deprecated; + + bool get isInstalled => installedVersion != null; + + factory Package.fromFormulaJson(Map j) { + final installs = (j['installed'] as List? ?? const []) + .cast>(); + return Package( + name: j['name'] as String, + fullName: (j['full_name'] as String?) ?? j['name'] as String, + kind: PackageKind.formula, + tap: j['tap'] as String?, + desc: j['desc'] as String?, + homepage: j['homepage'] as String?, + latestVersion: (j['versions'] as Map?)?['stable'] as String?, + installedVersion: installs.isEmpty + ? null + : installs.last['version'] as String?, + outdated: j['outdated'] == true, + pinned: j['pinned'] == true, + installedOnRequest: installs.any( + (i) => i['installed_on_request'] == true, + ), + dependencies: (j['dependencies'] as List? ?? const []).cast(), + caveats: j['caveats'] as String?, + deprecated: j['deprecated'] == true, + ); + } + + factory Package.fromCaskJson(Map j) { + final names = (j['name'] as List? ?? const []).cast(); + final installed = j['installed'] as String?; + return Package( + name: j['token'] as String, + fullName: (j['full_token'] as String?) ?? j['token'] as String, + kind: PackageKind.cask, + displayName: names.isEmpty ? null : names.first, + tap: j['tap'] as String?, + desc: j['desc'] as String?, + homepage: j['homepage'] as String?, + latestVersion: j['version'] as String?, + installedVersion: installed, + outdated: j['outdated'] == true, + pinned: j['pinned'] == true, + // Casks have no dependency-only installs. + installedOnRequest: installed != null, + caveats: j['caveats'] as String?, + deprecated: j['deprecated'] == true, + ); + } + + /// Parses the `{"formulae": [...], "casks": [...]}` shape of + /// `brew info --json=v2`. + static List listFromInfoJson(Map j) => [ + for (final f in (j['formulae'] as List? ?? const [])) + Package.fromFormulaJson(f as Map), + for (final c in (j['casks'] as List? ?? const [])) + Package.fromCaskJson(c as Map), + ]; +} + +/// An entry from `brew tap-info --json`. +class Tap { + const Tap({ + required this.name, + this.path, + this.remote, + this.official = false, + this.cloned = false, + this.branch, + this.lastCommit, + this.formulaNames = const [], + this.caskTokens = const [], + }); + + final String name; + final String? path; + final String? remote; + final bool official; + + /// Whether the tap is a local git checkout. homebrew/core and + /// homebrew/cask are usually served from the JSON API instead, so this is + /// false for them even though they're always available. + final bool cloned; + + /// Git details; only present for cloned taps. + final String? branch; + final String? lastCommit; + final List formulaNames; + final List caskTokens; + + factory Tap.fromJson(Map j) => Tap( + name: j['name'] as String, + path: j['path'] as String?, + // `remote` is the URL; `custom_remote` is just a flag for whether + // it was overridden. + remote: j['remote'] as String?, + official: j['official'] == true, + cloned: j['installed'] == true, + branch: j['branch'] as String?, + lastCommit: j['last_commit'] as String?, + formulaNames: (j['formula_names'] as List? ?? const []).cast(), + caskTokens: (j['cask_tokens'] as List? ?? const []).cast(), + ); +} + +/// An entry from `brew outdated --json=v2`. +class OutdatedEntry { + const OutdatedEntry({ + required this.name, + required this.kind, + required this.installedVersions, + required this.currentVersion, + this.pinned = false, + }); + + final String name; + final PackageKind kind; + final List installedVersions; + final String currentVersion; + final bool pinned; + + factory OutdatedEntry.fromJson(Map j, PackageKind kind) => + OutdatedEntry( + name: j['name'] as String, + kind: kind, + installedVersions: (j['installed_versions'] as List? ?? const []) + .cast(), + currentVersion: j['current_version'] as String? ?? '?', + pinned: j['pinned'] == true, + ); + + static List listFromJson(Map j) => [ + for (final f in (j['formulae'] as List? ?? const [])) + OutdatedEntry.fromJson(f as Map, PackageKind.formula), + for (final c in (j['casks'] as List? ?? const [])) + OutdatedEntry.fromJson(c as Map, PackageKind.cask), + ]; +} + +class SearchResult { + const SearchResult(this.name, this.kind); + + final String name; + final PackageKind kind; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..172f4b5 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'brew/brew_client.dart'; +import 'state/app_state.dart'; +import 'ui/common.dart'; +import 'ui/installed_page.dart'; +import 'ui/outdated_page.dart'; +import 'ui/search_page.dart'; +import 'ui/taps_page.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + AppState? state; + final brewPath = BrewClient.locate(); + if (brewPath != null) { + state = AppState( + BrewClient(brewPath: brewPath, askpassPath: _writeAskpass()), + ); + state.refresh(); + } + runApp(TaproomApp(state: state)); +} + +/// Writes a SUDO_ASKPASS helper that asks for the password with a native +/// dialog, for casks whose installers need sudo. Returns null if it can't. +String? _writeAskpass() { + try { + final f = File('${Directory.systemTemp.path}/taproom-askpass.sh'); + f.writeAsStringSync('''#!/bin/sh +exec /usr/bin/osascript -e 'text returned of (display dialog "Homebrew needs your password to continue." default answer "" with hidden answer with title "Taproom" with icon caution)' +'''); + Process.runSync('chmod', ['700', f.path]); + return f.path; + } catch (_) { + return null; + } +} + +class TaproomApp extends StatelessWidget { + const TaproomApp({super.key, required this.state}); + + /// Null when Homebrew isn't installed. + final AppState? state; + + @override + Widget build(BuildContext context) { + ThemeData theme(Brightness b) => ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFFE0A030), + brightness: b, + ), + ); + final app = MaterialApp( + title: 'Taproom', + debugShowCheckedModeBanner: false, + theme: theme(Brightness.light), + darkTheme: theme(Brightness.dark), + home: state == null ? const _NoBrew() : const HomeScreen(), + ); + // Above MaterialApp so dialogs (which live in the root overlay) see it. + return state == null ? app : AppScope(state: state!, child: app); + } +} + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _index = 0; + + static const _pages = [ + InstalledPage(), + OutdatedPage(), + SearchPage(), + TapsPage(), + ]; + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final outdated = state.outdated.length; + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + labelType: NavigationRailLabelType.all, + leading: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Icon(Icons.sports_bar, size: 32), + ), + destinations: [ + const NavigationRailDestination( + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), + label: Text('Installed'), + ), + NavigationRailDestination( + icon: Badge( + isLabelVisible: outdated > 0, + label: Text('$outdated'), + child: const Icon(Icons.update), + ), + label: const Text('Outdated'), + ), + const NavigationRailDestination( + icon: Icon(Icons.search), + label: Text('Search'), + ), + const NavigationRailDestination( + icon: Icon(Icons.water_drop_outlined), + selectedIcon: Icon(Icons.water_drop), + label: Text('Taps'), + ), + ], + trailing: Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'brew update — fetch the latest package index', + icon: const Icon(Icons.cloud_sync_outlined), + onPressed: () => runTask(context, state.brew.update()), + ), + IconButton( + tooltip: + 'brew cleanup — remove old versions and caches', + icon: const Icon(Icons.cleaning_services_outlined), + onPressed: () => runTask(context, state.brew.cleanup()), + ), + ], + ), + ), + ), + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: IndexedStack(index: _index, children: _pages), + ), + ], + ), + ); + } +} + +class _NoBrew extends StatelessWidget { + const _NoBrew(); + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sports_bar, size: 64), + const SizedBox(height: 16), + Text( + 'Homebrew not found', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + const Text('Looked in /opt/homebrew and /usr/local.'), + const SizedBox(height: 16), + FilledButton( + onPressed: () => openUrl('https://brew.sh'), + child: const Text('Get Homebrew'), + ), + ], + ), + ), + ); +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart new file mode 100644 index 0000000..8b7346d --- /dev/null +++ b/lib/state/app_state.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; + +import '../brew/brew_client.dart'; +import '../brew/models.dart'; + +/// Everything the app knows about the local Homebrew install. Pages read +/// from here; anything that changes the install calls [refresh] afterwards. +class AppState extends ChangeNotifier { + AppState(this.brew); + + final BrewClient brew; + + List installed = const []; + List outdated = const []; + List taps = const []; + + /// Also report casks that update themselves or have version :latest. + bool greedy = false; + bool loading = false; + Object? error; + + Future refresh() async { + loading = true; + error = null; + notifyListeners(); + try { + final results = await Future.wait([ + brew.installed(), + brew.outdated(greedy: greedy), + brew.taps(), + ]); + installed = results[0] as List; + outdated = results[1] as List; + taps = results[2] as List; + } catch (e) { + error = e; + } finally { + loading = false; + notifyListeners(); + } + } + + Future setGreedy(bool value) async { + greedy = value; + await refresh(); + } + + Package? installedPackage(String name, PackageKind kind) { + for (final p in installed) { + if (p.kind == kind && (p.name == name || p.fullName == name)) return p; + } + return null; + } +} diff --git a/lib/ui/common.dart b/lib/ui/common.dart new file mode 100644 index 0000000..5829521 --- /dev/null +++ b/lib/ui/common.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../brew/brew_client.dart'; +import '../brew/models.dart'; +import '../state/app_state.dart'; +import 'task_dialog.dart'; + +/// Makes the [AppState] available to the widget tree. +class AppScope extends InheritedNotifier { + const AppScope({super.key, required AppState state, required super.child}) + : super(notifier: state); + + /// Reads the state and rebuilds the caller when it changes. + static AppState of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.notifier!; + + /// Reads the state without subscribing, for use in callbacks. + static AppState read(BuildContext context) => + context.getInheritedWidgetOfExactType()!.notifier!; +} + +/// Runs [task] in a dialog showing its live output, then refreshes the app +/// state. Returns whether the command succeeded. +Future runTask(BuildContext context, BrewTask task) async { + final state = AppScope.read(context); + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => TaskDialog(task: task), + ); + // Not awaited: the lists update in place once brew answers. + state.refresh(); + return task.succeeded; +} + +Future confirm( + BuildContext context, { + required String title, + required String message, + String action = 'OK', +}) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(action), + ), + ], + ), + ); + return ok ?? false; +} + +void openUrl(String url) => Process.run('open', [url]); + +IconData kindIcon(PackageKind kind) => + kind == PackageKind.formula ? Icons.terminal : Icons.apps; + +/// Title row + optional controls shown at the top of every page. +class PageHeader extends StatelessWidget { + const PageHeader({ + super.key, + required this.title, + this.actions = const [], + this.bottom, + }); + + final String title; + final List actions; + final Widget? bottom; + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text(title, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(width: 12), + if (state.loading) + const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const Spacer(), + ...actions, + IconButton( + tooltip: 'Reload', + onPressed: state.loading ? null : state.refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + if (state.error != null) ...[ + const SizedBox(height: 8), + ErrorBanner(state.error!), + ], + if (bottom != null) ...[const SizedBox(height: 12), bottom!], + ], + ), + ); + } +} + +class ErrorBanner extends StatelessWidget { + const ErrorBanner(this.error, {super.key}); + + final Object error; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + '$error', + style: TextStyle(color: scheme.onErrorContainer), + ), + ); + } +} + +class Tag extends StatelessWidget { + const Tag(this.label, {super.key, this.color}); + + final String label; + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? Theme.of(context).colorScheme.secondary; + return Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + border: Border.all(color: c), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: c), + ), + ); + } +} + +class EmptyState extends StatelessWidget { + const EmptyState(this.message, {super.key, this.icon = Icons.inbox}); + + final String message; + final IconData icon; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.outline; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: color), + const SizedBox(height: 12), + Text(message, style: TextStyle(color: color)), + ], + ), + ); + } +} + +/// A search box used to filter lists. +class FilterField extends StatelessWidget { + const FilterField({super.key, required this.onChanged, this.hint = 'Filter'}); + + final ValueChanged onChanged; + final String hint; + + @override + Widget build(BuildContext context) => TextField( + onChanged: onChanged, + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.filter_list), + hintText: hint, + border: const OutlineInputBorder(), + ), + ); +} diff --git a/lib/ui/installed_page.dart b/lib/ui/installed_page.dart new file mode 100644 index 0000000..08f7f44 --- /dev/null +++ b/lib/ui/installed_page.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import '../brew/models.dart'; +import 'common.dart'; +import 'package_details.dart'; + +class InstalledPage extends StatefulWidget { + const InstalledPage({super.key}); + + @override + State createState() => _InstalledPageState(); +} + +class _InstalledPageState extends State { + String _filter = ''; + PackageKind? _kind; + + /// Hide formulae that were only pulled in as dependencies. + bool _requestedOnly = false; + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final q = _filter.toLowerCase(); + final packages = + state.installed + .where((p) => _kind == null || p.kind == _kind) + .where((p) => !_requestedOnly || p.installedOnRequest) + .where( + (p) => + q.isEmpty || + p.name.toLowerCase().contains(q) || + (p.displayName?.toLowerCase().contains(q) ?? false) || + (p.desc?.toLowerCase().contains(q) ?? false), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PageHeader( + title: 'Installed', + bottom: Row( + children: [ + Expanded( + child: FilterField( + onChanged: (v) => setState(() => _filter = v), + ), + ), + const SizedBox(width: 12), + SegmentedButton( + segments: const [ + ButtonSegment(value: null, label: Text('All')), + ButtonSegment( + value: PackageKind.formula, + label: Text('Formulae'), + ), + ButtonSegment(value: PackageKind.cask, label: Text('Casks')), + ], + selected: {_kind}, + onSelectionChanged: (s) => setState(() => _kind = s.first), + ), + const SizedBox(width: 12), + FilterChip( + label: const Text('Requested only'), + tooltip: 'Hide formulae installed only as dependencies', + selected: _requestedOnly, + onSelected: (v) => setState(() => _requestedOnly = v), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + '${packages.length} of ${state.installed.length}', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + Expanded( + child: packages.isEmpty + ? EmptyState( + state.loading ? 'Loading…' : 'Nothing installed matches.', + ) + : ListView.builder( + itemCount: packages.length, + itemBuilder: (context, i) => + PackageTile(package: packages[i]), + ), + ), + ], + ); + } +} + +class PackageTile extends StatelessWidget { + const PackageTile({super.key, required this.package}); + + final Package package; + + @override + Widget build(BuildContext context) { + final p = package; + final scheme = Theme.of(context).colorScheme; + return ListTile( + leading: Icon(kindIcon(p.kind)), + title: Row( + children: [ + Flexible(child: Text(p.name, overflow: TextOverflow.ellipsis)), + if (p.displayName != null && p.displayName != p.name) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + p.displayName!, + style: TextStyle(color: scheme.outline), + ), + ), + if (p.outdated) Tag('outdated', color: scheme.tertiary), + if (p.pinned) const Tag('pinned'), + if (p.kind == PackageKind.formula && !p.installedOnRequest) + Tag('dependency', color: scheme.outline), + ], + ), + subtitle: p.desc == null + ? null + : Text(p.desc!, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Text( + p.installedVersion ?? '', + style: const TextStyle(fontFamily: 'Menlo', fontSize: 12), + ), + onTap: () => showPackageDetails(context, p.fullName, p.kind), + ); + } +} diff --git a/lib/ui/outdated_page.dart b/lib/ui/outdated_page.dart new file mode 100644 index 0000000..ef7d76f --- /dev/null +++ b/lib/ui/outdated_page.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import 'common.dart'; +import 'package_details.dart'; + +class OutdatedPage extends StatelessWidget { + const OutdatedPage({super.key}); + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final scheme = Theme.of(context).colorScheme; + final upgradable = state.outdated.where((e) => !e.pinned).length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PageHeader( + title: 'Outdated', + actions: [ + Tooltip( + message: + 'Include casks that update themselves or use ' + 'version :latest (brew outdated --greedy)', + child: Row( + children: [ + const Text('Greedy'), + Switch( + value: state.greedy, + onChanged: state.loading ? null : state.setGreedy, + ), + ], + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + icon: const Icon(Icons.upgrade), + label: Text('Upgrade all ($upgradable)'), + onPressed: upgradable == 0 + ? null + : () => runTask( + context, + state.brew.upgrade(greedy: state.greedy), + ), + ), + const SizedBox(width: 8), + ], + ), + Expanded( + child: state.outdated.isEmpty + ? EmptyState( + state.loading ? 'Checking…' : 'Everything is up to date.', + icon: Icons.check_circle_outline, + ) + : ListView( + children: [ + for (final e in state.outdated) + ListTile( + leading: Icon(kindIcon(e.kind)), + title: Row( + children: [ + Text(e.name), + if (e.pinned) const Tag('pinned'), + ], + ), + subtitle: Text( + '${e.installedVersions.join(', ')} → ${e.currentVersion}', + style: const TextStyle( + fontFamily: 'Menlo', + fontSize: 12, + ), + ), + trailing: OutlinedButton( + onPressed: e.pinned + ? null + : () => runTask( + context, + state.brew.upgrade( + name: e.name, + kind: e.kind, + greedy: state.greedy, + ), + ), + child: const Text('Upgrade'), + ), + onTap: () => + showPackageDetails(context, e.name, e.kind), + ), + if (state.outdated.any((e) => e.pinned)) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Pinned formulae are skipped by upgrades; unpin them ' + 'from their details to upgrade.', + style: TextStyle(color: scheme.outline), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/ui/package_details.dart b/lib/ui/package_details.dart new file mode 100644 index 0000000..36c17ed --- /dev/null +++ b/lib/ui/package_details.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; + +import '../brew/models.dart'; +import 'common.dart'; + +Future showPackageDetails( + BuildContext context, + String name, + PackageKind kind, +) => showDialog( + context: context, + builder: (_) => PackageDetailsDialog(name: name, kind: kind), +); + +/// Full `brew info` for one package, with the actions that apply to it. +class PackageDetailsDialog extends StatefulWidget { + const PackageDetailsDialog({ + super.key, + required this.name, + required this.kind, + }); + + final String name; + final PackageKind kind; + + @override + State createState() => _PackageDetailsDialogState(); +} + +class _PackageDetailsDialogState extends State { + late Future _info; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() => + _info = AppScope.read(context).brew.info(widget.name, widget.kind); + + Future _act(Future Function() action) async { + await action(); + if (mounted) setState(_load); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + content: SizedBox( + width: 600, + child: FutureBuilder( + future: _info, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const SizedBox( + height: 160, + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) return ErrorBanner(snap.error!); + final p = snap.data; + if (p == null) return Text('No information for ${widget.name}.'); + return _Details(package: p); + }, + ), + ), + actions: [ + FutureBuilder( + future: _info, + builder: (context, snap) => snap.data == null + ? const SizedBox.shrink() + : Wrap(spacing: 8, children: _actions(snap.data!)), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + } + + List _actions(Package p) { + final brew = AppScope.read(context).brew; + if (!p.isInstalled) { + return [ + FilledButton.icon( + icon: const Icon(Icons.download), + label: const Text('Install'), + onPressed: () => + _act(() => runTask(context, brew.install(p.fullName, p.kind))), + ), + ]; + } + return [ + if (p.outdated && !p.pinned) + FilledButton.icon( + icon: const Icon(Icons.upgrade), + label: const Text('Upgrade'), + onPressed: () => _act( + () => + runTask(context, brew.upgrade(name: p.fullName, kind: p.kind)), + ), + ), + if (p.kind == PackageKind.formula) + OutlinedButton.icon( + icon: Icon(p.pinned ? Icons.push_pin_outlined : Icons.push_pin), + label: Text(p.pinned ? 'Unpin' : 'Pin'), + onPressed: () => _act( + () => runTask(context, brew.pin(p.fullName, pinned: !p.pinned)), + ), + ), + OutlinedButton( + onPressed: () => + _act(() => runTask(context, brew.reinstall(p.fullName, p.kind))), + child: const Text('Reinstall'), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + icon: const Icon(Icons.delete_outline), + label: const Text('Uninstall'), + onPressed: () => _act(() async { + final ok = await confirm( + context, + title: 'Uninstall ${p.name}?', + message: p.kind == PackageKind.formula + ? 'Homebrew will refuse if other installed formulae ' + 'depend on it.' + : 'This removes the app and its Homebrew-managed files.', + action: 'Uninstall', + ); + if (!ok || !mounted) return false; + return runTask(context, brew.uninstall(p.fullName, p.kind)); + }), + ), + ]; + } +} + +class _Details extends StatelessWidget { + const _Details({required this.package}); + + final Package package; + + @override + Widget build(BuildContext context) { + final p = package; + final theme = Theme.of(context); + final muted = TextStyle(color: theme.colorScheme.outline); + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(kindIcon(p.kind)), + const SizedBox(width: 8), + Flexible( + child: Text( + p.displayName ?? p.name, + style: theme.textTheme.headlineSmall, + ), + ), + Tag(p.kind.label), + if (p.isInstalled) + Tag('installed', color: theme.colorScheme.primary), + if (p.outdated) + Tag('outdated', color: theme.colorScheme.tertiary), + if (p.pinned) const Tag('pinned'), + if (p.deprecated) + Tag('deprecated', color: theme.colorScheme.error), + ], + ), + if (p.displayName != null) Text(p.fullName, style: muted), + const SizedBox(height: 12), + if (p.desc != null) Text(p.desc!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 12), + _field('Tap', p.tap ?? '—'), + _field('Latest', p.latestVersion ?? '—'), + _field('Installed', p.installedVersion ?? 'not installed'), + if (p.homepage != null) + Row( + children: [ + const SizedBox(width: 90, child: Text('Homepage')), + Flexible( + child: InkWell( + onTap: () => openUrl(p.homepage!), + child: Text( + p.homepage!, + style: TextStyle(color: theme.colorScheme.primary), + ), + ), + ), + ], + ), + if (p.dependencies.isNotEmpty) ...[ + const SizedBox(height: 12), + Text('Dependencies', style: theme.textTheme.titleSmall), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final d in p.dependencies) + ActionChip( + label: Text(d), + onPressed: () => + showPackageDetails(context, d, PackageKind.formula), + ), + ], + ), + ], + if (p.caveats != null && p.caveats!.trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text('Caveats', style: theme.textTheme.titleSmall), + const SizedBox(height: 6), + SelectableText( + p.caveats!.trim(), + style: const TextStyle(fontFamily: 'Menlo', fontSize: 12), + ), + ], + const SizedBox(height: 16), + ], + ), + ); + } + + Widget _field(String label, String value) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + SizedBox(width: 90, child: Text(label)), + Flexible(child: SelectableText(value)), + ], + ), + ); +} diff --git a/lib/ui/search_page.dart b/lib/ui/search_page.dart new file mode 100644 index 0000000..48633dc --- /dev/null +++ b/lib/ui/search_page.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../brew/models.dart'; +import 'common.dart'; +import 'package_details.dart'; + +class SearchPage extends StatefulWidget { + const SearchPage({super.key}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + final _controller = TextEditingController(); + Future>? _results; + String _query = ''; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _search() { + final q = _controller.text.trim(); + if (q.isEmpty) return; + setState(() { + _query = q; + _results = AppScope.read(context).brew.search(q); + }); + } + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PageHeader( + title: 'Search', + bottom: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + autofocus: true, + onSubmitted: (_) => _search(), + decoration: const InputDecoration( + isDense: true, + prefixIcon: Icon(Icons.search), + hintText: 'Formula or cask name — /regex/ also works', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + FilledButton(onPressed: _search, child: const Text('Search')), + ], + ), + ), + Expanded( + child: _results == null + ? const EmptyState( + 'Search all formulae and casks in your taps.', + icon: Icons.search, + ) + : FutureBuilder>( + future: _results, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Padding( + padding: const EdgeInsets.all(24), + child: ErrorBanner(snap.error!), + ); + } + final results = snap.data!; + if (results.isEmpty) { + return EmptyState('No matches for "$_query".'); + } + return ListView.builder( + itemCount: results.length, + itemBuilder: (context, i) { + final r = results[i]; + final installed = state.installedPackage( + r.name, + r.kind, + ); + return ListTile( + leading: Icon(kindIcon(r.kind)), + title: Row( + children: [ + Text(r.name), + Tag(r.kind.label), + if (installed != null) + Tag( + 'installed', + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + trailing: installed != null + ? null + : OutlinedButton( + onPressed: () => runTask( + context, + state.brew.install(r.name, r.kind), + ), + child: const Text('Install'), + ), + onTap: () => + showPackageDetails(context, r.name, r.kind), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/ui/taps_page.dart b/lib/ui/taps_page.dart new file mode 100644 index 0000000..7cf5a60 --- /dev/null +++ b/lib/ui/taps_page.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; + +import '../brew/models.dart'; +import 'common.dart'; +import 'package_details.dart'; + +/// Lists taps on the left; the selected tap's formulae and casks on the right. +class TapsPage extends StatefulWidget { + const TapsPage({super.key}); + + @override + State createState() => _TapsPageState(); +} + +class _TapsPageState extends State { + String? _selected; + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final taps = state.taps; + final selected = + taps.where((t) => t.name == _selected).firstOrNull ?? taps.firstOrNull; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PageHeader( + title: 'Taps', + actions: [ + FilledButton.icon( + icon: const Icon(Icons.add), + label: const Text('Add tap'), + onPressed: () => _addTap(context), + ), + const SizedBox(width: 8), + ], + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 280, + child: ListView( + children: [ + for (final t in taps) + ListTile( + selected: t.name == selected?.name, + leading: Icon( + t.official ? Icons.verified : Icons.source, + ), + title: Text(t.name), + subtitle: Text(_counts(t)), + onTap: () => setState(() => _selected = t.name), + ), + ], + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: selected == null + ? const EmptyState('No taps.') + // Keyed so filter/tab state resets when switching taps. + : _TapContents(key: ValueKey(selected.name), tap: selected), + ), + ], + ), + ), + ], + ); + } + + static String _counts(Tap t) => [ + if (t.formulaNames.isNotEmpty) '${t.formulaNames.length} formulae', + if (t.caskTokens.isNotEmpty) '${t.caskTokens.length} casks', + if (t.formulaNames.isEmpty && t.caskTokens.isEmpty) 'empty', + ].join(' · '); + + Future _addTap(BuildContext context) async { + final name = TextEditingController(); + final url = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add tap'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: name, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'user/repo', + ), + ), + const SizedBox(height: 8), + TextField( + controller: url, + decoration: const InputDecoration( + labelText: 'Git URL (optional)', + hintText: 'Defaults to github.com/user/homebrew-repo', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Tap'), + ), + ], + ), + ); + final n = name.text.trim(); + final u = url.text.trim(); + name.dispose(); + url.dispose(); + if (ok != true || n.isEmpty || !context.mounted) return; + await runTask(context, AppScope.read(context).brew.addTap(n, url: u)); + if (mounted) setState(() => _selected = n); + } +} + +class _TapContents extends StatefulWidget { + const _TapContents({super.key, required this.tap}); + + final Tap tap; + + @override + State<_TapContents> createState() => _TapContentsState(); +} + +class _TapContentsState extends State<_TapContents> { + String _filter = ''; + + @override + Widget build(BuildContext context) { + final t = widget.tap; + final muted = TextStyle(color: Theme.of(context).colorScheme.outline); + final hasBoth = t.formulaNames.isNotEmpty && t.caskTokens.isNotEmpty; + final kinds = [ + // An empty tap still gets one (empty) list. + if (t.formulaNames.isNotEmpty || t.caskTokens.isEmpty) + PackageKind.formula, + if (t.caskTokens.isNotEmpty) PackageKind.cask, + ]; + + return DefaultTabController( + length: kinds.length, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + t.name, + style: Theme.of(context).textTheme.titleLarge, + ), + if (t.official) const Tag('official'), + if (!t.cloned) Tag('via API', color: muted.color), + ], + ), + if (t.remote != null) + SelectableText(t.remote!, style: muted), + if (t.path != null && t.cloned) + SelectableText(t.path!, style: muted), + if (t.branch != null || t.lastCommit != null) + Text( + [ + if (t.branch != null) 'branch ${t.branch}', + if (t.lastCommit != null) + 'last commit ${t.lastCommit}', + ].join(' · '), + style: muted, + ), + ], + ), + ), + OutlinedButton.icon( + icon: const Icon(Icons.link_off), + label: const Text('Untap'), + // homebrew/core and homebrew/cask come from the JSON API + // unless cloned; there's nothing local to untap. + onPressed: !t.cloned + ? null + : () async { + final ok = await confirm( + context, + title: 'Untap ${t.name}?', + message: + 'Homebrew refuses if you have packages ' + 'installed from this tap.', + action: 'Untap', + ); + if (ok && context.mounted) { + await runTask( + context, + AppScope.read(context).brew.removeTap(t.name), + ); + } + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: FilterField(onChanged: (v) => setState(() => _filter = v)), + ), + if (hasBoth) + TabBar( + tabs: [ + Tab(text: 'Formulae (${t.formulaNames.length})'), + Tab(text: 'Casks (${t.caskTokens.length})'), + ], + ), + Expanded( + child: TabBarView( + children: [ + for (final k in kinds) + _NameList( + names: k == PackageKind.formula + ? t.formulaNames + : t.caskTokens, + kind: k, + filter: _filter, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _NameList extends StatelessWidget { + const _NameList({ + required this.names, + required this.kind, + required this.filter, + }); + + final List names; + final PackageKind kind; + final String filter; + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final q = filter.toLowerCase(); + final shown = q.isEmpty + ? names + : names.where((n) => n.toLowerCase().contains(q)).toList(); + if (shown.isEmpty) return const EmptyState('No matches.'); + return ListView.builder( + itemCount: shown.length, + itemExtent: 40, + itemBuilder: (context, i) { + final name = shown[i]; + final installed = state.installedPackage(name, kind) != null; + return ListTile( + dense: true, + leading: Icon(kindIcon(kind), size: 18), + title: Text(name), + trailing: installed + ? Icon( + Icons.check_circle, + size: 18, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () => showPackageDetails(context, name, kind), + ); + }, + ); + } +} diff --git a/lib/ui/task_dialog.dart b/lib/ui/task_dialog.dart new file mode 100644 index 0000000..8288b3e --- /dev/null +++ b/lib/ui/task_dialog.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import '../brew/brew_client.dart'; + +/// Runs a [BrewTask] and shows its output as it streams in. +class TaskDialog extends StatefulWidget { + const TaskDialog({super.key, required this.task}); + + final BrewTask task; + + @override + State createState() => _TaskDialogState(); +} + +class _TaskDialogState extends State { + final _scroll = ScrollController(); + + @override + void initState() { + super.initState(); + widget.task.addListener(_onOutput); + widget.task.run(); + } + + @override + void dispose() { + widget.task.removeListener(_onOutput); + _scroll.dispose(); + super.dispose(); + } + + void _onOutput() { + if (!mounted) return; + // Follow the output, unless the user scrolled up to read something. + final follow = + !_scroll.hasClients || + _scroll.position.pixels >= _scroll.position.maxScrollExtent - 40; + setState(() {}); + if (follow) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final task = widget.task; + final scheme = Theme.of(context).colorScheme; + final Widget status = task.running + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : task.succeeded + ? Icon(Icons.check_circle, color: scheme.primary) + : Icon(Icons.error, color: scheme.error); + + return AlertDialog( + title: Row( + children: [ + status, + const SizedBox(width: 12), + Expanded(child: Text(task.title)), + ], + ), + content: SizedBox( + width: 720, + height: 420, + child: Container( + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), + child: SingleChildScrollView( + controller: _scroll, + child: SelectableText( + task.lines.join('\n'), + style: const TextStyle(fontFamily: 'Menlo', fontSize: 12), + ), + ), + ), + ), + actions: [ + if (!task.running && !task.succeeded) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + 'Exited with code ${task.exitCode}', + style: TextStyle(color: scheme.error), + ), + ), + if (task.running) + TextButton(onPressed: task.cancel, child: const Text('Cancel')) + else + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + } +} diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..cccf817 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8f536ad --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* taproom.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "taproom.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* taproom.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* taproom.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/taproom.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/taproom"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/taproom.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/taproom"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/taproom.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/taproom"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..7a9ab5d --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..cf48304 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = taproom + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.philippeterson. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..9f56413 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..e89b7f3 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..d2a6979 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,213 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" +sdks: + dart: ">=3.13.5 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..bca3211 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,89 @@ +name: taproom +description: "A Flutter frontend for Homebrew" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.5 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 0000000..7228f0f --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:taproom/brew/brew_client.dart'; +import 'package:taproom/main.dart'; +import 'package:taproom/state/app_state.dart'; + +void main() { + testWidgets('shows installed packages and outdated badge', (tester) async { + final client = BrewClient( + brewPath: '/fake/brew', + runner: (exe, args, env) async { + final out = switch (args.first) { + 'info' => { + 'formulae': [ + { + 'name': 'jq', + 'desc': 'Lightweight and flexible command-line JSON processor', + 'installed': [ + {'version': '1.8.1', 'installed_on_request': true}, + ], + 'outdated': true, + }, + { + 'name': 'oniguruma', + 'installed': [ + {'version': '6.9.10', 'installed_on_request': false}, + ], + }, + ], + 'casks': [ + { + 'token': 'firefox', + 'name': ['Mozilla Firefox'], + 'installed': '141.0', + }, + ], + }, + 'outdated' => { + 'formulae': [ + { + 'name': 'jq', + 'installed_versions': ['1.8.1'], + 'current_version': '1.8.2', + }, + ], + 'casks': [], + }, + 'tap-info' => [ + { + 'name': 'homebrew/core', + 'official': true, + 'formula_names': ['jq'], + }, + ], + _ => throw UnimplementedError('$args'), + }; + return ProcessResult(0, 0, jsonEncode(out), ''); + }, + ); + final state = AppState(client); + await tester.binding.setSurfaceSize(const Size(1200, 800)); + await tester.pumpWidget(TaproomApp(state: state)); + await tester.runAsync(state.refresh); + await tester.pump(); + + expect(find.text('jq'), findsOneWidget); + expect(find.text('oniguruma'), findsOneWidget); + expect(find.text('Mozilla Firefox'), findsOneWidget); + expect(find.text('dependency'), findsOneWidget); + expect(find.text('3 of 3'), findsOneWidget); + // Badge on the Outdated rail item. + expect(find.text('1'), findsOneWidget); + + await tester.tap(find.text('Requested only')); + await tester.pump(); + expect(find.text('oniguruma'), findsNothing); + expect(find.text('2 of 3'), findsOneWidget); + }); +} diff --git a/test/brew_client_test.dart b/test/brew_client_test.dart new file mode 100644 index 0000000..8ac6568 --- /dev/null +++ b/test/brew_client_test.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:taproom/brew/brew_client.dart'; +import 'package:taproom/brew/models.dart'; + +/// Records calls and answers each with a canned result chosen by [respond]. +class FakeRunner { + FakeRunner(this.respond); + + final ProcessResult Function(List args) respond; + final calls = <(List, Map)>[]; + + Future call( + String exe, + List args, + Map env, + ) async { + calls.add((args, env)); + return respond(args); + } +} + +ProcessResult result(String stdout, {int exitCode = 0, String stderr = ''}) => + ProcessResult(0, exitCode, stdout, stderr); + +void main() { + test('search queries formulae and casks and tolerates no matches', () async { + final runner = FakeRunner( + (args) => args.contains('--formula') + ? result('jq\njq-lsp\n') + // brew exits 1 with a message when nothing matches. + : result('', exitCode: 1, stderr: 'Error: No casks found for "jq".'), + ); + final brew = BrewClient( + brewPath: '/opt/homebrew/bin/brew', + runner: runner.call, + ); + + final results = await brew.search('jq'); + + expect(results.map((r) => (r.name, r.kind)), [ + ('jq', PackageKind.formula), + ('jq-lsp', PackageKind.formula), + ]); + expect(runner.calls.map((c) => c.$1), [ + ['search', '--formula', 'jq'], + ['search', '--cask', 'jq'], + ]); + }); + + test('search output parsing skips headers and prose', () { + final r = BrewClient.parseSearchOutput( + '==> Formulae\nfoo\n\nIf you meant "foo" specifically:\nbar\n', + PackageKind.formula, + ); + expect(r.map((e) => e.name), ['foo', 'bar']); + }); + + test('queries run with a usable PATH and without auto-update', () async { + final runner = FakeRunner((_) => result('{"formulae": [], "casks": []}')); + final brew = BrewClient( + brewPath: '/opt/homebrew/bin/brew', + runner: runner.call, + ); + + await brew.installed(); + + final env = runner.calls.single.$2; + expect(env['PATH'], startsWith('/opt/homebrew/bin:/usr/bin:')); + expect(env['HOMEBREW_NO_AUTO_UPDATE'], '1'); + expect(env['HOMEBREW_NO_COLOR'], '1'); + }); + + test('a failing query throws BrewException with stderr', () async { + final runner = FakeRunner( + (_) => result('', exitCode: 1, stderr: 'Error: No available formula'), + ); + final brew = BrewClient( + brewPath: '/opt/homebrew/bin/brew', + runner: runner.call, + ); + + expect( + () => brew.info('nope', PackageKind.formula), + throwsA( + isA().having( + (e) => e.stderr, + 'stderr', + contains('No available formula'), + ), + ), + ); + }); + + test( + 'BrewTask streams stdout and stderr and records the exit code', + () async { + late Map seenEnv; + final brew = BrewClient( + brewPath: '/opt/homebrew/bin/brew', + askpassPath: '/tmp/askpass', + starter: (exe, args, env) { + seenEnv = env; + return Process.start('/bin/sh', [ + '-c', + 'echo installing; echo warning >&2; exit 3', + ]); + }, + ); + final task = brew.install('jq', PackageKind.formula); + var notified = 0; + task.addListener(() => notified++); + + final code = await task.run(); + + expect(code, 3); + expect(task.succeeded, isFalse); + expect(task.running, isFalse); + expect(task.lines.first, r'$ brew install --formula jq'); + expect(task.lines, containsAll(['installing', 'warning'])); + expect(notified, greaterThan(2)); + // Mutations may auto-update and may need sudo via the askpass helper. + expect(seenEnv.containsKey('HOMEBREW_NO_AUTO_UPDATE'), isFalse); + expect(seenEnv['SUDO_ASKPASS'], '/tmp/askpass'); + }, + ); + + test('upgrade builds the right arguments', () { + final brew = BrewClient(brewPath: '/b/brew'); + expect(brew.upgrade().args, ['upgrade']); + expect(brew.upgrade(greedy: true).args, ['upgrade', '--greedy']); + expect(brew.upgrade(name: 'firefox', kind: PackageKind.cask).args, [ + 'upgrade', + '--cask', + 'firefox', + ]); + expect(brew.addTap('a/b', url: '').args, ['tap', 'a/b']); + expect(brew.addTap('a/b', url: 'https://x/y.git').args, [ + 'tap', + 'a/b', + 'https://x/y.git', + ]); + }); + + // Read-only checks against the real Homebrew on this machine, to catch + // drift between brew's JSON and the parsers. + group('real brew', () { + final path = BrewClient.locate(); + final brew = path == null ? null : BrewClient(brewPath: path); + + test('lists installed packages', () async { + final installed = await brew!.installed(); + expect(installed, everyElement(isA())); + expect(installed.where((p) => !p.isInstalled), isEmpty); + }, skip: brew == null ? 'Homebrew not installed' : false); + + test('lists taps including homebrew/core', () async { + final taps = await brew!.taps(); + expect(taps.map((t) => t.name), contains('homebrew/core')); + final core = taps.firstWhere((t) => t.name == 'homebrew/core'); + expect(core.formulaNames, contains('jq')); + }, skip: brew == null ? 'Homebrew not installed' : false); + + test('searches', () async { + final r = await brew!.search('jq'); + expect(r.map((e) => e.name), contains('jq')); + }, skip: brew == null ? 'Homebrew not installed' : false); + + test('gets cask info', () async { + final p = await brew!.info('firefox', PackageKind.cask); + expect(p?.displayName, isNotEmpty); + expect(jsonEncode(p?.name), '"firefox"'); + }, skip: brew == null ? 'Homebrew not installed' : false); + }); +} diff --git a/test/fixtures/info_firefox.json b/test/fixtures/info_firefox.json new file mode 100644 index 0000000..e28ae88 --- /dev/null +++ b/test/fixtures/info_firefox.json @@ -0,0 +1,58 @@ +{ + "formulae": [], + "casks": [ + { + "token": "firefox", + "full_token": "firefox", + "old_tokens": [], + "tap": "homebrew/cask", + "name": [ + "Mozilla Firefox" + ], + "desc": "Web browser", + "homepage": "https://www.mozilla.org/firefox/", + "url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/157.0.1/mac/en-US/Firefox%20157.0.1.dmg", + "version": "157.0.1", + "autobump": true, + "no_autobump_message": null, + "skip_livecheck": false, + "installed": null, + "installed_time": null, + "bundle_version": null, + "bundle_short_version": null, + "pinned": false, + "pinned_version": null, + "outdated": false, + "sha256": "40a0a649120635460256dae9ad63e40377f77326cbc9c449a5d0a1d7f5dd7982", + "caveats": null, + "caveats_rosetta": null, + "depends_on": { + "macos": {} + }, + "conflicts_with": { + "cask": [ + "firefox@beta", + "firefox@cn", + "firefox@esr" + ] + }, + "container": null, + "rename": [], + "auto_updates": true, + "deprecated": false, + "deprecation_date": null, + "deprecation_reason": null, + "deprecation_replacement_formula": null, + "deprecation_replacement_cask": null, + "deprecate_args": null, + "disabled": false, + "disable_date": null, + "disable_reason": null, + "disable_replacement_formula": null, + "disable_replacement_cask": null, + "disable_args": null, + "tap_git_head": "086dfbab8a72cd87eab20a8ff7e974db70051883", + "ruby_source_path": "Casks/f/firefox.rb" + } + ] +} \ No newline at end of file diff --git a/test/fixtures/info_jq.json b/test/fixtures/info_jq.json new file mode 100644 index 0000000..bcc58c5 --- /dev/null +++ b/test/fixtures/info_jq.json @@ -0,0 +1,81 @@ +{ + "formulae": [ + { + "name": "jq", + "full_name": "jq", + "tap": "homebrew/core", + "oldnames": [], + "aliases": [], + "versioned_formulae": [], + "desc": "Lightweight and flexible command-line JSON processor", + "license": "MIT", + "homepage": "https://jqlang.github.io/jq/", + "versions": { + "stable": "1.8.2", + "head": "HEAD", + "bottle": true + }, + "patches": [], + "revision": 0, + "version_scheme": 0, + "compatibility_version": null, + "autobump": true, + "no_autobump_message": null, + "skip_livecheck": false, + "pour_bottle_only_if": null, + "keg_only": false, + "keg_only_reason": null, + "options": [], + "build_dependencies": [], + "dependencies": [ + "oniguruma" + ], + "test_dependencies": [], + "recommended_dependencies": [], + "optional_dependencies": [], + "uses_from_macos": [], + "uses_from_macos_bounds": [], + "conflicts_with": [], + "conflicts_with_reasons": [], + "link_overwrite": [], + "caveats": null, + "installed": [], + "linked_keg": null, + "pinned": false, + "outdated": false, + "deprecated": false, + "deprecation_date": null, + "deprecation_reason": null, + "deprecation_replacement_formula": null, + "deprecation_replacement_cask": null, + "deprecate_args": null, + "disabled": false, + "disable_date": null, + "disable_reason": null, + "disable_replacement_formula": null, + "disable_replacement_cask": null, + "disable_args": null, + "post_install_steps": [], + "post_install_defined": false, + "service": null, + "tap_git_head": "3c09418e50ad51f7f241f4062ffcd7bffe36e3ce", + "ruby_source_path": "Formula/j/jq.rb", + "head_dependencies": { + "build_dependencies": [ + "autoconf", + "automake", + "libtool" + ], + "dependencies": [ + "oniguruma" + ], + "test_dependencies": [], + "recommended_dependencies": [], + "optional_dependencies": [], + "uses_from_macos": [], + "uses_from_macos_bounds": [] + } + } + ], + "casks": [] +} \ No newline at end of file diff --git a/test/models_test.dart b/test/models_test.dart new file mode 100644 index 0000000..b9f6b98 --- /dev/null +++ b/test/models_test.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:taproom/brew/models.dart'; + +Map fixture(String name) => + jsonDecode(File('test/fixtures/$name').readAsStringSync()) + as Map; + +void main() { + test('parses a formula from real brew info output', () { + final p = Package.listFromInfoJson(fixture('info_jq.json')).single; + expect(p.name, 'jq'); + expect(p.kind, PackageKind.formula); + expect(p.tap, 'homebrew/core'); + expect(p.latestVersion, '1.8.2'); + expect(p.dependencies, ['oniguruma']); + expect(p.isInstalled, isFalse); + }); + + test('parses a cask from real brew info output', () { + final p = Package.listFromInfoJson(fixture('info_firefox.json')).single; + expect(p.name, 'firefox'); + expect(p.kind, PackageKind.cask); + expect(p.displayName, 'Mozilla Firefox'); + expect(p.tap, 'homebrew/cask'); + expect(p.latestVersion, isNotNull); + }); + + test('installed formula uses the newest install and on-request flag', () { + final p = Package.fromFormulaJson({ + 'name': 'git', + 'versions': {'stable': '2.50.0'}, + 'installed': [ + {'version': '2.49.0', 'installed_on_request': false}, + {'version': '2.50.0', 'installed_on_request': true}, + ], + 'outdated': false, + 'pinned': true, + }); + expect(p.installedVersion, '2.50.0'); + expect(p.installedOnRequest, isTrue); + expect(p.pinned, isTrue); + expect(p.fullName, 'git'); + }); + + test('dependency-only formula is not installed on request', () { + final p = Package.fromFormulaJson({ + 'name': 'oniguruma', + 'installed': [ + {'version': '6.9.10', 'installed_on_request': false}, + ], + }); + expect(p.isInstalled, isTrue); + expect(p.installedOnRequest, isFalse); + }); + + test('parses outdated formulae and casks', () { + final list = OutdatedEntry.listFromJson({ + 'formulae': [ + { + 'name': 'node', + 'installed_versions': ['22.1.0'], + 'current_version': '22.2.0', + 'pinned': true, + }, + ], + 'casks': [ + { + 'name': 'firefox', + 'installed_versions': ['140.0'], + 'current_version': '141.0', + }, + ], + }); + expect(list.map((e) => (e.name, e.kind, e.pinned)), [ + ('node', PackageKind.formula, true), + ('firefox', PackageKind.cask, false), + ]); + expect(list.last.installedVersions, ['140.0']); + }); + + test('parses an API-backed tap', () { + final t = Tap.fromJson({ + 'name': 'homebrew/core', + 'path': '/opt/homebrew/Library/Taps/homebrew/homebrew-core', + 'installed': false, + 'official': true, + 'formula_names': ['a', 'b'], + 'cask_tokens': [], + }); + expect(t.cloned, isFalse); + expect(t.official, isTrue); + expect(t.formulaNames, hasLength(2)); + expect(t.remote, isNull); + }); + + test('parses a cloned third-party tap', () { + // Shape from `brew tap-info --json hashicorp/tap`: custom_remote is a + // bool, not the URL. + final t = Tap.fromJson({ + 'name': 'hashicorp/tap', + 'path': '/opt/homebrew/Library/Taps/hashicorp/homebrew-tap', + 'installed': true, + 'official': false, + 'remote': 'https://github.com/hashicorp/homebrew-tap', + 'custom_remote': false, + 'last_commit': '2 days ago', + 'branch': 'main', + 'formula_names': ['hashicorp/tap/terraform'], + 'cask_tokens': [], + }); + expect(t.cloned, isTrue); + expect(t.remote, 'https://github.com/hashicorp/homebrew-tap'); + expect(t.branch, 'main'); + expect(t.lastCommit, '2 days ago'); + }); +}