diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 38d3b9ca..7848e2b0 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,13 @@ $(FLUTTER_BUILD_NUMBER) ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + vlc + infuse + LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace diff --git a/lib/main.dart b/lib/main.dart index a961c4bf..4ecc1a78 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; import 'profiles/profile_selection_policy.dart'; +import 'models/external_player_models.dart'; import 'mixins/mounted_set_state_mixin.dart'; import 'theme/mono_theme.dart'; import 'profiles/plex_home_service.dart'; @@ -487,6 +488,9 @@ void _startNonessentialInitialization(SettingsService settings) { if (PlatformDetector.isDesktopOS()) { bestEffort('Discord RPC', DiscordRPCService.instance.initialize); + // Detection forks helper processes; resolve it here so the External Player + // settings page never has to wait on a cold probe. + bestEffort('External player detection', KnownPlayers.getForCurrentPlatform); } if (settings.read(SettingsService.crashReporting)) { diff --git a/lib/models/external_player_models.dart b/lib/models/external_player_models.dart index 3f29325f..df724085 100644 --- a/lib/models/external_player_models.dart +++ b/lib/models/external_player_models.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import '../utils/app_logger.dart'; @@ -8,6 +10,31 @@ typedef PlayerLauncher = Future Function(String url); enum CustomPlayerType { command, urlScheme } +/// Host lookups behind [KnownPlayers.getForCurrentPlatform], split out so tests +/// can answer them without touching the real machine. +class PlayerInstallProbe { + const PlayerInstallProbe(); + + /// Drives which detectors run. Values match [Platform.operatingSystem]. + String get operatingSystem => Platform.operatingSystem; + + Map get environment => Platform.environment; + + Future run(String executable, List arguments) => Process.run(executable, arguments); + + Future fileExists(String path) => File(path).exists(); + + /// macOS Launch Services lookup — the database `open -a` resolves through. + /// Spotlight is deliberately not consulted: an empty `mdfind` result also + /// means indexing is off or incomplete, which would hide installed players. + Future applicationInstalled(String bundleId) async { + const channel = MethodChannel('com.plezy/app_lookup'); + return await channel.invokeMethod('isApplicationInstalled', {'bundleId': bundleId}) ?? false; + } + + Future schemeHasHandler(String scheme) => canLaunchUrl(Uri.parse(scheme)); +} + class ExternalPlayer { final String id; final String name; @@ -204,19 +231,6 @@ class KnownPlayers { return _androidPackageMap[id] ?? const []; } - static bool _isLinuxCommandAvailable(String command) { - if (!Platform.isLinux) { - return false; - } - - try { - final result = Process.runSync('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-command-probe', command]); - return result.exitCode == 0; - } on ProcessException { - return false; - } - } - static List androidPackageCandidates(ExternalPlayer player) { final knownPackages = _androidPackageCandidatesForId(player.id); if (knownPackages.isNotEmpty) return knownPackages; @@ -233,12 +247,7 @@ class KnownPlayers { id: 'vlc', name: 'VLC', iconAsset: 'assets/player_icons/vlc.svg', - isAvailable: - Platform.isAndroid || - Platform.isIOS || - Platform.isMacOS || - _isLinuxCommandAvailable('vlc') || - Platform.isWindows, + isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows, launch: (url) { if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('vlc')); if (Platform.isIOS) return _launchUrlScheme('vlc://', url); @@ -251,7 +260,7 @@ class KnownPlayers { id: 'mpv', name: 'mpv', iconAsset: 'assets/player_icons/mpv.svg', - isAvailable: Platform.isAndroid || Platform.isMacOS || _isLinuxCommandAvailable('mpv') || Platform.isWindows, + isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows, launch: (url) { if (Platform.isAndroid) return _launchAndroidIntentCandidates(url, _androidPackageCandidatesForId('mpv')); return _launchCommand('mpv', url); @@ -299,14 +308,121 @@ class KnownPlayers { id: 'celluloid', name: 'Celluloid', iconAsset: 'assets/player_icons/celluloid.svg', - isAvailable: _isLinuxCommandAvailable('celluloid'), + isAvailable: Platform.isLinux, launch: (url) => _launchCommand('celluloid', url), ), ]; - /// Get players available on the current platform - static List getForCurrentPlatform() { - return _allPlayers.where((p) => p.isAvailable).toList(); + /// Host lookups used to decide whether a supported player is actually + /// installed. Overridden in tests; production talks to the real process, + /// filesystem and URL-handler APIs. + @visibleForTesting + static PlayerInstallProbe probe = const PlayerInstallProbe(); + + static Future>? _installedPlayers; + + /// Players supported on this platform, minus the ones we can positively tell + /// are not installed. + /// + /// Detection forks `sh` or `where.exe` and calls out over platform channels, + /// so it is asynchronous and memoised: it runs once per process and a player + /// installed while Plezy is running only appears after a restart. Android is + /// the one platform left unprobed — where there is no reliable check the + /// player stays listed, because hiding a working player is worse than + /// listing a missing one. + static Future> getForCurrentPlatform() { + return _installedPlayers ??= _resolveForCurrentPlatform(); + } + + @visibleForTesting + static void resetForTesting() { + probe = const PlayerInstallProbe(); + _installedPlayers = null; + } + + static Future> _resolveForCurrentPlatform() async { + final supported = [ + for (final player in _allPlayers) + if (player.isAvailable) player, + ]; + + // Each detector must answer the same question its launcher asks, so a + // listed player is one we can actually start. + final detectors = Function()>{}; + switch (probe.operatingSystem) { + case 'linux': + detectors['vlc'] = () => _posixCommandExists('vlc'); + detectors['mpv'] = () => _posixCommandExists('mpv'); + detectors['celluloid'] = () => _posixCommandExists('celluloid'); + case 'macos': + detectors['vlc'] = () => probe.applicationInstalled('org.videolan.vlc'); + detectors['iina'] = () => probe.applicationInstalled('com.colliderli.iina'); + detectors['mpv'] = () => _posixCommandExists('mpv'); + case 'windows': + detectors['vlc'] = _windowsVlcExists; + detectors['mpv'] = () => _windowsCommandExists('mpv'); + detectors['potplayer'] = _windowsPotPlayerExists; + case 'ios': + // Same predicate _launchUrlScheme gates on, so detection can never + // hide a player the handoff would have reached. Both schemes are + // declared in LSApplicationQueriesSchemes. + detectors['vlc'] = () => probe.schemeHasHandler('vlc://'); + detectors['infuse'] = () => probe.schemeHasHandler('infuse://'); + } + if (detectors.isEmpty) return supported; + + final ids = detectors.keys.toList(growable: false); + final found = await Future.wait(ids.map((id) => _detect(id, detectors[id]!))); + final missing = { + for (var i = 0; i < ids.length; i++) + if (!found[i]) ids[i], + }; + if (missing.isEmpty) return supported; + + return [ + for (final player in supported) + if (!missing.contains(player.id)) player, + ]; + } + + /// Runs one detector, failing open: a probe that blows up must not hide a + /// player the user may well have installed. + static Future _detect(String id, Future Function() detector) async { + try { + return await detector(); + } catch (e, stackTrace) { + appLogger.w('External player detection failed for $id; listing it anyway', error: e, stackTrace: stackTrace); + return true; + } + } + + /// True when [command] resolves on `PATH`. The shell defers to the kernel, so + /// permission classes, ACLs and `noexec` mounts are honoured exactly as they + /// will be when [Process.start] execs the command. + static Future _posixCommandExists(String command) async { + final result = await probe.run('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-command-probe', command]); + return result.exitCode == 0; + } + + static Future _windowsCommandExists(String command) async { + final result = await probe.run('where.exe', [command]); + return result.exitCode == 0; + } + + /// Mirrors [_launchWindowsVlc]: any of the concrete install paths, or `vlc` + /// on `PATH`. + static Future _windowsVlcExists() async { + for (final candidate in _windowsVlcCommandCandidates(probe.environment)) { + if (candidate.contains(r'\') && await probe.fileExists(candidate)) return true; + } + return _windowsCommandExists('vlc'); + } + + /// Mirrors the PotPlayer launcher: the registered `potplayer://` handler, or + /// the executable on `PATH`. + static Future _windowsPotPlayerExists() async { + if (await probe.schemeHasHandler('potplayer://')) return true; + return _windowsCommandExists('PotPlayerMini64'); } /// Find a known player by ID diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index c6b0c2e4..355c792c 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -24,7 +24,6 @@ class ExternalPlayerScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final knownPlayers = KnownPlayers.getForCurrentPlatform(); return SettingsPage( title: Text(t.externalPlayer.title), children: [ @@ -51,9 +50,19 @@ class ExternalPlayerScreen extends StatelessWidget { final custom = svc.read(SettingsService.customExternalPlayers); return Column( children: [ - SettingsGroup( - title: t.externalPlayer.selectPlayer, - children: [for (final p in knownPlayers) _PlayerTile(player: p, selectedId: selected.id)], + FutureBuilder>( + future: KnownPlayers.getForCurrentPlatform(), + builder: (context, snapshot) { + final detected = snapshot.data; + if (detected == null) return const SizedBox.shrink(); + return SettingsGroup( + title: t.externalPlayer.selectPlayer, + children: [ + for (final p in _withSelected(detected, selected.id)) + _PlayerTile(player: p, selectedId: selected.id), + ], + ); + }, ), SettingsGroup( title: t.externalPlayer.customPlayers, @@ -76,6 +85,15 @@ class ExternalPlayerScreen extends StatelessWidget { } } +/// Keeps the current choice on screen when detection missed it, so a false +/// negative cannot leave the list with nothing selected. +List _withSelected(List detected, String selectedId) { + if (detected.any((p) => p.id == selectedId)) return detected; + final selected = KnownPlayers.findById(selectedId); + if (selected == null || !selected.isAvailable) return detected; + return [...detected, selected]; +} + class _PlayerTile extends StatelessWidget { final ExternalPlayer player; final String selectedId; diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 3e02c9e0..b0b8580c 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; }; 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; }; 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; }; + 6AD8B1702ED7B50000E9E1C1 /* AppLookupPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */; }; 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; }; 6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; }; B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; }; @@ -94,6 +95,7 @@ 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; }; 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; }; 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = ""; }; + 6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLookupPlugin.swift; sourceTree = ""; }; 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; }; 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = ""; }; 6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; @@ -216,6 +218,7 @@ 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */, + 6AD8B1712ED7B50000E9E1C1 /* AppLookupPlugin.swift */, 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */, 6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */, 6AD8B16C2ED7B50000E9E1B7 /* Runner-Bridging-Header.h */, @@ -496,6 +499,7 @@ 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */, 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */, 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */, + 6AD8B1702ED7B50000E9E1C1 /* AppLookupPlugin.swift in Sources */, 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */, 6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */, ); diff --git a/macos/Runner/AppLookupPlugin.swift b/macos/Runner/AppLookupPlugin.swift new file mode 100644 index 00000000..7adcf70c --- /dev/null +++ b/macos/Runner/AppLookupPlugin.swift @@ -0,0 +1,36 @@ +import Cocoa +import FlutterMacOS + +// MARK: - AppLookupPlugin +/// Answers "is this application installed?" from Launch Services, the same +/// database `open -a` resolves through. Spotlight is deliberately not used: +/// an empty `mdfind` result also means indexing is off or incomplete, which +/// would hide players the user actually has. +class AppLookupPlugin: NSObject, FlutterPlugin { + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.plezy/app_lookup", + binaryMessenger: registrar.messenger + ) + registrar.addMethodCallDelegate(AppLookupPlugin(), channel: channel) + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isApplicationInstalled": + guard let args = call.arguments as? [String: Any], + let bundleId = args["bundleId"] as? String, !bundleId.isEmpty + else { + result( + FlutterError( + code: "INVALID_ARGUMENTS", message: "bundleId is required", details: nil)) + return + } + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) + result(url != nil) + + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 82c442a2..144e29e0 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -41,6 +41,10 @@ class MainFlutterWindow: NSWindow { WindowUtilsPlugin.installWindowDelegate() WindowUtilsPlugin.syncWindowChrome() + // Register Launch Services lookups used to detect installed external players + AppLookupPlugin.register( + with: flutterViewController.registrar(forPlugin: "AppLookupPlugin")) + RegisterGeneratedPlugins(registry: flutterViewController) // Enable window position/size persistence diff --git a/test/models/external_player_models_test.dart b/test/models/external_player_models_test.dart index 09d7c6d0..93e5a6ae 100644 --- a/test/models/external_player_models_test.dart +++ b/test/models/external_player_models_test.dart @@ -1,22 +1,244 @@ import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/models/external_player_models.dart'; +/// The POSIX one-liner `_posixCommandExists` hands to `sh`. +const _commandProbe = r'command -v "$1" >/dev/null 2>&1'; + void main() { - test('Linux exposes only known players whose commands are available', () { - final players = KnownPlayers.getForCurrentPlatform(); + setUp(KnownPlayers.resetForTesting); + tearDown(KnownPlayers.resetForTesting); - expect(players.map((player) => player.id), [ - 'system_default', - if (_commandIsAvailable('vlc')) 'vlc', - if (_commandIsAvailable('mpv')) 'mpv', - if (_commandIsAvailable('celluloid')) 'celluloid', - ]); + group('detection failures', () { + test('a probe that throws lists the player instead of hiding it', () async { + final installed = await _resolveWith(_FakeProbe.everythingInstalled()); + final failed = await _resolveWith(_FakeProbe(failEveryLookup: true)); + + expect(failed, installed); + }); + + test('detection runs once per process', () async { + final probe = _FakeProbe.everythingInstalled(); + KnownPlayers.probe = probe; + + final first = await KnownPlayers.getForCurrentPlatform(); + final runsAfterFirst = probe.runs.length; + final second = await KnownPlayers.getForCurrentPlatform(); + + expect(second, same(first)); + expect(probe.runs, hasLength(runsAfterFirst)); + }); + + test('undetected players keep their declared order', () async { + final all = await _resolveWith(_FakeProbe.everythingInstalled()); + final none = await _resolveWith(_FakeProbe()); + + expect(none.first, 'system_default'); + expect(none, all.where(none.contains)); + }); + }); + + // Which detectors run is driven by the probe, so every platform's wiring is + // exercised on whatever host runs the suite. + group('detector wiring', () { + test('Linux asks the shell to resolve each command on PATH', () async { + final probe = _FakeProbe(operatingSystem: 'linux'); + await _resolveWith(probe); + + // Detectors run concurrently, so assert what is probed, not the order. + expect( + probe.runs, + unorderedEquals([ + ['sh', '-c', _commandProbe, 'plezy-command-probe', 'vlc'], + ['sh', '-c', _commandProbe, 'plezy-command-probe', 'mpv'], + ['sh', '-c', _commandProbe, 'plezy-command-probe', 'celluloid'], + ]), + ); + }); + + test('macOS asks Launch Services for bundles and the shell for mpv', () async { + final probe = _FakeProbe(operatingSystem: 'macos'); + await _resolveWith(probe); + + expect(probe.bundleLookups, unorderedEquals(['org.videolan.vlc', 'com.colliderli.iina'])); + expect(probe.runs, [ + ['sh', '-c', _commandProbe, 'plezy-command-probe', 'mpv'], + ]); + }); + + test('Windows falls back to where.exe when no install path matches', () async { + final probe = _FakeProbe(operatingSystem: 'windows'); + await _resolveWith(probe); + + expect( + probe.runs, + unorderedEquals([ + ['where.exe', 'vlc'], + ['where.exe', 'mpv'], + ['where.exe', 'PotPlayerMini64'], + ]), + ); + }); + + test('Windows skips where.exe once a concrete VLC install path matches', () async { + final probe = _FakeProbe( + operatingSystem: 'windows', + paths: {r'C:\Program Files\VideoLAN\VLC\vlc.exe'}, + schemes: {'potplayer://'}, + ); + await _resolveWith(probe); + + expect(probe.runs, [ + ['where.exe', 'mpv'], + ]); + }); + + test('iOS asks the same URL-handler question the launcher gates on', () async { + final probe = _FakeProbe(operatingSystem: 'ios'); + await _resolveWith(probe); + + expect(probe.schemeLookups, unorderedEquals(['vlc://', 'infuse://'])); + expect(probe.runs, isEmpty); + }); + + test('platforms without a detector probe nothing', () async { + final probe = _FakeProbe(operatingSystem: 'android'); + await _resolveWith(probe); + + expect(probe.runs, isEmpty); + expect(probe.bundleLookups, isEmpty); + expect(probe.schemeLookups, isEmpty); + }); + }); + + group('Linux', () { + test('lists only players whose command resolves on PATH', () async { + expect(await _resolveWith(_FakeProbe(commands: {'mpv'})), ['system_default', 'mpv']); + expect(await _resolveWith(_FakeProbe(commands: {'vlc', 'celluloid'})), ['system_default', 'vlc', 'celluloid']); + expect(await _resolveWith(_FakeProbe()), ['system_default']); + }); }, skip: !Platform.isLinux); + + group('macOS', () { + test('lists only applications Launch Services knows about', () async { + expect(await _resolveWith(_FakeProbe(bundleIds: {'org.videolan.vlc'})), ['system_default', 'vlc']); + expect(await _resolveWith(_FakeProbe(bundleIds: {'com.colliderli.iina'})), ['system_default', 'iina']); + expect(await _resolveWith(_FakeProbe()), ['system_default']); + }); + + test('detects mpv on PATH rather than as a bundle', () async { + expect(await _resolveWith(_FakeProbe(commands: {'mpv'})), ['system_default', 'mpv']); + }); + }, skip: !Platform.isMacOS); + + group('Windows', () { + test('accepts VLC from a concrete install path', () async { + final probe = _FakeProbe( + paths: {r'C:\Program Files\VideoLAN\VLC\vlc.exe'}, + environment: {'ProgramFiles': r'C:\Program Files'}, + ); + + expect(await _resolveWith(probe), ['system_default', 'vlc']); + }); + + test('falls back to where.exe when no install path matches', () async { + final probe = _FakeProbe(commands: {'vlc', 'mpv'}); + + expect(await _resolveWith(probe), ['system_default', 'vlc', 'mpv']); + expect(probe.runs, contains(equals(['where.exe', 'vlc']))); + }); + + test('accepts PotPlayer from its registered URL handler', () async { + final probe = _FakeProbe(schemes: {'potplayer://'}); + + expect(await _resolveWith(probe), ['system_default', 'potplayer']); + }); + + test('accepts PotPlayer from PATH when no handler is registered', () async { + expect(await _resolveWith(_FakeProbe(commands: {'PotPlayerMini64'})), ['system_default', 'potplayer']); + }); + }, skip: !Platform.isWindows); } -bool _commandIsAvailable(String command) { - final result = Process.runSync('sh', ['-c', r'command -v "$1" >/dev/null 2>&1', 'plezy-test-command-probe', command]); - return result.exitCode == 0; +Future> _resolveWith(PlayerInstallProbe probe) async { + KnownPlayers.resetForTesting(); + KnownPlayers.probe = probe; + final players = await KnownPlayers.getForCurrentPlatform(); + return players.map((player) => player.id).toList(); +} + +/// Answers the host lookups from fixed sets, and records the subprocesses and +/// bundle lookups it is asked for so tests can assert what production issues. +class _FakeProbe extends PlayerInstallProbe { + _FakeProbe({ + this.commands = const {}, + this.paths = const {}, + this.bundleIds = const {}, + this.schemes = const {}, + this.environment = const {}, + this.failEveryLookup = false, + String? operatingSystem, + }) : operatingSystem = operatingSystem ?? Platform.operatingSystem, + _everything = false; + + _FakeProbe.everythingInstalled() + : commands = const {}, + paths = const {}, + bundleIds = const {}, + schemes = const {}, + environment = const {}, + failEveryLookup = false, + operatingSystem = Platform.operatingSystem, + _everything = true; + + final Set commands; + final Set paths; + final Set bundleIds; + final Set schemes; + final bool failEveryLookup; + final bool _everything; + final List> runs = []; + final List bundleLookups = []; + final List schemeLookups = []; + + @override + final Map environment; + + @override + final String operatingSystem; + + @override + Future run(String executable, List arguments) async { + runs.add([executable, ...arguments]); + if (failEveryLookup) throw ProcessException(executable, arguments, 'probe unavailable'); + if (_everything) return ProcessResult(0, 0, '', ''); + + switch (executable) { + case 'sh': + return ProcessResult(0, commands.contains(arguments.last) ? 0 : 1, '', ''); + case 'where.exe': + return ProcessResult(0, commands.contains(arguments.single) ? 0 : 1, '', ''); + default: + fail('unexpected probe subprocess: $executable $arguments'); + } + } + + @override + Future applicationInstalled(String bundleId) async { + bundleLookups.add(bundleId); + if (failEveryLookup) throw MissingPluginException('no app lookup plugin'); + return _everything || bundleIds.contains(bundleId); + } + + @override + Future fileExists(String path) async => _everything || paths.contains(path); + + @override + Future schemeHasHandler(String scheme) async { + schemeLookups.add(scheme); + if (failEveryLookup) throw MissingPluginException('no url launcher plugin'); + return _everything || schemes.contains(scheme); + } } diff --git a/test/screens/settings/external_player_screen_test.dart b/test/screens/settings/external_player_screen_test.dart index 1365212f..e26b07c7 100644 --- a/test/screens/settings/external_player_screen_test.dart +++ b/test/screens/settings/external_player_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focusable_button.dart'; @@ -16,12 +18,15 @@ void main() { setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); + KnownPlayers.resetForTesting(); + KnownPlayers.probe = const _AllPlayersInstalled(); settings = await SettingsService.getInstance(); LocaleSettings.setLocaleSync(AppLocale.en); }); tearDown(() { SettingsService.resetForTesting(); + KnownPlayers.resetForTesting(); }); testWidgets('only custom players expose a focusable delete action', (tester) async { @@ -43,7 +48,7 @@ void main() { await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen())); await tester.pumpAndSettle(); - for (final player in KnownPlayers.getForCurrentPlatform()) { + for (final player in await KnownPlayers.getForCurrentPlatform()) { final title = player.id == KnownPlayers.systemDefault.id ? 'System Default' : player.name; final row = find.widgetWithText(FocusableListTile, title); expect(row, findsOneWidget); @@ -66,4 +71,63 @@ void main() { expect(settings.read(SettingsService.selectedExternalPlayer), KnownPlayers.systemDefault); expect(find.text(customPlayer.name), findsNothing); }); + + testWidgets('a selected known player stays listed when detection misses it', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 1400); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + // VLC is supported on every platform Plezy ships, so with detection + // reporting nothing installed it is always one of the filtered-out ids. + KnownPlayers.resetForTesting(); + KnownPlayers.probe = const _NoPlayersInstalled(); + final selected = KnownPlayers.findById('vlc')!; + expect(selected.isAvailable, isTrue); + expect((await KnownPlayers.getForCurrentPlatform()).map((p) => p.id), isNot(contains('vlc'))); + + await settings.write(SettingsService.useExternalPlayer, true); + await settings.write(SettingsService.selectedExternalPlayer, selected); + + await tester.pumpWidget(MaterialApp(theme: monoTheme(dark: true), home: const ExternalPlayerScreen())); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(FocusableListTile, selected.name), findsOneWidget); + expect(find.widgetWithText(FocusableListTile, 'System Default'), findsOneWidget); + }); +} + +/// Reports every player as installed so the screen renders the full platform +/// list regardless of what the host running the suite happens to have. +class _AllPlayersInstalled extends PlayerInstallProbe { + const _AllPlayersInstalled(); + + @override + Future run(String executable, List arguments) async => ProcessResult(0, 0, '', ''); + + @override + Future applicationInstalled(String bundleId) async => true; + + @override + Future fileExists(String path) async => true; + + @override + Future schemeHasHandler(String scheme) async => true; +} + +/// Reports nothing as installed, so only players without a detector survive. +class _NoPlayersInstalled extends PlayerInstallProbe { + const _NoPlayersInstalled(); + + @override + Future run(String executable, List arguments) async => ProcessResult(0, 1, '', ''); + + @override + Future applicationInstalled(String bundleId) async => false; + + @override + Future fileExists(String path) async => false; + + @override + Future schemeHasHandler(String scheme) async => false; } diff --git a/tvos/Runner/Info.plist b/tvos/Runner/Info.plist index 65f2a95f..f6815021 100644 --- a/tvos/Runner/Info.plist +++ b/tvos/Runner/Info.plist @@ -33,6 +33,13 @@ + + LSApplicationQueriesSchemes + + vlc + infuse + GCSupportedGameControllers