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/README.md b/README.md index 65ff3d7..dda40b0 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. 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. ## Screenshots diff --git a/README_zh.md b/README_zh.md index 8d87705..92f59c1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,6 +19,8 @@ Raw Viewer 旨在为摄影师和摄影爱好者提供一个轻量、流畅的工 - **EXIF 元数据:** 自动读取并显示 EXIF 中的真实拍摄时间。 - **流畅交互:** 支持顺滑的快速翻页,并适配了触屏的捏合缩放(Pinch-to-zoom)与鼠标滚轮缩放。 - **Windows 右键菜单:** 可在设置页一键将“在RawView中打开”安装到当前用户的资源管理器右键菜单,支持文件、多个文件、文件夹,以及文件夹空白处右键打开当前目录。 +- **默认打开格式:** Windows 通过系统默认应用设置选择关联,并显示实际生效的状态;macOS 在设置页提供按格式切换的开关。 +- **单图打开:** 直接打开单张图片时立即进入预览;相邻文件只会在预览顶栏主动加载目录或返回图库时加载。 - **跨平台支持:** 原生支持桌面端 (Windows, macOS) 与移动端 (Android)。 ## 界面截图 diff --git a/innosetup/rawviewer.iss b/innosetup/rawviewer.iss index 8fe63d8..cb97914 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,62 @@ 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\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 +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..bc49f01 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,33 @@ 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 _getFileAssociationSettings() async { + final values = await fileAssociationChannel + .invokeMapMethod('getFileAssociationState'); + return FileAssociationSettings.fromPlatformMap(values); + } + Future _getWindowsContextMenuSettings() async { final values = await windowsShellChannel.invokeMapMethod( 'getContextMenuState', @@ -316,6 +357,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 +476,7 @@ class _HomePageState extends State { } Future _listenForDesktopOpenRequests() async { - if (!Platform.isMacOS && !Platform.isWindows) { + if (!Platform.isMacOS && !Platform.isWindows && !Platform.isLinux) { return; } @@ -431,6 +494,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 +541,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 +565,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 +576,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 +591,7 @@ class _HomePageState extends State { required bool clearCache, String? openedDirectoryPath, int? openedDirectoryCount, + String? deferredDirectoryPath, }) { if (!mounted) { return; @@ -520,10 +608,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 +858,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 +1170,16 @@ class _HomePageState extends State { onSettingsChanged: _updateSettings, onWindowsContextMenuChanged: Platform.isWindows ? _setWindowsContextMenuEnabled : null, + onFileAssociationsChanged: + Platform.isMacOS ? _setFileAssociations : null, + onOpenDefaultAppsSettings: Platform.isWindows + ? () => fileAssociationChannel + .invokeMethod('openDefaultAppsSettings') + : null, + onRefreshFileAssociations: + (Platform.isWindows || Platform.isMacOS) + ? _getFileAssociationSettings + : null, onClose: () { Navigator.pop(dialogContext); }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 79b53e9..7180966 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -60,6 +60,23 @@ } } }, + "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", + "fileAssociationsDisableAll": "Disable all", + "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 +111,17 @@ "showPreviewOverviewTooltip": "Show overview map", "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": { + "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..69b307e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -329,6 +329,66 @@ 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 @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: + /// **'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: + /// **'Failed to update file associations: {error}'** + String fileAssociationsUpdateFailed(String error); + /// No description provided for @homeEmptyState. /// /// In en, this message translates to: @@ -491,6 +551,30 @@ 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 @loadDirectoryButtonLabel. + /// + /// In en, this message translates to: + /// **'Load directory'** + String get loadDirectoryButtonLabel; + + /// 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..cbbbb47 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -131,6 +131,40 @@ class AppLocalizationsEn extends AppLocalizations { return 'Failed to update Windows context menu: $error'; } + @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'; + } + + @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'; + } + @override String get homeEmptyState => 'Open or drop RAW and image files/folders'; @@ -214,6 +248,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get centerCurrentPreviewThumbnailTooltip => 'Center current thumbnail'; + @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'; + + @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..9835f3b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -131,6 +131,40 @@ class AppLocalizationsZh extends AppLocalizations { return '更新 Windows 右键菜单失败:$error'; } + @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 文件'; + } + + @override + String get fileAssociationsEnableAll => '开启所有'; + + @override + String get fileAssociationsEnableRaw => '仅开启 RAW'; + + @override + String get fileAssociationsDisableAll => '全部关闭'; + + @override + String fileAssociationsUpdateFailed(String error) { + return '更新文件关联失败:$error'; + } + @override String get homeEmptyState => '打开或拖放 RAW 与图片文件/文件夹'; @@ -214,6 +248,20 @@ class AppLocalizationsZh extends AppLocalizations { @override String get centerCurrentPreviewThumbnailTooltip => '居中当前缩略图'; + @override + String get loadDirectoryTooltip => '加载此目录中的图片'; + + @override + String get loadDirectoryButtonLabel => '加载目录'; + + @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..b7417fb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -60,6 +60,23 @@ } } }, + "fileAssociationsSectionTitle": "默认打开格式", + "openDefaultAppsSettings": "打开默认应用设置", + "refreshFileAssociations": "刷新文件关联", + "fileAssociationDefault": "默认应用:Raw Viewer", + "fileAssociationNotDefault": "Raw Viewer 不是默认应用", + "fileAssociationFormatSubtitle": "使用 Raw Viewer 打开 .{extension} 文件", + "fileAssociationsEnableAll": "开启所有", + "fileAssociationsEnableRaw": "仅开启 RAW", + "fileAssociationsDisableAll": "全部关闭", + "fileAssociationsUpdateFailed": "更新文件关联失败:{error}", + "@fileAssociationsUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "homeEmptyState": "打开或拖放 RAW 与图片文件/文件夹", "openFolder": "打开文件夹", "openFiles": "打开文件", @@ -94,6 +111,17 @@ "showPreviewOverviewTooltip": "显示鸟瞰图", "hidePreviewOverviewTooltip": "隐藏鸟瞰图", "centerCurrentPreviewThumbnailTooltip": "居中当前缩略图", + "loadDirectoryTooltip": "加载此目录中的图片", + "loadDirectoryButtonLabel": "加载目录", + "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..89876c6 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,42 @@ 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: Tooltip( + message: l10n.loadDirectoryTooltip, + child: DesktopCommandButton( + icon: _isLoadingDirectory + ? Icons.hourglass_top_outlined + : Icons.folder_open_outlined, + label: l10n.loadDirectoryButtonLabel, + 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 +514,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 +526,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 +582,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 +613,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 +699,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 +745,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 +767,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 +852,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..c4a8f01 100644 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -1,6 +1,8 @@ +import 'dart:async'; 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 +100,53 @@ class WindowsContextMenuSettings { typedef WindowsContextMenuToggleHandler = Future Function(bool enabled); +class FileAssociationSettings { + final bool supported; + final bool requiresSystemSettings; + final Map bindings; + + const FileAssociationSettings({ + this.supported = false, + this.requiresSystemSettings = false, + this.bindings = const {}, + }); + + bool isBound(String extension) => bindings[extension] == true; + + FileAssociationSettings copyWith({ + bool? supported, + bool? requiresSystemSettings, + Map? bindings, + }) { + return FileAssociationSettings( + supported: supported ?? this.supported, + requiresSystemSettings: + requiresSystemSettings ?? this.requiresSystemSettings, + 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, + requiresSystemSettings: values?['requiresSystemSettings'] == 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 +167,7 @@ class ViewerSettings { final double previewFilmstripOpacity; final double previewFilmstripHeight; final WindowsContextMenuSettings windowsContextMenu; + final FileAssociationSettings fileAssociations; const ViewerSettings({ this.rawViewMode = RawViewMode.decodedRaw, @@ -132,6 +182,7 @@ class ViewerSettings { this.previewFilmstripOpacity = kDefaultPreviewOverlayOpacity, this.previewFilmstripHeight = kPreviewFilmstripHeight, this.windowsContextMenu = const WindowsContextMenuSettings(), + this.fileAssociations = const FileAssociationSettings(), }); ViewerSettings copyWith({ @@ -147,6 +198,7 @@ class ViewerSettings { double? previewFilmstripOpacity, double? previewFilmstripHeight, WindowsContextMenuSettings? windowsContextMenu, + FileAssociationSettings? fileAssociations, }) { return ViewerSettings( rawViewMode: rawViewMode ?? this.rawViewMode, @@ -166,6 +218,7 @@ class ViewerSettings { previewFilmstripHeight: previewFilmstripHeight ?? this.previewFilmstripHeight, windowsContextMenu: windowsContextMenu ?? this.windowsContextMenu, + fileAssociations: fileAssociations ?? this.fileAssociations, ); } } @@ -175,6 +228,9 @@ class SettingsPage extends StatefulWidget { final VoidCallback onClose; final ValueChanged onSettingsChanged; final WindowsContextMenuToggleHandler? onWindowsContextMenuChanged; + final FileAssociationChangeHandler? onFileAssociationsChanged; + final Future Function()? onOpenDefaultAppsSettings; + final Future Function()? onRefreshFileAssociations; const SettingsPage({ super.key, @@ -182,15 +238,20 @@ class SettingsPage extends StatefulWidget { required this.onClose, 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; String _languageLabel(AppLanguage language, AppLocalizations l10n) { switch (language) { @@ -207,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) { @@ -219,6 +294,11 @@ class _SettingsPageState extends State { bool get _showWindowsContextMenuSection => Platform.isWindows && widget.onWindowsContextMenuChanged != null; + bool get _showFileAssociationSection => + (widget.onFileAssociationsChanged != null || + widget.onOpenDefaultAppsSettings != null) && + _currentSettings.fileAssociations.supported; + List _withDividers(List children) { return [ for (var index = 0; index < children.length; index++) ...[ @@ -322,6 +402,141 @@ class _SettingsPageState extends State { } } + Future _handleFileAssociationChanged( + 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; + } + + setState(() { + _isUpdatingFileAssociations = true; + }); + + try { + final nextState = await onFileAssociationsChanged(extensions); + 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'))), + ); + await _refreshFileAssociations(); + } finally { + if (mounted) { + setState(() { + _isUpdatingFileAssociations = false; + }); + } + } + } + + 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; + + return Padding( + key: const ValueKey('file-association-actions'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + 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, + ), + ], + ], + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -430,7 +645,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 +781,53 @@ class _SettingsPageState extends State { ), ]), ), + if (_showFileAssociationSection) + DesktopSettingsSection( + title: l10n.fileAssociationsSectionTitle, + children: _withDividers([ + _buildFileAssociationActions(l10n), + for (final extension in supportedExtensions) + DesktopSettingsRow( + key: ValueKey('file-association-$extension'), + title: extension.substring(1).toUpperCase(), + subtitle: l10n.fileAssociationFormatSubtitle( + extension.substring(1).toUpperCase(), + ), + 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, + ), + ) + : _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/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/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 5cef4da..cab7de0 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,6 +1,29 @@ import Cocoa 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 +96,131 @@ 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 associations = FileAssociations( + bundleIdentifier: Bundle.main.bundleIdentifier ?? "" + ) + 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.associations.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.associations.setAssociations(extensions: Set(extensions)) { + result(FlutterError( + code: "file_association_error", + message: error, + details: nil + )) + } else { + result(self.associations.state()) + } + default: + result(FlutterMethodNotImplemented) + } + } + } + +} + @main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -84,11 +232,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/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 4789daa..f0a2a95 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -22,6 +22,115 @@ $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + CFBundleDocumentTypes + + + CFBundleTypeName + Raw Viewer image + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + 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 + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + com.sony.arw-raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensionarw + + + UTTypeIdentifier + com.canon.cr2-raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensioncr2 + + + UTTypeIdentifier + com.canon.cr3-raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensioncr3 + + + UTTypeIdentifier + com.adobe.raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensiondng + + + UTTypeIdentifier + com.nikon.raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensionnef + + + UTTypeIdentifier + com.olympus.raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensionorf + + + UTTypeIdentifier + com.fuji.raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensionraf + + + UTTypeIdentifier + com.panasonic.rw2-raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensionrw2 + + + UTTypeIdentifier + com.samsung.raw-image + UTTypeConformsTo + public.camera-raw-image + UTTypeTagSpecification + public.filename-extensionsrw + + + UTTypeIdentifier + org.webmproject.webp + UTTypeConformsTo + public.image + 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..e5e646d --- /dev/null +++ b/test/core/file_association_test.dart @@ -0,0 +1,43 @@ +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.requiresSystemSettings, isFalse); + 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); + }); + + 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/test/widget_test.dart b/test/widget_test.dart index 6fbdc56..6ecceba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -258,6 +258,122 @@ 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.text('Load directory'), 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); + expect(find.text('Load directory'), findsOneWidget); + }); + test('keeps preview opening and discrete navigation responsive', () { expect( kImagePreviewOpenTransitionDuration, @@ -498,7 +614,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)); @@ -626,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', () { 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).") + } +} 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 b1f8fce..8aa38a3 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,38 @@ 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() == "openDefaultAppsSettings") { + std::string error_message; + if (!OpenWindowsDefaultAppsSettings(&error_message)) { + result->Error("file_association_error", error_message); + return; + } + + result->Success(); + 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..9b44944 100644 --- a/windows/runner/shell_integration.cpp +++ b/windows/runner/shell_integration.cpp @@ -3,9 +3,11 @@ #include #include #include +#include #include #include +#include namespace { @@ -16,6 +18,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 +71,27 @@ 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 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 +205,49 @@ void NotifyShellChanged() { ::SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr); } +bool IsFileAssociationInstalled(const wchar_t* extension, + const std::wstring& executable_path) { + // 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 executable(length, L'\0'); + if (::AssocQueryStringW(ASSOCF_NONE, ASSOCSTR_EXECUTABLE, + association.c_str(), nullptr, executable.data(), + &length) != S_OK) { + return false; + } + return _wcsicmp(executable.c_str(), 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; + } + 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() { const std::wstring executable_path = GetExecutablePath(); if (executable_path.empty()) { @@ -241,3 +312,67 @@ 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("requiresSystemSettings"), + flutter::EncodableValue(true)}, + {flutter::EncodableValue("bindings"), flutter::EncodableValue(bindings)}, + }; +} + +bool OpenWindowsDefaultAppsSettings(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; + } + + for (const auto* extension : kFileAssociationExtensions) { + if (!WriteFileAssociation(extension, executable_path)) { + if (error_message != nullptr) { + *error_message = + "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 6fefdb4..e894493 100644 --- a/windows/runner/shell_integration.h +++ b/windows/runner/shell_integration.h @@ -8,5 +8,7 @@ flutter::EncodableMap GetWindowsContextMenuState(); bool SetWindowsContextMenuEnabled(bool enabled, const std::wstring& menu_text, std::string* error_message); +flutter::EncodableMap GetWindowsFileAssociationState(); +bool OpenWindowsDefaultAppsSettings(std::string* error_message); #endif // RUNNER_SHELL_INTEGRATION_H_