mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-05-24 00:12:16 +00:00
On macOS, the libmdbx / Isar database lives under
`getApplicationDocumentsDirectory()` -> `~/Documents/...`. With iCloud
Drive's "Desktop & Documents Folders" sync enabled (a common default),
macOS protects ~/Documents with TCC and denies unsigned / sideloaded /
dev / not-yet-permission-granted builds the file access libmdbx needs
to open its database. The result is a black screen on launch with the
following error in the Flutter / app log:
[ERROR:flutter/runtime/dart_isolate.cc(1402)] Unhandled exception:
IsarError: Cannot open Environment: MdbxError (13): Permission denied
POSIX errno 13 is EACCES, raised by the OS for the access denial — not
errno 15 (ENOTBLK / "Block device required"), and not iCloud "Optimise
Mac Storage" evicting files. Verified on macOS 26.3 / Apple Silicon
with iCloud Desktop & Documents sync active: a Terminal `mkdir`+`echo
> file` to the same path succeeds (Terminal inherits the user's TCC
grant), but the unsigned dev build fails on first DB open with the
error above.
Fix: on macOS only, host the database under `getApplicationSupport-
Directory()` -> `~/Library/Application Support/<bundle id>/...`. That
location is app-private, not TCC-gated, and Apple's recommended
location for app data files. iOS, Windows, Linux are unchanged — they
keep using Documents (iOS for Files-app visibility next to backups,
Windows / Linux because Documents is the conventional location and
neither has TCC).
Includes a one-shot best-effort migration: existing macOS users with a
DB at `~/Documents/Mangayomi/databases/` have it renamed to the new
path on first launch. Migration is skipped if the new location is
non-empty so we never overwrite user data, and any failure falls back
to a fresh DB rather than crashing on launch (the user can then move
the legacy directory manually if needed). Subsequent launches skip the
migration branch because the new path already exists.
Repro
- macOS with iCloud Drive's "Desktop & Documents Folders" sync enabled
- Unsigned / sideloaded / dev build of Mangayomi (or signed build that
hasn't yet received the user's "Files and Folders > Documents" TCC
grant)
- Launch -> black screen, IsarError MdbxError (13)
Verification
- Reproduced the exact error on this branch's parent commit
(upstream/main 25c1d72c) on macOS 26.3, iCloud Desktop & Documents
sync active, captured `MdbxError (13): Permission denied`
- After this patch the same build launches cleanly and opens the
database at `~/Library/Application Support/<bundle>/Mangayomi/
databases/mangayomiDb.isar`
- Existing 15 MB Isar database from a prior run preserved through the
rebuild — no data loss
Notes
- This is a narrower follow-up to the earlier proposed Application-
Support move that was correctly rejected for being cross-platform
and missing migration. This change is gated by `Platform.isMacOS`
and migrates existing macOS users.
- Hive (`Hive.initFlutter` in main.dart) still uses Documents on
macOS. It is initialized after Isar via `_postLaunchInit` and is
unawaited, so a Hive failure wouldn't reproduce the black screen.
If Hive turns out to be affected by the same TCC denial, a
follow-up PR can move it the same way.
375 lines
12 KiB
Dart
375 lines
12 KiB
Dart
// ignore_for_file: depend_on_referenced_packages
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:isar_community/isar.dart';
|
|
import 'package:mangayomi/eval/model/source_preference.dart';
|
|
import 'package:mangayomi/main.dart';
|
|
import 'package:mangayomi/models/category.dart';
|
|
import 'package:mangayomi/models/changed.dart';
|
|
import 'package:mangayomi/models/chapter.dart';
|
|
import 'package:mangayomi/models/custom_button.dart';
|
|
import 'package:mangayomi/models/download.dart';
|
|
import 'package:mangayomi/models/update.dart';
|
|
import 'package:mangayomi/models/history.dart';
|
|
import 'package:mangayomi/models/manga.dart';
|
|
import 'package:mangayomi/models/settings.dart';
|
|
import 'package:mangayomi/models/source.dart';
|
|
import 'package:mangayomi/models/sync_preference.dart';
|
|
import 'package:mangayomi/models/track.dart';
|
|
import 'package:mangayomi/models/track_preference.dart';
|
|
import 'package:mangayomi/utils/extensions/string_extensions.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:path/path.dart' as path;
|
|
|
|
class StorageProvider {
|
|
static final StorageProvider _instance = StorageProvider._internal();
|
|
StorageProvider._internal();
|
|
factory StorageProvider() => _instance;
|
|
|
|
Future<bool> requestPermission() async {
|
|
if (!Platform.isAndroid) return true;
|
|
Permission permission = Permission.manageExternalStorage;
|
|
if (await permission.isGranted) return true;
|
|
if (await permission.request().isGranted) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> deleteBtDirectory() async {
|
|
final btDir = Directory(await _btDirectoryPath());
|
|
if (await btDir.exists()) await btDir.delete(recursive: true);
|
|
}
|
|
|
|
Future<void> deleteTmpDirectory() async {
|
|
final tmpDir = Directory(await _tempDirectoryPath());
|
|
if (await tmpDir.exists()) await tmpDir.delete(recursive: true);
|
|
}
|
|
|
|
Future<Directory?> getDefaultDirectory() async {
|
|
Directory? directory;
|
|
if (Platform.isAndroid) {
|
|
directory = Directory("/storage/emulated/0/Mangayomi/");
|
|
} else {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
// The documents dir in iOS is already named "Mangayomi".
|
|
// Appending "Mangayomi" to the documents dir would create
|
|
// unnecessarily nested Mangayomi/Mangayomi/ folder.
|
|
if (Platform.isIOS) return dir;
|
|
directory = Directory(path.join(dir.path, 'Mangayomi'));
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
Future<Directory?> getMpvDirectory() async {
|
|
final defaultDirectory = await getDefaultDirectory();
|
|
String dbDir = path.join(defaultDirectory!.path, 'mpv');
|
|
await Directory(dbDir).create(recursive: true);
|
|
return Directory(dbDir);
|
|
}
|
|
|
|
Future<Directory?> getExtensionServerDirectory() async {
|
|
final defaultDirectory = await getDefaultDirectory();
|
|
String dbDir = path.join(defaultDirectory!.path, 'extension_server');
|
|
await Directory(dbDir).create(recursive: true);
|
|
return Directory(dbDir);
|
|
}
|
|
|
|
Future<Directory?> getBtDirectory() async {
|
|
final dbDir = await _btDirectoryPath();
|
|
await createDirectorySafely(dbDir);
|
|
return Directory(dbDir);
|
|
}
|
|
|
|
Future<String> _btDirectoryPath() async {
|
|
final defaultDirectory = await getDefaultDirectory();
|
|
return path.join(defaultDirectory!.path, 'torrents');
|
|
}
|
|
|
|
Future<Directory?> getTmpDirectory() async {
|
|
final tmpPath = await _tempDirectoryPath();
|
|
await createDirectorySafely(tmpPath);
|
|
return Directory(tmpPath);
|
|
}
|
|
|
|
Future<Directory> getCacheDirectory(String? imageCacheFolderName) async {
|
|
final cacheImagesDirectory = path.join(
|
|
(await getApplicationCacheDirectory()).path,
|
|
imageCacheFolderName ?? 'cacheimagecover',
|
|
);
|
|
return Directory(cacheImagesDirectory);
|
|
}
|
|
|
|
Future<Directory> createCacheDirectory(String? imageCacheFolderName) async {
|
|
final cachePath = await getCacheDirectory(imageCacheFolderName);
|
|
await createDirectorySafely(cachePath.path);
|
|
return cachePath;
|
|
}
|
|
|
|
Future<String> _tempDirectoryPath() async {
|
|
final defaultDirectory = await getDirectory();
|
|
return path.join(defaultDirectory!.path, 'tmp');
|
|
}
|
|
|
|
Future<Directory?> getIosBackupDirectory() async {
|
|
final defaultDirectory = await getDefaultDirectory();
|
|
String dbDir = path.join(defaultDirectory!.path, 'backup');
|
|
await createDirectorySafely(dbDir);
|
|
return Directory(dbDir);
|
|
}
|
|
|
|
Future<Directory?> getDirectory() async {
|
|
Directory? directory;
|
|
String dPath = "";
|
|
try {
|
|
final setting = isar.settings.getSync(227);
|
|
dPath = setting?.downloadLocation ?? "";
|
|
} catch (e) {
|
|
debugPrint("Could not get downloadLocation from Isar settings: $e");
|
|
}
|
|
if (Platform.isAndroid) {
|
|
directory = Directory(
|
|
dPath.isEmpty ? "/storage/emulated/0/Mangayomi/" : "$dPath/",
|
|
);
|
|
} else {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final p = dPath.isEmpty ? dir.path : dPath;
|
|
// The documents dir in iOS is already named "Mangayomi".
|
|
// Appending "Mangayomi" to the documents dir would create
|
|
// unnecessarily nested Mangayomi/Mangayomi/ folder.
|
|
if (Platform.isIOS) return Directory(p);
|
|
directory = Directory(path.join(p, 'Mangayomi'));
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
Future<Directory?> getMangaMainDirectory(Chapter chapter) async {
|
|
final manga = chapter.manga.value!;
|
|
final itemType = chapter.manga.value!.itemType;
|
|
final itemTypePath = itemType == ItemType.manga
|
|
? "Manga"
|
|
: itemType == ItemType.anime
|
|
? "Anime"
|
|
: "Novel";
|
|
final dir = await getDirectory();
|
|
return Directory(
|
|
path.join(
|
|
dir!.path,
|
|
'downloads',
|
|
itemTypePath,
|
|
'${manga.source} (${manga.lang!.toUpperCase()})',
|
|
manga.name!.replaceForbiddenCharacters('_'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Directory?> getMangaChapterDirectory(
|
|
Chapter chapter, {
|
|
Directory? mangaMainDirectory,
|
|
}) async {
|
|
final basedir = mangaMainDirectory ?? await getMangaMainDirectory(chapter);
|
|
String scanlator = chapter.scanlator?.isNotEmpty ?? false
|
|
? "${chapter.scanlator!.replaceForbiddenCharacters('_')}_"
|
|
: "";
|
|
return Directory(
|
|
path.join(
|
|
basedir!.path,
|
|
scanlator + chapter.name!.replaceForbiddenCharacters('_').trim(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Directory?> getDatabaseDirectory() async {
|
|
// On macOS, host the libmdbx / Isar database under Application Support
|
|
// (app-private, not TCC-gated) instead of Documents. macOS denies
|
|
// unsigned/sideloaded/dev builds access to ~/Documents when iCloud
|
|
// "Desktop & Documents Folders" sync is enabled, surfacing as
|
|
// `IsarError: Cannot open Environment: MdbxError (13): Permission denied`
|
|
// and a black screen on launch. iOS keeps Documents so the DB remains
|
|
// visible alongside backups via the Files app. Windows / Linux are
|
|
// untouched — Documents is the conventional location there.
|
|
final dir = Platform.isMacOS
|
|
? await getApplicationSupportDirectory()
|
|
: await getApplicationDocumentsDirectory();
|
|
String dbDir;
|
|
if (Platform.isAndroid) return dir;
|
|
if (Platform.isIOS) {
|
|
// Put the database files inside /databases like on Windows, Linux
|
|
// So they are not just in the app folders root dir
|
|
dbDir = path.join(dir.path, 'databases');
|
|
} else {
|
|
dbDir = path.join(dir.path, 'Mangayomi', 'databases');
|
|
}
|
|
if (Platform.isMacOS) {
|
|
await _migrateLegacyMacosDatabase(dbDir);
|
|
}
|
|
await createDirectorySafely(dbDir);
|
|
return Directory(dbDir);
|
|
}
|
|
|
|
/// One-shot migration: if a pre-existing macOS user has their database
|
|
/// under the legacy Documents path and the new Application Support path
|
|
/// is empty, rename it across so library / history / progress are not
|
|
/// silently reset. Subsequent launches skip this branch because the new
|
|
/// path already exists.
|
|
Future<void> _migrateLegacyMacosDatabase(String newDbDir) async {
|
|
try {
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final legacyDir = Directory(
|
|
path.join(docs.path, 'Mangayomi', 'databases'),
|
|
);
|
|
if (!await legacyDir.exists()) return;
|
|
final newDir = Directory(newDbDir);
|
|
if (await newDir.exists()) {
|
|
// Only migrate when the new location is empty — never overwrite.
|
|
final entries = await newDir
|
|
.list(followLinks: false)
|
|
.take(1)
|
|
.toList();
|
|
if (entries.isNotEmpty) return;
|
|
}
|
|
await Directory(path.dirname(newDbDir)).create(recursive: true);
|
|
await legacyDir.rename(newDbDir);
|
|
debugPrint(
|
|
'[storage] Migrated macOS DB from ${legacyDir.path} to $newDbDir',
|
|
);
|
|
} catch (e) {
|
|
// Migration is best-effort. Falling back to a fresh DB is preferable
|
|
// to crashing on launch — the user can manually move the legacy
|
|
// ~/Documents/Mangayomi/databases/ contents if needed.
|
|
debugPrint('[storage] macOS DB migration skipped: $e');
|
|
}
|
|
}
|
|
|
|
Future<Directory?> getGalleryDirectory() async {
|
|
String gPath;
|
|
if (Platform.isAndroid) {
|
|
gPath = "/storage/emulated/0/Pictures/Mangayomi/";
|
|
} else {
|
|
gPath = path.join((await getDirectory())!.path, 'Pictures');
|
|
}
|
|
await createDirectorySafely(gPath);
|
|
return Directory(gPath);
|
|
}
|
|
|
|
Future<void> createDirectorySafely(String dirPath) async {
|
|
final dir = Directory(dirPath);
|
|
try {
|
|
await dir.create(recursive: true);
|
|
} catch (_) {
|
|
if (await requestPermission()) {
|
|
try {
|
|
await dir.create(recursive: true);
|
|
} catch (e) {
|
|
debugPrint('Initial directory creation failed for $dirPath: $e');
|
|
}
|
|
} else {
|
|
debugPrint('Permission denied. Cannot create: $dirPath');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Isar> initDB(String? path, {bool inspector = false}) async {
|
|
Directory? dir;
|
|
if (path == null) {
|
|
dir = await getDatabaseDirectory();
|
|
} else {
|
|
dir = Directory(path);
|
|
}
|
|
|
|
final isar = await Isar.open(
|
|
[
|
|
MangaSchema,
|
|
ChangedPartSchema,
|
|
ChapterSchema,
|
|
CategorySchema,
|
|
CustomButtonSchema,
|
|
UpdateSchema,
|
|
HistorySchema,
|
|
DownloadSchema,
|
|
SourceSchema,
|
|
SettingsSchema,
|
|
TrackPreferenceSchema,
|
|
TrackSchema,
|
|
SyncPreferenceSchema,
|
|
SourcePreferenceSchema,
|
|
SourcePreferenceStringValueSchema,
|
|
],
|
|
directory: dir!.path,
|
|
name: "mangayomiDb",
|
|
inspector: inspector,
|
|
);
|
|
try {
|
|
final settings = await isar.settings.filter().idEqualTo(227).findFirst();
|
|
if (settings == null) {
|
|
await isar.writeTxn(() async => isar.settings.put(Settings()));
|
|
}
|
|
} catch (_) {
|
|
if (await requestPermission()) {
|
|
try {
|
|
final settings = await isar.settings
|
|
.filter()
|
|
.idEqualTo(227)
|
|
.findFirst();
|
|
if (settings == null) {
|
|
await isar.writeTxn(() async => isar.settings.put(Settings()));
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Failed after retry with permission: $e");
|
|
}
|
|
} else {
|
|
debugPrint("Permission denied during Database init fallback.");
|
|
}
|
|
}
|
|
|
|
final prefs = await isar.trackPreferences
|
|
.filter()
|
|
.syncIdIsNotNull()
|
|
.findAll();
|
|
await isar.writeTxn(() async {
|
|
for (final pref in prefs) {
|
|
await isar.trackPreferences.put(pref..refreshing = true);
|
|
}
|
|
});
|
|
|
|
final customButton = await isar.customButtons
|
|
.filter()
|
|
.idIsNotNull()
|
|
.findFirst();
|
|
if (customButton == null) {
|
|
await isar.writeTxn(() async {
|
|
await isar.customButtons.put(
|
|
CustomButton(
|
|
title: "+85 s",
|
|
codePress:
|
|
"""local intro_length = mp.get_property_native("user-data/current-anime/intro-length")
|
|
aniyomi.right_seek_by(intro_length)""",
|
|
codeLongPress:
|
|
"""aniyomi.int_picker("Change intro length", "%ds", 0, 255, 1, "user-data/current-anime/intro-length")""",
|
|
codeStartup: """function update_button(_, length)
|
|
if length ~= nil then
|
|
if length == 0 then
|
|
aniyomi.hide_button()
|
|
return
|
|
else
|
|
aniyomi.show_button()
|
|
end
|
|
aniyomi.set_button_title("+" .. length .. " s")
|
|
end
|
|
end
|
|
|
|
if \$isPrimary then
|
|
mp.observe_property("user-data/current-anime/intro-length", "number", update_button)
|
|
end""",
|
|
isFavourite: true,
|
|
pos: 0,
|
|
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
return isar;
|
|
}
|
|
}
|