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