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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user