topfans/frontend/utils/ioPath.js

560 lines
18 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
const cleanedDirs = []
for (const top of topEntries) {
if (!top.isDirectory) continue
if (PROTECTED_SUBDIRS.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. 整个 tmp/ 目录递归删除(下次上传 getSandboxFileUri 会自动重建)
// eslint-disable-next-line no-await-in-loop
const ok = await new Promise((resolve) => {
tmpEntry.removeRecursively(
() => resolve(true),
() => resolve(false)
)
})
if (ok) {
deleted++
cleanedDirs.push(`${top.name}/tmp`)
}
}
console.log('[ioPath] clearAllSandboxTmpFiles:', { scanned, deleted, dirs: cleanedDirs })
return { scanned, deleted, dirs: cleanedDirs }
} catch (e) {
console.warn('[ioPath] clearAllSandboxTmpFiles failed:', e?.message)
return { scanned: 0, deleted: 0, dirs: [], error: e?.message }
}
// #endif
// #ifndef APP-PLUS
return Promise.resolve({ scanned: 0, deleted: 0, dirs: [] })
// #endif
}
/**
* 通过 plus.android 读取 APK 安装包大小Android 专用,绕开 sandbox 限制)
* 在 iOS / H5 / 小程序上返回 0调用方应改用 sandbox 大小)
* @returns {Promise<number>} APK 字节数
*/
async function getAndroidApkSize() {
// #ifdef APP-PLUS
try {
// 仅 Android
if (typeof plus === 'undefined' || !plus.android) return 0
const main = plus.android.runtimeMainActivity()
if (!main) return 0
// 走 Context.getApplicationInfo()App.vue#setPermissions 验证过可工作)
// 不走 PackageManager.getApplicationInfo(pkg, 0):
// plus.android proxy 对 PackageManager 的 getApplicationInfo 重载方法暴露不稳定,
// 会报 "pm.getApplicationInfo is not a function"。
// Context.getApplicationInfo() 返回当前应用的 ApplicationInfo,正是我们想要的。
const appInfo = main.getApplicationInfo()
if (!appInfo) return 0
const sourceDir = plus.android.invoke(appInfo, 'getSourceDir') || appInfo.plusGetAttribute?.('sourceDir')
if (!sourceDir) return 0
const File = plus.android.importClass('java.io.File')
const f = new File(sourceDir)
const size = f.length()
if (typeof size === 'number' && size > 0) return size
return 0
} catch (e) {
console.warn('[ioPath] getAndroidApkSize failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 计算"app 已用空间" = Android APK 安装包大小 + sandbox 运行时数据大小
* 在 iOS / H5 上只算 sandboxiOS bundle 不易从 js 取大小,按 sandbox 估算)
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
*/
export async function getSandboxTotalSize() {
// #ifdef APP-PLUS
try {
// Android: APK 大小 + sandbox doc 目录运行时数据
const apkSize = await getAndroidApkSize()
let sandboxBytes = 0
try {
const root = await getSandboxRootDir()
sandboxBytes = await _sumDirSize(root)
} catch (e) {
console.warn('[ioPath] _sumDirSize failed:', e?.message)
}
return apkSize + sandboxBytes
} catch (e) {
console.warn('[ioPath] getSandboxTotalSize failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 扫描 sandbox doc 下"所有业务子目录的 tmp/ 文件总字节数"(不包含白名单 preload/share/image
* 用于 sandboxTmpHandler.computeSize
*
* 与 getSandboxTotalSize 的区别:
* - getSandboxTotalSize: 沙盒 doc 下所有文件(含 preload/share/image 白名单)
* - scanSandboxTmpFiles: 只统计 tmp/ 子目录(用于清理项;与 clearAllSandboxTmpFiles 一致)
*/
export async function scanSandboxTmpFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const topEntries = await _listDir(root)
let total = 0
for (const top of topEntries) {
if (!top.isDirectory) continue
if (PROTECTED_SUBDIRS.has(top.name)) continue
const tmpEntry = await new Promise((resolve) => {
top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null))
})
if (!tmpEntry) continue
total += await _sumDirSize(tmpEntry)
}
return total
} catch (e) {
console.warn('[ioPath] scanSandboxTmpFiles failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 计数 sandbox doc 下 tmp/ 子目录的文件总数reviewer P0修正导出供 sandboxTmpHandler 使用)
* 与 scanSandboxTmpFiles 范围一致(仅 tmp/,不含白名单 preload/share/image
*/
export async function countSandboxTmpFiles() {
// #ifdef APP-PLUS
try {
const root = await getSandboxRootDir()
const topEntries = await _listDir(root)
let count = 0
const walk = async (dir) => {
const entries = await _listDir(dir)
for (const e of entries) {
if (e.isFile) count++
else if (e.isDirectory) await walk(e)
}
}
for (const top of topEntries) {
if (!top.isDirectory) continue
if (PROTECTED_SUBDIRS.has(top.name)) continue
const tmpEntry = await new Promise((resolve) => {
top.getDirectory('tmp', { create: false }, (entry) => resolve(entry), () => resolve(null))
})
if (!tmpEntry) continue
await walk(tmpEntry)
}
return count
} catch (e) {
console.warn('[ioPath] countSandboxTmpFiles failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 递归求和目录下所有文件大小bytes
*/
async function _sumDirSize(dirEntry) {
// #ifdef APP-PLUS
let total = 0
let entries
try {
entries = await _listDir(dirEntry)
} catch (e) {
// 单个目录读失败 → 跳过整个目录(不影响兄弟目录)
console.warn('[ioPath] _sumDirSize read failed for a dir, skipping:', e?.message)
return total
}
for (const entry of entries) {
if (entry.isDirectory) {
total += await _sumDirSize(entry)
} else if (entry.isFile) {
try {
const file = await new Promise((res, rej) => entry.file(res, rej))
total += (file.size || 0)
} catch (e) { /* skip individual file */ }
}
}
return total
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
/**
* 列出目录所有 entryDirectoryEntry / FileEntry
*/
async function _listDir(dirEntry) {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
const reader = dirEntry.createReader()
const collected = []
const readAll = () => {
reader.readEntries(
(es) => {
if (es.length === 0) return resolve(collected)
collected.push(...es)
readAll()
},
(err) => reject(err)
)
}
readAll()
})
// #endif
// #ifndef APP-PLUS
return []
// #endif
}
/**
* 获取设备级存储信息HTML5+ 标准 APIAndroid/iOS 均支持)
* 不需要任何 native plugin非 APP-PLUS 平台或失败时返回安全零值
* 注意:单位为 KBplus.io 原始返回),调用方按需 * 1024 转字节
* @returns {Promise<{totalBytes:number, freeBytes:number}>}
*/
export function getDeviceStorageInfo() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
try {
plus.io.getStorageInfo({
success: (res) => {
// 调试日志:打印 plus.io.getStorageInfo 的真实返回 shape
console.log('[ioPath][debug] plus.io.getStorageInfo res =', JSON.stringify(res), 'keys =', res ? Object.keys(res) : null)
resolve({
totalBytes: Number(res?.totalSize) || 0,
freeBytes: Number(res?.availableSize) || 0,
})
},
fail: (e) => {
console.warn('[ioPath] getStorageInfo failed:', e?.message)
resolve({ totalBytes: 0, freeBytes: 0 })
},
})
} catch (e) {
console.warn('[ioPath] getStorageInfo threw:', e?.message)
resolve({ totalBytes: 0, freeBytes: 0 })
}
// #endif
// #ifndef APP-PLUS
resolve({ totalBytes: 0, freeBytes: 0 })
// #endif
})
}