From c0f26bebd3c9b0d835b9e8acae826d7350bd7f20 Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 22:26:08 +0800 Subject: [PATCH 1/7] feat: add file associations and deferred directory loading --- README.md | 2 + README_zh.md | 2 + innosetup/rawviewer.iss | 55 ++++ lib/core/platform_channels.dart | 4 + lib/gallery/widgets/desktop_command_bar.dart | 1 - lib/gallery/widgets/gallery_chrome.dart | 1 - lib/gallery/widgets/media_thumbnail_tile.dart | 1 - lib/home_page.dart | 298 +++++++++++++++--- lib/l10n/app_en.arb | 20 ++ lib/l10n/app_localizations.dart | 36 +++ lib/l10n/app_localizations_en.dart | 24 ++ lib/l10n/app_localizations_zh.dart | 24 ++ lib/l10n/app_zh.arb | 20 ++ lib/preview/image_preview_page.dart | 196 +++++++++--- lib/preview/widgets/preview_overview_map.dart | 1 - lib/settings_page.dart | 129 +++++++- macos/Runner/AppDelegate.swift | 205 ++++++++++++ macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Info.plist | 160 ++++++++++ macos/Runner/MainFlutterWindow.swift | 5 + macos/Runner/Release.entitlements | 2 + test/core/file_association_test.dart | 30 ++ test/widget_test.dart | 117 ++++++- windows/runner/flutter_window.cpp | 72 +++++ windows/runner/flutter_window.h | 3 + windows/runner/shell_integration.cpp | 162 ++++++++++ windows/runner/shell_integration.h | 5 + 27 files changed, 1480 insertions(+), 97 deletions(-) create mode 100644 test/core/file_association_test.dart diff --git a/README.md b/README.md index 65ff3d7..50e9cd2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Raw Viewer allows photographers and enthusiasts to seamlessly view, browse, and - **EXIF Metadata:** Reads and displays true image capture timestamps directly from EXIF data. - **Smooth Interaction:** Fast page scroll, smooth pinch-to-zoom (touch), and scroll-to-zoom (mouse) functionality. - **Windows Context Menu:** Lets you install an “Open in RawView” Explorer entry for the current user, supporting files, multi-file selection, folders, and folder background opening. +- **Default File Associations:** Registers supported image formats on Windows and macOS, with per-format switches in Settings. +- **Single-File Open:** Opening one image jumps directly to its preview; neighboring files are loaded only when requested from the preview toolbar or when returning to the gallery. - **Cross-Platform:** Built for desktop (Windows, macOS) and mobile (Android) natively. ## Screenshots diff --git a/README_zh.md b/README_zh.md index 8d87705..2000f3d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,6 +19,8 @@ Raw Viewer 旨在为摄影师和摄影爱好者提供一个轻量、流畅的工 - **EXIF 元数据:** 自动读取并显示 EXIF 中的真实拍摄时间。 - **流畅交互:** 支持顺滑的快速翻页,并适配了触屏的捏合缩放(Pinch-to-zoom)与鼠标滚轮缩放。 - **Windows 右键菜单:** 可在设置页一键将“在RawView中打开”安装到当前用户的资源管理器右键菜单,支持文件、多个文件、文件夹,以及文件夹空白处右键打开当前目录。 +- **默认打开格式:** Windows 和 macOS 可将支持的图片格式关联到 Raw Viewer,并在设置页按格式自定义。 +- **单图打开:** 直接打开单张图片时立即进入预览;相邻文件只会在预览顶栏主动加载目录或返回图库时加载。 - **跨平台支持:** 原生支持桌面端 (Windows, macOS) 与移动端 (Android)。 ## 界面截图 diff --git a/innosetup/rawviewer.iss b/innosetup/rawviewer.iss index 8fe63d8..84489b2 100644 --- a/innosetup/rawviewer.iss +++ b/innosetup/rawviewer.iss @@ -29,6 +29,7 @@ Compression=lzma SolidCompression=yes WizardStyle=modern UninstallDisplayIcon={app}\{#MyAppExeName} +ChangesAssociations=yes [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -43,5 +44,59 @@ Source: "..\build\windows\packaged\*"; DestDir: "{app}"; Flags: ignoreversion re Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +[Registry] +Root: HKCU; Subkey: "Software\Classes\.arw"; ValueType: string; ValueName: ""; ValueData: "RawViewer.arw"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.cr2"; ValueType: string; ValueName: ""; ValueData: "RawViewer.cr2"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.cr3"; ValueType: string; ValueName: ""; ValueData: "RawViewer.cr3"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.dng"; ValueType: string; ValueName: ""; ValueData: "RawViewer.dng"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.nef"; ValueType: string; ValueName: ""; ValueData: "RawViewer.nef"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.orf"; ValueType: string; ValueName: ""; ValueData: "RawViewer.orf"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.raf"; ValueType: string; ValueName: ""; ValueData: "RawViewer.raf"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.rw2"; ValueType: string; ValueName: ""; ValueData: "RawViewer.rw2"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.srw"; ValueType: string; ValueName: ""; ValueData: "RawViewer.srw"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.jpg"; ValueType: string; ValueName: ""; ValueData: "RawViewer.jpg"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.jpeg"; ValueType: string; ValueName: ""; ValueData: "RawViewer.jpeg"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.png"; ValueType: string; ValueName: ""; ValueData: "RawViewer.png"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\.webp"; ValueType: string; ValueName: ""; ValueData: "RawViewer.webp"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\RawViewer.arw"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.cr2"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.cr3"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.dng"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.nef"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.orf"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.raf"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.rw2"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.srw"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.jpg"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.jpeg"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.png"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.webp"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.arw\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.cr2\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.cr3\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.dng\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.nef\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.orf\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.raf\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.rw2\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.srw\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.jpg\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.jpeg\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.png\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.webp\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.arw\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.cr2\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.cr3\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.dng\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.nef\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.orf\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.raf\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.rw2\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.srw\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.jpg\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.jpeg\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.png\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\RawViewer.webp\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey + [Run] Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent diff --git a/lib/core/platform_channels.dart b/lib/core/platform_channels.dart index 78861ff..d81f90c 100644 --- a/lib/core/platform_channels.dart +++ b/lib/core/platform_channels.dart @@ -1,5 +1,9 @@ import 'package:flutter/services.dart'; const MethodChannel desktopOpenChannel = MethodChannel('rawviewer/open_paths'); +const MethodChannel macOSDirectoryAccessChannel = + MethodChannel('rawviewer/macos_directory_access'); const MethodChannel windowsShellChannel = MethodChannel('rawviewer/windows_shell'); +const MethodChannel fileAssociationChannel = + MethodChannel('rawviewer/file_associations'); diff --git a/lib/gallery/widgets/desktop_command_bar.dart b/lib/gallery/widgets/desktop_command_bar.dart index e757fb9..5d1a8ad 100644 --- a/lib/gallery/widgets/desktop_command_bar.dart +++ b/lib/gallery/widgets/desktop_command_bar.dart @@ -224,4 +224,3 @@ class ThumbnailSizeControls extends StatelessWidget { ); } } - diff --git a/lib/gallery/widgets/gallery_chrome.dart b/lib/gallery/widgets/gallery_chrome.dart index ad171be..efd106e 100644 --- a/lib/gallery/widgets/gallery_chrome.dart +++ b/lib/gallery/widgets/gallery_chrome.dart @@ -115,4 +115,3 @@ class GalleryStatusBar extends StatelessWidget { ); } } - diff --git a/lib/gallery/widgets/media_thumbnail_tile.dart b/lib/gallery/widgets/media_thumbnail_tile.dart index 5958e2e..ed3d9c4 100644 --- a/lib/gallery/widgets/media_thumbnail_tile.dart +++ b/lib/gallery/widgets/media_thumbnail_tile.dart @@ -335,4 +335,3 @@ class _MediaThumbnailTileState extends State { return Hero(tag: widget.filePath, child: image); } } - diff --git a/lib/home_page.dart b/lib/home_page.dart index c3730bd..795227b 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -36,6 +36,16 @@ import 'worker_service.dart'; enum _OpenedSourceKind { none, folder, files } +class _LoadedDirectory { + const _LoadedDirectory({ + required this.path, + required this.files, + }); + + final String path; + final List files; +} + class HomePage extends StatefulWidget { final ValueChanged onAppLanguageChanged; @@ -50,6 +60,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { String? _currentDirectoryPath; + String? _deferredDirectoryPath; int? _openedDirectoryCount; String? _lastSyncedWindowsContextMenuText; List _files = []; @@ -76,6 +87,7 @@ class _HomePageState extends State { _initCache(); unawaited(_listenForDesktopOpenRequests()); unawaited(_refreshWindowsContextMenuState()); + unawaited(_refreshFileAssociationState()); } @override @@ -226,10 +238,12 @@ class _HomePageState extends State { unawaited(_persistPreviewToolbarOpacity(settings.previewToolbarOpacity)); } if (previewFilmstripOpacityChanged) { - unawaited(_persistPreviewFilmstripOpacity(settings.previewFilmstripOpacity)); + unawaited( + _persistPreviewFilmstripOpacity(settings.previewFilmstripOpacity)); } if (previewFilmstripHeightChanged) { - unawaited(_persistPreviewFilmstripHeight(settings.previewFilmstripHeight)); + unawaited( + _persistPreviewFilmstripHeight(settings.previewFilmstripHeight)); } if (rawViewModeChanged) { unawaited(_persistRawViewMode(settings.rawViewMode)); @@ -278,6 +292,27 @@ class _HomePageState extends State { } } + Future _refreshFileAssociationState() async { + if (!Platform.isWindows && !Platform.isMacOS) { + return; + } + + try { + final values = await fileAssociationChannel + .invokeMapMethod('getFileAssociationState'); + if (!mounted) return; + setState(() { + _settings = _settings.copyWith( + fileAssociations: FileAssociationSettings.fromPlatformMap(values), + ); + }); + } on MissingPluginException { + // Ignore when file association integration is not implemented. + } on PlatformException { + // Ignore transient platform integration failures at startup. + } + } + Future _getWindowsContextMenuSettings() async { final values = await windowsShellChannel.invokeMapMethod( 'getContextMenuState', @@ -316,6 +351,28 @@ class _HomePageState extends State { } } + Future _setFileAssociations( + Set extensions, + ) async { + try { + final values = await fileAssociationChannel + .invokeMapMethod('setFileAssociations', { + 'extensions': extensions.toList(growable: false), + }); + final nextState = FileAssociationSettings.fromPlatformMap(values); + if (mounted) { + setState(() { + _settings = _settings.copyWith(fileAssociations: nextState); + }); + } + return nextState; + } on PlatformException catch (error) { + throw Exception(error.message ?? 'Unknown file association error'); + } on MissingPluginException { + throw Exception('File association integration is not supported'); + } + } + Future _syncWindowsContextMenuLanguage(String menuText) async { if (!Platform.isWindows || !_settings.windowsContextMenu.enabled) { _lastSyncedWindowsContextMenuText = null; @@ -413,7 +470,7 @@ class _HomePageState extends State { } Future _listenForDesktopOpenRequests() async { - if (!Platform.isMacOS && !Platform.isWindows) { + if (!Platform.isMacOS && !Platform.isWindows && !Platform.isLinux) { return; } @@ -431,6 +488,14 @@ class _HomePageState extends State { }); try { + if (Platform.isLinux) { + final initialPaths = Platform.executableArguments; + if (initialPaths.isNotEmpty) { + await _handleIncomingPaths(initialPaths); + } + return; + } + final initialPaths = await desktopOpenChannel.invokeListMethod('getInitialPaths'); if (initialPaths != null && initialPaths.isNotEmpty) { @@ -470,7 +535,15 @@ class _HomePageState extends State { } if (directories.isNotEmpty) { - final directoryFiles = directories.expand(_listRawFilesInDirectory); + final directoryFiles = []; + try { + for (final directory in directories) { + directoryFiles.addAll(_listRawFilesInDirectory(directory)); + } + } on FileSystemException catch (error) { + _showDirectoryLoadError(error); + return; + } final nextFiles = _deduplicateMediaFiles([...directoryFiles, ...files]); _applyOpenedFiles( files: nextFiles, @@ -486,7 +559,9 @@ class _HomePageState extends State { return; } - final shouldReplaceCurrent = _openedSourceKind != _OpenedSourceKind.files; + final shouldOpenSingleFile = files.length == 1; + final shouldReplaceCurrent = + _openedSourceKind != _OpenedSourceKind.files || shouldOpenSingleFile; final nextFiles = shouldReplaceCurrent ? files : _deduplicateMediaFiles([..._files, ...files]); @@ -495,7 +570,13 @@ class _HomePageState extends State { files: nextFiles, sourceKind: _OpenedSourceKind.files, clearCache: shouldReplaceCurrent, + deferredDirectoryPath: + shouldOpenSingleFile ? path.dirname(files.single.path) : null, ); + + if (shouldOpenSingleFile) { + _scheduleSingleFilePreview(files.single.path); + } } void _applyOpenedFiles({ @@ -504,6 +585,7 @@ class _HomePageState extends State { required bool clearCache, String? openedDirectoryPath, int? openedDirectoryCount, + String? deferredDirectoryPath, }) { if (!mounted) { return; @@ -520,10 +602,170 @@ class _HomePageState extends State { _openedSourceKind = sourceKind; _currentDirectoryPath = openedDirectoryPath; _openedDirectoryCount = openedDirectoryCount; + _deferredDirectoryPath = deferredDirectoryPath; _files = files; }); } + void _scheduleSingleFilePreview(String filePath) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) { + return; + } + if (_files.length != 1 || _files.single.path != filePath) { + return; + } + _openPreview( + mediaGroups: buildAdaptiveMediaGroups(_files), + initialIndex: 0, + deferDirectoryLoad: _deferredDirectoryPath != null, + ); + }); + } + + Future?> _loadDeferredDirectoryForPreview() async { + final directoryPath = _deferredDirectoryPath; + if (directoryPath == null) { + return buildAdaptiveMediaGroups(_files); + } + + final loadedDirectory = await _loadDeferredDirectoryFiles(directoryPath); + if (loadedDirectory == null) { + return null; + } + final mediaGroups = buildAdaptiveMediaGroups(loadedDirectory.files); + if (mediaGroups.isEmpty) { + return null; + } + _applyOpenedFiles( + files: loadedDirectory.files, + sourceKind: _OpenedSourceKind.folder, + clearCache: false, + openedDirectoryPath: loadedDirectory.path, + openedDirectoryCount: 1, + ); + return mediaGroups; + } + + Future _loadDeferredDirectoryAfterClose() async { + if (_deferredDirectoryPath == null) return; + final directoryPath = _deferredDirectoryPath!; + try { + final loadedDirectory = await _loadDeferredDirectoryFiles(directoryPath); + if (loadedDirectory == null) { + return; + } + _applyOpenedFiles( + files: loadedDirectory.files, + sourceKind: _OpenedSourceKind.folder, + clearCache: false, + openedDirectoryPath: loadedDirectory.path, + openedDirectoryCount: 1, + ); + } catch (error) { + _showDirectoryLoadError(error); + } + } + + Future<_LoadedDirectory?> _loadDeferredDirectoryFiles( + String directoryPath, + ) async { + try { + return _LoadedDirectory( + path: directoryPath, + files: _listRawFilesInDirectory(directoryPath), + ); + } on FileSystemException { + if (!Platform.isMacOS || !mounted) { + rethrow; + } + + final l10n = AppLocalizations.of(context); + final selectedDirectory = await macOSDirectoryAccessChannel + .invokeMethod('selectDirectory', { + 'title': l10n?.grantDirectoryAccessDialogTitle, + 'initialDirectory': directoryPath, + }); + if (selectedDirectory == null) { + return null; + } + final resolvedDirectory = + path.normalize(path.absolute(selectedDirectory)); + return _LoadedDirectory( + path: resolvedDirectory, + files: _listRawFilesInDirectory(resolvedDirectory), + ); + } + } + + void _showDirectoryLoadError(Object error) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); + if (l10n == null) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.loadDirectoryFailedMessage('$error'))), + ); + } + + void _openPreview({ + required List mediaGroups, + required int initialIndex, + required bool deferDirectoryLoad, + }) { + if (mediaGroups.isEmpty || !mounted) return; + + final screenWidth = MediaQuery.sizeOf(context).width; + final dpr = MediaQuery.devicePixelRatioOf(context); + final totalPadding = 16.0 + (_crossAxisCount - 1) * 8.0; + final cellWidth = (screenWidth - totalPadding) / _crossAxisCount; + final thumbnailResizeWidth = + bucketDecodeWidth((cellWidth * dpr).clamp(100.0, 800.0)); + + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: kImagePreviewOpenTransitionDuration, + reverseTransitionDuration: kImagePreviewCloseTransitionDuration, + pageBuilder: (context, animation, secondaryAnimation) { + return ExcludeSemantics( + child: ImagePreviewPage( + mediaGroups: mediaGroups, + initialIndex: initialIndex, + thumbnailResizeWidth: thumbnailResizeWidth, + imageStore: _imageStore, + timestampRepository: _timestampRepository, + initialSettings: _settings, + onLoadDirectory: + deferDirectoryLoad ? _loadDeferredDirectoryForPreview : null, + onRawViewModeChanged: (mode) => _updateSettings( + _settings.copyWith(rawViewMode: mode), + ), + onPreviewFilmstripHeightChanged: (height) => _updateSettings( + _settings.copyWith(previewFilmstripHeight: height), + ), + onClose: () { + Navigator.pop(context); + unawaited(_loadDeferredDirectoryAfterClose()); + }, + ), + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + child: child, + ); + }, + ), + ); + } + List _listRawFilesInDirectory(String directoryPath) => listMediaFilesInDirectory(directoryPath); @@ -610,44 +852,10 @@ class _HomePageState extends State { ? (ratio) => _updateMediaAspectRatio(filePath, ratio) : null, onTap: () { - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: kImagePreviewOpenTransitionDuration, - reverseTransitionDuration: kImagePreviewCloseTransitionDuration, - pageBuilder: (context, animation, secondaryAnimation) { - return ExcludeSemantics( - child: ImagePreviewPage( - mediaGroups: mediaGroups, - initialIndex: index, - thumbnailResizeWidth: thumbnailResizeWidth, - imageStore: _imageStore, - timestampRepository: _timestampRepository, - initialSettings: _settings, - onRawViewModeChanged: (mode) => _updateSettings( - _settings.copyWith(rawViewMode: mode), - ), - onPreviewFilmstripHeightChanged: (height) => _updateSettings( - _settings.copyWith(previewFilmstripHeight: height), - ), - onClose: () { - Navigator.pop(context); - }, - ), - ); - }, - transitionsBuilder: - (context, animation, secondaryAnimation, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ), - child: child, - ); - }, - ), + _openPreview( + mediaGroups: mediaGroups, + initialIndex: index, + deferDirectoryLoad: false, ); }, ); @@ -956,6 +1164,10 @@ class _HomePageState extends State { onSettingsChanged: _updateSettings, onWindowsContextMenuChanged: Platform.isWindows ? _setWindowsContextMenuEnabled : null, + onFileAssociationsChanged: + (Platform.isWindows || Platform.isMacOS) + ? _setFileAssociations + : null, onClose: () { Navigator.pop(dialogContext); }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 79b53e9..7f0bdb6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -60,6 +60,16 @@ } } }, + "fileAssociationsSectionTitle": "Default file associations", + "fileAssociationFormatSubtitle": "Open .{extension} files with Raw Viewer", + "fileAssociationsUpdateFailed": "Failed to update file associations: {error}", + "@fileAssociationsUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "homeEmptyState": "Open or drop RAW and image files/folders", "openFolder": "Open folder", "openFiles": "Open files", @@ -94,6 +104,16 @@ "showPreviewOverviewTooltip": "Show overview map", "hidePreviewOverviewTooltip": "Hide overview map", "centerCurrentPreviewThumbnailTooltip": "Center current thumbnail", + "loadDirectoryTooltip": "Load images from this directory", + "grantDirectoryAccessDialogTitle": "Allow Raw Viewer to access this folder", + "loadDirectoryFailedMessage": "Could not load the directory: {error}", + "@loadDirectoryFailedMessage": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "largerThumbnailsTooltip": "Larger thumbnails", "smallerThumbnailsTooltip": "Smaller thumbnails", "gridColumnsTooltip": "{count} columns", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d8640a7..3bb4be6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -329,6 +329,24 @@ abstract class AppLocalizations { /// **'Failed to update Windows context menu: {error}'** String windowsContextMenuUpdateFailed(String error); + /// No description provided for @fileAssociationsSectionTitle. + /// + /// In en, this message translates to: + /// **'Default file associations'** + String get fileAssociationsSectionTitle; + + /// No description provided for @fileAssociationFormatSubtitle. + /// + /// In en, this message translates to: + /// **'Open .{extension} files with Raw Viewer'** + String fileAssociationFormatSubtitle(Object extension); + + /// No description provided for @fileAssociationsUpdateFailed. + /// + /// In en, this message translates to: + /// **'Failed to update file associations: {error}'** + String fileAssociationsUpdateFailed(String error); + /// No description provided for @homeEmptyState. /// /// In en, this message translates to: @@ -491,6 +509,24 @@ abstract class AppLocalizations { /// **'Center current thumbnail'** String get centerCurrentPreviewThumbnailTooltip; + /// No description provided for @loadDirectoryTooltip. + /// + /// In en, this message translates to: + /// **'Load images from this directory'** + String get loadDirectoryTooltip; + + /// No description provided for @grantDirectoryAccessDialogTitle. + /// + /// In en, this message translates to: + /// **'Allow Raw Viewer to access this folder'** + String get grantDirectoryAccessDialogTitle; + + /// No description provided for @loadDirectoryFailedMessage. + /// + /// In en, this message translates to: + /// **'Could not load the directory: {error}'** + String loadDirectoryFailedMessage(String error); + /// No description provided for @largerThumbnailsTooltip. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6362996..80b54ff 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -131,6 +131,19 @@ class AppLocalizationsEn extends AppLocalizations { return 'Failed to update Windows context menu: $error'; } + @override + String get fileAssociationsSectionTitle => 'Default file associations'; + + @override + String fileAssociationFormatSubtitle(Object extension) { + return 'Open .$extension files with Raw Viewer'; + } + + @override + String fileAssociationsUpdateFailed(String error) { + return 'Failed to update file associations: $error'; + } + @override String get homeEmptyState => 'Open or drop RAW and image files/folders'; @@ -214,6 +227,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get centerCurrentPreviewThumbnailTooltip => 'Center current thumbnail'; + @override + String get loadDirectoryTooltip => 'Load images from this directory'; + + @override + String get grantDirectoryAccessDialogTitle => 'Allow Raw Viewer to access this folder'; + + @override + String loadDirectoryFailedMessage(String error) { + return 'Could not load the directory: $error'; + } + @override String get largerThumbnailsTooltip => 'Larger thumbnails'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 33f8f59..12a684d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -131,6 +131,19 @@ class AppLocalizationsZh extends AppLocalizations { return '更新 Windows 右键菜单失败:$error'; } + @override + String get fileAssociationsSectionTitle => '默认打开格式'; + + @override + String fileAssociationFormatSubtitle(Object extension) { + return '使用 Raw Viewer 打开 .$extension 文件'; + } + + @override + String fileAssociationsUpdateFailed(String error) { + return '更新文件关联失败:$error'; + } + @override String get homeEmptyState => '打开或拖放 RAW 与图片文件/文件夹'; @@ -214,6 +227,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get centerCurrentPreviewThumbnailTooltip => '居中当前缩略图'; + @override + String get loadDirectoryTooltip => '加载此目录中的图片'; + + @override + String get grantDirectoryAccessDialogTitle => '允许 Raw Viewer 访问此文件夹'; + + @override + String loadDirectoryFailedMessage(String error) { + return '无法加载目录:$error'; + } + @override String get largerThumbnailsTooltip => '放大缩略图'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9836f38..8d98516 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -60,6 +60,16 @@ } } }, + "fileAssociationsSectionTitle": "默认打开格式", + "fileAssociationFormatSubtitle": "使用 Raw Viewer 打开 .{extension} 文件", + "fileAssociationsUpdateFailed": "更新文件关联失败:{error}", + "@fileAssociationsUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "homeEmptyState": "打开或拖放 RAW 与图片文件/文件夹", "openFolder": "打开文件夹", "openFiles": "打开文件", @@ -94,6 +104,16 @@ "showPreviewOverviewTooltip": "显示鸟瞰图", "hidePreviewOverviewTooltip": "隐藏鸟瞰图", "centerCurrentPreviewThumbnailTooltip": "居中当前缩略图", + "loadDirectoryTooltip": "加载此目录中的图片", + "grantDirectoryAccessDialogTitle": "允许 Raw Viewer 访问此文件夹", + "loadDirectoryFailedMessage": "无法加载目录:{error}", + "@loadDirectoryFailedMessage": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "largerThumbnailsTooltip": "放大缩略图", "smallerThumbnailsTooltip": "缩小缩略图", "gridColumnsTooltip": "{count} 列", diff --git a/lib/preview/image_preview_page.dart b/lib/preview/image_preview_page.dart index 26771a7..60858d7 100644 --- a/lib/preview/image_preview_page.dart +++ b/lib/preview/image_preview_page.dart @@ -46,6 +46,7 @@ class ImagePreviewPage extends StatefulWidget { final ViewerSettings initialSettings; final VoidCallback onClose; + final Future?> Function()? onLoadDirectory; /// Reports a mode change so it can be persisted. The chosen mode is app-wide, /// not per-file: this switch is the only place it is set. @@ -61,6 +62,7 @@ class ImagePreviewPage extends StatefulWidget { required this.timestampRepository, required this.initialSettings, required this.onClose, + this.onLoadDirectory, required this.onRawViewModeChanged, required this.onPreviewFilmstripHeightChanged, }); @@ -70,6 +72,7 @@ class ImagePreviewPage extends StatefulWidget { } class _ImagePreviewPageState extends State { + late List _mediaGroups; late PageController _pageController; late int _currentIndex; late int _targetPage; @@ -78,6 +81,9 @@ class _ImagePreviewPageState extends State { late double _previewFilmstripHeight; bool _isFilmstripHeightDirty = false; bool _showPreviewOverview = true; + bool _isDirectoryLoaded = true; + bool _isLoadingDirectory = false; + String? _forcedPairedJpegPrimaryPath; final Map _rotationQuarterTurns = {}; final Map> _previewKeys = >{}; @@ -113,6 +119,8 @@ class _ImagePreviewPageState extends State { @override void initState() { super.initState(); + _mediaGroups = List.of(widget.mediaGroups); + _isDirectoryLoaded = widget.onLoadDirectory == null; _currentIndex = widget.initialIndex; _targetPage = widget.initialIndex; _rawViewMode = widget.initialSettings.rawViewMode; @@ -121,10 +129,62 @@ class _ImagePreviewPageState extends State { ); _pageController = PageController(initialPage: widget.initialIndex); _currentTimestampFuture = widget.timestampRepository.load( - widget.mediaGroups[_currentIndex].primary.path, + _mediaGroups[_currentIndex].primary.path, ); } + Future _loadDirectory() async { + final onLoadDirectory = widget.onLoadDirectory; + if (onLoadDirectory == null || _isDirectoryLoaded || _isLoadingDirectory) { + return; + } + + final currentPath = _mediaGroups[_currentIndex].primary.path; + setState(() { + _isLoadingDirectory = true; + }); + + try { + final mediaGroups = await onLoadDirectory(); + if (!mounted || mediaGroups == null || mediaGroups.isEmpty) return; + + final nextIndex = mediaGroups.indexWhere( + (group) => + group.primary.path == currentPath || + group.pairedJpeg?.path == currentPath, + ); + final resolvedIndex = nextIndex < 0 ? 0 : nextIndex; + final resolvedGroup = mediaGroups[resolvedIndex]; + final openedPairedJpeg = + resolvedGroup.pairedJpeg?.path == currentPath && resolvedGroup.isRaw; + setState(() { + _mediaGroups = mediaGroups; + _currentIndex = resolvedIndex; + _targetPage = resolvedIndex; + _currentTimestampFuture = widget.timestampRepository.load(currentPath); + _forcedPairedJpegPrimaryPath = + openedPairedJpeg ? resolvedGroup.primary.path : null; + _isDirectoryLoaded = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _pageController.hasClients) { + _pageController.jumpToPage(resolvedIndex); + } + }); + } catch (error) { + if (mounted) { + final l10n = AppLocalizations.of(context)!; + _showPreviewMessage(l10n.loadDirectoryFailedMessage('$error')); + } + } finally { + if (mounted) { + setState(() { + _isLoadingDirectory = false; + }); + } + } + } + @override void dispose() { _scrollStopTimer?.cancel(); @@ -140,7 +200,7 @@ class _ImagePreviewPageState extends State { setState(() { _currentIndex = index; _currentTimestampFuture = widget.timestampRepository.load( - widget.mediaGroups[_currentIndex].primary.path, + _mediaGroups[_currentIndex].primary.path, ); if ((_targetPage - index).abs() <= 1) { _targetPage = index; @@ -214,7 +274,7 @@ class _ImagePreviewPageState extends State { } void _preloadForScrollPosition(double page) { - final index = page.round().clamp(0, widget.mediaGroups.length - 1); + final index = page.round().clamp(0, _mediaGroups.length - 1); if (index == _lastScrollPrefetchIndex) return; _lastScrollPrefetchIndex = index; _preloadThumbnails(index, isFastScrolling: true); @@ -270,9 +330,9 @@ class _ImagePreviewPageState extends State { TaskPriority priority = TaskPriority.low, required int targetWidth, }) { - if (index < 0 || index >= widget.mediaGroups.length) return; + if (index < 0 || index >= _mediaGroups.length) return; - final mediaFile = widget.mediaGroups[index].primary; + final mediaFile = _mediaGroups[index].primary; final String filePath = mediaFile.path; if (mediaFile.isRaw) { @@ -356,11 +416,39 @@ class _ImagePreviewPageState extends State { ); } + Widget _buildDirectoryLoadPanel(AppLocalizations l10n) { + final bottomPadding = MediaQuery.paddingOf(context).bottom; + return PreviewHoverReveal( + restingOpacity: widget.initialSettings.previewFilmstripOpacity, + child: Container( + key: const ValueKey('preview-directory-load-panel'), + padding: EdgeInsets.fromLTRB(12, 10, 12, bottomPadding + 10), + decoration: BoxDecoration( + color: RawViewerColors.surface.withValues(alpha: 0.96), + border: const Border( + top: BorderSide(color: RawViewerColors.border), + ), + ), + child: Center( + child: DesktopIconButton( + icon: _isLoadingDirectory + ? Icons.hourglass_top_outlined + : Icons.folder_open_outlined, + tooltip: l10n.loadDirectoryTooltip, + onPressed: _isLoadingDirectory + ? null + : () => unawaited(_loadDirectory()), + ), + ), + ), + ); + } + void _switchPage(int delta) { int newTarget = _targetPage + delta; if (newTarget < 0) newTarget = 0; - if (newTarget >= widget.mediaGroups.length) { - newTarget = widget.mediaGroups.length - 1; + if (newTarget >= _mediaGroups.length) { + newTarget = _mediaGroups.length - 1; } if (newTarget == _targetPage && newTarget == _currentIndex) { @@ -423,7 +511,7 @@ class _ImagePreviewPageState extends State { } void _jumpToPageFast(int index) { - if (index < 0 || index >= widget.mediaGroups.length) return; + if (index < 0 || index >= _mediaGroups.length) return; _targetPage = index; _preloadThumbnails(index, isFastScrolling: true); _scrollStopTimer?.cancel(); @@ -435,7 +523,7 @@ class _ImagePreviewPageState extends State { } void _jumpToPage(int index) { - if (index < 0 || index >= widget.mediaGroups.length) { + if (index < 0 || index >= _mediaGroups.length) { return; } if (index == _currentIndex && index == _targetPage) { @@ -491,6 +579,9 @@ class _ImagePreviewPageState extends State { /// The mode this file can actually display, which may fall back from the /// app-wide preference when the preferred source is missing here. RawViewMode _effectiveViewModeFor(MediaGroup mediaGroup) { + if (_forcedPairedJpegPrimaryPath == mediaGroup.primary.path) { + return RawViewMode.pairedJpeg; + } return resolveRawViewMode( preferred: _rawViewMode, hasEmbeddedJpeg: _hasEmbeddedJpegFor(mediaGroup), @@ -519,6 +610,7 @@ class _ImagePreviewPageState extends State { // Apply here so the visible page changes on the next frame, and report it // so it is persisted for the next file and the next launch. setState(() { + _forcedPairedJpegPrimaryPath = null; _rawViewMode = mode; }); widget.onRawViewModeChanged(mode); @@ -604,16 +696,19 @@ class _ImagePreviewPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final currentMediaGroup = widget.mediaGroups[_currentIndex]; - final currentFilePath = widget.mediaGroups[_currentIndex].primary.path; + final currentMediaGroup = _mediaGroups[_currentIndex]; + final currentFilePath = _mediaGroups[_currentIndex].primary.path; final currentPreviewKey = _previewKeyFor(currentFilePath); final currentViewMode = _effectiveViewModeFor(currentMediaGroup); final bottomSafePadding = MediaQuery.paddingOf(context).bottom; final previewFilmstripHeight = _clampedPreviewFilmstripHeight(context); final previewFilmstripTotalHeight = previewFilmstripHeight + bottomSafePadding; + final showDirectoryLoadPanel = + widget.onLoadDirectory != null && !_isDirectoryLoaded; + final showNavigationPanel = _showPreviewFilmstrip || showDirectoryLoadPanel; final previewBottomInset = - _showPreviewFilmstrip ? previewFilmstripTotalHeight : 0.0; + showNavigationPanel ? previewFilmstripTotalHeight : 0.0; final controlsBottomInset = bottomSafePadding + previewBottomInset + 12; final pageDragDevices = Set.from(ScrollConfiguration.of(context).dragDevices) @@ -647,10 +742,10 @@ class _ImagePreviewPageState extends State { dragStartBehavior: DragStartBehavior.down, allowImplicitScrolling: true, padEnds: true, - itemCount: widget.mediaGroups.length, + itemCount: _mediaGroups.length, onPageChanged: _onPageChanged, itemBuilder: (context, index) { - final mediaGroup = widget.mediaGroups[index]; + final mediaGroup = _mediaGroups[index]; final filePath = mediaGroup.primary.path; // Fit between the bars; the full-page viewport clips zoomed // images only at the window edges, beyond this padding. @@ -669,7 +764,8 @@ class _ImagePreviewPageState extends State { // Safe to forward the snapshot: the child is rebuilt from // this build method, so it is never staler than this page. settings: widget.initialSettings, - rotationQuarterTurns: _rotationQuarterTurns[filePath] ?? 0, + rotationQuarterTurns: + _rotationQuarterTurns[filePath] ?? 0, viewMode: _effectiveViewModeFor(mediaGroup), onEmbeddedJpegAvailability: (hasEmbeddedJpeg) => _recordEmbeddedJpegAvailability( @@ -753,44 +849,48 @@ class _ImagePreviewPageState extends State { ), ), ), - if (_showPreviewFilmstrip) + if (showNavigationPanel) Positioned( left: 0, right: 0, bottom: 0, height: previewFilmstripTotalHeight + - previewFilmstripResizeHandleAboveBar, - child: Stack( - children: [ - Positioned( - left: 0, - right: 0, - bottom: 0, - child: PreviewHoverReveal( - restingOpacity: - widget.initialSettings.previewFilmstripOpacity, - child: PreviewFilmstrip( - mediaGroups: widget.mediaGroups, - currentIndex: _currentIndex, - imageStore: widget.imageStore, - height: previewFilmstripHeight, - decodeWidth: _previewFilmstripDecodeWidth, - centerCurrentThumbnailTooltip: - l10n.centerCurrentPreviewThumbnailTooltip, - onIndexSelected: _jumpToPage, - onFastIndexSelected: _jumpToPageFast, - ), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - height: previewFilmstripResizeHandleHeight, - child: _buildPreviewFilmstripResizeHandle(), - ), - ], - ), + (_isDirectoryLoaded + ? previewFilmstripResizeHandleAboveBar + : 0), + child: _isDirectoryLoaded + ? Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: PreviewHoverReveal( + restingOpacity: + widget.initialSettings.previewFilmstripOpacity, + child: PreviewFilmstrip( + mediaGroups: _mediaGroups, + currentIndex: _currentIndex, + imageStore: widget.imageStore, + height: previewFilmstripHeight, + decodeWidth: _previewFilmstripDecodeWidth, + centerCurrentThumbnailTooltip: + l10n.centerCurrentPreviewThumbnailTooltip, + onIndexSelected: _jumpToPage, + onFastIndexSelected: _jumpToPageFast, + ), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + height: previewFilmstripResizeHandleHeight, + child: _buildPreviewFilmstripResizeHandle(), + ), + ], + ) + : _buildDirectoryLoadPanel(l10n), ), Positioned( top: 0, diff --git a/lib/preview/widgets/preview_overview_map.dart b/lib/preview/widgets/preview_overview_map.dart index be7bfc7..79f2792 100644 --- a/lib/preview/widgets/preview_overview_map.dart +++ b/lib/preview/widgets/preview_overview_map.dart @@ -91,4 +91,3 @@ class _PreviewOverviewViewportPainter extends CustomPainter { return oldDelegate.viewportRect != viewportRect; } } - diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 8ed8acc..d14109c 100644 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'core/media_types.dart'; import 'core/preview_filmstrip_size.dart'; import 'core/raw_view_mode.dart'; import 'l10n/app_localizations.dart'; @@ -98,6 +99,47 @@ class WindowsContextMenuSettings { typedef WindowsContextMenuToggleHandler = Future Function(bool enabled); +class FileAssociationSettings { + final bool supported; + final Map bindings; + + const FileAssociationSettings({ + this.supported = false, + this.bindings = const {}, + }); + + bool isBound(String extension) => bindings[extension] == true; + + FileAssociationSettings copyWith({ + bool? supported, + Map? bindings, + }) { + return FileAssociationSettings( + supported: supported ?? this.supported, + bindings: bindings ?? this.bindings, + ); + } + + factory FileAssociationSettings.fromPlatformMap( + Map? values, + ) { + final rawBindings = values?['bindings']; + final bindings = {}; + if (rawBindings is Map) { + for (final extension in supportedExtensions) { + bindings[extension] = rawBindings[extension] == true; + } + } + return FileAssociationSettings( + supported: values?['supported'] == true, + bindings: bindings, + ); + } +} + +typedef FileAssociationChangeHandler = Future Function( + Set extensions); + class ViewerSettings { // Which image the preview shows for RAW files. Chosen from the preview's own // top-right switch rather than this settings page, and persisted. @@ -118,6 +160,7 @@ class ViewerSettings { final double previewFilmstripOpacity; final double previewFilmstripHeight; final WindowsContextMenuSettings windowsContextMenu; + final FileAssociationSettings fileAssociations; const ViewerSettings({ this.rawViewMode = RawViewMode.decodedRaw, @@ -132,6 +175,7 @@ class ViewerSettings { this.previewFilmstripOpacity = kDefaultPreviewOverlayOpacity, this.previewFilmstripHeight = kPreviewFilmstripHeight, this.windowsContextMenu = const WindowsContextMenuSettings(), + this.fileAssociations = const FileAssociationSettings(), }); ViewerSettings copyWith({ @@ -147,6 +191,7 @@ class ViewerSettings { double? previewFilmstripOpacity, double? previewFilmstripHeight, WindowsContextMenuSettings? windowsContextMenu, + FileAssociationSettings? fileAssociations, }) { return ViewerSettings( rawViewMode: rawViewMode ?? this.rawViewMode, @@ -166,6 +211,7 @@ class ViewerSettings { previewFilmstripHeight: previewFilmstripHeight ?? this.previewFilmstripHeight, windowsContextMenu: windowsContextMenu ?? this.windowsContextMenu, + fileAssociations: fileAssociations ?? this.fileAssociations, ); } } @@ -175,6 +221,7 @@ class SettingsPage extends StatefulWidget { final VoidCallback onClose; final ValueChanged onSettingsChanged; final WindowsContextMenuToggleHandler? onWindowsContextMenuChanged; + final FileAssociationChangeHandler? onFileAssociationsChanged; const SettingsPage({ super.key, @@ -182,6 +229,7 @@ class SettingsPage extends StatefulWidget { required this.onClose, required this.onSettingsChanged, this.onWindowsContextMenuChanged, + this.onFileAssociationsChanged, }); @override @@ -191,6 +239,7 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { late ViewerSettings _currentSettings; bool _isUpdatingWindowsContextMenu = false; + bool _isUpdatingFileAssociations = false; String _languageLabel(AppLanguage language, AppLocalizations l10n) { switch (language) { @@ -219,6 +268,11 @@ class _SettingsPageState extends State { bool get _showWindowsContextMenuSection => Platform.isWindows && widget.onWindowsContextMenuChanged != null; + bool get _showFileAssociationSection => + (Platform.isWindows || Platform.isMacOS) && + widget.onFileAssociationsChanged != null && + _currentSettings.fileAssociations.supported; + List _withDividers(List children) { return [ for (var index = 0; index < children.length; index++) ...[ @@ -322,6 +376,47 @@ class _SettingsPageState extends State { } } + Future _handleFileAssociationChanged( + String extension, + bool enabled, + ) async { + final onFileAssociationsChanged = widget.onFileAssociationsChanged; + if (onFileAssociationsChanged == null || _isUpdatingFileAssociations) { + return; + } + + final nextBindings = Map.from( + _currentSettings.fileAssociations.bindings, + )..[extension] = enabled; + + setState(() { + _isUpdatingFileAssociations = true; + }); + + try { + final nextState = await onFileAssociationsChanged( + nextBindings.entries + .where((entry) => entry.value) + .map((entry) => entry.key) + .toSet(), + ); + if (!mounted) return; + _updateSettings(_currentSettings.copyWith(fileAssociations: nextState)); + } catch (error) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.fileAssociationsUpdateFailed('$error'))), + ); + } finally { + if (mounted) { + setState(() { + _isUpdatingFileAssociations = false; + }); + } + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -430,7 +525,8 @@ class _SettingsPageState extends State { title: l10n.previewFilmstripOpacityTitle, value: _currentSettings.previewFilmstripOpacity, onChanged: (value) => _updateSettings( - _currentSettings.copyWith(previewFilmstripOpacity: value), + _currentSettings.copyWith( + previewFilmstripOpacity: value), ), ), _buildOpacityRow( @@ -565,6 +661,37 @@ class _SettingsPageState extends State { ), ]), ), + if (_showFileAssociationSection) + DesktopSettingsSection( + title: l10n.fileAssociationsSectionTitle, + children: _withDividers([ + for (final extension in supportedExtensions) + DesktopSettingsRow( + key: ValueKey('file-association-$extension'), + title: extension.substring(1).toUpperCase(), + subtitle: l10n.fileAssociationFormatSubtitle( + extension.substring(1).toUpperCase(), + ), + control: _isUpdatingFileAssociations + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Switch( + value: _currentSettings.fileAssociations + .isBound(extension), + onChanged: (value) => + _handleFileAssociationChanged( + extension, + value, + ), + ), + ), + ]), + ), ], ), ), diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 5cef4da..38ad7af 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,6 +1,30 @@ import Cocoa +import CoreServices import FlutterMacOS +final class ScopedFileAccess { + static let shared = ScopedFileAccess() + + private var accessedPaths = Set() + private var accessedURLs: [URL] = [] + + private init() {} + + func retainAccess(to url: URL) { + let normalizedURL = url.standardizedFileURL + let path = normalizedURL.path + guard accessedPaths.insert(path).inserted else { + return + } + + guard normalizedURL.startAccessingSecurityScopedResource() else { + accessedPaths.remove(path) + return + } + accessedURLs.append(normalizedURL) + } +} + final class OpenPathChannel { static let shared = OpenPathChannel() @@ -73,6 +97,183 @@ final class OpenPathChannel { } } +final class DirectoryAccessChannel { + static let shared = DirectoryAccessChannel() + + private let channelName = "rawviewer/macos_directory_access" + private weak var flutterViewController: FlutterViewController? + + private init() {} + + func attach(to flutterViewController: FlutterViewController) { + self.flutterViewController = flutterViewController + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: flutterViewController.engine.binaryMessenger + ) + + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result(FlutterMethodNotImplemented) + return + } + guard call.method == "selectDirectory" else { + result(FlutterMethodNotImplemented) + return + } + + self.selectDirectory(arguments: call.arguments, result: result) + } + } + + private func selectDirectory(arguments: Any?, result: @escaping FlutterResult) { + guard let window = flutterViewController?.view.window else { + result(FlutterError( + code: "window_unavailable", + message: "Unable to present the directory access dialog.", + details: nil + )) + return + } + + let values = arguments as? [String: Any] ?? [:] + let dialog = NSOpenPanel() + dialog.canChooseFiles = false + dialog.canChooseDirectories = true + dialog.allowsMultipleSelection = false + dialog.showsHiddenFiles = false + + if let initialDirectory = values["initialDirectory"] as? String, + !initialDirectory.isEmpty { + dialog.directoryURL = URL(fileURLWithPath: initialDirectory) + } + if let title = values["title"] as? String, !title.isEmpty { + dialog.title = title + dialog.message = title + dialog.prompt = title + } + + dialog.beginSheetModal(for: window) { response in + guard response == .OK, let url = dialog.url else { + result(nil) + return + } + + let selectedURL = url.standardizedFileURL + ScopedFileAccess.shared.retainAccess(to: selectedURL) + result(selectedURL.path) + } + } +} + +final class FileAssociationChannel { + static let shared = FileAssociationChannel() + + private let channelName = "rawviewer/file_associations" + private let supportedExtensions = [ + "arw", "cr2", "cr3", "dng", "nef", "orf", "raf", "rw2", "srw", + "jpg", "jpeg", "png", "webp", + ] + private var channel: FlutterMethodChannel? + + private init() {} + + func attach(to flutterViewController: FlutterViewController) { + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: flutterViewController.engine.binaryMessenger + ) + self.channel = channel + + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result(FlutterMethodNotImplemented) + return + } + + switch call.method { + case "getFileAssociationState": + result(self.state()) + case "setFileAssociations": + guard let arguments = call.arguments as? [String: Any], + let extensions = arguments["extensions"] as? [String] + else { + result(FlutterError( + code: "invalid_arguments", + message: "Expected an extensions list.", + details: nil + )) + return + } + + if let error = self.setAssociations(extensions: Set(extensions)) { + result(FlutterError( + code: "file_association_error", + message: error, + details: nil + )) + } else { + result(self.state()) + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func contentTypeIdentifier(for fileExtension: String) -> String { + return "com.rawviewer.\(fileExtension)" + } + + private func isAssociated(fileExtension: String) -> Bool { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + return false + } + let contentType = contentTypeIdentifier(for: fileExtension) as CFString + guard let handler = LSCopyDefaultRoleHandlerForContentType( + contentType, + LSRolesMask.all + )?.takeRetainedValue() as String? + else { + return false + } + return handler == bundleIdentifier + } + + private func state() -> [String: Any] { + var bindings: [String: Bool] = [:] + for fileExtension in supportedExtensions { + bindings[".\(fileExtension)"] = isAssociated(fileExtension: fileExtension) + } + return ["supported": true, "bindings": bindings] + } + + private func setAssociations(extensions: Set) -> String? { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + return "Unable to resolve the application bundle identifier." + } + + for fileExtension in supportedExtensions { + let extensionWithDot = ".\(fileExtension)" + let shouldAssociate = extensions.contains(fileExtension) || + extensions.contains(extensionWithDot) + let contentType = contentTypeIdentifier(for: fileExtension) as CFString + let handler: CFString = shouldAssociate + ? bundleIdentifier as CFString + : "" as CFString + let status = LSSetDefaultRoleHandlerForContentType( + contentType, + LSRolesMask.all, + handler + ) + if status != noErr { + return "Failed to update the default application for .\(fileExtension) (\(status))." + } + } + return nil + } +} + @main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -84,11 +285,15 @@ class AppDelegate: FlutterAppDelegate { } override func application(_ sender: NSApplication, openFile filename: String) -> Bool { + ScopedFileAccess.shared.retainAccess(to: URL(fileURLWithPath: filename)) OpenPathChannel.shared.handle(paths: [filename]) return true } override func application(_ sender: NSApplication, openFiles filenames: [String]) { + for filename in filenames { + ScopedFileAccess.shared.retainAccess(to: URL(fileURLWithPath: filename)) + } OpenPathChannel.shared.handle(paths: filenames) sender.reply(toOpenOrPrint: .success) } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 466aa1a..15a7e49 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,6 +8,8 @@ com.apple.security.files.user-selected.read-write + com.apple.security.files.downloads.read-write + com.apple.security.network.server diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..d94dc35 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -22,6 +22,166 @@ $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + CFBundleDocumentTypes + + + CFBundleTypeName + Raw Viewer image + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + com.rawviewer.arw + com.rawviewer.cr2 + com.rawviewer.cr3 + com.rawviewer.dng + com.rawviewer.nef + com.rawviewer.orf + com.rawviewer.raf + com.rawviewer.rw2 + com.rawviewer.srw + com.rawviewer.jpg + com.rawviewer.jpeg + com.rawviewer.png + com.rawviewer.webp + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + com.rawviewer.arw + UTTypeDescription + Sony ARW image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionarw + + + UTTypeIdentifier + com.rawviewer.cr2 + UTTypeDescription + Canon CR2 image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensioncr2 + + + UTTypeIdentifier + com.rawviewer.cr3 + UTTypeDescription + Canon CR3 image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensioncr3 + + + UTTypeIdentifier + com.rawviewer.dng + UTTypeDescription + DNG image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensiondng + + + UTTypeIdentifier + com.rawviewer.nef + UTTypeDescription + Nikon NEF image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionnef + + + UTTypeIdentifier + com.rawviewer.orf + UTTypeDescription + Olympus ORF image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionorf + + + UTTypeIdentifier + com.rawviewer.raf + UTTypeDescription + Fujifilm RAF image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionraf + + + UTTypeIdentifier + com.rawviewer.rw2 + UTTypeDescription + Panasonic RW2 image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionrw2 + + + UTTypeIdentifier + com.rawviewer.srw + UTTypeDescription + Samsung SRW image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionsrw + + + UTTypeIdentifier + com.rawviewer.jpg + UTTypeDescription + JPEG image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionjpg + + + UTTypeIdentifier + com.rawviewer.jpeg + UTTypeDescription + JPEG image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionjpeg + + + UTTypeIdentifier + com.rawviewer.png + UTTypeDescription + PNG image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionpng + + + UTTypeIdentifier + com.rawviewer.webp + UTTypeDescription + WebP image + UTTypeConformsTo + public.data + UTTypeTagSpecification + public.filename-extensionwebp + + NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index c9764b8..bc3fe59 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -76,6 +76,9 @@ class MainFlutterWindow: NSWindow { let dragHandlingView = DragHandlingView(frame: contentBounds) dragHandlingView.autoresizingMask = [.width, .height] dragHandlingView.onPathsDropped = { paths in + for path in paths { + ScopedFileAccess.shared.retainAccess(to: URL(fileURLWithPath: path)) + } OpenPathChannel.shared.handle(paths: paths) } @@ -83,6 +86,8 @@ class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) OpenPathChannel.shared.attach(to: flutterViewController) + DirectoryAccessChannel.shared.attach(to: flutterViewController) + FileAssociationChannel.shared.attach(to: flutterViewController) registerForDraggedTypes([.fileURL]) super.awakeFromNib() diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 19afff1..fdc5c27 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.files.user-selected.read-write + com.apple.security.files.downloads.read-write + diff --git a/test/core/file_association_test.dart b/test/core/file_association_test.dart new file mode 100644 index 0000000..3eb6354 --- /dev/null +++ b/test/core/file_association_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rawviewer/core/media_types.dart'; +import 'package:rawviewer/settings_page.dart'; + +void main() { + test('parses platform association state for every supported extension', () { + final state = FileAssociationSettings.fromPlatformMap({ + 'supported': true, + 'bindings': { + '.arw': true, + '.jpg': false, + }, + }); + + expect(state.supported, isTrue); + expect(state.isBound('.arw'), isTrue); + expect(state.isBound('.jpg'), isFalse); + expect(state.bindings.keys, containsAll(supportedExtensions)); + expect(state.isBound('.unsupported'), isFalse); + }); + + test('missing platform bindings default to unbound', () { + final state = FileAssociationSettings.fromPlatformMap({ + 'supported': true, + }); + + expect(state.bindings, isEmpty); + expect(state.isBound('.arw'), isFalse); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 6fbdc56..8c2a704 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -258,6 +258,120 @@ void main() { expect(tester.getTopLeft(zoomIn), initialPosition); }); + testWidgets('single-file preview can load its directory on demand', + (tester) async { + final groups = [ + const MediaGroup( + primary: MediaFile( + path: '/missing-test-image.jpg', + kind: MediaKind.bitmap, + ), + ), + ]; + var loadCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: ImagePreviewPage( + mediaGroups: groups, + initialIndex: 0, + thumbnailResizeWidth: 256, + imageStore: ImageStore(LruCache(1024)), + timestampRepository: TimestampRepository(), + initialSettings: const ViewerSettings(), + onLoadDirectory: () async { + loadCount++; + return [ + ...groups, + const MediaGroup( + primary: MediaFile( + path: '/missing-test-image-2.jpg', + kind: MediaKind.bitmap, + ), + ), + ]; + }, + onClose: () {}, + onRawViewModeChanged: (_) {}, + onPreviewFilmstripHeightChanged: (_) {}, + ), + ), + ); + await tester.pump(); + + final loadButton = find.byTooltip('Load images from this directory'); + expect(loadButton, findsOneWidget); + expect(find.byKey(const ValueKey('preview-directory-load-panel')), + findsOneWidget); + expect(find.byType(PreviewFilmstrip), findsNothing); + await tester.tap(loadButton); + await tester.pumpAndSettle(); + + expect(loadCount, 1); + expect(find.byTooltip('Load images from this directory'), findsNothing); + expect(find.byKey(const ValueKey('preview-directory-load-panel')), + findsNothing); + expect(find.byType(PreviewFilmstrip), findsOneWidget); + }); + + testWidgets( + 'single-file preview keeps directory loading available on cancel', + (tester) async { + const groups = [ + MediaGroup( + primary: MediaFile( + path: '/missing-test-image.jpg', + kind: MediaKind.bitmap, + ), + ), + ]; + var loadCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: ImagePreviewPage( + mediaGroups: groups, + initialIndex: 0, + thumbnailResizeWidth: 256, + imageStore: ImageStore(LruCache(1024)), + timestampRepository: TimestampRepository(), + initialSettings: const ViewerSettings(), + onLoadDirectory: () async { + loadCount++; + return null; + }, + onClose: () {}, + onRawViewModeChanged: (_) {}, + onPreviewFilmstripHeightChanged: (_) {}, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byTooltip('Load images from this directory')); + await tester.pumpAndSettle(); + + expect(loadCount, 1); + expect(find.byKey(const ValueKey('preview-directory-load-panel')), + findsOneWidget); + expect(find.byType(PreviewFilmstrip), findsNothing); + expect(find.byTooltip('Load images from this directory'), findsOneWidget); + }); + test('keeps preview opening and discrete navigation responsive', () { expect( kImagePreviewOpenTransitionDuration, @@ -498,7 +612,8 @@ void main() { final row = find.byKey(ValueKey(key)); await tester.scrollUntilVisible(row, 200); await tester.pumpAndSettle(); - final slider = find.descendant(of: row, matching: find.byType(Slider)); + final slider = + find.descendant(of: row, matching: find.byType(Slider)); expect(tester.widget(slider).value, value); final sliderRect = tester.getRect(slider); final titleRect = tester.getRect(find.text(title)); diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index b1f8fce..da9e44f 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -15,6 +15,7 @@ namespace { constexpr char kOpenPathChannelName[] = "rawviewer/open_paths"; constexpr char kShellIntegrationChannelName[] = "rawviewer/windows_shell"; +constexpr char kFileAssociationChannelName[] = "rawviewer/file_associations"; constexpr UINT_PTR kFlutterContentWindowSubclassId = 1; flutter::EncodableList EncodePaths(const std::vector& paths) { @@ -89,6 +90,7 @@ bool FlutterWindow::OnCreate() { SetChildContent(flutter_controller_->view()->GetNativeWindow()); ConfigureOpenPathChannel(); ConfigureShellIntegrationChannel(); + ConfigureFileAssociationChannel(); DragAcceptFiles(GetHandle(), TRUE); flutter_content_window_ = flutter_controller_->view()->GetNativeWindow(); @@ -123,6 +125,7 @@ void FlutterWindow::OnDestroy() { } open_path_channel_ = nullptr; shell_integration_channel_ = nullptr; + file_association_channel_ = nullptr; open_path_listener_ready_ = false; if (flutter_controller_) { @@ -296,6 +299,75 @@ void FlutterWindow::ConfigureShellIntegrationChannel() { }); } +void FlutterWindow::ConfigureFileAssociationChannel() { + file_association_channel_ = + std::make_unique>( + flutter_controller_->engine()->messenger(), + kFileAssociationChannelName, + &flutter::StandardMethodCodec::GetInstance()); + + file_association_channel_->SetMethodCallHandler( + [](const flutter::MethodCall& call, + std::unique_ptr> + result) { + if (call.method_name() == "getFileAssociationState") { + result->Success( + flutter::EncodableValue(GetWindowsFileAssociationState())); + return; + } + + if (call.method_name() == "setFileAssociations") { + const auto* arguments = + std::get_if(call.arguments()); + if (arguments == nullptr) { + result->Error("invalid_arguments", + "Expected a map containing extensions."); + return; + } + + const auto extensions_it = + arguments->find(flutter::EncodableValue("extensions")); + if (extensions_it == arguments->end()) { + result->Error("invalid_arguments", + "Expected an extensions list."); + return; + } + + const auto* extension_list = + std::get_if(&extensions_it->second); + if (extension_list == nullptr) { + result->Error("invalid_arguments", + "Expected extensions as a list of strings."); + return; + } + + std::vector extensions; + extensions.reserve(extension_list->size()); + for (const auto& value : *extension_list) { + const auto* extension = std::get_if(&value); + if (extension == nullptr) { + result->Error("invalid_arguments", + "Expected extensions as a list of strings."); + return; + } + extensions.push_back(*extension); + } + + std::string error_message; + if (!SetWindowsFileAssociations(extensions, &error_message)) { + result->Error("file_association_error", error_message); + return; + } + + result->Success( + flutter::EncodableValue(GetWindowsFileAssociationState())); + return; + } + + result->NotImplemented(); + }); +} + void FlutterWindow::HandleDropFiles(HDROP drop) { const std::vector dropped_paths = ExtractDroppedPaths(drop); DragFinish(drop); diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 7b1d717..193447f 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -35,6 +35,7 @@ class FlutterWindow : public Win32Window { void ConfigureOpenPathChannel(); void ConfigureShellIntegrationChannel(); + void ConfigureFileAssociationChannel(); void HandleDropFiles(HDROP drop); void HandleOpenPaths(const std::vector& paths); std::vector ConsumePendingOpenPaths(); @@ -51,6 +52,8 @@ class FlutterWindow : public Win32Window { open_path_channel_; std::unique_ptr> shell_integration_channel_; + std::unique_ptr> + file_association_channel_; HWND flutter_content_window_ = nullptr; }; diff --git a/windows/runner/shell_integration.cpp b/windows/runner/shell_integration.cpp index 4c9b043..30aa9cd 100644 --- a/windows/runner/shell_integration.cpp +++ b/windows/runner/shell_integration.cpp @@ -5,7 +5,10 @@ #include #include +#include +#include #include +#include namespace { @@ -16,6 +19,11 @@ constexpr wchar_t kDirectoryVerbKey[] = constexpr wchar_t kDirectoryBackgroundVerbKey[] = L"Software\\Classes\\Directory\\Background\\shell\\RawViewOpen"; +constexpr std::array kFileAssociationExtensions = {{ + L"arw", L"cr2", L"cr3", L"dng", L"nef", L"orf", L"raf", + L"rw2", L"srw", L"jpg", L"jpeg", L"png", L"webp", +}}; + struct ContextMenuState { bool supported; bool enabled; @@ -64,6 +72,31 @@ std::wstring BuildCommand(const std::wstring& executable_path, return Quote(executable_path) + L" \"%1\""; } +std::wstring FileAssociationProgId(const wchar_t* extension) { + return std::wstring(L"RawViewer.") + extension; +} + +std::wstring FileAssociationExtensionKey(const wchar_t* extension) { + return std::wstring(L"Software\\Classes\\.") + extension; +} + +std::wstring FileAssociationProgIdKey(const wchar_t* extension) { + return std::wstring(L"Software\\Classes\\") + + FileAssociationProgId(extension); +} + +std::wstring FileAssociationCommand(const std::wstring& executable_path) { + return Quote(executable_path) + L" \"%1\""; +} + +std::string NarrowExtension(const wchar_t* extension) { + std::string result; + for (const auto* character = extension; *character != L'\0'; ++character) { + result.push_back(static_cast(*character)); + } + return result; +} + bool SetStringValue(HKEY root, const std::wstring& sub_key, const wchar_t* value_name, const std::wstring& value) { HKEY key = nullptr; @@ -177,6 +210,78 @@ void NotifyShellChanged() { ::SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr); } +bool IsFileAssociationInstalled(const wchar_t* extension, + const std::wstring& executable_path) { + std::wstring prog_id; + if (!ReadStringValue(HKEY_CURRENT_USER, + FileAssociationExtensionKey(extension), nullptr, + &prog_id) || + _wcsicmp(prog_id.c_str(), FileAssociationProgId(extension).c_str()) != + 0) { + return false; + } + + std::wstring command; + if (!ReadStringValue( + HKEY_CURRENT_USER, + FileAssociationProgIdKey(extension) + L"\\shell\\open\\command", + nullptr, &command)) { + return false; + } + + return _wcsicmp(command.c_str(), + FileAssociationCommand(executable_path).c_str()) == 0; +} + +bool WriteFileAssociation(const wchar_t* extension, + const std::wstring& executable_path) { + const auto prog_id = FileAssociationProgId(extension); + const auto prog_id_key = FileAssociationProgIdKey(extension); + if (!SetStringValue(HKEY_CURRENT_USER, prog_id_key, nullptr, + L"Raw Viewer image")) { + return false; + } + if (!SetStringValue(HKEY_CURRENT_USER, prog_id_key + L"\\DefaultIcon", + nullptr, Quote(executable_path) + L",0")) { + return false; + } + if (!SetStringValue(HKEY_CURRENT_USER, + prog_id_key + L"\\shell\\open\\command", nullptr, + FileAssociationCommand(executable_path))) { + return false; + } + return SetStringValue(HKEY_CURRENT_USER, + FileAssociationExtensionKey(extension), nullptr, + prog_id); +} + +bool RemoveFileAssociation(const wchar_t* extension) { + std::wstring current_prog_id; + const auto extension_key = FileAssociationExtensionKey(extension); + if (ReadStringValue(HKEY_CURRENT_USER, extension_key, nullptr, + ¤t_prog_id) && + _wcsicmp(current_prog_id.c_str(), + FileAssociationProgId(extension).c_str()) == 0) { + HKEY key = nullptr; + const LONG open_result = ::RegOpenKeyExW( + HKEY_CURRENT_USER, extension_key.c_str(), 0, KEY_SET_VALUE, &key); + if (open_result != ERROR_SUCCESS) { + return open_result == ERROR_FILE_NOT_FOUND; + } + const LONG delete_result = ::RegDeleteValueW(key, nullptr); + ::RegCloseKey(key); + if (delete_result != ERROR_SUCCESS && delete_result != ERROR_FILE_NOT_FOUND) { + return false; + } + } + + const LONG delete_prog_id_result = + ::RegDeleteTreeW(HKEY_CURRENT_USER, + FileAssociationProgIdKey(extension).c_str()); + return delete_prog_id_result == ERROR_SUCCESS || + delete_prog_id_result == ERROR_FILE_NOT_FOUND; +} + ContextMenuState QueryContextMenuState() { const std::wstring executable_path = GetExecutablePath(); if (executable_path.empty()) { @@ -241,3 +346,60 @@ bool SetWindowsContextMenuEnabled(bool enabled, const std::wstring& menu_text, NotifyShellChanged(); return true; } + +flutter::EncodableMap GetWindowsFileAssociationState() { + const std::wstring executable_path = GetExecutablePath(); + flutter::EncodableMap bindings; + for (const auto* extension : kFileAssociationExtensions) { + bindings.emplace( + flutter::EncodableValue("." + NarrowExtension(extension)), + flutter::EncodableValue( + !executable_path.empty() && + IsFileAssociationInstalled(extension, executable_path))); + } + return flutter::EncodableMap{ + {flutter::EncodableValue("supported"), flutter::EncodableValue(true)}, + {flutter::EncodableValue("bindings"), flutter::EncodableValue(bindings)}, + }; +} + +bool SetWindowsFileAssociations(const std::vector& extensions, + std::string* error_message) { + const std::wstring executable_path = GetExecutablePath(); + if (executable_path.empty()) { + if (error_message != nullptr) { + *error_message = "Unable to resolve the current executable path."; + } + return false; + } + + std::set selected; + for (auto extension : extensions) { + if (!extension.empty() && extension.front() == '.') { + extension.erase(extension.begin()); + } + for (auto& character : extension) { + character = static_cast(std::tolower( + static_cast(character))); + } + selected.insert(extension); + } + + for (const auto* extension : kFileAssociationExtensions) { + const std::string extension_utf8 = NarrowExtension(extension); + const bool should_install = selected.find(extension_utf8) != selected.end(); + const bool success = should_install + ? WriteFileAssociation(extension, executable_path) + : RemoveFileAssociation(extension); + if (!success) { + if (error_message != nullptr) { + *error_message = + "Failed to update Windows file association registry entries."; + } + return false; + } + } + + NotifyShellChanged(); + return true; +} diff --git a/windows/runner/shell_integration.h b/windows/runner/shell_integration.h index 6fefdb4..b9e1cac 100644 --- a/windows/runner/shell_integration.h +++ b/windows/runner/shell_integration.h @@ -4,9 +4,14 @@ #include #include +#include flutter::EncodableMap GetWindowsContextMenuState(); bool SetWindowsContextMenuEnabled(bool enabled, const std::wstring& menu_text, std::string* error_message); +flutter::EncodableMap GetWindowsFileAssociationState(); +bool SetWindowsFileAssociations( + const std::vector& extensions, + std::string* error_message); #endif // RUNNER_SHELL_INTEGRATION_H_ From 0090f3acd5159209ccd66605fb436c63d7d4a359 Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 22:32:57 +0800 Subject: [PATCH 2/7] feat: add load directory button label to UI and localization --- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 ++++++ lib/l10n/app_localizations_en.dart | 3 +++ lib/l10n/app_localizations_zh.dart | 3 +++ lib/l10n/app_zh.arb | 1 + lib/preview/image_preview_page.dart | 19 +++++++++++-------- lib/ui/desktop_controls.dart | 25 +++++++++++++++++-------- test/widget_test.dart | 2 ++ 8 files changed, 44 insertions(+), 16 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7f0bdb6..72ccf4a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -105,6 +105,7 @@ "hidePreviewOverviewTooltip": "Hide overview map", "centerCurrentPreviewThumbnailTooltip": "Center current thumbnail", "loadDirectoryTooltip": "Load images from this directory", + "loadDirectoryButtonLabel": "Load directory", "grantDirectoryAccessDialogTitle": "Allow Raw Viewer to access this folder", "loadDirectoryFailedMessage": "Could not load the directory: {error}", "@loadDirectoryFailedMessage": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3bb4be6..7e673e5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -515,6 +515,12 @@ abstract class AppLocalizations { /// **'Load images from this directory'** String get loadDirectoryTooltip; + /// No description provided for @loadDirectoryButtonLabel. + /// + /// In en, this message translates to: + /// **'Load directory'** + String get loadDirectoryButtonLabel; + /// No description provided for @grantDirectoryAccessDialogTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 80b54ff..52ce288 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -230,6 +230,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loadDirectoryTooltip => 'Load images from this directory'; + @override + String get loadDirectoryButtonLabel => 'Load directory'; + @override String get grantDirectoryAccessDialogTitle => 'Allow Raw Viewer to access this folder'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 12a684d..6948bbc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -230,6 +230,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get loadDirectoryTooltip => '加载此目录中的图片'; + @override + String get loadDirectoryButtonLabel => '加载目录'; + @override String get grantDirectoryAccessDialogTitle => '允许 Raw Viewer 访问此文件夹'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8d98516..5daeed5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -105,6 +105,7 @@ "hidePreviewOverviewTooltip": "隐藏鸟瞰图", "centerCurrentPreviewThumbnailTooltip": "居中当前缩略图", "loadDirectoryTooltip": "加载此目录中的图片", + "loadDirectoryButtonLabel": "加载目录", "grantDirectoryAccessDialogTitle": "允许 Raw Viewer 访问此文件夹", "loadDirectoryFailedMessage": "无法加载目录:{error}", "@loadDirectoryFailedMessage": { diff --git a/lib/preview/image_preview_page.dart b/lib/preview/image_preview_page.dart index 60858d7..89876c6 100644 --- a/lib/preview/image_preview_page.dart +++ b/lib/preview/image_preview_page.dart @@ -430,14 +430,17 @@ class _ImagePreviewPageState extends State { ), ), child: Center( - child: DesktopIconButton( - icon: _isLoadingDirectory - ? Icons.hourglass_top_outlined - : Icons.folder_open_outlined, - tooltip: l10n.loadDirectoryTooltip, - onPressed: _isLoadingDirectory - ? null - : () => unawaited(_loadDirectory()), + child: Tooltip( + message: l10n.loadDirectoryTooltip, + child: DesktopCommandButton( + icon: _isLoadingDirectory + ? Icons.hourglass_top_outlined + : Icons.folder_open_outlined, + label: l10n.loadDirectoryButtonLabel, + onPressed: _isLoadingDirectory + ? null + : () => unawaited(_loadDirectory()), + ), ), ), ), diff --git a/lib/ui/desktop_controls.dart b/lib/ui/desktop_controls.dart index 51f8c01..63581f4 100644 --- a/lib/ui/desktop_controls.dart +++ b/lib/ui/desktop_controls.dart @@ -245,7 +245,7 @@ class DesktopIconButton extends StatelessWidget { class DesktopCommandButton extends StatelessWidget { final IconData icon; final String label; - final VoidCallback onPressed; + final VoidCallback? onPressed; final bool emphasized; const DesktopCommandButton({ @@ -258,11 +258,17 @@ class DesktopCommandButton extends StatelessWidget { @override Widget build(BuildContext context) { - final foreground = - emphasized ? RawViewerColors.text : RawViewerColors.mutedText; - final background = emphasized - ? RawViewerColors.accentMuted - : RawViewerColors.raisedSurface; + final enabled = onPressed != null; + final foreground = !enabled + ? RawViewerColors.mutedBorder + : emphasized + ? RawViewerColors.text + : RawViewerColors.mutedText; + final background = !enabled + ? RawViewerColors.surface + : emphasized + ? RawViewerColors.accentMuted + : RawViewerColors.raisedSurface; return Material( color: background, @@ -278,8 +284,11 @@ class DesktopCommandButton extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), border: Border.all( - color: - emphasized ? const Color(0xFF37776D) : RawViewerColors.border, + color: !enabled + ? RawViewerColors.mutedBorder + : emphasized + ? const Color(0xFF37776D) + : RawViewerColors.border, ), ), child: Row( diff --git a/test/widget_test.dart b/test/widget_test.dart index 8c2a704..9efdbe9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -308,6 +308,7 @@ void main() { final loadButton = find.byTooltip('Load images from this directory'); expect(loadButton, findsOneWidget); + expect(find.text('Load directory'), findsOneWidget); expect(find.byKey(const ValueKey('preview-directory-load-panel')), findsOneWidget); expect(find.byType(PreviewFilmstrip), findsNothing); @@ -370,6 +371,7 @@ void main() { findsOneWidget); expect(find.byType(PreviewFilmstrip), findsNothing); expect(find.byTooltip('Load images from this directory'), findsOneWidget); + expect(find.text('Load directory'), findsOneWidget); }); test('keeps preview opening and discrete navigation responsive', () { From 5bae6e0c7cb540327a56be833ea129127c868d28 Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 22:38:26 +0800 Subject: [PATCH 3/7] feat: add bulk actions for file associations in settings --- lib/l10n/app_en.arb | 3 ++ lib/l10n/app_localizations.dart | 18 ++++++++ lib/l10n/app_localizations_en.dart | 9 ++++ lib/l10n/app_localizations_zh.dart | 9 ++++ lib/l10n/app_zh.arb | 3 ++ lib/settings_page.dart | 72 +++++++++++++++++++++++++----- test/widget_test.dart | 63 ++++++++++++++++++++++++++ 7 files changed, 166 insertions(+), 11 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 72ccf4a..e950db4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -62,6 +62,9 @@ }, "fileAssociationsSectionTitle": "Default file associations", "fileAssociationFormatSubtitle": "Open .{extension} files with Raw Viewer", + "fileAssociationsEnableAll": "Enable all", + "fileAssociationsEnableRaw": "Enable RAW only", + "fileAssociationsDisableAll": "Disable all", "fileAssociationsUpdateFailed": "Failed to update file associations: {error}", "@fileAssociationsUpdateFailed": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7e673e5..e2c199d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -341,6 +341,24 @@ abstract class AppLocalizations { /// **'Open .{extension} files with Raw Viewer'** String fileAssociationFormatSubtitle(Object extension); + /// No description provided for @fileAssociationsEnableAll. + /// + /// In en, this message translates to: + /// **'Enable all'** + String get fileAssociationsEnableAll; + + /// No description provided for @fileAssociationsEnableRaw. + /// + /// In en, this message translates to: + /// **'Enable RAW only'** + String get fileAssociationsEnableRaw; + + /// No description provided for @fileAssociationsDisableAll. + /// + /// In en, this message translates to: + /// **'Disable all'** + String get fileAssociationsDisableAll; + /// No description provided for @fileAssociationsUpdateFailed. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 52ce288..eb1587a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -139,6 +139,15 @@ class AppLocalizationsEn extends AppLocalizations { return 'Open .$extension files with Raw Viewer'; } + @override + String get fileAssociationsEnableAll => 'Enable all'; + + @override + String get fileAssociationsEnableRaw => 'Enable RAW only'; + + @override + String get fileAssociationsDisableAll => 'Disable all'; + @override String fileAssociationsUpdateFailed(String error) { return 'Failed to update file associations: $error'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6948bbc..ee2293a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -139,6 +139,15 @@ class AppLocalizationsZh extends AppLocalizations { return '使用 Raw Viewer 打开 .$extension 文件'; } + @override + String get fileAssociationsEnableAll => '开启所有'; + + @override + String get fileAssociationsEnableRaw => '仅开启 RAW'; + + @override + String get fileAssociationsDisableAll => '全部关闭'; + @override String fileAssociationsUpdateFailed(String error) { return '更新文件关联失败:$error'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5daeed5..d2cf46c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -62,6 +62,9 @@ }, "fileAssociationsSectionTitle": "默认打开格式", "fileAssociationFormatSubtitle": "使用 Raw Viewer 打开 .{extension} 文件", + "fileAssociationsEnableAll": "开启所有", + "fileAssociationsEnableRaw": "仅开启 RAW", + "fileAssociationsDisableAll": "全部关闭", "fileAssociationsUpdateFailed": "更新文件关联失败:{error}", "@fileAssociationsUpdateFailed": { "placeholders": { diff --git a/lib/settings_page.dart b/lib/settings_page.dart index d14109c..ac62f3d 100644 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -269,7 +270,6 @@ class _SettingsPageState extends State { Platform.isWindows && widget.onWindowsContextMenuChanged != null; bool get _showFileAssociationSection => - (Platform.isWindows || Platform.isMacOS) && widget.onFileAssociationsChanged != null && _currentSettings.fileAssociations.supported; @@ -380,26 +380,27 @@ class _SettingsPageState extends State { String extension, bool enabled, ) async { + final nextBindings = Map.from( + _currentSettings.fileAssociations.bindings, + )..[extension] = enabled; + await _updateFileAssociations({ + for (final supportedExtension in supportedExtensions) + if (nextBindings[supportedExtension] == true) supportedExtension, + }); + } + + Future _updateFileAssociations(Set extensions) async { final onFileAssociationsChanged = widget.onFileAssociationsChanged; if (onFileAssociationsChanged == null || _isUpdatingFileAssociations) { return; } - final nextBindings = Map.from( - _currentSettings.fileAssociations.bindings, - )..[extension] = enabled; - setState(() { _isUpdatingFileAssociations = true; }); try { - final nextState = await onFileAssociationsChanged( - nextBindings.entries - .where((entry) => entry.value) - .map((entry) => entry.key) - .toSet(), - ); + final nextState = await onFileAssociationsChanged(extensions); if (!mounted) return; _updateSettings(_currentSettings.copyWith(fileAssociations: nextState)); } catch (error) { @@ -417,6 +418,54 @@ class _SettingsPageState extends State { } } + Widget _buildFileAssociationActions(AppLocalizations l10n) { + final enabled = !_isUpdatingFileAssociations; + + return Padding( + key: const ValueKey('file-association-actions'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DesktopCommandButton( + key: const ValueKey('file-association-enable-all'), + icon: Icons.done_all, + label: l10n.fileAssociationsEnableAll, + onPressed: enabled + ? () => unawaited( + _updateFileAssociations( + Set.of(supportedExtensions), + ), + ) + : null, + emphasized: true, + ), + DesktopCommandButton( + key: const ValueKey('file-association-enable-raw'), + icon: Icons.camera_alt_outlined, + label: l10n.fileAssociationsEnableRaw, + onPressed: enabled + ? () => unawaited( + _updateFileAssociations( + Set.of(rawExtensions), + ), + ) + : null, + ), + DesktopCommandButton( + key: const ValueKey('file-association-disable-all'), + icon: Icons.block_outlined, + label: l10n.fileAssociationsDisableAll, + onPressed: enabled + ? () => unawaited(_updateFileAssociations(const {})) + : null, + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -665,6 +714,7 @@ class _SettingsPageState extends State { DesktopSettingsSection( title: l10n.fileAssociationsSectionTitle, children: _withDividers([ + _buildFileAssociationActions(l10n), for (final extension in supportedExtensions) DesktopSettingsRow( key: ValueKey('file-association-$extension'), diff --git a/test/widget_test.dart b/test/widget_test.dart index 9efdbe9..6ecceba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -743,6 +743,69 @@ void main() { expect(updatedSettings!.previewToolbarOpacity, toolbarOpacity); expect(updatedSettings!.previewOverlayOpacity, toolsOpacity); }); + + testWidgets('updates file associations through bulk actions', + (tester) async { + final appliedExtensions = >[]; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + home: SettingsPage( + settings: ViewerSettings( + fileAssociations: FileAssociationSettings( + supported: true, + bindings: { + for (final extension in supportedExtensions) extension: false, + }, + ), + ), + onClose: () {}, + onSettingsChanged: (_) {}, + onFileAssociationsChanged: (extensions) async { + appliedExtensions.add(Set.of(extensions)); + return FileAssociationSettings( + supported: true, + bindings: { + for (final extension in supportedExtensions) + extension: extensions.contains(extension), + }, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final settingsList = find.byType(Scrollable).first; + final actions = find.byKey(const ValueKey('file-association-actions')); + await tester.scrollUntilVisible(actions, 300, scrollable: settingsList); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('file-association-enable-all')), + ); + await tester.pumpAndSettle(); + expect(appliedExtensions.last, unorderedEquals(supportedExtensions)); + + await tester.tap( + find.byKey(const ValueKey('file-association-enable-raw')), + ); + await tester.pumpAndSettle(); + expect(appliedExtensions.last, unorderedEquals(rawExtensions)); + + await tester.tap( + find.byKey(const ValueKey('file-association-disable-all')), + ); + await tester.pumpAndSettle(); + expect(appliedExtensions.last, isEmpty); + }); }); group('buildJustifiedGridRows', () { From e37f504c621214fb842be1574279def6e905dad3 Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 23:02:26 +0800 Subject: [PATCH 4/7] fix: replace active preview on desktop open requests --- lib/home_page.dart | 123 ++++++++++++++++++---------- test/gallery/desktop_open_test.dart | 111 +++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 45 deletions(-) create mode 100644 test/gallery/desktop_open_test.dart diff --git a/lib/home_page.dart b/lib/home_page.dart index 795227b..daaae8e 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -61,6 +61,8 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { String? _currentDirectoryPath; String? _deferredDirectoryPath; + int _openedSourceGeneration = 0; + Route? _previewRoute; int? _openedDirectoryCount; String? _lastSyncedWindowsContextMenuText; List _files = []; @@ -545,6 +547,7 @@ class _HomePageState extends State { return; } final nextFiles = _deduplicateMediaFiles([...directoryFiles, ...files]); + _prepareForIncomingPaths(); _applyOpenedFiles( files: nextFiles, sourceKind: _OpenedSourceKind.folder, @@ -566,6 +569,7 @@ class _HomePageState extends State { ? files : _deduplicateMediaFiles([..._files, ...files]); + _prepareForIncomingPaths(); _applyOpenedFiles( files: nextFiles, sourceKind: _OpenedSourceKind.files, @@ -579,6 +583,15 @@ class _HomePageState extends State { } } + void _prepareForIncomingPaths() { + _openedSourceGeneration++; + final previewRoute = _previewRoute; + _previewRoute = null; + if (previewRoute != null && previewRoute.isActive) { + Navigator.of(context).removeRoute(previewRoute); + } + } + void _applyOpenedFiles({ required List files, required _OpenedSourceKind sourceKind, @@ -608,8 +621,11 @@ class _HomePageState extends State { } void _scheduleSingleFilePreview(String filePath) { + final generation = _openedSourceGeneration; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) { + if (!mounted || + generation != _openedSourceGeneration || + !(ModalRoute.of(context)?.isCurrent ?? false)) { return; } if (_files.length != 1 || _files.single.path != filePath) { @@ -623,14 +639,19 @@ class _HomePageState extends State { }); } - Future?> _loadDeferredDirectoryForPreview() async { + Future?> _loadDeferredDirectoryForPreview( + int generation, + ) async { + if (!mounted || generation != _openedSourceGeneration) return null; final directoryPath = _deferredDirectoryPath; if (directoryPath == null) { return buildAdaptiveMediaGroups(_files); } final loadedDirectory = await _loadDeferredDirectoryFiles(directoryPath); - if (loadedDirectory == null) { + if (!mounted || + generation != _openedSourceGeneration || + loadedDirectory == null) { return null; } final mediaGroups = buildAdaptiveMediaGroups(loadedDirectory.files); @@ -647,12 +668,18 @@ class _HomePageState extends State { return mediaGroups; } - Future _loadDeferredDirectoryAfterClose() async { - if (_deferredDirectoryPath == null) return; + Future _loadDeferredDirectoryAfterClose(int generation) async { + if (!mounted || + generation != _openedSourceGeneration || + _deferredDirectoryPath == null) { + return; + } final directoryPath = _deferredDirectoryPath!; try { final loadedDirectory = await _loadDeferredDirectoryFiles(directoryPath); - if (loadedDirectory == null) { + if (!mounted || + generation != _openedSourceGeneration || + loadedDirectory == null) { return; } _applyOpenedFiles( @@ -663,7 +690,9 @@ class _HomePageState extends State { openedDirectoryCount: 1, ); } catch (error) { - _showDirectoryLoadError(error); + if (generation == _openedSourceGeneration) { + _showDirectoryLoadError(error); + } } } @@ -723,47 +752,51 @@ class _HomePageState extends State { final thumbnailResizeWidth = bucketDecodeWidth((cellWidth * dpr).clamp(100.0, 800.0)); - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: kImagePreviewOpenTransitionDuration, - reverseTransitionDuration: kImagePreviewCloseTransitionDuration, - pageBuilder: (context, animation, secondaryAnimation) { - return ExcludeSemantics( - child: ImagePreviewPage( - mediaGroups: mediaGroups, - initialIndex: initialIndex, - thumbnailResizeWidth: thumbnailResizeWidth, - imageStore: _imageStore, - timestampRepository: _timestampRepository, - initialSettings: _settings, - onLoadDirectory: - deferDirectoryLoad ? _loadDeferredDirectoryForPreview : null, - onRawViewModeChanged: (mode) => _updateSettings( - _settings.copyWith(rawViewMode: mode), - ), - onPreviewFilmstripHeightChanged: (height) => _updateSettings( - _settings.copyWith(previewFilmstripHeight: height), - ), - onClose: () { - Navigator.pop(context); - unawaited(_loadDeferredDirectoryAfterClose()); - }, + final generation = _openedSourceGeneration; + final route = PageRouteBuilder( + transitionDuration: kImagePreviewOpenTransitionDuration, + reverseTransitionDuration: kImagePreviewCloseTransitionDuration, + pageBuilder: (context, animation, secondaryAnimation) { + return ExcludeSemantics( + child: ImagePreviewPage( + mediaGroups: mediaGroups, + initialIndex: initialIndex, + thumbnailResizeWidth: thumbnailResizeWidth, + imageStore: _imageStore, + timestampRepository: _timestampRepository, + initialSettings: _settings, + onLoadDirectory: deferDirectoryLoad + ? () => _loadDeferredDirectoryForPreview(generation) + : null, + onRawViewModeChanged: (mode) => _updateSettings( + _settings.copyWith(rawViewMode: mode), ), - ); - }, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, + onPreviewFilmstripHeightChanged: (height) => _updateSettings( + _settings.copyWith(previewFilmstripHeight: height), ), - child: child, - ); - }, - ), + onClose: () { + if (generation != _openedSourceGeneration) return; + Navigator.pop(context); + unawaited(_loadDeferredDirectoryAfterClose(generation)); + }, + ), + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + child: child, + ); + }, ); + _previewRoute = route; + unawaited(Navigator.of(context).push(route).whenComplete(() { + if (identical(_previewRoute, route)) _previewRoute = null; + })); } List _listRawFilesInDirectory(String directoryPath) => diff --git a/test/gallery/desktop_open_test.dart b/test/gallery/desktop_open_test.dart new file mode 100644 index 0000000..b20da42 --- /dev/null +++ b/test/gallery/desktop_open_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rawviewer/app.dart'; +import 'package:rawviewer/core/platform_channels.dart'; +import 'package:rawviewer/preview/image_preview_page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late Directory directory; + late File first; + late File second; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + directory = Directory.systemTemp.createTempSync('rawviewer-open-test-'); + final bytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', + ); + first = File('${directory.path}/a.png')..writeAsBytesSync(bytes); + second = File('${directory.path}/b.png')..writeAsBytesSync(bytes); + }); + + tearDown(() { + desktopOpenChannel.setMethodCallHandler(null); + directory.deleteSync(recursive: true); + }); + + Future openPaths(WidgetTester tester, List paths) async { + final completed = Completer(); + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + desktopOpenChannel.name, + const StandardMethodCodec() + .encodeMethodCall(MethodCall('openPaths', paths)), + (_) => completed.complete(), + ); + await completed.future; + } + + testWidgets('second desktop open replaces the current preview', + (tester) async { + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + await openPaths(tester, [first.path]); + await tester.pumpAndSettle(); + final oldPreview = + tester.widget(find.byType(ImagePreviewPage)); + expect(oldPreview.mediaGroups.single.primary.path, first.path); + + await openPaths(tester, [second.path]); + await tester.pumpAndSettle(); + expect(find.byType(ImagePreviewPage), findsOneWidget); + expect( + tester + .widget(find.byType(ImagePreviewPage)) + .mediaGroups + .single + .primary + .path, + second.path); + // An old route must not load a new source's directory or close its preview. + expect(await oldPreview.onLoadDirectory!(), isNull); + oldPreview.onClose(); + await tester.pumpAndSettle(); + expect(find.byType(ImagePreviewPage), findsOneWidget); + + tester.widget(find.byType(ImagePreviewPage)).onClose(); + await tester.pumpAndSettle(); + expect(find.byType(ImagePreviewPage), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); + + testWidgets('rapid desktop opens keep only the latest preview', + (tester) async { + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + await openPaths(tester, [first.path]); + await openPaths(tester, [second.path]); + await openPaths(tester, [second.path]); + await tester.pumpAndSettle(); + expect(find.byType(ImagePreviewPage, skipOffstage: false), findsOneWidget); + expect( + tester + .widget(find.byType(ImagePreviewPage)) + .mediaGroups + .single + .primary + .path, + second.path); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); + + testWidgets('opening a directory dismisses an existing single-file preview', + (tester) async { + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + await openPaths(tester, [first.path]); + await tester.pumpAndSettle(); + await openPaths(tester, [directory.path]); + await tester.pumpAndSettle(); + expect(find.byType(ImagePreviewPage), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} From f261389b159d592490d5e5dc2b41fa21289fe38d Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 23:12:39 +0800 Subject: [PATCH 5/7] fix: use canonical macOS file types and restore associations --- .github/workflows/ci.yml | 7 ++ macos/Runner.xcodeproj/project.pbxproj | 4 + macos/Runner/AppDelegate.swift | 65 ++----------- macos/Runner/FileAssociations.swift | 102 +++++++++++++++++++++ macos/Runner/Info.plist | 117 +++++++----------------- tool/macos_file_association_check.swift | 65 +++++++++++++ 6 files changed, 217 insertions(+), 143 deletions(-) create mode 100644 macos/Runner/FileAssociations.swift create mode 100644 tool/macos_file_association_check.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f4fe9d..5f938a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,13 @@ jobs: shell: bash run: flutter build ${{ matrix.build_target }} --release + - name: Check macOS file associations + if: matrix.target == 'macos' + shell: bash + run: | + swiftc macos/Runner/FileAssociations.swift tool/macos_file_association_check.swift -o "$RUNNER_TEMP/rawviewer-association-check" + "$RUNNER_TEMP/rawviewer-association-check" + - name: Upload build artifact uses: actions/upload-artifact@v6 with: diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 8c7852a..c3027cc 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + A81300000000000000000001 /* FileAssociations.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81300000000000000000002 /* FileAssociations.swift */; }; 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 */; }; @@ -63,6 +64,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A81300000000000000000002 /* FileAssociations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAssociations.swift; sourceTree = ""; }; 080B4F11E5CBD92798D2E6C7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 269E605647D1CE9A602FDD68 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -194,6 +196,7 @@ isa = PBXGroup; children = ( 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + A81300000000000000000002 /* FileAssociations.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, 33E51914231749380026EE4D /* Release.entitlements */, @@ -448,6 +451,7 @@ files = ( 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + A81300000000000000000001 /* FileAssociations.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 38ad7af..cab7de0 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,5 +1,4 @@ import Cocoa -import CoreServices import FlutterMacOS final class ScopedFileAccess { @@ -170,10 +169,9 @@ final class FileAssociationChannel { static let shared = FileAssociationChannel() private let channelName = "rawviewer/file_associations" - private let supportedExtensions = [ - "arw", "cr2", "cr3", "dng", "nef", "orf", "raf", "rw2", "srw", - "jpg", "jpeg", "png", "webp", - ] + private let associations = FileAssociations( + bundleIdentifier: Bundle.main.bundleIdentifier ?? "" + ) private var channel: FlutterMethodChannel? private init() {} @@ -193,7 +191,7 @@ final class FileAssociationChannel { switch call.method { case "getFileAssociationState": - result(self.state()) + result(self.associations.state()) case "setFileAssociations": guard let arguments = call.arguments as? [String: Any], let extensions = arguments["extensions"] as? [String] @@ -206,14 +204,14 @@ final class FileAssociationChannel { return } - if let error = self.setAssociations(extensions: Set(extensions)) { + if let error = self.associations.setAssociations(extensions: Set(extensions)) { result(FlutterError( code: "file_association_error", message: error, details: nil )) } else { - result(self.state()) + result(self.associations.state()) } default: result(FlutterMethodNotImplemented) @@ -221,57 +219,6 @@ final class FileAssociationChannel { } } - private func contentTypeIdentifier(for fileExtension: String) -> String { - return "com.rawviewer.\(fileExtension)" - } - - private func isAssociated(fileExtension: String) -> Bool { - guard let bundleIdentifier = Bundle.main.bundleIdentifier else { - return false - } - let contentType = contentTypeIdentifier(for: fileExtension) as CFString - guard let handler = LSCopyDefaultRoleHandlerForContentType( - contentType, - LSRolesMask.all - )?.takeRetainedValue() as String? - else { - return false - } - return handler == bundleIdentifier - } - - private func state() -> [String: Any] { - var bindings: [String: Bool] = [:] - for fileExtension in supportedExtensions { - bindings[".\(fileExtension)"] = isAssociated(fileExtension: fileExtension) - } - return ["supported": true, "bindings": bindings] - } - - private func setAssociations(extensions: Set) -> String? { - guard let bundleIdentifier = Bundle.main.bundleIdentifier else { - return "Unable to resolve the application bundle identifier." - } - - for fileExtension in supportedExtensions { - let extensionWithDot = ".\(fileExtension)" - let shouldAssociate = extensions.contains(fileExtension) || - extensions.contains(extensionWithDot) - let contentType = contentTypeIdentifier(for: fileExtension) as CFString - let handler: CFString = shouldAssociate - ? bundleIdentifier as CFString - : "" as CFString - let status = LSSetDefaultRoleHandlerForContentType( - contentType, - LSRolesMask.all, - handler - ) - if status != noErr { - return "Failed to update the default application for .\(fileExtension) (\(status))." - } - } - return nil - } } @main diff --git a/macos/Runner/FileAssociations.swift b/macos/Runner/FileAssociations.swift new file mode 100644 index 0000000..a3b71a3 --- /dev/null +++ b/macos/Runner/FileAssociations.swift @@ -0,0 +1,102 @@ +import CoreServices +import Foundation + +final class FileAssociations { + // JPG and JPEG are two extensions of the same Launch Services type. + static let contentTypes = [ + "arw": "com.sony.arw-raw-image", + "cr2": "com.canon.cr2-raw-image", + "cr3": "com.canon.cr3-raw-image", + "dng": "com.adobe.raw-image", + "nef": "com.nikon.raw-image", + "orf": "com.olympus.raw-image", + "raf": "com.fuji.raw-image", + "rw2": "com.panasonic.rw2-raw-image", + "srw": "com.samsung.raw-image", + "jpg": "public.jpeg", + "jpeg": "public.jpeg", + "png": "public.png", + "webp": "org.webmproject.webp", + ] + + private let bundleIdentifier: String + private let defaults: UserDefaults + private let currentHandler: (String) -> String? + private let availableHandlers: (String) -> [String] + private let setHandler: (String, String) -> OSStatus + private let previousHandlersKey = "previousFileAssociationHandlers" + + init( + bundleIdentifier: String, + defaults: UserDefaults = .standard, + currentHandler: @escaping (String) -> String? = { type in + LSCopyDefaultRoleHandlerForContentType(type as CFString, .all)? + .takeRetainedValue() as String? + }, + availableHandlers: @escaping (String) -> [String] = { type in + LSCopyAllRoleHandlersForContentType(type as CFString, .all)? + .takeRetainedValue() as? [String] ?? [] + }, + setHandler: @escaping (String, String) -> OSStatus = { type, handler in + LSSetDefaultRoleHandlerForContentType(type as CFString, .all, handler as CFString) + } + ) { + self.bundleIdentifier = bundleIdentifier + self.defaults = defaults + self.currentHandler = currentHandler + self.availableHandlers = availableHandlers + self.setHandler = setHandler + } + + func state() -> [String: Any] { + var bindings: [String: Bool] = [:] + for (fileExtension, contentType) in Self.contentTypes { + bindings[".\(fileExtension)"] = currentHandler(contentType) == bundleIdentifier + } + return ["supported": !bundleIdentifier.isEmpty, "bindings": bindings] + } + + func setAssociations(extensions: Set) -> String? { + guard !bundleIdentifier.isEmpty else { + return "Unable to resolve the application bundle identifier." + } + let selected = Set(extensions.map { + $0.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + }) + let groups = Dictionary(grouping: Self.contentTypes.keys, by: { Self.contentTypes[$0]! }) + var previousHandlers = defaults.dictionary(forKey: previousHandlersKey) as? [String: String] ?? [:] + + for contentType in groups.keys.sorted() { + let aliases = groups[contentType]! + let current = currentHandler(contentType) + let isBound = current == bundleIdentifier + // A changed alias controls the shared type, in either direction. + let shouldBind = isBound + ? aliases.allSatisfy { selected.contains($0) } + : aliases.contains { selected.contains($0) } + if shouldBind == isBound { continue } + + let handler: String + if shouldBind { + handler = bundleIdentifier + if let current { previousHandlers[contentType] = current } + } else { + let alternatives = availableHandlers(contentType).filter { $0 != bundleIdentifier } + let previous = previousHandlers[contentType] + guard let replacement = previous.flatMap({ alternatives.contains($0) ? $0 : nil }) + ?? alternatives.first else { + return "No other application is available for \(aliases.sorted().joined(separator: ", "))." + } + handler = replacement + } + + let status = setHandler(contentType, handler) + if status != noErr { + return "Failed to update the default application for \(aliases.sorted().joined(separator: ", ")) (\(status))." + } + if !shouldBind { previousHandlers.removeValue(forKey: contentType) } + defaults.set(previousHandlers, forKey: previousHandlersKey) + } + return nil + } +} diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index d94dc35..f0a2a95 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -30,22 +30,21 @@ CFBundleTypeRole Viewer LSHandlerRank - Owner + Alternate LSItemContentTypes - com.rawviewer.arw - com.rawviewer.cr2 - com.rawviewer.cr3 - com.rawviewer.dng - com.rawviewer.nef - com.rawviewer.orf - com.rawviewer.raf - com.rawviewer.rw2 - com.rawviewer.srw - com.rawviewer.jpg - com.rawviewer.jpeg - com.rawviewer.png - com.rawviewer.webp + com.sony.arw-raw-image + com.canon.cr2-raw-image + com.canon.cr3-raw-image + com.adobe.raw-image + com.nikon.raw-image + com.olympus.raw-image + com.fuji.raw-image + com.panasonic.rw2-raw-image + com.samsung.raw-image + org.webmproject.webp + public.jpeg + public.png @@ -53,131 +52,81 @@ UTTypeIdentifier - com.rawviewer.arw - UTTypeDescription - Sony ARW image + com.sony.arw-raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensionarw UTTypeIdentifier - com.rawviewer.cr2 - UTTypeDescription - Canon CR2 image + com.canon.cr2-raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensioncr2 UTTypeIdentifier - com.rawviewer.cr3 - UTTypeDescription - Canon CR3 image + com.canon.cr3-raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensioncr3 UTTypeIdentifier - com.rawviewer.dng - UTTypeDescription - DNG image + com.adobe.raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensiondng UTTypeIdentifier - com.rawviewer.nef - UTTypeDescription - Nikon NEF image + com.nikon.raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensionnef UTTypeIdentifier - com.rawviewer.orf - UTTypeDescription - Olympus ORF image + com.olympus.raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensionorf UTTypeIdentifier - com.rawviewer.raf - UTTypeDescription - Fujifilm RAF image + com.fuji.raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensionraf UTTypeIdentifier - com.rawviewer.rw2 - UTTypeDescription - Panasonic RW2 image + com.panasonic.rw2-raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensionrw2 UTTypeIdentifier - com.rawviewer.srw - UTTypeDescription - Samsung SRW image + com.samsung.raw-image UTTypeConformsTo - public.data + public.camera-raw-image UTTypeTagSpecification public.filename-extensionsrw UTTypeIdentifier - com.rawviewer.jpg - UTTypeDescription - JPEG image + org.webmproject.webp UTTypeConformsTo - public.data - UTTypeTagSpecification - public.filename-extensionjpg - - - UTTypeIdentifier - com.rawviewer.jpeg - UTTypeDescription - JPEG image - UTTypeConformsTo - public.data - UTTypeTagSpecification - public.filename-extensionjpeg - - - UTTypeIdentifier - com.rawviewer.png - UTTypeDescription - PNG image - UTTypeConformsTo - public.data - UTTypeTagSpecification - public.filename-extensionpng - - - UTTypeIdentifier - com.rawviewer.webp - UTTypeDescription - WebP image - UTTypeConformsTo - public.data + public.image UTTypeTagSpecification public.filename-extensionwebp diff --git a/tool/macos_file_association_check.swift b/tool/macos_file_association_check.swift new file mode 100644 index 0000000..1994b96 --- /dev/null +++ b/tool/macos_file_association_check.swift @@ -0,0 +1,65 @@ +import CoreServices +import Foundation +import UniformTypeIdentifiers + +// Run from the repository root: +// swiftc macos/Runner/FileAssociations.swift tool/macos_file_association_check.swift -o /tmp/rawviewer-association-check +// /tmp/rawviewer-association-check +@main +struct FileAssociationCheck { + static func main() throws { + let data = try Data(contentsOf: URL(fileURLWithPath: "macos/Runner/Info.plist")) + let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as! [String: Any] + let documentTypes = plist["CFBundleDocumentTypes"] as! [[String: Any]] + let declared = Set(documentTypes.flatMap { $0["LSItemContentTypes"] as! [String] }) + for (ext, type) in FileAssociations.contentTypes { + precondition(UTType(filenameExtension: ext)?.identifier == type, "Wrong system type for \(ext)") + precondition(declared.contains(type), "Undeclared type: \(type)") + } + + let suite = "rawviewer-association-test-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let app = "test.rawviewer" + var handlers = Dictionary(uniqueKeysWithValues: Set(FileAssociations.contentTypes.values).map { ($0, "test.original") }) + var writes: [(String, String)] = [] + var failure: OSStatus = noErr + var alternatives = ["test.other", "test.original", app] + func makeAssociations() -> FileAssociations { + FileAssociations( + bundleIdentifier: app, defaults: defaults, + currentHandler: { handlers[$0] }, + availableHandlers: { _ in alternatives }, + setHandler: { type, handler in + precondition(!handler.isEmpty) + if failure != noErr { return failure } + writes.append((type, handler)) + handlers[type] = handler + return noErr + } + ) + } + var associations = makeAssociations() + precondition(associations.setAssociations(extensions: [".jpg"]) == nil) + precondition(writes.count == 1 && writes[0].0 == "public.jpeg") + let state = associations.state()["bindings"] as! [String: Bool] + precondition(state[".jpg"] == true && state[".jpeg"] == true) + // Recreate the service to verify that restoring the previous app survives a restart. + associations = makeAssociations() + precondition(associations.setAssociations(extensions: [".jpeg"]) == nil) + precondition(handlers["public.jpeg"] == "test.original") + precondition(writes.count == 2) + precondition(associations.setAssociations(extensions: []) == nil) + precondition(writes.count == 2, "Unrelated associations must be preserved") + + failure = -50 + precondition(associations.setAssociations(extensions: [".png"]) != nil) + precondition(handlers["public.png"] == "test.original") + failure = noErr + precondition(associations.setAssociations(extensions: [".png"]) == nil) + alternatives = [app] + precondition(associations.setAssociations(extensions: []) != nil) + precondition(handlers["public.png"] == app) + print("macOS association checks passed (system types, declarations, aliases, restoration, failures).") + } +} From a2f6fcaf704c4e00b622e236bfa645009acf387a Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 23:13:24 +0800 Subject: [PATCH 6/7] fix: manage Windows default associations through system settings --- README.md | 2 +- README_zh.md | 2 +- innosetup/rawviewer.iss | 29 ++-- lib/home_page.dart | 14 +- lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 +++ lib/l10n/app_localizations_en.dart | 12 ++ lib/l10n/app_localizations_zh.dart | 12 ++ lib/l10n/app_zh.arb | 4 + lib/settings_page.dart | 186 +++++++++++++++++------ test/core/file_association_test.dart | 13 ++ test/file_association_settings_test.dart | 119 +++++++++++++++ windows/runner/CMakeLists.txt | 1 + windows/runner/flutter_window.cpp | 43 +----- windows/runner/shell_integration.cpp | 117 ++++++-------- windows/runner/shell_integration.h | 5 +- 16 files changed, 405 insertions(+), 182 deletions(-) create mode 100644 test/file_association_settings_test.dart diff --git a/README.md b/README.md index 50e9cd2..dda40b0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Raw Viewer allows photographers and enthusiasts to seamlessly view, browse, and - **EXIF Metadata:** Reads and displays true image capture timestamps directly from EXIF data. - **Smooth Interaction:** Fast page scroll, smooth pinch-to-zoom (touch), and scroll-to-zoom (mouse) functionality. - **Windows Context Menu:** Lets you install an “Open in RawView” Explorer entry for the current user, supporting files, multi-file selection, folders, and folder background opening. -- **Default File Associations:** Registers supported image formats on Windows and macOS, with per-format switches in Settings. +- **Default File Associations:** Registers supported image formats on Windows and macOS. Windows uses the system Default Apps settings and displays effective associations; macOS provides per-format switches. - **Single-File Open:** Opening one image jumps directly to its preview; neighboring files are loaded only when requested from the preview toolbar or when returning to the gallery. - **Cross-Platform:** Built for desktop (Windows, macOS) and mobile (Android) natively. diff --git a/README_zh.md b/README_zh.md index 2000f3d..92f59c1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,7 +19,7 @@ Raw Viewer 旨在为摄影师和摄影爱好者提供一个轻量、流畅的工 - **EXIF 元数据:** 自动读取并显示 EXIF 中的真实拍摄时间。 - **流畅交互:** 支持顺滑的快速翻页,并适配了触屏的捏合缩放(Pinch-to-zoom)与鼠标滚轮缩放。 - **Windows 右键菜单:** 可在设置页一键将“在RawView中打开”安装到当前用户的资源管理器右键菜单,支持文件、多个文件、文件夹,以及文件夹空白处右键打开当前目录。 -- **默认打开格式:** Windows 和 macOS 可将支持的图片格式关联到 Raw Viewer,并在设置页按格式自定义。 +- **默认打开格式:** Windows 通过系统默认应用设置选择关联,并显示实际生效的状态;macOS 在设置页提供按格式切换的开关。 - **单图打开:** 直接打开单张图片时立即进入预览;相邻文件只会在预览顶栏主动加载目录或返回图库时加载。 - **跨平台支持:** 原生支持桌面端 (Windows, macOS) 与移动端 (Android)。 diff --git a/innosetup/rawviewer.iss b/innosetup/rawviewer.iss index 84489b2..cb97914 100644 --- a/innosetup/rawviewer.iss +++ b/innosetup/rawviewer.iss @@ -45,19 +45,22 @@ Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Registry] -Root: HKCU; Subkey: "Software\Classes\.arw"; ValueType: string; ValueName: ""; ValueData: "RawViewer.arw"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.cr2"; ValueType: string; ValueName: ""; ValueData: "RawViewer.cr2"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.cr3"; ValueType: string; ValueName: ""; ValueData: "RawViewer.cr3"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.dng"; ValueType: string; ValueName: ""; ValueData: "RawViewer.dng"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.nef"; ValueType: string; ValueName: ""; ValueData: "RawViewer.nef"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.orf"; ValueType: string; ValueName: ""; ValueData: "RawViewer.orf"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.raf"; ValueType: string; ValueName: ""; ValueData: "RawViewer.raf"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.rw2"; ValueType: string; ValueName: ""; ValueData: "RawViewer.rw2"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.srw"; ValueType: string; ValueName: ""; ValueData: "RawViewer.srw"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.jpg"; ValueType: string; ValueName: ""; ValueData: "RawViewer.jpg"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.jpeg"; ValueType: string; ValueName: ""; ValueData: "RawViewer.jpeg"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.png"; ValueType: string; ValueName: ""; ValueData: "RawViewer.png"; Flags: uninsdeletevalue -Root: HKCU; Subkey: "Software\Classes\.webp"; ValueType: string; ValueName: ""; ValueData: "RawViewer.webp"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\RegisteredApplications"; ValueType: string; ValueName: "RawViewer"; ValueData: "Software\RawViewer\Capabilities"; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\RawViewer\Capabilities"; ValueType: string; ValueName: "ApplicationName"; ValueData: "Raw Viewer"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities"; ValueType: string; ValueName: "ApplicationDescription"; ValueData: "RAW and standard image viewer"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".arw"; ValueData: "RawViewer.arw"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".cr2"; ValueData: "RawViewer.cr2"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".cr3"; ValueData: "RawViewer.cr3"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".dng"; ValueData: "RawViewer.dng"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".nef"; ValueData: "RawViewer.nef"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".orf"; ValueData: "RawViewer.orf"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".raf"; ValueData: "RawViewer.raf"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".rw2"; ValueData: "RawViewer.rw2"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".srw"; ValueData: "RawViewer.srw"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".jpg"; ValueData: "RawViewer.jpg"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".jpeg"; ValueData: "RawViewer.jpeg"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".png"; ValueData: "RawViewer.png"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\RawViewer\Capabilities\FileAssociations"; ValueType: string; ValueName: ".webp"; ValueData: "RawViewer.webp"; Flags: uninsdeletekey Root: HKCU; Subkey: "Software\Classes\RawViewer.arw"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey Root: HKCU; Subkey: "Software\Classes\RawViewer.cr2"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey Root: HKCU; Subkey: "Software\Classes\RawViewer.cr3"; ValueType: string; ValueName: ""; ValueData: "Raw Viewer image"; Flags: uninsdeletekey diff --git a/lib/home_page.dart b/lib/home_page.dart index daaae8e..8ecaeef 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -315,6 +315,12 @@ class _HomePageState extends State { } } + Future _getFileAssociationSettings() async { + final values = await fileAssociationChannel + .invokeMapMethod('getFileAssociationState'); + return FileAssociationSettings.fromPlatformMap(values); + } + Future _getWindowsContextMenuSettings() async { final values = await windowsShellChannel.invokeMapMethod( 'getContextMenuState', @@ -1198,8 +1204,14 @@ class _HomePageState extends State { onWindowsContextMenuChanged: Platform.isWindows ? _setWindowsContextMenuEnabled : null, onFileAssociationsChanged: + Platform.isMacOS ? _setFileAssociations : null, + onOpenDefaultAppsSettings: Platform.isWindows + ? () => fileAssociationChannel + .invokeMethod('openDefaultAppsSettings') + : null, + onRefreshFileAssociations: (Platform.isWindows || Platform.isMacOS) - ? _setFileAssociations + ? _getFileAssociationSettings : null, onClose: () { Navigator.pop(dialogContext); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e950db4..7180966 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -61,6 +61,10 @@ } }, "fileAssociationsSectionTitle": "Default file associations", + "openDefaultAppsSettings": "Open default apps", + "refreshFileAssociations": "Refresh file associations", + "fileAssociationDefault": "Default: Raw Viewer", + "fileAssociationNotDefault": "Raw Viewer is not the default", "fileAssociationFormatSubtitle": "Open .{extension} files with Raw Viewer", "fileAssociationsEnableAll": "Enable all", "fileAssociationsEnableRaw": "Enable RAW only", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e2c199d..69b307e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -335,6 +335,30 @@ abstract class AppLocalizations { /// **'Default file associations'** String get fileAssociationsSectionTitle; + /// No description provided for @openDefaultAppsSettings. + /// + /// In en, this message translates to: + /// **'Open default apps'** + String get openDefaultAppsSettings; + + /// No description provided for @refreshFileAssociations. + /// + /// In en, this message translates to: + /// **'Refresh file associations'** + String get refreshFileAssociations; + + /// No description provided for @fileAssociationDefault. + /// + /// In en, this message translates to: + /// **'Default: Raw Viewer'** + String get fileAssociationDefault; + + /// No description provided for @fileAssociationNotDefault. + /// + /// In en, this message translates to: + /// **'Raw Viewer is not the default'** + String get fileAssociationNotDefault; + /// No description provided for @fileAssociationFormatSubtitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eb1587a..cbbbb47 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -134,6 +134,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fileAssociationsSectionTitle => 'Default file associations'; + @override + String get openDefaultAppsSettings => 'Open default apps'; + + @override + String get refreshFileAssociations => 'Refresh file associations'; + + @override + String get fileAssociationDefault => 'Default: Raw Viewer'; + + @override + String get fileAssociationNotDefault => 'Raw Viewer is not the default'; + @override String fileAssociationFormatSubtitle(Object extension) { return 'Open .$extension files with Raw Viewer'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ee2293a..9835f3b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -134,6 +134,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get fileAssociationsSectionTitle => '默认打开格式'; + @override + String get openDefaultAppsSettings => '打开默认应用设置'; + + @override + String get refreshFileAssociations => '刷新文件关联'; + + @override + String get fileAssociationDefault => '默认应用:Raw Viewer'; + + @override + String get fileAssociationNotDefault => 'Raw Viewer 不是默认应用'; + @override String fileAssociationFormatSubtitle(Object extension) { return '使用 Raw Viewer 打开 .$extension 文件'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d2cf46c..b7417fb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -61,6 +61,10 @@ } }, "fileAssociationsSectionTitle": "默认打开格式", + "openDefaultAppsSettings": "打开默认应用设置", + "refreshFileAssociations": "刷新文件关联", + "fileAssociationDefault": "默认应用:Raw Viewer", + "fileAssociationNotDefault": "Raw Viewer 不是默认应用", "fileAssociationFormatSubtitle": "使用 Raw Viewer 打开 .{extension} 文件", "fileAssociationsEnableAll": "开启所有", "fileAssociationsEnableRaw": "仅开启 RAW", diff --git a/lib/settings_page.dart b/lib/settings_page.dart index ac62f3d..c4a8f01 100644 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -102,10 +102,12 @@ typedef WindowsContextMenuToggleHandler = Future class FileAssociationSettings { final bool supported; + final bool requiresSystemSettings; final Map bindings; const FileAssociationSettings({ this.supported = false, + this.requiresSystemSettings = false, this.bindings = const {}, }); @@ -113,10 +115,13 @@ class FileAssociationSettings { FileAssociationSettings copyWith({ bool? supported, + bool? requiresSystemSettings, Map? bindings, }) { return FileAssociationSettings( supported: supported ?? this.supported, + requiresSystemSettings: + requiresSystemSettings ?? this.requiresSystemSettings, bindings: bindings ?? this.bindings, ); } @@ -133,6 +138,7 @@ class FileAssociationSettings { } return FileAssociationSettings( supported: values?['supported'] == true, + requiresSystemSettings: values?['requiresSystemSettings'] == true, bindings: bindings, ); } @@ -223,6 +229,8 @@ class SettingsPage extends StatefulWidget { final ValueChanged onSettingsChanged; final WindowsContextMenuToggleHandler? onWindowsContextMenuChanged; final FileAssociationChangeHandler? onFileAssociationsChanged; + final Future Function()? onOpenDefaultAppsSettings; + final Future Function()? onRefreshFileAssociations; const SettingsPage({ super.key, @@ -231,13 +239,16 @@ class SettingsPage extends StatefulWidget { required this.onSettingsChanged, this.onWindowsContextMenuChanged, this.onFileAssociationsChanged, + this.onOpenDefaultAppsSettings, + this.onRefreshFileAssociations, }); @override State createState() => _SettingsPageState(); } -class _SettingsPageState extends State { +class _SettingsPageState extends State + with WidgetsBindingObserver { late ViewerSettings _currentSettings; bool _isUpdatingWindowsContextMenu = false; bool _isUpdatingFileAssociations = false; @@ -257,6 +268,20 @@ class _SettingsPageState extends State { void initState() { super.initState(); _currentSettings = widget.settings; + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_refreshFileAssociations()); + } } void _updateSettings(ViewerSettings settings) { @@ -270,7 +295,8 @@ class _SettingsPageState extends State { Platform.isWindows && widget.onWindowsContextMenuChanged != null; bool get _showFileAssociationSection => - widget.onFileAssociationsChanged != null && + (widget.onFileAssociationsChanged != null || + widget.onOpenDefaultAppsSettings != null) && _currentSettings.fileAssociations.supported; List _withDividers(List children) { @@ -409,6 +435,7 @@ class _SettingsPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.fileAssociationsUpdateFailed('$error'))), ); + await _refreshFileAssociations(); } finally { if (mounted) { setState(() { @@ -418,6 +445,36 @@ class _SettingsPageState extends State { } } + Future _refreshFileAssociations() async { + final refresh = widget.onRefreshFileAssociations; + if (refresh == null) return; + try { + final nextState = await refresh(); + if (!mounted) return; + _updateSettings(_currentSettings.copyWith(fileAssociations: nextState)); + } on Exception { + // Preserve the last known state if the platform is temporarily unavailable. + } + } + + Future _openDefaultAppsSettings() async { + final open = widget.onOpenDefaultAppsSettings; + if (open == null || _isUpdatingFileAssociations) return; + setState(() => _isUpdatingFileAssociations = true); + try { + await open(); + await _refreshFileAssociations(); + } catch (error) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.fileAssociationsUpdateFailed('$error'))), + ); + } finally { + if (mounted) setState(() => _isUpdatingFileAssociations = false); + } + } + Widget _buildFileAssociationActions(AppLocalizations l10n) { final enabled = !_isUpdatingFileAssociations; @@ -428,39 +485,53 @@ class _SettingsPageState extends State { spacing: 8, runSpacing: 8, children: [ - DesktopCommandButton( - key: const ValueKey('file-association-enable-all'), - icon: Icons.done_all, - label: l10n.fileAssociationsEnableAll, - onPressed: enabled - ? () => unawaited( - _updateFileAssociations( - Set.of(supportedExtensions), - ), - ) - : null, - emphasized: true, - ), - DesktopCommandButton( - key: const ValueKey('file-association-enable-raw'), - icon: Icons.camera_alt_outlined, - label: l10n.fileAssociationsEnableRaw, - onPressed: enabled - ? () => unawaited( - _updateFileAssociations( - Set.of(rawExtensions), - ), - ) - : null, - ), - DesktopCommandButton( - key: const ValueKey('file-association-disable-all'), - icon: Icons.block_outlined, - label: l10n.fileAssociationsDisableAll, - onPressed: enabled - ? () => unawaited(_updateFileAssociations(const {})) - : null, - ), + if (_currentSettings.fileAssociations.requiresSystemSettings) ...[ + DesktopCommandButton( + key: const ValueKey('file-association-system-settings'), + icon: Icons.open_in_new, + label: l10n.openDefaultAppsSettings, + onPressed: enabled ? _openDefaultAppsSettings : null, + ), + DesktopIconButton( + icon: Icons.refresh, + tooltip: l10n.refreshFileAssociations, + onPressed: _refreshFileAssociations, + ), + ] else ...[ + DesktopCommandButton( + key: const ValueKey('file-association-enable-all'), + icon: Icons.done_all, + label: l10n.fileAssociationsEnableAll, + onPressed: enabled + ? () => unawaited( + _updateFileAssociations( + Set.of(supportedExtensions), + ), + ) + : null, + emphasized: true, + ), + DesktopCommandButton( + key: const ValueKey('file-association-enable-raw'), + icon: Icons.camera_alt_outlined, + label: l10n.fileAssociationsEnableRaw, + onPressed: enabled + ? () => unawaited( + _updateFileAssociations( + Set.of(rawExtensions), + ), + ) + : null, + ), + DesktopCommandButton( + key: const ValueKey('file-association-disable-all'), + icon: Icons.block_outlined, + label: l10n.fileAssociationsDisableAll, + onPressed: enabled + ? () => unawaited(_updateFileAssociations(const {})) + : null, + ), + ], ], ), ); @@ -722,23 +793,38 @@ class _SettingsPageState extends State { subtitle: l10n.fileAssociationFormatSubtitle( extension.substring(1).toUpperCase(), ), - control: _isUpdatingFileAssociations - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + control: _currentSettings + .fileAssociations.requiresSystemSettings + ? Tooltip( + message: _currentSettings.fileAssociations + .isBound(extension) + ? l10n.fileAssociationDefault + : l10n.fileAssociationNotDefault, + child: Icon( + _currentSettings.fileAssociations + .isBound(extension) + ? Icons.check_circle_outline + : Icons.radio_button_unchecked, + size: 20, ), ) - : Switch( - value: _currentSettings.fileAssociations - .isBound(extension), - onChanged: (value) => - _handleFileAssociationChanged( - extension, - value, - ), - ), + : _isUpdatingFileAssociations + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Switch( + value: _currentSettings.fileAssociations + .isBound(extension), + onChanged: (value) => + _handleFileAssociationChanged( + extension, + value, + ), + ), ), ]), ), diff --git a/test/core/file_association_test.dart b/test/core/file_association_test.dart index 3eb6354..e5e646d 100644 --- a/test/core/file_association_test.dart +++ b/test/core/file_association_test.dart @@ -13,6 +13,7 @@ void main() { }); expect(state.supported, isTrue); + expect(state.requiresSystemSettings, isFalse); expect(state.isBound('.arw'), isTrue); expect(state.isBound('.jpg'), isFalse); expect(state.bindings.keys, containsAll(supportedExtensions)); @@ -27,4 +28,16 @@ void main() { expect(state.bindings, isEmpty); expect(state.isBound('.arw'), isFalse); }); + + test('preserves the system-managed association capability', () { + final state = FileAssociationSettings.fromPlatformMap({ + 'supported': true, + 'requiresSystemSettings': true, + 'bindings': {'.jpg': false}, + }); + expect(state.requiresSystemSettings, isTrue); + expect(state.copyWith(bindings: {'.jpg': true}).requiresSystemSettings, + isTrue); + expect(state.isBound('.jpg'), isFalse); + }); } diff --git a/test/file_association_settings_test.dart b/test/file_association_settings_test.dart new file mode 100644 index 0000000..5b57e37 --- /dev/null +++ b/test/file_association_settings_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rawviewer/l10n/app_localizations.dart'; +import 'package:rawviewer/settings_page.dart'; + +const _systemState = FileAssociationSettings( + supported: true, + requiresSystemSettings: true, + bindings: {'.jpg': false}, +); + +Widget _settings({ + required Future Function() open, + required Future Function() refresh, + ValueChanged? onChanged, + String locale = 'en', +}) { + return MaterialApp( + locale: Locale(locale), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: SettingsPage( + settings: const ViewerSettings(fileAssociations: _systemState), + onClose: () {}, + onSettingsChanged: onChanged ?? (_) {}, + onOpenDefaultAppsSettings: open, + onRefreshFileAssociations: refresh, + ), + ); +} + +void main() { + for (final locale in ['en', 'zh']) { + testWidgets('system association controls fit a narrow window in $locale', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var openCount = 0; + ViewerSettings? updated; + await tester.pumpWidget(_settings( + locale: locale, + open: () async { + openCount++; + }, + refresh: () async => _systemState, + onChanged: (value) => updated = value, + )); + await tester.pumpAndSettle(); + final button = + find.byKey(const ValueKey('file-association-system-settings')); + await tester.scrollUntilVisible(button, 250); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('file-association-enable-all')), + findsNothing); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(openCount, 1); + expect(updated!.fileAssociations.isBound('.jpg'), isFalse); + final row = find.byKey(const ValueKey('file-association-.jpg')); + await tester.scrollUntilVisible(row, 250); + await tester.pumpAndSettle(); + expect(find.descendant(of: row, matching: find.byType(Switch)), + findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('returning from system settings refreshes effective associations', + (tester) async { + var platformState = _systemState; + ViewerSettings? updated; + await tester.pumpWidget(_settings( + open: () async {}, + refresh: () async => platformState, + onChanged: (value) => updated = value, + )); + await tester.pumpAndSettle(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + platformState = _systemState.copyWith(bindings: {'.jpg': true}); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(updated!.fileAssociations.isBound('.jpg'), isTrue); + final row = find.byKey(const ValueKey('file-association-.jpg')); + await tester.scrollUntilVisible(row, 250); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: row, matching: find.byTooltip('Default: Raw Viewer')), + findsOneWidget); + }); + + testWidgets( + 'a failed settings launch reports the error without changing defaults', + (tester) async { + ViewerSettings? updated; + await tester.pumpWidget(_settings( + open: () async => throw Exception('Launch failed'), + refresh: () async => _systemState, + onChanged: (value) => updated = value, + )); + await tester.pumpAndSettle(); + final button = + find.byKey(const ValueKey('file-association-system-settings')); + await tester.scrollUntilVisible(button, 250); + await tester.pumpAndSettle(); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.textContaining('Launch failed'), findsOneWidget); + expect(updated, isNull); + }); +} diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 3956c85..37865c3 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -35,6 +35,7 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "advapi32.lib") +target_link_libraries(${BINARY_NAME} PRIVATE "shlwapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE "comctl32.lib") target_link_libraries(${BINARY_NAME} PRIVATE native_lib) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index da9e44f..8aa38a3 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -316,51 +316,14 @@ void FlutterWindow::ConfigureFileAssociationChannel() { return; } - if (call.method_name() == "setFileAssociations") { - const auto* arguments = - std::get_if(call.arguments()); - if (arguments == nullptr) { - result->Error("invalid_arguments", - "Expected a map containing extensions."); - return; - } - - const auto extensions_it = - arguments->find(flutter::EncodableValue("extensions")); - if (extensions_it == arguments->end()) { - result->Error("invalid_arguments", - "Expected an extensions list."); - return; - } - - const auto* extension_list = - std::get_if(&extensions_it->second); - if (extension_list == nullptr) { - result->Error("invalid_arguments", - "Expected extensions as a list of strings."); - return; - } - - std::vector extensions; - extensions.reserve(extension_list->size()); - for (const auto& value : *extension_list) { - const auto* extension = std::get_if(&value); - if (extension == nullptr) { - result->Error("invalid_arguments", - "Expected extensions as a list of strings."); - return; - } - extensions.push_back(*extension); - } - + if (call.method_name() == "openDefaultAppsSettings") { std::string error_message; - if (!SetWindowsFileAssociations(extensions, &error_message)) { + if (!OpenWindowsDefaultAppsSettings(&error_message)) { result->Error("file_association_error", error_message); return; } - result->Success( - flutter::EncodableValue(GetWindowsFileAssociationState())); + result->Success(); return; } diff --git a/windows/runner/shell_integration.cpp b/windows/runner/shell_integration.cpp index 30aa9cd..9b44944 100644 --- a/windows/runner/shell_integration.cpp +++ b/windows/runner/shell_integration.cpp @@ -3,10 +3,9 @@ #include #include #include +#include #include -#include -#include #include #include @@ -76,10 +75,6 @@ std::wstring FileAssociationProgId(const wchar_t* extension) { return std::wstring(L"RawViewer.") + extension; } -std::wstring FileAssociationExtensionKey(const wchar_t* extension) { - return std::wstring(L"Software\\Classes\\.") + extension; -} - std::wstring FileAssociationProgIdKey(const wchar_t* extension) { return std::wstring(L"Software\\Classes\\") + FileAssociationProgId(extension); @@ -212,25 +207,22 @@ void NotifyShellChanged() { bool IsFileAssociationInstalled(const wchar_t* extension, const std::wstring& executable_path) { - std::wstring prog_id; - if (!ReadStringValue(HKEY_CURRENT_USER, - FileAssociationExtensionKey(extension), nullptr, - &prog_id) || - _wcsicmp(prog_id.c_str(), FileAssociationProgId(extension).c_str()) != - 0) { + // Ask the Shell for the effective association, including UserChoice. + const auto association = std::wstring(L".") + extension; + DWORD length = 0; + if (::AssocQueryStringW(ASSOCF_NONE, ASSOCSTR_EXECUTABLE, + association.c_str(), nullptr, nullptr, &length) != + S_FALSE || + length == 0) { return false; } - - std::wstring command; - if (!ReadStringValue( - HKEY_CURRENT_USER, - FileAssociationProgIdKey(extension) + L"\\shell\\open\\command", - nullptr, &command)) { + std::wstring executable(length, L'\0'); + if (::AssocQueryStringW(ASSOCF_NONE, ASSOCSTR_EXECUTABLE, + association.c_str(), nullptr, executable.data(), + &length) != S_OK) { return false; } - - return _wcsicmp(command.c_str(), - FileAssociationCommand(executable_path).c_str()) == 0; + return _wcsicmp(executable.c_str(), executable_path.c_str()) == 0; } bool WriteFileAssociation(const wchar_t* extension, @@ -250,36 +242,10 @@ bool WriteFileAssociation(const wchar_t* extension, FileAssociationCommand(executable_path))) { return false; } - return SetStringValue(HKEY_CURRENT_USER, - FileAssociationExtensionKey(extension), nullptr, - prog_id); -} - -bool RemoveFileAssociation(const wchar_t* extension) { - std::wstring current_prog_id; - const auto extension_key = FileAssociationExtensionKey(extension); - if (ReadStringValue(HKEY_CURRENT_USER, extension_key, nullptr, - ¤t_prog_id) && - _wcsicmp(current_prog_id.c_str(), - FileAssociationProgId(extension).c_str()) == 0) { - HKEY key = nullptr; - const LONG open_result = ::RegOpenKeyExW( - HKEY_CURRENT_USER, extension_key.c_str(), 0, KEY_SET_VALUE, &key); - if (open_result != ERROR_SUCCESS) { - return open_result == ERROR_FILE_NOT_FOUND; - } - const LONG delete_result = ::RegDeleteValueW(key, nullptr); - ::RegCloseKey(key); - if (delete_result != ERROR_SUCCESS && delete_result != ERROR_FILE_NOT_FOUND) { - return false; - } - } - - const LONG delete_prog_id_result = - ::RegDeleteTreeW(HKEY_CURRENT_USER, - FileAssociationProgIdKey(extension).c_str()); - return delete_prog_id_result == ERROR_SUCCESS || - delete_prog_id_result == ERROR_FILE_NOT_FOUND; + const auto extension_name = std::wstring(L".") + extension; + return SetStringValue( + HKEY_CURRENT_USER, L"Software\\RawViewer\\Capabilities\\FileAssociations", + extension_name.c_str(), prog_id); } ContextMenuState QueryContextMenuState() { @@ -359,12 +325,13 @@ flutter::EncodableMap GetWindowsFileAssociationState() { } return flutter::EncodableMap{ {flutter::EncodableValue("supported"), flutter::EncodableValue(true)}, + {flutter::EncodableValue("requiresSystemSettings"), + flutter::EncodableValue(true)}, {flutter::EncodableValue("bindings"), flutter::EncodableValue(bindings)}, }; } -bool SetWindowsFileAssociations(const std::vector& extensions, - std::string* error_message) { +bool OpenWindowsDefaultAppsSettings(std::string* error_message) { const std::wstring executable_path = GetExecutablePath(); if (executable_path.empty()) { if (error_message != nullptr) { @@ -373,33 +340,39 @@ bool SetWindowsFileAssociations(const std::vector& extensions, return false; } - std::set selected; - for (auto extension : extensions) { - if (!extension.empty() && extension.front() == '.') { - extension.erase(extension.begin()); - } - for (auto& character : extension) { - character = static_cast(std::tolower( - static_cast(character))); - } - selected.insert(extension); - } - for (const auto* extension : kFileAssociationExtensions) { - const std::string extension_utf8 = NarrowExtension(extension); - const bool should_install = selected.find(extension_utf8) != selected.end(); - const bool success = should_install - ? WriteFileAssociation(extension, executable_path) - : RemoveFileAssociation(extension); - if (!success) { + if (!WriteFileAssociation(extension, executable_path)) { if (error_message != nullptr) { *error_message = - "Failed to update Windows file association registry entries."; + "Failed to register supported Windows file types."; } return false; } } + const std::wstring capabilities = L"Software\\RawViewer\\Capabilities"; + if (!SetStringValue(HKEY_CURRENT_USER, capabilities, L"ApplicationName", + L"Raw Viewer") || + !SetStringValue(HKEY_CURRENT_USER, capabilities, L"ApplicationDescription", + L"RAW and standard image viewer") || + !SetStringValue(HKEY_CURRENT_USER, L"Software\\RegisteredApplications", + L"RawViewer", capabilities)) { + if (error_message != nullptr) { + *error_message = "Failed to register Raw Viewer in Default Apps."; + } + return false; + } NotifyShellChanged(); + // Windows owns default selection. Registration must not overwrite defaults + // or delete ProgIDs that a UserChoice entry may still reference. + const auto launched = reinterpret_cast(::ShellExecuteW( + nullptr, L"open", L"ms-settings:defaultapps?registeredAppUser=RawViewer", + nullptr, nullptr, SW_SHOWNORMAL)); + if (launched <= 32) { + if (error_message != nullptr) { + *error_message = "Unable to open Windows Default Apps settings."; + } + return false; + } return true; } diff --git a/windows/runner/shell_integration.h b/windows/runner/shell_integration.h index b9e1cac..e894493 100644 --- a/windows/runner/shell_integration.h +++ b/windows/runner/shell_integration.h @@ -4,14 +4,11 @@ #include #include -#include flutter::EncodableMap GetWindowsContextMenuState(); bool SetWindowsContextMenuEnabled(bool enabled, const std::wstring& menu_text, std::string* error_message); flutter::EncodableMap GetWindowsFileAssociationState(); -bool SetWindowsFileAssociations( - const std::vector& extensions, - std::string* error_message); +bool OpenWindowsDefaultAppsSettings(std::string* error_message); #endif // RUNNER_SHELL_INTEGRATION_H_ From c5425745002985f2c447e02ebe9780136b6a2889 Mon Sep 17 00:00:00 2001 From: stmtc2333 Date: Sat, 5 Sep 2026 23:52:15 +0800 Subject: [PATCH 7/7] revert: replace active preview on desktop open requests --- lib/home_page.dart | 123 ++++++++++------------------ test/gallery/desktop_open_test.dart | 111 ------------------------- 2 files changed, 45 insertions(+), 189 deletions(-) delete mode 100644 test/gallery/desktop_open_test.dart diff --git a/lib/home_page.dart b/lib/home_page.dart index 8ecaeef..bc49f01 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -61,8 +61,6 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { String? _currentDirectoryPath; String? _deferredDirectoryPath; - int _openedSourceGeneration = 0; - Route? _previewRoute; int? _openedDirectoryCount; String? _lastSyncedWindowsContextMenuText; List _files = []; @@ -553,7 +551,6 @@ class _HomePageState extends State { return; } final nextFiles = _deduplicateMediaFiles([...directoryFiles, ...files]); - _prepareForIncomingPaths(); _applyOpenedFiles( files: nextFiles, sourceKind: _OpenedSourceKind.folder, @@ -575,7 +572,6 @@ class _HomePageState extends State { ? files : _deduplicateMediaFiles([..._files, ...files]); - _prepareForIncomingPaths(); _applyOpenedFiles( files: nextFiles, sourceKind: _OpenedSourceKind.files, @@ -589,15 +585,6 @@ class _HomePageState extends State { } } - void _prepareForIncomingPaths() { - _openedSourceGeneration++; - final previewRoute = _previewRoute; - _previewRoute = null; - if (previewRoute != null && previewRoute.isActive) { - Navigator.of(context).removeRoute(previewRoute); - } - } - void _applyOpenedFiles({ required List files, required _OpenedSourceKind sourceKind, @@ -627,11 +614,8 @@ class _HomePageState extends State { } void _scheduleSingleFilePreview(String filePath) { - final generation = _openedSourceGeneration; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - generation != _openedSourceGeneration || - !(ModalRoute.of(context)?.isCurrent ?? false)) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) { return; } if (_files.length != 1 || _files.single.path != filePath) { @@ -645,19 +629,14 @@ class _HomePageState extends State { }); } - Future?> _loadDeferredDirectoryForPreview( - int generation, - ) async { - if (!mounted || generation != _openedSourceGeneration) return null; + Future?> _loadDeferredDirectoryForPreview() async { final directoryPath = _deferredDirectoryPath; if (directoryPath == null) { return buildAdaptiveMediaGroups(_files); } final loadedDirectory = await _loadDeferredDirectoryFiles(directoryPath); - if (!mounted || - generation != _openedSourceGeneration || - loadedDirectory == null) { + if (loadedDirectory == null) { return null; } final mediaGroups = buildAdaptiveMediaGroups(loadedDirectory.files); @@ -674,18 +653,12 @@ class _HomePageState extends State { return mediaGroups; } - Future _loadDeferredDirectoryAfterClose(int generation) async { - if (!mounted || - generation != _openedSourceGeneration || - _deferredDirectoryPath == null) { - return; - } + Future _loadDeferredDirectoryAfterClose() async { + if (_deferredDirectoryPath == null) return; final directoryPath = _deferredDirectoryPath!; try { final loadedDirectory = await _loadDeferredDirectoryFiles(directoryPath); - if (!mounted || - generation != _openedSourceGeneration || - loadedDirectory == null) { + if (loadedDirectory == null) { return; } _applyOpenedFiles( @@ -696,9 +669,7 @@ class _HomePageState extends State { openedDirectoryCount: 1, ); } catch (error) { - if (generation == _openedSourceGeneration) { - _showDirectoryLoadError(error); - } + _showDirectoryLoadError(error); } } @@ -758,51 +729,47 @@ class _HomePageState extends State { final thumbnailResizeWidth = bucketDecodeWidth((cellWidth * dpr).clamp(100.0, 800.0)); - final generation = _openedSourceGeneration; - final route = PageRouteBuilder( - transitionDuration: kImagePreviewOpenTransitionDuration, - reverseTransitionDuration: kImagePreviewCloseTransitionDuration, - pageBuilder: (context, animation, secondaryAnimation) { - return ExcludeSemantics( - child: ImagePreviewPage( - mediaGroups: mediaGroups, - initialIndex: initialIndex, - thumbnailResizeWidth: thumbnailResizeWidth, - imageStore: _imageStore, - timestampRepository: _timestampRepository, - initialSettings: _settings, - onLoadDirectory: deferDirectoryLoad - ? () => _loadDeferredDirectoryForPreview(generation) - : null, - onRawViewModeChanged: (mode) => _updateSettings( - _settings.copyWith(rawViewMode: mode), + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: kImagePreviewOpenTransitionDuration, + reverseTransitionDuration: kImagePreviewCloseTransitionDuration, + pageBuilder: (context, animation, secondaryAnimation) { + return ExcludeSemantics( + child: ImagePreviewPage( + mediaGroups: mediaGroups, + initialIndex: initialIndex, + thumbnailResizeWidth: thumbnailResizeWidth, + imageStore: _imageStore, + timestampRepository: _timestampRepository, + initialSettings: _settings, + onLoadDirectory: + deferDirectoryLoad ? _loadDeferredDirectoryForPreview : null, + onRawViewModeChanged: (mode) => _updateSettings( + _settings.copyWith(rawViewMode: mode), + ), + onPreviewFilmstripHeightChanged: (height) => _updateSettings( + _settings.copyWith(previewFilmstripHeight: height), + ), + onClose: () { + Navigator.pop(context); + unawaited(_loadDeferredDirectoryAfterClose()); + }, ), - onPreviewFilmstripHeightChanged: (height) => _updateSettings( - _settings.copyWith(previewFilmstripHeight: height), + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, ), - onClose: () { - if (generation != _openedSourceGeneration) return; - Navigator.pop(context); - unawaited(_loadDeferredDirectoryAfterClose(generation)); - }, - ), - ); - }, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ), - child: child, - ); - }, + child: child, + ); + }, + ), ); - _previewRoute = route; - unawaited(Navigator.of(context).push(route).whenComplete(() { - if (identical(_previewRoute, route)) _previewRoute = null; - })); } List _listRawFilesInDirectory(String directoryPath) => diff --git a/test/gallery/desktop_open_test.dart b/test/gallery/desktop_open_test.dart deleted file mode 100644 index b20da42..0000000 --- a/test/gallery/desktop_open_test.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:rawviewer/app.dart'; -import 'package:rawviewer/core/platform_channels.dart'; -import 'package:rawviewer/preview/image_preview_page.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - late Directory directory; - late File first; - late File second; - - setUp(() { - SharedPreferences.setMockInitialValues({}); - directory = Directory.systemTemp.createTempSync('rawviewer-open-test-'); - final bytes = base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', - ); - first = File('${directory.path}/a.png')..writeAsBytesSync(bytes); - second = File('${directory.path}/b.png')..writeAsBytesSync(bytes); - }); - - tearDown(() { - desktopOpenChannel.setMethodCallHandler(null); - directory.deleteSync(recursive: true); - }); - - Future openPaths(WidgetTester tester, List paths) async { - final completed = Completer(); - tester.binding.defaultBinaryMessenger.handlePlatformMessage( - desktopOpenChannel.name, - const StandardMethodCodec() - .encodeMethodCall(MethodCall('openPaths', paths)), - (_) => completed.complete(), - ); - await completed.future; - } - - testWidgets('second desktop open replaces the current preview', - (tester) async { - await tester.pumpWidget(const MyApp()); - await tester.pumpAndSettle(); - await openPaths(tester, [first.path]); - await tester.pumpAndSettle(); - final oldPreview = - tester.widget(find.byType(ImagePreviewPage)); - expect(oldPreview.mediaGroups.single.primary.path, first.path); - - await openPaths(tester, [second.path]); - await tester.pumpAndSettle(); - expect(find.byType(ImagePreviewPage), findsOneWidget); - expect( - tester - .widget(find.byType(ImagePreviewPage)) - .mediaGroups - .single - .primary - .path, - second.path); - // An old route must not load a new source's directory or close its preview. - expect(await oldPreview.onLoadDirectory!(), isNull); - oldPreview.onClose(); - await tester.pumpAndSettle(); - expect(find.byType(ImagePreviewPage), findsOneWidget); - - tester.widget(find.byType(ImagePreviewPage)).onClose(); - await tester.pumpAndSettle(); - expect(find.byType(ImagePreviewPage), findsNothing); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }); - - testWidgets('rapid desktop opens keep only the latest preview', - (tester) async { - await tester.pumpWidget(const MyApp()); - await tester.pumpAndSettle(); - await openPaths(tester, [first.path]); - await openPaths(tester, [second.path]); - await openPaths(tester, [second.path]); - await tester.pumpAndSettle(); - expect(find.byType(ImagePreviewPage, skipOffstage: false), findsOneWidget); - expect( - tester - .widget(find.byType(ImagePreviewPage)) - .mediaGroups - .single - .primary - .path, - second.path); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }); - - testWidgets('opening a directory dismisses an existing single-file preview', - (tester) async { - await tester.pumpWidget(const MyApp()); - await tester.pumpAndSettle(); - await openPaths(tester, [first.path]); - await tester.pumpAndSettle(); - await openPaths(tester, [directory.path]); - await tester.pumpAndSettle(); - expect(find.byType(ImagePreviewPage), findsNothing); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }); -}