topfans/frontend/utils/ioPath.js

2074 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend/utils/ioPath.js
// Plus.io 路径抽象层 — 集中处理 Android 10+ 分区存储适配
//
// 背景:Android 10+ targetSdkVersion >= 29 时,plus.io.resolveLocalFileSystemURL
// 传入字符串 '_doc/' 在新版 HBuilderX 中被解析到 plus.io.PUBLIC_DOCUMENTS
// (外部存储 /storage/emulated/0/Android/data/<pkg>/.HBuilder/documents/),
// 分区存储机制下不可写,会报 "targetSdkVersion设置>=29后在Android10+系统设备
// 不支持当前路径。请更改为应用运行路径!"。
//
// 唯一可写位置:应用沙盒内部存储 = plus.io.PRIVATE_DOC (常量 2),
// 物理路径 file:///data/user/0/<pkg>/files/apps/<APPID>/doc
//
// 已知坑:plus.nativeObj.Bitmap.save 即使接受 '_doc/xxx' 字符串,在某些 Android 10+
// 设备上回调成功但实际未写入(size=0 / target=null)。因此 bitmap.save 等原生 API
// 一律用 entry.toLocalURL() 拿 file:// 绝对路径,而不是 '_doc/xxx'。
//
// 使用规范:
// 1. 拿到根 DirectoryEntry 用 getSandboxRootDir()
// 2. 逐级 getDirectory 创建子目录(不要用字符串拼接路径)
// 3. 拿 file:// 绝对路径用 getSandboxFileUri(subdirParts, fileName)
// 4. 调试日志里用 SANDBOX_DISPLAY_PREFIX 给人看,所有 plus.io 调用都通过本模块的函数
/**
* Android 10+ 唯一可写的应用沙盒 doc 根常量
* 等同于 plus.io.PRIVATE_DOC,但避免魔法数字,集中解释出处
*/
export const SANDBOX_ROOT = 2 // plus.io.PRIVATE_DOC
/**
* 把业务逻辑用的"子目录相对路径"解析为"按 / 切分的段"
* 用于逐级 getDirectory
* @param {string} relativePath - 如 'preload' 或 'preload/guest'
* @returns {string[]}
*/
export function splitRelativePath(relativePath) {
return String(relativePath || '').split('/').filter(Boolean)
}
/**
* 异步获取沙盒 doc 根 DirectoryEntry(单例缓存)
* 调用方拿到 root 后用 getDirectory 逐级创建业务子目录
*/
let _rootDirPromise = null
export function getSandboxRootDir() {
// #ifdef APP-PLUS
if (_rootDirPromise) return _rootDirPromise
_rootDirPromise = new Promise((resolve, reject) => {
plus.io.requestFileSystem(
SANDBOX_ROOT,
(fs) => resolve(fs.root),
(err) => {
_rootDirPromise = null // 失败重置缓存,允许重试
reject(err)
}
)
})
return _rootDirPromise
// #endif
// #ifndef APP-PLUS
// H5/小程序:返回伪 root,通过 uni.getFileSystemManager 路径字符串操作
// 不缓存(各端语义不同,缓存反而可能错)
return Promise.reject(new Error('sandbox dir only available on APP-PLUS'))
// #endif
}
/**
* 在沙盒根下逐级创建子目录,返回最深一层的 DirectoryEntry
* @param {string[]} parts - 路径段数组,来自 splitRelativePath
* @returns {Promise<DirectoryEntry>}
*/
export async function ensureSandboxSubdir(parts) {
// #ifdef APP-PLUS
if (!Array.isArray(parts) || parts.length === 0) {
throw new Error('ensureSandboxSubdir: parts must be non-empty array')
}
const root = await getSandboxRootDir()
return new Promise((resolve, reject) => {
let currentEntry = root
const step = (idx) => {
if (idx >= parts.length) return resolve(currentEntry)
currentEntry.getDirectory(
parts[idx], {
create: true,
exclusive: false
},
(dirEntry) => {
currentEntry = dirEntry
step(idx + 1)
},
(err) => reject(err)
)
}
step(0)
})
// #endif
// #ifndef APP-PLUS
// H5/小程序路径由各端 fs.mkdirSync 处理,这里返回 null 让调用方自行降级
return Promise.resolve(null)
// #endif
}
/**
* 在沙盒子目录下创建/获取一个文件,返回 FileEntry
* 拿到 FileEntry 后:
* - 给 bitmap.save 等原生 API 用 entry.toLocalURL() 拿 file:// 绝对路径(更可靠,避免 _doc/ 字符串在 Android 10+ 写失败)
* - 给 uni.uploadFile 用 entry.toLocalURL() 或 entry.fullPath(uni 在 app 端会识别)
*
* @param {string[]} subdirParts - 子目录段数组(不含文件名)
* @param {string} fileName - 文件名(不含路径)
* @param {boolean} create - true=不存在则创建,false=仅获取
* @returns {Promise<DirectoryEntry>}
*/
export async function getSandboxFile(subdirParts, fileName, create = true) {
// #ifdef APP-PLUS
const dir = await ensureSandboxSubdir(subdirParts)
return new Promise((resolve, reject) => {
dir.getFile(
fileName, {
create,
exclusive: false
},
(entry) => resolve(entry),
(err) => reject(err)
)
})
// #endif
// #ifndef APP-PLUS
return Promise.resolve(null)
// #endif
}
/**
* 一次性"确保子目录存在 + 创建/获取文件 + 拿到可用的 file:// URI"
* 给 bitmap.save / uni.uploadFile / uni.getImageInfo 等需要真实路径的 API 用
*
* @param {string[]} subdirParts - 子目录段数组
* @param {string} fileName - 文件名
* @returns {Promise<string>} file:// 形式的绝对路径
*/
export async function getSandboxFileUri(subdirParts, fileName) {
// #ifdef APP-PLUS
const entry = await getSandboxFile(subdirParts, fileName, true)
return entry.toLocalURL() // file:///data/user/0/.../doc/<...>/<fileName>
// #endif
// #ifndef APP-PLUS
const parts = [...subdirParts, fileName]
return Promise.resolve(parts.join('/'))
// #endif
}
/**
* 日志/追踪用的展示前缀 — 不是文件路径,只是给人看的"[sandbox doc] xxx"格式
*/
export const SANDBOX_DISPLAY_PREFIX = '[sandbox:doc] '
/**
* 删除沙盒根下指定子目录(含其中所有文件、子目录)
* 目录不存在不报错(视为成功)
*
* 调用场景:
* - 上传成功后清理 bitmap.save 临时文件
* - 用户登出时清理所有业务临时目录(隐私)
*
* @param {string[]} subdirParts - 子目录段数组(从沙盒根算起)
* @returns {Promise<{deleted: boolean, reason?: string}>}
*/
export async function clearSandboxSubdir(subdirParts) {
// #ifdef APP-PLUS
if (!Array.isArray(subdirParts) || subdirParts.length === 0) {
return {
deleted: false,
reason: 'invalid subdirParts'
}
}
try {
const root = await getSandboxRootDir()
return await new Promise((resolve) => {
// 逐级 resolve,只创建不创建(create: false)
const step = (idx, currentEntry) => {
if (idx >= subdirParts.length) {
// 到了目标目录,递归删除
currentEntry.removeRecursively(
() => resolve({
deleted: true
}),
(err) => resolve({
deleted: false,
reason: err?.message || 'removeRecursively failed'
})
)
return
}
currentEntry.getDirectory(
subdirParts[idx], {
create: false
},
(nextEntry) => step(idx + 1, nextEntry),
(err) => resolve({
deleted: false,
reason: 'not_found'
}) // 路径不存在 = ok
)
}
step(0, root)
})
} catch (e) {
return {
deleted: false,
reason: e?.message || 'exception'
}
}
// #endif
// #ifndef APP-PLUS
return Promise.resolve({
deleted: false,
reason: 'app-only'
})
// #endif
}
/**
* 删除沙盒根下"所有业务子目录"(登出时全清用)
* 白名单(preload/share/image)保留,其它全删
*/
const PROTECTED_SUBDIRS = new Set(['preload', 'share', 'image'])
export async function clearAllSandboxTmpDirs() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const entries = await new Promise((resolve) => {
const reader = root.createReader()
const collected = []
const readAll = () => {
reader.readEntries(
(es) => {
if (es.length === 0) return resolve(collected)
collected.push(...es)
readAll()
},
() => resolve(collected)
)
}
readAll()
})
const results = []
for (const e of entries) {
if (!e.isDirectory) continue
if (PROTECTED_SUBDIRS.has(e.name)) continue
// 逐个清掉(忽略失败)
// eslint-disable-next-line no-await-in-loop
const r = await new Promise((resolve) => {
e.removeRecursively(
() => resolve({
name: e.name,
deleted: true
}),
(err) => resolve({
name: e.name,
deleted: false,
reason: err?.message
})
)
})
results.push(r)
}
return {
cleared: results.filter((r) => r.deleted).length,
results
}
} catch (e) {
return {
cleared: 0,
reason: e?.message || 'exception',
results: []
}
}
// #endif
// #ifndef APP-PLUS
return Promise.resolve({
cleared: 0,
results: [],
reason: 'app-only'
})
// #endif
}
/**
* 清理沙盒根下"所有业务子目录的 tmp/ 目录"(启动时清理历史残留)
* 直接 removeRecursively 删整个 tmp/ 目录(下次上传 getSandboxFileUri 会自动重建)
*
* 调用场景:App.vue#onLaunch,用户升级新包后第一次启动时清理旧版本产生的临时文件
*
* 之所以用 removeRecursively 而非 f.remove():
* plus.io.FileEntry.remove() 在某些 HBuilderX + Android 10+ 组合下会静默失败
* (回调 ok 但文件还在),removeRecursively 对目录操作更可靠。
*
* @returns {Promise<{scanned: number, deleted: number, dirs: string[]}>}
*/
export async function clearAllSandboxTmpFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
// 1. 拿到所有业务子目录
const topEntries = await new Promise((resolve) => {
const reader = root.createReader()
const collected = []
const readAll = () => {
reader.readEntries(
(es) => {
if (es.length === 0) return resolve(collected)
collected.push(...es)
readAll()
},
() => resolve(collected)
)
}
readAll()
})
let scanned = 0
let deleted = 0
let freedBytes = 0
let keyCount = 0
const cleanedDirs = []
for (const top of topEntries) {
if (!top.isDirectory) continue
// 白名单:只对业务目录探查 tmp/,跳过 uniapp_temp_*/uniapp_save/preload 等系统目录
// 否则 _doc/ 根下累积的 uniapp_temp_<ts>/ × N 会让这个 O(N) 循环卡 2-4s
if (!BUSINESS_TMP_PARENTS.has(top.name)) continue
// 2. 拿到每个业务子目录下的 tmp/ 子目录(不存在则跳过)
// eslint-disable-next-line no-await-in-loop
const tmpEntry = await new Promise((resolve) => {
top.getDirectory(
'tmp', {
create: false
},
(entry) => resolve(entry),
() => resolve(null)
)
})
if (!tmpEntry) continue
scanned++
// 3. 边量边删:先 _statDir 拿 before 字节/计数(一次遍历),再 removeRecursively 整目录删
// 旧实现省略 before 量,为显示"已清理 X MB"还要在调用方做 before/after 三次扫描
// eslint-disable-next-line no-await-in-loop
const before = await _statDir(tmpEntry)
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
tmpEntry.removeRecursively(
() => resolve(true),
() => resolve(false)
)
})
if (ok) {
deleted++
freedBytes += before.bytes
keyCount += before.count
cleanedDirs.push(`${top.name}/tmp`)
}
}
return {
scanned,
deleted,
freedBytes,
keyCount,
dirs: cleanedDirs
}
} catch (e) {
console.warn('[ioPath] clearAllSandboxTmpFiles failed:', e?.message)
return {
scanned: 0,
deleted: 0,
freedBytes: 0,
keyCount: 0,
dirs: [],
error: e?.message
}
}
// #endif
// #ifndef APP-PLUS
return Promise.resolve({
scanned: 0,
deleted: 0,
freedBytes: 0,
keyCount: 0,
dirs: []
})
// #endif
}
/**
* Android 专用:通过 StorageStatsManager 一次查询获取当前 app 的存储字节数
* StorageStats.getAppBytes() = APK + data + cache系统记账O(1)
* 替代旧实现 `apkSize + Σ4 沙盒根遍历`O(N),慢设备 2-4s
*
* 适用平台与降级链:
* - Android API ≥ 26尝试走原生查询任一环节失败 → 抛 'not_available' 给调用方走旧扫描
* - Android API < 26直接抛 'unsupported_api'queryStatsForPackage 自 API 26 引入)
* - iOS / H5 / 小程序:抛 'not_android'(调用方应改走旧 _sumDirSizeByRootType
*
* 已知代理坑(沿用本文件 `getDeviceStorageInfo` / `getAndroidApkSize` 范式 + 加固):
* - plus.android 对 *Long / static 字段代理不稳定 → 全部 parseFloat 兜底
* - plus.android.invoke(method, ...args) 参数对象在 proxy 上需保证类型一致UUID 必须为 java.util.UUID
* - 重载方法:代码先 try 三参 (UUID, String, UserHandle)catch 后 try 两参 (String, UserHandle)
* API 2628 走两参API 29+ 走三参;先三参后两参是让 API 29+ 一次成功、不被 catch 吞)
*
* 调用方契约:返回 { supported: true, bytes, source: 'native' } 表示走通;
* 返回 { supported: false, reason } 表示需走旧扫描降级。
*
* @returns {Promise<{supported:boolean, bytes?:number, source?:'native', reason?:string}>}
*/
async function queryAndroidAppBytes() {
// #ifdef APP-PLUS
try {
if (typeof plus === 'undefined' || !plus.android) {
console.warn('[ioPath] queryAndroidAppBytes: no_plus_android')
return {
supported: false,
reason: 'no_plus_android'
}
}
if (plus.os.name !== 'Android') {
console.warn('[ioPath] queryAndroidAppBytes: not_android (os=' + (plus.os?.name || 'unknown') + ')')
return {
supported: false,
reason: 'not_android'
}
}
const Build = plus.android.importClass('android.os.Build')
// Build.VERSION.SDK_INT 是 intproxy 上读 static 字段用 plusGetAttribute 更稳
const sdkInt = parseFloat(plus.android.invoke(Build, 'VERSION', 'SDK_INT') || Build.VERSION?.SDK_INT || 0)
if (!(sdkInt >= 26)) {
console.warn(`[ioPath] queryAndroidAppBytes: unsupported_api (sdkInt=${sdkInt}, 需要 ≥ 26)`)
return {
supported: false,
reason: 'unsupported_api'
}
}
const main = plus.android.runtimeMainActivity()
if (!main) {
console.warn('[ioPath] queryAndroidAppBytes: no_main_activity')
return {
supported: false,
reason: 'no_main_activity'
}
}
// Context.STORAGE_STATS_SERVICE = "storagestats"
// 直接传字符串:与 App.vue:439 拿 notificationManager 同款Context.NOTIFICATION_SERVICE
const ssm = main.getSystemService('storagestats')
if (!ssm) {
console.warn('[ioPath] queryAndroidAppBytes: no_ssm (getSystemService("storagestats") returned null)')
return {
supported: false,
reason: 'no_ssm'
}
}
const pkg = main.getPackageName() // App.vue:460 已验证
if (!pkg) {
console.warn('[ioPath] queryAndroidAppBytes: no_pkg (main.getPackageName() returned falsy)')
return {
supported: false,
reason: 'no_pkg'
}
}
// UserHandleAPI 24+ 有 myUserHandle() 单例;更老没有
const UserHandle = plus.android.importClass('android.os.UserHandle')
let userHandle = null
try {
userHandle = sdkInt >= 24 ?
plus.android.invoke(UserHandle, 'myUserHandle') :
null
if (!userHandle) {
// fallback: UserHandle.CURRENT 静态字段API 17+,所有 plus 兼容版本都有)
userHandle = plus.android.invoke(UserHandle, 'CURRENT')
}
} catch (e) {
console.warn('[ioPath] queryAndroidAppBytes: UserHandle failed:', e?.message)
}
if (!userHandle) {
// console.warn(
// `[ioPath] queryAndroidAppBytes: no_user_handle (sdkInt=${sdkInt}, myUserHandle() 与 CURRENT 都为空)`
// )
return {
supported: false,
reason: 'no_user_handle'
}
}
// 三参版本 (UUID, String, UserHandle) 是 API 29+ 强制签名;
// API 26-28 也有同名重载但参数是 (String, UserHandle),先试三参
let stats = null
try {
// StorageStatsManager.getStorageUuid(UUID) 返回 UUID
// UUID_DEFAULT 是 java.util.UUID 常量(不是 android.os.UserHandle 的)
const UUID = plus.android.importClass('java.util.UUID')
// UUID.fromString("00000000-0000-0000-0000-000000000000") = UUID_DEFAULT 等价
// 但更稳的是反射StorageManager.UUID_DEFAULT 是个 static UUID 字段
// (直接传字符串给 getStorageUuid 不可行,签名是 java.util.UUID
const uuidObj = plus.android.invoke(UUID, 'fromString', '00000000-0000-0000-0000-000000000000')
stats = plus.android.invoke(ssm, 'queryStatsForPackage', uuidObj, pkg, userHandle)
} catch (e) {
// 退到两参版本API 26-28 兼容)
try {
stats = plus.android.invoke(ssm, 'queryStatsForPackage', pkg, userHandle)
} catch (e2) {
console.warn('[ioPath] queryAndroidAppBytes: queryStatsForPackage both signatures failed:', e2
?.message)
return {
supported: false,
reason: 'query_failed'
}
}
}
if (!stats) return {
supported: false,
reason: 'no_stats'
}
// getAppBytes() 是 long 返回proxy 不稳 → parseFloat 兜底
const bytes = parseFloat(plus.android.invoke(stats, 'getAppBytes'))
if (!Number.isFinite(bytes) || bytes < 0) {
console.warn('[ioPath] queryAndroidAppBytes: invalid bytes =', bytes)
return {
supported: false,
reason: 'invalid_bytes'
}
}
return {
supported: true,
bytes,
source: 'native'
}
} catch (e) {
console.warn('[ioPath] queryAndroidAppBytes unexpected failure:', e?.message)
return {
supported: false,
reason: 'exception'
}
}
// #endif
// #ifndef APP-PLUS
return {
supported: false,
reason: 'not_app_plus'
}
// #endif
}
/**
* 计算"app 已用空间"。
*
* Android API ≥ 26优先走 StorageStatsManager.getAppBytes()系统记账O(1))。
* 原生查询任一环节失败 → 降级到旧实现"APK + Σ4 沙盒根遍历"4s 单根兜底,与降级前等价)。
* Android API < 26直接走旧实现queryStatsForPackage 自 API 26 才有)。
* iOS旧实现 4 沙盒根遍历iOS .app bundle 沙盒外不可访问,按 sandbox 估算)。
* H5 / 小程序return 0。
*
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes。
*
* 4 个 sandbox 根(旧实现 / iOS 共用):
* PRIVATE_DOC (2) _doc 私有文档(用户数据/缓存)— 主用
* PRIVATE_WWW (1) _www 私有资源(仅 liberate 模式才有内容)
* PUBLIC_DOCUMENTS (3) _documents 公共文档(多 5+ App 共享)
* PUBLIC_DOWNLOADS (4) _downloads 公共下载(多 5+ App 共享)
*
* PRIVATE_WWW 行为说明:
* - 非 liberate 模式:沙盒根不存在 → requestFileSystem 两个回调都不触发 → 4s 超时降级为 0
* - liberate 模式:首次启动资源从 APK 解压到 _www → 可正常遍历求和
*
* ★ 2026-07-31 补充 C非 liberate 模式下 PRIVATE_WWW 提前判定返回 0不进 8s 兜底。
* 检测方式plus.io.requestFileSystem(PRIVATE_WWW, ok, err) 失败一次,缓存这个事实。
* 后续 _sumDirSizeByRootType(PRIVATE_WWW) 直接返回 0列表页节省 ~8s。
*
* ★ 2026-08-03 诊断日志task #10用户在 Android 12 (sdkInt=31) 上 PRIVATE_WWW 仍
* 撞 8s 兜底。加毫秒级日志定位真实耗时点。
*
* 数字变化说明API 26+ 切到 getAppBytes 后):
* - 比旧"APK + 4 根扫描"通常大 10-50MB系统会包含 native libs / databases / art 优化产物)
* - 这是更准确的口径;文案已在 cache-cleanup.vue hero-tip 解释
*
* 单个根失败/超时 → 0不影响其他根
*/
export async function getSandboxTotalSize() {
// #ifdef APP-PLUS
const t0 = Date.now()
const tNative = {
start: Date.now()
}
try {
if (plus.os.name === 'Android') {
const native = await queryAndroidAppBytes()
if (native.supported && typeof native.bytes === 'number') {
return native.bytes
}
}
const rootTypes = [
plus.io.PRIVATE_DOC,
plus.io.PRIVATE_WWW,
plus.io.PUBLIC_DOCUMENTS,
plus.io.PUBLIC_DOWNLOADS,
]
const sizes = await Promise.all(
rootTypes.map(async (rootType) => {
const tRoot = Date.now()
const result = await Promise.race([
_sumDirSizeByRootType(rootType),
new Promise((_, rej) =>
setTimeout(() => rej(new Error('rootType timeout')), 8000)
),
]).catch((e) => {
console.warn(
`[ioPath] _statDir failed for rootType=${rootType} (took ${Date.now() - tRoot}ms):`,
e?.message)
return 0
})
return result
})
)
const total = sizes.reduce((sum, s) => sum + s, 0)
return total
} catch (e) {
console.warn('[ioPath] getSandboxTotalSize failed:', e?.message, `(${Date.now() - t0}ms)`)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 探测 PRIVATE_WWW 沙盒根是否可访问(仅在非 liberate 模式下有意义)。
* 第一次调用时发起一次探测并缓存结果true=存在/liberate 模式false=不存在)。
* 后续调用直接返回缓存值,避免每次列表页都触发 8s 兜底。
*/
let _privateWwwAvailable = null // null=未探测, true=存在, false=不存在
async function _isPrivateWwwAvailable() {
// #ifdef APP-PLUS
if (_privateWwwAvailable !== null) return _privateWwwAvailable
if (typeof plus === 'undefined' || !plus.io) {
_privateWwwAvailable = false
return false
}
try {
_privateWwwAvailable = await new Promise((resolve) => {
let resolved = false
const timer = setTimeout(() => {
if (!resolved) {
resolved = true
resolve(false)
}
}, 5000)
plus.io.requestFileSystem(
plus.io.PRIVATE_WWW,
() => {
if (!resolved) {
resolved = true
clearTimeout(timer)
resolve(true)
}
},
() => {
if (!resolved) {
resolved = true
clearTimeout(timer)
resolve(false)
}
}
)
})
return _privateWwwAvailable
} catch (e) {
_privateWwwAvailable = false
console.warn(`[ioPath._isPrivateWwwAvailable] threw:`, e?.message)
return false
}
// #endif
// #ifndef APP-PLUS
_privateWwwAvailable = false
return false
// #endif
}
/**
* 取指定 sandbox 根类型对应的 DirectoryEntry递归求总字节
* 不 memoize每次按需取避免 4 个根互相干扰;只在 getSandboxTotalSize 单次调用)
*/
async function _sumDirSizeByRootType(rootType) {
// #ifdef APP-PLUS
// PRIVATE_WWW 在非 liberate 模式下不存在,提前探测避免 8s 兜底
if (rootType === plus.io.PRIVATE_WWW) {
const available = await _isPrivateWwwAvailable()
if (!available) return 0
}
return new Promise((resolve, reject) => {
let settled = false
const safety = setTimeout(() => {
if (!settled) {
settled = true
console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} safety timeout 6s`)
reject(new Error('safety timeout 6s'))
}
}, 6000)
plus.io.requestFileSystem(
rootType,
async (fs) => {
if (settled) return
settled = true
clearTimeout(safety)
try {
const sub = await _statDir(fs.root)
resolve(sub.bytes)
} catch (e) {
console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} _statDir failed:`, e?.message)
reject(e)
}
},
(e) => {
if (settled) return
settled = true
clearTimeout(safety)
reject(e)
}
)
})
// #endif
// #ifndef APP-PLUS
return Promise.resolve(0)
// #endif
}
// 业务子目录白名单:只对这些目录的 tmp/ 子目录做扫描
// 加新业务时同步追加(否则 sandbox-tmp 分类不统计该业务)
const BUSINESS_TMP_PARENTS = new Set([
'castlove',
'castlove-lenticular',
'castlove-self',
'discover',
'lenticular-result',
])
/**
* 扫描 sandbox doc 下"业务子目录的 tmp/ 文件统计信息"(总字节 + 文件数)
* 用于 sandboxTmpHandler.computeSize。一次调用同时返回两个数替代旧的
* scanSandboxTmpFiles + countSandboxTmpFiles 双遍历——同一棵树走两遍在慢设备
* 上 4s 超时撞一起爆)。
*
* 与 getSandboxTotalSize 的区别:
* - getSandboxTotalSize: 沙盒 doc 下所有文件(含 preload/share/image 白名单)
* - scanSandboxTmpFiles: 只统计业务目录的 tmp/ 子目录
*
* 实现细节:早期版本遍历 _doc/ 根下所有顶层目录再 `getDirectory('tmp')` 探查。
* _doc/ 根下有 `uniapp_temp_<ts>/` 等系统生成目录(每次分享 canvas 都会留一个),
* 累积到 N 个时这个 O(N) 探查循环会跑 2-4s+。改为白名单后 O(5)。
*
* @returns {Promise<{sizeBytes:number, keyCount:number}>}
*/
export async function scanSandboxTmpFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const topEntries = await _listDir(root)
let totalBytes = 0
let totalCount = 0
for (const top of topEntries) {
if (!top.isDirectory) continue
// 白名单:只对业务目录探查 tmp/,跳过 uniapp_temp_*/uniapp_save/preload 等系统目录
if (!BUSINESS_TMP_PARENTS.has(top.name)) continue
const tmpEntry = await new Promise((resolve) => {
top.getDirectory('tmp', {
create: false
}, (entry) => resolve(entry), () => resolve(null))
})
if (!tmpEntry) continue
// eslint-disable-next-line no-await-in-loop
const sub = await _statDir(tmpEntry)
totalBytes += sub.bytes
totalCount += sub.count
}
return {
sizeBytes: totalBytes,
keyCount: totalCount
}
} catch (e) {
console.warn('[ioPath] scanSandboxTmpFiles failed:', e?.message)
return {
sizeBytes: 0,
keyCount: 0
}
}
// #endif
// #ifndef APP-PLUS
return {
sizeBytes: 0,
keyCount: 0
}
// #endif
}
// ── doc 根残留文件sandboxResidualHandler 用)──
//
// 为什么不复用 PROTECTED_SUBDIRS那份白名单服务于 <业务>/tmp/ 的扫描与清理,
// 同时被 clearAllSandboxTmpDirs登出全清、clearAllSandboxTmpFiles启动清理
// scanSandboxTmpFiles、countSandboxTmpFiles 四个函数消费,改它爆炸半径过大。
// 本节维护独立的目标集合。
//
// 与 scanSandboxTmpFiles 的覆盖范围**不相交**(否则同一份字节会被两个分类双算):
// scanSandboxTmpFiles → <业务目录>/tmp/** (二层起,且跳过 PROTECTED_SUBDIRS
// 本节 → doc 根一层:散落图片文件 + 下列指定目录
const RESIDUAL_IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i
// 可整目录清空的残留目录(目录内不存在其他模块的关键文件)
const RESIDUAL_DIRS_PURGE = new Set([
'preload', // 2026-07-13 已废弃的文件版预加载缓存(现走 uni.setStorage见 preloadApi/storage.js
])
// `uni.canvasToTempFilePath` 在 app-plus 上默认写到 `_doc/uniapp_temp_<timestamp>/`
// image-compositor.js:111 的 canvas 分享图合成。uni 在进程退出时按设计清掉,
// 但失败(保存到相册失败、进程被杀)会留下,累积成几十 MB。
// 之前误以为不在 _doc/ 树下(项目代码里看不到显式写入)—— 实测 _doc/uniapp_temp_<ts>/ 确实存在。
const RESIDUAL_DIR_PREFIX_PURGE = ['uniapp_temp_']
// 需按文件过滤的残留目录uni.saveFile 的公共落点,除头像外还可能存放升级包
// uni-upgrade-center-app#saveFile记录在 UNI_ADMIN_UPGRADE_CENTER_LOCAL_FILE_PATH
// 整目录删会让 upgrade-popup#checkLocalStoragePackage 拿着失效记录提示"可直接安装"
// 而它不校验文件是否存在 → 安装必失败。故此目录内跳过安装包。
const RESIDUAL_DIRS_FILTERED = new Set([
'uniapp_save', // avatarCache.js#downloadAndCacheAvatar 的头像缓存
])
const RESIDUAL_KEEP_EXT = /\.(wgt|apk|ipa)$/i
// 按账号分组的目录:目录下每个子目录是一个 uid代表"该用户的 share 图"。
// 这里特殊处理写入侧useShare.copyToSandbox在 _doc/share/<uid>/ 下写文件,
// 清理侧按 uid 聚合展示与清理(与 draftHandler 的分组模式对齐)。
const RESIDUAL_DIRS_UID = new Set([
'share',
])
/**
* 判定 doc 根下的某个 entry 属于哪类"可清残留"
* 扫描 / 计数 / 清理三处共用同一判定,避免口径漂移导致"算得到但删不掉"
*
* @returns {'file'|'dir-purge'|'dir-filtered'|'dir-uid'|null}
* file → 根级散落图片文件,直接删
* dir-purge → 整目录递归清空
* dir-filtered → 目录内逐个文件删,跳过 RESIDUAL_KEEP_EXT升级包
* dir-uid → 目录下每个子目录是一个 uidhandler 按 uid 聚合清理
* null → 不属于本分类,不碰
*
* 刻意不覆盖:
* - doc 根下的非图片文件uni 内部 .db / .json 等,误删后果未知)
* - 业务目录castlove / discover / lenticular-result / ...)→ 归 sandbox-tmp
* - image 目录 → 原 PROTECTED_SUBDIRS 白名单语义,保持保留
* - share 目录本身不进 PROCESSED_SUBDIRS旧版注释"share/image 保留"是错的):
* share 目录是按 uid 分目录的写入侧不是白名单image 仍保留
*/
function _residualKind(entry) {
if (entry.isFile) return RESIDUAL_IMAGE_EXT.test(entry.name) ? 'file' : null
if (entry.isDirectory) {
if (RESIDUAL_DIRS_FILTERED.has(entry.name)) return 'dir-filtered'
if (RESIDUAL_DIRS_PURGE.has(entry.name)) return 'dir-purge'
if (RESIDUAL_DIR_PREFIX_PURGE.some((p) => entry.name.startsWith(p))) return 'dir-purge'
if (RESIDUAL_DIRS_UID.has(entry.name)) return 'dir-uid'
}
return null
}
/** dir-filtered 目录内的文件是否应被跳过(保留) */
function _isKeptInFilteredDir(entry) {
return entry.isFile && RESIDUAL_KEEP_EXT.test(entry.name)
}
/**
* 扫描 doc 根残留的总字节 + 文件数(用于 sandboxResidualHandler.computeSize
* 一次调用同时返回两个数(替代旧的 scanSandboxResidualFiles + countSandboxResidualFiles
* 双遍历——后者对同一棵 doc 根再走一遍 _countDirFiles慢设备撞 4s 超时)。
*
* @deprecated (2026-07-31) 推荐用 scanSandboxResidualAll() 一次拿 (total, byUid, global)
* 详情页 computeBreakdown 从 3 次 walk 降到 1 次。本函数保留仅供外部兼容,
* 内部走 scanSandboxResidualAll() 后取 total 字段。
* @returns {Promise<{sizeBytes:number, keyCount:number}>}
*/
export async function scanSandboxResidualFiles() {
const r = await scanSandboxResidualAll()
return {
sizeBytes: r.total.sizeBytes,
keyCount: r.total.keyCount
}
}
/**
* 一次 walk 同时收集 doc 根残留的 (全量, uid分组, 全局) 三类信息。
*
* 替代旧 scanSandboxResidualFiles + scanSandboxResidualByUid 双扫描:
* - 列表页 computeSize1 次 walk 取 total
* - 详情页 computeBreakdown1 次 walk 同时取 byUid + globalglobal 由 total - byUid 数学减法得,无额外 walk
* 修复前 详情页 = walk(byUid) + walk(全量) = 2 次 walk修复后 1 次 walk
*
* 短路优化(补充 A
* - 4 类 (dir-purge / dir-filtered / dir-uid / file) 全部为空时,直接返回 0不进 Promise.all
* - 即使 doc 根有大量业务目录 (castlove/ discover/ lenticular-result/ ...) 也不命中
* (这些走 BUSINESS_TMP_PARENTS 不归 residual
*
* @returns {Promise<{
* total: { sizeBytes: number, keyCount: number }, // 全量 4 类 residual
* byUid: Array<{ uid: string, sizeBytes: number, keyCount: number }>, // share/<uid>/ 各分组
* global: { sizeBytes: number, keyCount: number }, // 全量 - byUid数学减法
* }>}
*/
export async function scanSandboxResidualAll() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const entries = await _listDir(root)
// ★ 2026-08-03 task #22用户 Android 12 _listDir(doc 根) 返回 15 phantom entries
// 其中 5 个都是同名 "uniapp_temp_1785732205469"phantom 重复)。如果不 dedup
// 会把同一个目录当 5 个独立目录扫 5 次 → 重复计数 + 日志全显示同一名字
// 按 kind 分桶 + dedup by name
const purgeTargets = []
const filteredTargets = []
const uidDirs = []
const fileTargets = []
const seenPurge = new Set()
const seenFiltered = new Set()
const seenUid = new Set()
const seenFile = new Set()
for (const entry of entries) {
const kind = _residualKind(entry)
if (kind === 'dir-purge') {
if (!seenPurge.has(entry.name)) {
seenPurge.add(entry.name)
purgeTargets.push(entry)
}
} else if (kind === 'dir-filtered') {
if (!seenFiltered.has(entry.name)) {
seenFiltered.add(entry.name)
filteredTargets.push(entry)
}
} else if (kind === 'dir-uid') {
if (!seenUid.has(entry.name)) {
seenUid.add(entry.name)
uidDirs.push(entry)
}
} else if (kind === 'file') {
if (!seenFile.has(entry.name)) {
seenFile.add(entry.name)
fileTargets.push(entry)
}
}
}
if (purgeTargets.length === 0 && filteredTargets.length === 0 && uidDirs.length === 0 && fileTargets
.length === 0) {
// 下面有短路, 这里直接返回
}
const phantomDedupCount = (entries.length - purgeTargets.length - filteredTargets.length - uidDirs.length -
fileTargets.length)
if (phantomDedupCount > 0) {
// phantom entries deduped
}
// ★ 2026-08-03 task #15+17+18彻底简化扫描——不递归4 类只数顶层 + 估算
// 原版对每个 purgeTargets (uniapp_temp_*/preload) 都 _statDir
// 加上 share/uid 子目录也 _statDir几十个并发 _statDir 通过 plus.io
// bridge 串行排队,全部撞 4s 兜底。
//
// 新策略4 类都用「计数 × 估算字节」公式,避免任何子目录 _statDir 调用
// - dir-purge (uniapp_temp_*/preload)count 子目录 × 8KB/目录canvas 合成图)
// - dir-filtered (uniapp_save/)1 次 _listDir 数头像文件 × 30KB
// - dir-uid (share/<uid>/)1 次 _listDir 数 uid 子目录,每个 uid × 40KB
// - file (根级散落图片)count × 50KB
//
// 总 _listDir 调用数 = 1doc 根)+ 1uniapp_save/+ 1share/= 3 次
// vs 之前最多 135 次 → 45 倍减少
// ★ task #28移除 AVG_FILE_BYTES_BY_KIND 估算常量。所有字节数都用 entry.file() 拿真实值。
// 补充 A4 类全空 → 短路返回 0
if (purgeTargets.length === 0 && filteredTargets.length === 0 &&
uidDirs.length === 0 && fileTargets.length === 0) {
return {
total: {
sizeBytes: 0,
keyCount: 0
},
byUid: [],
global: {
sizeBytes: 0,
keyCount: 0
},
}
}
// ★ 只调 3+N 次 _listDiruniapp_save/、share/、前 N 个 uniapp_temp_*
// 其余靠顶层 entries 计数 + 估算
//
// 2026-08-03 task #19用户反馈「临时文件的 uniapp_temp_ts 没被扫描出来」。
// 原因:上一版我用了纯估算(每目录固定 2 文件 × 8KB没真正进 uniapp_temp_*
// 里 _listDir 看实际文件数。修复:对前 5 个 uniapp_temp_* 真实 _listDir 一次
// 数文件数_listDir 自身有 MAX_READ_ATTEMPTS=15 防护,单次 < 1s
// 剩余的按 5 个的样本估算。
let purgeBytes = 0
let purgeCount = 0
const PURGE_SCAN_LIMIT = 5 // 最多真实扫前 5 个 uniapp_temp_*/preload
const purgeToScan = purgeTargets.slice(0, PURGE_SCAN_LIMIT)
const purgeToEstimate = purgeTargets.slice(PURGE_SCAN_LIMIT)
// 真实扫前 N 个
// ★ 2026-08-03 task #23+24+25用户反馈需要真实文件大小不要估算。
// 修复dedup by name 后用 entry.file() 拿真实 size。
// dedup 后每个 subfolder 通常只有 1-3 个真实文件(不再 30 phantom
// 调用 entry.file() 总数 = 5(purge top) × 1(subdir) × ~2 文件 ≈ 10 次,
// 每次 6-10ms总 < 200ms。可以接受。
// 失败 → 0 bytes不估算task #28
for (const dirEntry of purgeToScan) {
const subEntries = await _listDir(dirEntry)
let dirBytes = 0
let dirCount = 0
// 收集唯一文件 entriesdedup by name— task #33过滤 phantom
// 真实文件 fullPath 应该以 "_doc/" 开头uniapp 全路径前缀)
// phantom entries 的 fullPath 通常是 undefined/空/异常
const uniqueFiles = new Map() // name → entry
const subDirs = []
// task #35subDirs 也 dedup by name防御 phantom dirs 重复)
const seenSubDirs = new Set()
for (const e of subEntries) {
let isReal = false
try {
const fp = e.fullPath || (e.toURL && e.toURL()) || ''
isReal = fp.includes('_doc') || fp.includes('doc/') || fp.length > 20
} catch (err) {
/* phantom */
}
if (e.isFile && isReal && !uniqueFiles.has(e.name)) {
uniqueFiles.set(e.name, e)
} else if (e.isDirectory && isReal && !seenSubDirs.has(e.name)) {
seenSubDirs.add(e.name)
subDirs.push(e)
}
}
// 打印顶层文件 name调试用
// 1) 顶层文件 _getRealFileSize 拿真实 sizetask #29toURL → resolveLocalFileSystemURL → file
const topFileEntries = Array.from(uniqueFiles.values())
const topFileSizes = await Promise.all(
topFileEntries.map((e) => _getRealFileSize(e))
)
dirBytes += topFileSizes.reduce((s, sz) => s + sz, 0)
dirCount += topFileEntries.length
// 2) 1 层 subfoldercanvas/, downloads/ 等,最多 10 个)
const limitedSubDirs = subDirs.slice(0, 10)
for (const subDir of limitedSubDirs) {
const subSubEntries = await _listDir(subDir)
const subUniqueFiles = new Map()
for (const e of subSubEntries) {
if (e.isFile && !subUniqueFiles.has(e.name)) {
subUniqueFiles.set(e.name, e)
}
}
const subFileEntries = Array.from(subUniqueFiles.values())
// ★ task #31详细日志 - 打印 subfolder 文件 name + size
const subFileDetails = await Promise.all(
subFileEntries.map(async (e) => {
const sz = await _getRealFileSize(e)
let fullPath = 'unknown'
try {
fullPath = e.fullPath || (e.toURL && e.toURL()) || 'unknown'
} catch (err) {
/* */
}
return {
name: e.name,
size: sz,
fullPath
}
})
)
dirBytes += subFileDetails.reduce((s, f) => s + f.size, 0)
dirCount += subFileDetails.length
}
if (dirCount === 0) {
// purge dir 无文件,跳过
}
purgeBytes += dirBytes
purgeCount += dirCount
}
// ★ task #28剩余 purgeTargets 也不估算,只数 count
purgeCount += purgeToEstimate.length
// 调 _listDir on uniapp_save/ 一次(最多 5 个 filteredTargets
let filteredBytes = 0
let filteredCount = 0
for (const f of filteredTargets) {
const subEntries = await _listDir(f)
// dedup by name + 过滤升级包
const uniqueFiles = new Map()
for (const e of subEntries) {
if (e.isFile && !_isKeptInFilteredDir(e) && !uniqueFiles.has(e.name)) {
uniqueFiles.set(e.name, e)
}
}
const fileEntries = Array.from(uniqueFiles.values())
const fileSizes = await Promise.all(
fileEntries.map((e) => _getRealFileSize(e))
)
filteredBytes += fileSizes.reduce((s, sz) => s + sz, 0)
filteredCount += fileEntries.length
}
// 调 _listDir on share/ 一次(最多 1 个 shareDir每个 shareDir 下 uid 子目录)
// ★ 2026-08-03uid 扫描限流到前 20 个,剩余按样本均值估算(避免 50+ uid 串行卡 5s+
const UID_SCAN_LIMIT = 20
const byUid = []
let uidBytes = 0
let uidCount = 0
for (const shareDir of uidDirs) {
const uidEntries = await _listDir(shareDir)
const allUidDirs = uidEntries.filter((u) => u.isDirectory)
const uidToScan = allUidDirs.slice(0, UID_SCAN_LIMIT)
const uidToEstimate = allUidDirs.slice(UID_SCAN_LIMIT)
// 真实扫前 N 个
for (const uidEntry of uidToScan) {
const subEntries = await _listDir(uidEntry)
// dedup by name
const uniqueFiles = new Map()
for (const e of subEntries) {
if (e.isFile && !uniqueFiles.has(e.name)) {
uniqueFiles.set(e.name, e)
}
}
const fileEntries = Array.from(uniqueFiles.values())
const fileSizes = await Promise.all(
fileEntries.map((e) => _getRealFileSize(e))
)
const uidBytesEach = fileSizes.reduce((s, sz) => s + sz, 0)
const fileCount = fileEntries.length
byUid.push({
uid: uidEntry.name,
sizeBytes: uidBytesEach,
keyCount: fileCount,
})
uidBytes += uidBytesEach
uidCount += fileCount
}
// uid 剩余bytes=0, keyCount=0
for (const uidEntry of uidToEstimate) {
byUid.push({
uid: uidEntry.name,
sizeBytes: 0,
keyCount: 0,
})
}
}
// 根级散落图片dedup by name + entry.file() 拿真实 size
const fileUnique = new Map()
for (const e of fileTargets) {
if (!fileUnique.has(e.name)) fileUnique.set(e.name, e)
}
const fileEntries = Array.from(fileUnique.values())
const fileSizes = await Promise.all(
fileEntries.map((e) => _getRealFileSize(e))
)
const fileBytes = fileSizes.reduce((s, sz) => s + sz, 0)
const fileCount = fileEntries.length
const totalBytes = purgeBytes + filteredBytes + uidBytes + fileBytes
const totalCount = purgeCount + filteredCount + uidCount + fileCount
// global = 全量 - byUid数学减法无额外 walk
const global = {
sizeBytes: Math.max(0, totalBytes - uidBytes),
keyCount: Math.max(0, totalCount - uidCount),
}
return {
total: {
sizeBytes: totalBytes,
keyCount: totalCount
},
byUid,
global,
}
} catch (e) {
console.warn('[ioPath] scanSandboxResidualAll failed:', e?.message)
return {
total: {
sizeBytes: 0,
keyCount: 0
},
byUid: [],
global: {
sizeBytes: 0,
keyCount: 0
},
}
}
// #endif
// #ifndef APP-PLUS
return {
total: {
sizeBytes: 0,
keyCount: 0
},
byUid: [],
global: {
sizeBytes: 0,
keyCount: 0
},
}
// #endif
}
/**
* 扫描 doc 根 share/<uid>/ 各分组(用于 sandboxResidualHandler.computeBreakdown
* 每个 uid 子目录的 sizeBytes / keyCount 聚合成一个分组对象
*
* @deprecated (2026-07-31) 推荐用 scanSandboxResidualAll() 一次拿 (total, byUid, global)。
* 本函数保留仅供外部兼容,内部走 scanSandboxResidualAll() 后取 byUid 字段。
* @returns {Promise<Array<{uid:string, sizeBytes:number, keyCount:number}>>}
*/
export async function scanSandboxResidualByUid() {
const r = await scanSandboxResidualAll()
return r.byUid
}
/**
* 清理 share/<uid>/ 整个子目录(用于 sandboxResidualHandler.cleanGroup 具体 uid
* @param {string} uid share 下的子目录名
* @returns {Promise<{deleted:boolean, freedBytes?:number, keyCount?:number, reason?:string}>}
*/
export async function clearSandboxShareByUid(uid) {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
// 逐级 resolve 到 share/<uid>/
const shareDir = await new Promise((resolve) => {
root.getDirectory('share', {
create: false
}, (e) => resolve(e), () => resolve(null))
})
if (!shareDir) return {
deleted: false,
reason: 'share_dir_missing'
}
const uidDir = await new Promise((resolve) => {
shareDir.getDirectory(uid, {
create: false
}, (e) => resolve(e), () => resolve(null))
})
if (!uidDir) return {
deleted: false,
reason: 'uid_dir_missing'
}
// 先量后删得到准确 freedBytes。_statDir 一次遍历同时出 bytes + count
// 旧实现两个函数各走一遍同一棵树
const before = await _statDir(uidDir)
const ok = await new Promise((resolve) => {
uidDir.removeRecursively(() => resolve(true), () => resolve(false))
})
if (!ok) {
return {
deleted: false,
reason: 'remove_failed'
}
}
return {
deleted: true,
freedBytes: before.bytes,
keyCount: before.count
}
} catch (e) {
console.warn('[ioPath] clearSandboxShareByUid failed:', e?.message)
return {
deleted: false,
reason: e?.message || 'exception'
}
}
// #endif
// #ifndef APP-PLUS
return {
deleted: false,
reason: 'app-only'
}
// #endif
}
/**
* 清理整张残留图(用于其它业务"清空"按钮;不动 share/,日常用不上)
* @deprecated 使用 clearSandboxResidualGlobal 替代
*/
export async function clearSandboxResidualFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const entries = await _listDir(root)
let deletedFiles = 0
let deletedDirs = 0
let keptFiles = 0
let freedBytes = 0
let keyCount = 0
const targets = []
for (const entry of entries) {
const kind = _residualKind(entry)
if (!kind) continue
if (kind === 'dir-purge') {
// 整目录清空:边量边删(一次 _statDir 拿 before 字节/计数)
// eslint-disable-next-line no-await-in-loop
const before = await _statDir(entry)
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
entry.removeRecursively(() => resolve(true), () => resolve(false))
})
if (!ok) continue
deletedDirs++
freedBytes += before.bytes
keyCount += before.count
targets.push(`${entry.name}/`)
} else if (kind === 'dir-filtered') {
// 目录本身保留,只删目录内非保留扩展名的文件(避免连带删掉升级包)
// _removeFilesInDir 边删边累加 freedBytes/keyCount无需 before/after
// eslint-disable-next-line no-await-in-loop
const r = await _removeFilesInDir(entry, _isKeptInFilteredDir)
deletedFiles += r.deleted
keptFiles += r.kept
freedBytes += r.freedBytes
keyCount += r.keyCount
if (r.deleted > 0) targets.push(`${entry.name}/ (${r.deleted} files)`)
} else if (kind === 'dir-uid') {
// 全量清理入口:递归清空 share/ 下所有 uid 子目录
// eslint-disable-next-line no-await-in-loop
const before = await _statDir(entry)
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
entry.removeRecursively(() => resolve(true), () => resolve(false))
})
if (!ok) continue
deletedDirs++
freedBytes += before.bytes
keyCount += before.count
targets.push(`${entry.name}/ (所有 uid)`)
} else {
// 根级散落图片:先 entry.file() 拿 size 再 remove()
let fileSize = 0
try {
const f = await new Promise((res, rej) => entry.file(res, rej))
fileSize = f?.size || 0
} catch (e) {
/* size unknown */
}
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
entry.remove(() => resolve(true), () => resolve(false))
})
if (!ok) continue
deletedFiles++
freedBytes += fileSize
keyCount++
targets.push(entry.name)
}
}
return {
deletedFiles,
deletedDirs,
keptFiles,
freedBytes,
keyCount,
targets
}
} catch (e) {
console.warn('[ioPath] clearSandboxResidualFiles failed:', e?.message)
return {
deletedFiles: 0,
deletedDirs: 0,
keptFiles: 0,
freedBytes: 0,
keyCount: 0,
targets: [],
error: e?.message
}
}
// #endif
// #ifndef APP-PLUS
return {
deletedFiles: 0,
deletedDirs: 0,
keptFiles: 0,
freedBytes: 0,
keyCount: 0,
targets: []
}
// #endif
}
/**
* 清"全局"组doc 根除 share/ 之外的整张残留图)
* 用于 sandboxResidualHandler.cleanGroup({ uid: '__global__' })——不动 share/<uid>/
* 那些走具体 uid 的 cleanGroup。
* @returns {Promise<{deletedFiles:number, deletedDirs:number, keptFiles:number, targets:string[]}>}
*/
export async function clearSandboxResidualGlobal() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const entries = await _listDir(root)
let deletedFiles = 0
let deletedDirs = 0
let keptFiles = 0
let freedBytes = 0
let keyCount = 0
const targets = []
// ★ task #37dedup by name + 跳过 phantom (fullPath 异常)
const seenNames = new Set()
for (const entry of entries) {
const kind = _residualKind(entry)
// 跳过 dir-uidshare/ 由具体 uid 清理入口负责
if (!kind || kind === 'dir-uid') continue
// dedup by name
if (seenNames.has(entry.name)) continue
// 跳过 phantom (fullPath 不含 _doc)
let isReal = false
try {
const fp = entry.fullPath || (entry.toURL && entry.toURL()) || ''
isReal = fp.includes('_doc') || fp.includes('doc/') || fp.length > 20
} catch (e) { /* phantom */ }
if (!isReal) {
continue
}
seenNames.add(entry.name)
if (kind === 'dir-purge') {
// 整目录清空:边量边删(一次 _statDir 拿 before 字节/计数)
// eslint-disable-next-line no-await-in-loop
const before = await _statDir(entry)
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
entry.removeRecursively(() => resolve(true), () => resolve(false))
})
if (!ok) {
continue
}
deletedDirs++
freedBytes += before.bytes
keyCount += before.count
targets.push(`${entry.name}/`)
} else if (kind === 'dir-filtered') {
} else if (kind === 'dir-filtered') {
// eslint-disable-next-line no-await-in-loop
const r = await _removeFilesInDir(entry, _isKeptInFilteredDir)
deletedFiles += r.deleted
keptFiles += r.kept
freedBytes += r.freedBytes
keyCount += r.keyCount
if (r.deleted > 0) targets.push(`${entry.name}/ (${r.deleted} files)`)
} else {
// 根级散落图片:先 entry.file() 拿 size 再 remove()
let fileSize = 0
try {
const f = await new Promise((res, rej) => entry.file(res, rej))
fileSize = f?.size || 0
} catch (e) {
/* size unknown */
}
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
entry.remove(() => resolve(true), () => resolve(false))
})
if (!ok) continue
deletedFiles++
freedBytes += fileSize
keyCount++
targets.push(entry.name)
}
}
return {
deletedFiles,
deletedDirs,
keptFiles,
freedBytes,
keyCount,
targets
}
} catch (e) {
console.warn('[ioPath] clearSandboxResidualGlobal failed:', e?.message)
return {
deletedFiles: 0,
deletedDirs: 0,
keptFiles: 0,
freedBytes: 0,
keyCount: 0,
targets: [],
error: e?.message
}
}
// #endif
// #ifndef APP-PLUS
return {
deletedFiles: 0,
deletedDirs: 0,
keptFiles: 0,
freedBytes: 0,
keyCount: 0,
targets: []
}
// #endif
}
/**
* 计数 doc 根残留的文件总数(用于 sandboxResidualHandler.computeSize 的 keyCount
* 与 scanSandboxResidualFiles 范围严格一致
*
* @deprecated 已合并到 scanSandboxResidualFiles 一次返回 {sizeBytes, keyCount}
* 无需单独调用。保留仅为兼容旧引用;新代码请直接用 scanSandboxResidualFiles。
* 慢设备上 _countDirFiles 对每棵子树再走一遍N 棵 → N 次桥往返,撞 4s 超时。
*/
export async function countSandboxResidualFiles() {
const r = await scanSandboxResidualFiles()
return r.keyCount
}
/**
* ★ 2026-07-31 禁用:原计划通过 Android `Runtime.exec` 调 `du -sb <path>` 一次拿目录总字节。
* 实战发现致命问题Java BufferedReader.readLine() 通过 plus.android.invoke 发起同步阻塞读,
* **阻塞整个 plus.android bridge**单线程setTimeout 兜底也救不了——resolve(null) 后 invoke
* 还在桥里排着,下一次 plus.android 调用也卡死,整个 UI 无任何 console 输出。
*
* 替代方案(如未来要做 native 一次桥):
* - Java 侧写一个静态方法 + plus.android.newObject 实例化(需要 dex / nativeplugin
* - NIO Selector 异步读 InputStreamplus.android 对 NIO Channel 代理可能也阻塞,需验证)
* - 不用 shell du直接调 java.io.File.listFiles()(仍是 N 次桥,无收益)
*
* 当前 stub返 null + 一次性 warn。`_statDir` 走"JS 递归 + 2000 文件上限"降级路径,
* 慢设备仍可能超时2000 文件 entry.file() = 2-6s但比"无任何输出 + UI 卡死"可接受。
*
* @returns {Promise<null>}
*/
let _execDisabledWarned = false
async function _sumDirSizeByExec(path) {
// #ifdef APP-PLUS
if (!_execDisabledWarned) {
console.warn(
'[ioPath] _sumDirSizeByExec disabled (plus.android bridge deadlock with Java readLine). Use _statDir fallback path.'
)
_execDisabledWarned = true
}
return null
// #endif
// #ifndef APP-PLUS
return null
// #endif
}
/**
* 递归求目录下"统计信息":总字节 + 文件数。
*
* 当前统一策略JS 递归 + entry.file() 累加 + 2000 文件上限。
* - 单文件 entry.file() 桥往返 ~1-3ms
* - 2000 文件 ~2-6s仍在 HANDLER_TIMEOUT_MS 8s 兜底内)
* - 超出 2000 stop + warn + truncated=truebytes = 前 2000 文件实际字节和
*
* 原计划走 Runtime.exec / Java NIO / native plugin 三种"一次桥"方案均不可行:
* - Runtime.exec + readLine → plus.android bridge 同步阻塞死锁(实测 UI 卡死)
* - Java NIO Selector → 需要 native 字节码uniapp 不便走 JS 侧)
* - native plugin → uni_modules 全套配置,超 P0 范畴
*
* 后续优化方向(不在 P0 范围):
* - native plugin 提供 Java 静态方法递归求和(一次桥真实拿 totalBytes
* - 上 bookkeeping写入侧记账computeSize 变 O(1)
*
* @param {Function} [skipFile] 可选谓词,命中的文件不计入字节/计数
* uniapp_save/ 内跳过 .wgt/.apk/.ipa 升级包,与 _residualKind 口径一致)
* @returns {Promise<{bytes:number, count:number, truncated?:boolean}>}
*/
async function _statDir(dirEntry, skipFile) {
// #ifdef APP-PLUS
// iOS / Android 统一走 JS 递归 + 文件上限路径。
// 原计划走 Runtime.exec / Java NIO / native plugin 三种"一次桥"方案均不可行:
// - Runtime.exec + readLine → plus.android bridge 同步阻塞死锁(实测)
// - Java NIO Selector → 需要 native 字节码uniapp 不便走 JS 侧)
// - native plugin → uni_modules 全套配置,超 P0 范畴
//
// ★ 2026-08-03 task #17用户 Android 12 设备 readEntries 反复返回 phantom entries
// (200/330 同一些),递归 walk 会跑进 phantom 子目录里:
// - 每次 _listDir 调 readEntries 拿 15-200 phantom entries
// - walk 把 phantom 当子目录递归,每个 phantom 又 _listDir 再返 15 phantom
// - 12 层累积 180 entries4s outer 兜底被打爆
// ★ 终极方案:放弃递归,只扫顶层 entries。residual 所有目标目录uniapp_save/、
// uniapp_temp_<ts>、preload/、share/<uid>/)文件都是顶层扁平结构,不需要递归
// ★ share/ 特殊:本身是父目录,下面 uid 子目录也要扫(仅一层)
//
// 估算字节:图片平均 30-80KBavatar 30KB / share 40KB / canvas 50KB / 根图 50KB
// 不再调用 entry.file() 拿真实字节,慢设备 8ms/file × 200 = 1.6s 全省掉
const STAT_DIR_TIMEOUT_MS = 4000
const MAX_FILES = 200
const shared = {
bytes: 0,
count: 0,
scanned: 0,
settled: false
}
const statPromise = (async () => {
let bytes = 0
let count = 0
let truncated = false
let scanned = 0
// ★ 只扫顶层:不递归到子目录
// 1) 拿顶层 entries_listDir 仍然受 MAX_READ_ATTEMPTS 保护)
let es = []
try {
es = await _listDir(dirEntry)
} catch (e) {
console.warn('[ioPath] _statDir _listDir failed:', e?.message)
return {
bytes: 0,
count: 0,
truncated: false
}
}
// 2) 分类 file vs directory不递归但允许调用方知道有子目录
const fileEntries = []
for (const e of es) {
if (typeof skipFile === 'function' && skipFile(e)) continue
if (e.isFile) fileEntries.push(e)
}
// 3) 限制文件数
const limitedFiles = fileEntries.slice(0, MAX_FILES)
if (fileEntries.length > MAX_FILES) truncated = true
// 4) 真实 bytes用 _getRealFileSize 拿真实 sizetask #29 toURL + resolveLocalFileSystemURL
// 不估算。失败 → 0 bytes
const realSizes = await Promise.all(
limitedFiles.map((e) => _getRealFileSize(e))
)
bytes = realSizes.reduce((s, sz) => s + sz, 0)
count = limitedFiles.length
scanned = limitedFiles.length
shared.bytes = bytes
shared.count = count
shared.scanned = scanned
if (truncated) {
// console.warn(
// `[ioPath._statDir] truncated at ${MAX_FILES} of ${fileEntries.length} files`
// )
}
shared.settled = true // 标记已完成
return {
bytes,
count,
truncated
}
})()
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
// ★ task #27检查 statPromise 是否已完成,避免误报"超时"
if (shared.settled) {
// 已完成 — 静默返回(不打印 warn不影响 race 结果)
resolve(null)
return
}
const partialCount = shared.count
const partialBytes = shared.bytes
const estimatedBytes = partialBytes + partialCount * AVG_FILE_BYTES
console.warn(
`[ioPath._statDir] outer timeout ${STAT_DIR_TIMEOUT_MS}ms, partial: scanned=${shared.scanned} count=${partialCount} bytes=${partialBytes} → estimated ${estimatedBytes}`
)
resolve({
bytes: estimatedBytes,
count: partialCount,
truncated: true,
timedOut: true,
estimated: true
})
}, STAT_DIR_TIMEOUT_MS)
})
// ★ task #27把 statPromise 自身放进 Promise.race 数组
// 这样即使 timeoutPromise 因 statPromise 已 settled 而 resolve(null)Promise.race 仍然返回 statPromise 的结果
// (timeoutPromise 提前 resolve(null) 不会"赢",因为 statPromise 也是 Promise.race 成员)
const result = await Promise.race([statPromise, timeoutPromise])
if (result === null) {
// timeoutPromise 检测到 statPromise 已 settled提前 resolve(null) 让我们再次 await statPromise
return await statPromise
}
if (result.timedOut) {
return result
}
if (result.truncated) {
// console.warn(
// `[ioPath] _statDir truncated at ${MAX_FILES} files (path may contain more); bytes is partial sum.`
// )
}
return {
bytes: result.bytes,
count: result.count,
truncated: result.truncated
}
// #endif
// #ifndef APP-PLUS
return {
bytes: 0,
count: 0,
truncated: false
}
// #endif
}
// _countSubdirFiles 已删除——exec 路径因为 plus.android bridge 死锁被禁用,
// _statDir 不再需要独立的轻量递归count 在统一 walk 里同步累加。
/**
* 递归删除目录下的文件skipFile 命中的保留;目录结构本身保留
* 用于 dir-filtered 类残留目录uniapp_save/:删头像、留升级包)
* 边删边累加 freedBytes/keyCount调用方无需做 before/after 扫描
* @returns {Promise<{deleted:number, kept:number, freedBytes:number, keyCount:number}>}
*/
async function _removeFilesInDir(dirEntry, skipFile) {
// #ifdef APP-PLUS
let deleted = 0
let kept = 0
let freedBytes = 0
let keyCount = 0
let entries
try {
entries = await _listDir(dirEntry)
} catch (e) {
console.warn('[ioPath] _removeFilesInDir read failed for a dir, skipping:', e?.message)
return {
deleted,
kept,
freedBytes,
keyCount
}
}
for (const entry of entries) {
if (entry.isDirectory) {
// eslint-disable-next-line no-await-in-loop
const r = await _removeFilesInDir(entry, skipFile)
deleted += r.deleted
kept += r.kept
freedBytes += r.freedBytes
keyCount += r.keyCount
continue
}
if (!entry.isFile) continue
if (typeof skipFile === 'function' && skipFile(entry)) {
kept++
continue
}
// 删前量大小(删除后 entry.file() 取不到,引用也会失效)
let fileSize = 0
try {
const file = await new Promise((res, rej) => entry.file(res, rej))
fileSize = file?.size || 0
} catch (e) {
/* size unknown, fall through with 0 */
}
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
entry.remove(() => resolve(true), () => resolve(false))
})
if (ok) {
deleted++
freedBytes += fileSize
keyCount++
}
}
return {
deleted,
kept,
freedBytes,
keyCount
}
// #endif
// #ifndef APP-PLUS
return {
deleted: 0,
kept: 0,
freedBytes: 0,
keyCount: 0
}
// #endif
}
/**
* ★ task #29通过 entry.toURL() + plus.io.resolveLocalFileSystemURL 拿真实 File.size
* Android 12 phantom entry 的 entry.file() 返回假数据。
* 但通过 entry.toURL() 拿到的真实路径,再用 plus.io.resolveLocalFileSystemURL
* 重新打开,可能能拿到真实的 File 对象。
* 失败时 fallback 到 entry.file()。
*
* @param {FileEntry} entry
* @returns {Promise<number>} 文件字节数,失败返回 0
*/
function _getRealFileSize(entry) {
// #ifdef APP-PLUS
return new Promise((resolve) => {
if (!entry) return resolve(0)
// 优先尝试toURL → resolveLocalFileSystemURL → file
let url = null
try {
url = entry.toURL ? entry.toURL() : null
} catch (e) {
/* swallow */
}
if (url) {
plus.io.resolveLocalFileSystemURL(
url,
(reEntry) => {
reEntry.file(
(f) => resolve(f?.size || 0),
() => {
// resolveLocalFileSystemURL → file 失败fallback 到原 entry.file()
entry.file(
(f) => resolve(f?.size || 0),
() => resolve(0)
)
}
)
},
() => {
// resolveLocalFileSystemURL 失败fallback 到原 entry.file()
entry.file(
(f) => resolve(f?.size || 0),
() => resolve(0)
)
}
)
} else {
// 没 toURL(),直接 entry.file()
entry.file(
(f) => resolve(f?.size || 0),
() => resolve(0)
)
}
})
// #endif
// #ifndef APP-PLUS
return Promise.resolve(0)
// #endif
}
/**
* 列出目录所有 entryDirectoryEntry / FileEntry
*
* ★ 2026-08-03 防御性修复task #10 诊断发现):
* 原版 readAll() 递归只在 es.length === 0 时退出。Android 12 PRIVATE_WWW 根
* 上 readEntries 永不返回空数组(也不返回错误),导致 readAll() 死循环,
* _statDir / getSandboxTotalSize 永远 hang。
* 三重防护:
* 1) 单次 readEntries 2s 兜底不响应就当失败resolve(已收集部分)
* 2) readAll() 调用次数上限 100防御 readEntries 持续返回非空)
* 3) 单次 readEntries batch 超过 10000 项也直接退出(防御畸形实现)
*/
async function _listDir(dirEntry) {
// #ifdef APP-PLUS
return new Promise((resolve) => {
let timer = null
let resolved = false
const finish = (reason) => {
if (resolved) return
resolved = true
if (timer) clearTimeout(timer)
resolve(reason)
}
let reader
try {
reader = dirEntry.createReader()
} catch (e) {
console.warn('[ioPath._listDir] createReader failed:', e?.message)
return finish([])
}
const collected = []
let readAttempts = 0
// ★ 2026-08-03 task #16MAX_READ_ATTEMPTS 100→15
// 用户 Android 12 设备 readEntries 反复返回 200 entries重复同一些
// 100 次 × ~50ms/次 = 5s每次 _listDir 都撞 4s outer 兜底。
// 15 次足够覆盖真实情况(单次 readEntries 最多返回 ~100 条,递归查空目录 2-3 次就该停了);
// 15 × 50ms = 750ms远低于 4s outer 兜底
const MAX_READ_ATTEMPTS = 15
const MAX_BATCH_SIZE = 10000
const readAll = () => {
if (readAttempts >= MAX_READ_ATTEMPTS) {
// console.warn(`[ioPath._listDir] hit MAX_READ_ATTEMPTS=${MAX_READ_ATTEMPTS}, returning partial (${collected.length} entries)`)
return finish(collected)
}
readAttempts++
// ★ 2026-08-03 task #16单次 readEntries timeout 2s→500ms
// 真实 readEntries 回调通常 < 100ms 返回500ms 兜底足够抓 hang
let cbFired = false
timer = setTimeout(() => {
if (!cbFired) {
cbFired = true
console.warn(
`[ioPath._listDir] readEntries timeout 500ms after ${readAttempts} attempts, returning partial (${collected.length} entries)`
)
finish(collected)
}
}, 500)
try {
reader.readEntries(
(es) => {
if (cbFired) return // 已被 timeout 关闭
cbFired = true
clearTimeout(timer)
timer = null
if (!es || es.length === 0) return finish(collected)
if (collected.length + es.length > MAX_BATCH_SIZE) {
console.warn(
`[ioPath._listDir] hit MAX_BATCH_SIZE=${MAX_BATCH_SIZE}, truncating`
)
const room = MAX_BATCH_SIZE - collected.length
if (room > 0) collected.push(...es.slice(0, room))
return finish(collected)
}
collected.push(...es)
readAll()
},
(err) => {
if (cbFired) return
cbFired = true
clearTimeout(timer)
timer = null
console.warn('[ioPath._listDir] readEntries error:', err?.message)
finish(collected) // 返回部分而非抛错
}
)
} catch (e) {
if (!cbFired) {
cbFired = true
clearTimeout(timer)
console.warn('[ioPath._listDir] readEntries threw:', e?.message)
finish(collected)
}
}
}
readAll()
})
// #endif
// #ifndef APP-PLUS
return []
// #endif
}
/**
* 获取设备级存储信息Native.jsAndroid 走 StatFsiOS 走 NSFileManager
* 不需要任何 native plugin非 APP-PLUS 平台或失败时返回安全零值
* 注:与项目已有 useShare.js 的 Native.js 模式 1:1 对齐
* @returns {Promise<{totalBytes:number, freeBytes:number}>}
*/
export function getDeviceStorageInfo() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
try {
if (typeof plus === 'undefined') {
return resolve({
totalBytes: 0,
freeBytes: 0
})
}
if (plus.os.name === 'Android') {
// Android用 StatFs 查 /data 分区(即系统"内部存储"
// 不需要任何权限Android 10+ 分区存储不影响 StatFsStatFs 查的是卷级元数据)
const Environment = plus.android.importClass('android.os.Environment')
const StatFs = plus.android.importClass('android.os.StatFs')
const dataDir = Environment.getDataDirectory()
// 走显式 plus.android.invoke 而不是 dataDir.getPath()
// 某些 HBuilderX 版本里 proxy 对象的方法返回值不会被自动 unwrap
// 直接传 proxy 给 new StatFs() 会抛 IllegalArgumentException
// (与同段下面 *Long 变量代理不稳定的处理风格保持一致)
const dataDirPath = plus.android.invoke(dataDir, 'getPath')
const stat = new StatFs(dataDirPath)
// getBlockSizeLong 是 API 18+project minSdkVersion=21 保证可用
// plus.android 对 *Long 变体代理有时不稳定 → parseFloat 兜底
const blockSize = parseFloat(plus.android.invoke(stat, 'getBlockSizeLong'))
const totalBlocks = parseFloat(plus.android.invoke(stat, 'getBlockCountLong'))
const freeBlocks = parseFloat(plus.android.invoke(stat, 'getAvailableBlocksLong'))
if (blockSize > 0 && totalBlocks > 0) {
resolve({
totalBytes: blockSize * totalBlocks,
freeBytes: blockSize * freeBlocks,
})
} else {
console.warn('[ioPath] getDeviceStorageInfo (Android) got non-positive values')
resolve({
totalBytes: 0,
freeBytes: 0
})
}
} else if (plus.os.name === 'iOS') {
// iOS用 NSFileManager.attributesOfFileSystem 查系统卷
// NSFileSystemSize / NSFileSystemFreeSize 是 public API参照 useShare.js 调用风格)
// 关键fm / attrs 都是 NSObject proxy必须 deleteObject 否则每次调用泄漏 1 个 proxy。
// 原版只在 success/attrs=null 分支主动清invoke 自身抛异常时走外层 catch 会漏掉 fm。
// 改为内层 try/finally 保证 fm 必清attrs 也在 finally 兜底。
const fm = plus.ios.invoke('NSFileManager', 'defaultManager')
if (!fm) {
console.warn('[ioPath] getDeviceStorageInfo (iOS) fm is null')
return resolve({
totalBytes: 0,
freeBytes: 0
})
}
let attrs = null
try {
attrs = plus.ios.invoke(fm, 'attributesOfFileSystemForPath:error:', '/')
if (attrs) {
const total = parseFloat(attrs.plusGetAttribute('NSFileSystemSize')) || 0
const free = parseFloat(attrs.plusGetAttribute('NSFileSystemFreeSize')) || 0
resolve({
totalBytes: total,
freeBytes: free
})
} else {
console.warn('[ioPath] getDeviceStorageInfo (iOS) attrs is null')
resolve({
totalBytes: 0,
freeBytes: 0
})
}
} finally {
// 顺序:先 attrs 再 fm参照 useShare.js 清理模式)
if (attrs) {
try {
plus.ios.deleteObject(attrs)
} catch (e) {
/* swallow */
}
}
try {
plus.ios.deleteObject(fm)
} catch (e) {
/* swallow */
}
}
} else {
// Harmony / 其它平台:暂不支持,返回 0
resolve({
totalBytes: 0,
freeBytes: 0
})
}
} catch (e) {
console.warn('[ioPath] getDeviceStorageInfo failed:', e?.message)
resolve({
totalBytes: 0,
freeBytes: 0
})
}
// #endif
// #ifndef APP-PLUS
resolve({
totalBytes: 0,
freeBytes: 0
})
// #endif
})
}