From 7a8354d63b0d128f9f90755105383d1d65633fea Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 2 Jul 2026 22:51:44 +0900 Subject: [PATCH] fix(android): avoid black screen when external cache dir is unavailable (#4889) The fs capability granted the built-in `fs:allow-cache-read` and `fs:allow-cache-write` sets. Those sets bundle `scope-cache`, which carries the external `$CACHE` base directory. At startup Tauri resolves every granted scope entry, so `$CACHE` resolves through Android's `getExternalCacheDir`. On devices whose external storage volume cannot be prepared (e.g. custom ROMs where `/storage/emulated/0/Android/data/ /cache` fails to mkdir) that returns null, the resolve errors, and the graceful "skip unresolvable entry" arm is gated to non-Android, so the error propagates: fs scope build fails, app init aborts, and the window stays black. Readest only performs I/O under the internal app cache (`$APPCACHE` -> `getCacheDir`, always available), so it never needs the external `$CACHE` scope. Grant the scope-free `fs:read-all` and `fs:write-all` command sets to preserve command coverage, and move the `$APPCACHE` scope (plus the iOS container path) into `fs:scope`. Startup then never resolves external storage. Add a regression guard asserting the default capability grants no external-`$CACHE` fs permission while keeping the internal cache scope and full read/write command coverage. Closes #4853 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src-tauri/capabilities/default.json | 30 ++------ .../capability-external-cache-scope.test.ts | 77 +++++++++++++++++++ 2 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 apps/readest-app/src/__tests__/android/capability-external-cache-scope.test.ts diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 127dd322..a9fd7bad 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -16,6 +16,8 @@ "fs:allow-stat", "fs:allow-fstat", "fs:allow-lstat", + "fs:read-all", + "fs:write-all", { "identifier": "fs:scope-appconfig-recursive", "allow": [ @@ -49,28 +51,6 @@ } ] }, - { - "identifier": "fs:allow-cache-read", - "allow": [ - { - "path": "$APPCACHE/**/*" - }, - { - "path": "/private/var/mobile/Containers/Data/Application/**/*" - } - ] - }, - { - "identifier": "fs:allow-cache-write", - "allow": [ - { - "path": "$APPCACHE/**/*" - }, - { - "path": "/private/var/mobile/Containers/Data/Application/**/*" - } - ] - }, { "identifier": "fs:scope", "allow": [ @@ -88,6 +68,12 @@ }, { "path": "$TEMP/**/*" + }, + { + "path": "$APPCACHE/**/*" + }, + { + "path": "/private/var/mobile/Containers/Data/Application/**/*" } ] }, diff --git a/apps/readest-app/src/__tests__/android/capability-external-cache-scope.test.ts b/apps/readest-app/src/__tests__/android/capability-external-cache-scope.test.ts new file mode 100644 index 00000000..fa3622ec --- /dev/null +++ b/apps/readest-app/src/__tests__/android/capability-external-cache-scope.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** + * Regression guard for issue #4853: fresh install black-screens on Android + * devices whose external storage volume can't be prepared (e.g. custom ROMs + * where `/storage/emulated/0/Android/data//cache` fails to mkdir). + * + * Root cause was in this capability file. The fs plugin's built-in permission + * sets `fs:allow-cache-read` / `fs:allow-cache-write` expand to + * `read-all`/`write-all` + `scope-cache`, and `scope-cache` carries the + * EXTERNAL cache base dir (`$CACHE` -> Android `getExternalCacheDir`). An inline + * `allow` of the `$APPCACHE` glob on those grants only ADDS scope; it cannot + * strip the set's built-in `$CACHE`. At startup Tauri resolves every scope entry + * (`ScopeObject::deserialize` -> `app.path().parse("$CACHE")` -> + * `getExternalCacheDir`). On the broken device that returns null, the resolve + * errors, and the graceful "skip unresolvable entry" arm is + * `#[cfg(not(target_os = "android"))]` — disabled on Android — so the error + * propagates, fs capability/scope build fails, app init aborts -> black screen. + * + * Readest only ever does I/O under the INTERNAL app cache (`$APPCACHE` -> + * `getCacheDir`, always available). It must never pull in the external `$CACHE` + * scope. The fix grants the raw command sets (`fs:read-all`/`fs:write-all`) plus + * an `$APPCACHE` scope entry instead of the cache permission bundles. + * + * Invariant: the `default` capability must not grant any fs permission that + * carries the external `$CACHE` base directory, while still scoping the internal + * `$APPCACHE` and keeping full read/write command coverage. + */ + +// fs plugin permission identifiers whose scope resolves the EXTERNAL `$CACHE` +// (see packages/tauri-plugins/plugins/fs/permissions/autogenerated/ +// base-directories/cache.toml). Granting any of these resolves +// `getExternalCacheDir` at startup. +const EXTERNAL_CACHE_FS_PERMISSIONS = [ + 'fs:scope-cache', + 'fs:scope-cache-index', + 'fs:scope-cache-recursive', + 'fs:allow-cache-read', + 'fs:allow-cache-write', + 'fs:allow-cache-read-recursive', + 'fs:allow-cache-write-recursive', + 'fs:allow-cache-meta', + 'fs:allow-cache-meta-recursive', +]; + +type PermissionEntry = string | { identifier: string; allow?: { path?: string }[] }; + +const capability = JSON.parse( + readFileSync(resolve(process.cwd(), 'src-tauri/capabilities/default.json'), 'utf-8'), +) as { permissions: PermissionEntry[] }; + +const identifierOf = (entry: PermissionEntry): string => + typeof entry === 'string' ? entry : entry.identifier; + +const grantedIdentifiers = capability.permissions.map(identifierOf); + +const scopePaths = capability.permissions.flatMap((entry) => + typeof entry === 'string' ? [] : (entry.allow ?? []).map((a) => a.path).filter(Boolean), +); + +describe('default capability fs cache scope (#4853)', () => { + it('does not grant any external-$CACHE fs permission', () => { + const offenders = grantedIdentifiers.filter((id) => EXTERNAL_CACHE_FS_PERMISSIONS.includes(id)); + expect(offenders).toEqual([]); + }); + + it('still scopes the internal app cache ($APPCACHE)', () => { + expect(scopePaths).toContain('$APPCACHE/**/*'); + }); + + it('keeps full fs read/write command coverage via the scope-free command sets', () => { + expect(grantedIdentifiers).toContain('fs:read-all'); + expect(grantedIdentifiers).toContain('fs:write-all'); + }); +});