topfans/frontend/utils/ioPath.js
2026-07-13 14:40:34 +08:00

332 lines
11 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
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
}