2074 lines
67 KiB
JavaScript
2074 lines
67 KiB
JavaScript
// 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 26–28 走两参;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 是 int,proxy 上读 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'
|
||
}
|
||
}
|
||
|
||
// UserHandle:API 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 → 目录下每个子目录是一个 uid,handler 按 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 双扫描:
|
||
* - 列表页 computeSize:1 次 walk 取 total
|
||
* - 详情页 computeBreakdown:1 次 walk 同时取 byUid + global(global 由 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 调用数 = 1(doc 根)+ 1(uniapp_save/)+ 1(share/)= 3 次
|
||
// vs 之前最多 135 次 → 45 倍减少
|
||
// ★ task #28:移除 AVG_FILE_BYTES_BY_KIND 估算常量。所有字节数都用 entry.file() 拿真实值。
|
||
|
||
// 补充 A:4 类全空 → 短路返回 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 次 _listDir:uniapp_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
|
||
|
||
// 收集唯一文件 entries(dedup by name)— task #33:过滤 phantom
|
||
// 真实文件 fullPath 应该以 "_doc/" 开头(uniapp 全路径前缀)
|
||
// phantom entries 的 fullPath 通常是 undefined/空/异常
|
||
const uniqueFiles = new Map() // name → entry
|
||
const subDirs = []
|
||
// task #35:subDirs 也 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 拿真实 size(task #29:toURL → 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 层 subfolder(canvas/, 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-03:uid 扫描限流到前 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 #37:dedup by name + 跳过 phantom (fullPath 异常)
|
||
const seenNames = new Set()
|
||
for (const entry of entries) {
|
||
const kind = _residualKind(entry)
|
||
// 跳过 dir-uid:share/ 由具体 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 异步读 InputStream(plus.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=true,bytes = 前 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 entries,4s outer 兜底被打爆
|
||
// ★ 终极方案:放弃递归,只扫顶层 entries。residual 所有目标目录(uniapp_save/、
|
||
// uniapp_temp_<ts>、preload/、share/<uid>/)文件都是顶层扁平结构,不需要递归
|
||
// ★ share/ 特殊:本身是父目录,下面 uid 子目录也要扫(仅一层)
|
||
//
|
||
// 估算字节:图片平均 30-80KB(avatar 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 拿真实 size(task #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
|
||
}
|
||
|
||
/**
|
||
* 列出目录所有 entry(DirectoryEntry / 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 #16:MAX_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.js:Android 走 StatFs,iOS 走 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+ 分区存储不影响 StatFs(StatFs 查的是卷级元数据)
|
||
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
|
||
})
|
||
} |