diff --git a/lib/core/infrastructure/platform_file_system_service.dart b/lib/core/infrastructure/platform_file_system_service.dart index 1c524f72..7d005fd1 100644 --- a/lib/core/infrastructure/platform_file_system_service.dart +++ b/lib/core/infrastructure/platform_file_system_service.dart @@ -132,7 +132,18 @@ class PlatformFileSystemService implements FileSystemService { // The actual tracking is done in ProtectedFilesRegistry.registerExternalPath() } - /// Gets the app documents directory with caching + /// Gets the model storage directory with caching. + /// + /// On desktop platforms, uses platform-specific local app data directories + /// that are NOT synced to cloud services (OneDrive, iCloud, Dropbox). + /// This is critical because native code (LiteRT-LM JNI) cannot reliably + /// access files in cloud-synced folders. + /// + /// Storage locations: + /// - Windows: %LOCALAPPDATA%\flutter_gemma (truly local, never synced) + /// - macOS: ~/Library/Application Support/flutter_gemma + /// - Linux: ~/.local/share/flutter_gemma + /// - Android/iOS: App documents directory (standard behavior) Future _getDocumentsDirectory() async { // Web doesn't support local file system if (kIsWeb) { @@ -143,7 +154,40 @@ class PlatformFileSystemService implements FileSystemService { return _documentsDirectory!; } - _documentsDirectory = await getApplicationDocumentsDirectory(); + if (Platform.isWindows) { + // Use LOCALAPPDATA on Windows - truly local and never synced + final localAppData = Platform.environment['LOCALAPPDATA']; + if (localAppData != null) { + _documentsDirectory = Directory(path.join(localAppData, 'OroForge', 'models')); + } else { + _documentsDirectory = await getApplicationSupportDirectory(); + } + } else if (Platform.isMacOS) { + // Use Application Support on macOS - not synced by default + final home = Platform.environment['HOME']; + if (home != null) { + _documentsDirectory = Directory(path.join(home, 'Library', 'Application Support', 'OroForge', 'models')); + } else { + _documentsDirectory = await getApplicationSupportDirectory(); + } + } else if (Platform.isLinux) { + // Use XDG data directory on Linux + final xdgDataHome = Platform.environment['XDG_DATA_HOME']; + if (xdgDataHome != null) { + _documentsDirectory = Directory(path.join(xdgDataHome, 'OroForge', 'models')); + } else { + final home = Platform.environment['HOME']; + if (home != null) { + _documentsDirectory = Directory(path.join(home, '.local', 'share', 'OroForge', 'models')); + } else { + _documentsDirectory = await getApplicationSupportDirectory(); + } + } + } else { + // Mobile platforms use standard documents directory + _documentsDirectory = await getApplicationDocumentsDirectory(); + } + return _documentsDirectory!; } } diff --git a/lib/core/model_management/utils/file_system_manager.dart b/lib/core/model_management/utils/file_system_manager.dart index 70190bd4..0c0ff089 100644 --- a/lib/core/model_management/utils/file_system_manager.dart +++ b/lib/core/model_management/utils/file_system_manager.dart @@ -76,9 +76,71 @@ class ModelFileSystemManager { } } + /// Cached model storage directory + static Directory? _modelStorageDirectory; + + /// Gets the model storage directory. + /// + /// On desktop platforms, uses local app data directories that are NOT synced + /// to cloud services (OneDrive, iCloud, Dropbox). This is critical because + /// native code (LiteRT-LM JNI) cannot reliably access files in cloud-synced + /// folders. + /// + /// Storage locations: + /// - Windows: %LOCALAPPDATA%\flutter_gemma (truly local, never synced) + /// - macOS: ~/Library/Application Support/flutter_gemma + /// - Linux: ~/.local/share/flutter_gemma + /// - Android/iOS: App documents directory (standard behavior) + static Future getModelStorageDirectory() async { + if (_modelStorageDirectory != null) { + return _modelStorageDirectory!; + } + + if (Platform.isWindows) { + // Use LOCALAPPDATA on Windows - truly local and never synced + final localAppData = Platform.environment['LOCALAPPDATA']; + if (localAppData != null) { + _modelStorageDirectory = Directory(path.join(localAppData, 'OroForge', 'models')); + } else { + _modelStorageDirectory = await getApplicationSupportDirectory(); + } + } else if (Platform.isMacOS) { + // Use Application Support on macOS - not synced by default + final localAppData = Platform.environment['HOME']; + if (localAppData != null) { + _modelStorageDirectory = Directory(path.join(localAppData, 'Library', 'Application Support', 'OroForge', 'models')); + } else { + _modelStorageDirectory = await getApplicationSupportDirectory(); + } + } else if (Platform.isLinux) { + // Use XDG data directory on Linux + final xdgDataHome = Platform.environment['XDG_DATA_HOME']; + if (xdgDataHome != null) { + _modelStorageDirectory = Directory(path.join(xdgDataHome, 'OroForge', 'models')); + } else { + final home = Platform.environment['HOME']; + if (home != null) { + _modelStorageDirectory = Directory(path.join(home, '.local', 'share', 'OroForge', 'models')); + } else { + _modelStorageDirectory = await getApplicationSupportDirectory(); + } + } + } else { + // Mobile platforms use standard documents directory + _modelStorageDirectory = await getApplicationDocumentsDirectory(); + } + + // Ensure directory exists + if (!await _modelStorageDirectory!.exists()) { + await _modelStorageDirectory!.create(recursive: true); + } + + return _modelStorageDirectory!; + } + /// Gets the full file path for a model file with Android path correction static Future getModelFilePath(String filename) async { - final directory = await getApplicationDocumentsDirectory(); + final directory = await getModelStorageDirectory(); return getCorrectedPath(directory.path, filename); } @@ -90,7 +152,7 @@ class ModelFileSystemManager { List? protectedFiles, List? supportedExtensions, }) async { - final directory = await getApplicationDocumentsDirectory(); + final directory = await getModelStorageDirectory(); final extensions = supportedExtensions ?? ModelFileSystemManager.supportedExtensions; final protected = protectedFiles ?? []; @@ -132,7 +194,7 @@ class ModelFileSystemManager { static Future getStorageInfo({ List? protectedFiles, }) async { - final directory = await getApplicationDocumentsDirectory(); + final directory = await getModelStorageDirectory(); final files = directory .listSync() diff --git a/lib/mobile/smart_downloader.dart b/lib/mobile/smart_downloader.dart index b2ec90a2..eb68c100 100644 --- a/lib/mobile/smart_downloader.dart +++ b/lib/mobile/smart_downloader.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter_gemma/core/domain/download_error.dart'; import 'package:flutter_gemma/core/domain/download_exception.dart'; import 'package:flutter_gemma/core/model_management/cancel_token.dart'; +import 'package:path/path.dart' as p; /// Smart downloader with HTTP-aware retry logic /// @@ -177,7 +179,28 @@ class SmartDownloader { StreamSubscription? listener; try { - final (baseDirectory, directory, filename) = await Task.split(filePath: targetPath); + // CRITICAL: Task.split() has a bug on Windows where it strips the drive letter + // from paths that don't match known base directories, causing files to be written + // to the wrong location. We handle Windows paths manually to preserve the full path. + final BaseDirectory baseDirectory; + final String directory; + final String filename; + + if (!kIsWeb && Platform.isWindows) { + // On Windows, use BaseDirectory.root with the FULL directory path including drive letter. + // background_downloader's baseDirectoryPath(BaseDirectory.root) returns '' on Windows, + // so path.join('', fullDirectory, filename) correctly produces the absolute path. + baseDirectory = BaseDirectory.root; + directory = p.dirname(targetPath); // Full directory with drive letter (e.g., C:\Users\...) + filename = p.basename(targetPath); + debugPrint('🔵 Windows path handling: dir=$directory, file=$filename'); + } else { + // On other platforms, Task.split() works correctly + final splitResult = await Task.split(filePath: targetPath); + baseDirectory = splitResult.$1; + directory = splitResult.$2; + filename = splitResult.$3; + } final task = DownloadTask( url: url,