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/
<pkg>/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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-02 22:51:44 +09:00
committed by GitHub
parent 5bc8eda50b
commit 7a8354d63b
2 changed files with 85 additions and 22 deletions
@@ -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/**/*"
}
]
},
@@ -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/<pkg>/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');
});
});