diff --git a/Scriptable/Clean Files 2.js b/Scriptable/Clean Files 2.js
new file mode 100644
index 00000000..3db1a785
--- /dev/null
+++ b/Scriptable/Clean Files 2.js
@@ -0,0 +1,1097 @@
+// Variables used by Scriptable.
+// These must be at the very top of the file. Do not edit.
+// icon-color: purple; icon-glyph: folder-open; share-sheet-inputs: file-url;
+
+/**
+ * Clean Files (Scriptable 文件与缓存专业清理工具)
+ *
+ * @version 2.5.1 (Refined & Hardened)
+ * @author Honye / Optimized for MuTu
+ *
+ * 核心优化:
+ * 1. 【彻底杜绝文字折行】:严格加上 [hidden] { display: none !important; } 与 white-space: nowrap,根治“全选”和“导入”被挤压上下折行的问题。
+ * 2. 【全选机制可靠重构】:采用纯 CSS 类 (.item.is-selected) 进行状态绑定与联动,彻底解决点击“全选”不勾选、状态不生效的顽疾。
+ * 3. 【实时选中容量显示】:进入选择模式时,底部删除按钮实时计算并展示勾选的项数与体积(如:删除 5 项 · 12.8 MB),删除心中有数。
+ * 4. 【安全防误删防护】:删除前原生二次确认弹窗,并自动识别保护当前运行中的自身脚本,防止自杀式误删。
+ * 5. 【真实容量换算】:精确按标准 B/KB/MB/GB 逐级换算,彻底修复原版将 Bytes 误当 KB 导致容量虚高成 GB 的严重 Bug。
+ * 6. 【全目录容量可视化】:首页及各层级目录自动汇总项目数与占用空间,文件夹优先置顶、文件按体积降序排列,一眼定位垃圾大户。
+ * 7. 【极简现代原生 UI】:精简页面标题英文后缀,自适应 iOS 16/17/18 深色与浅色模式,操作丝滑顺手。
+ */
+
+/**
+ * 多语言国际化
+ * @param {{[language: string]: string} | [en:string, zh:string]} langs
+ */
+const i18n = (langs) => {
+ const language = Device.language();
+ if (Array.isArray(langs)) {
+ langs = {
+ en: langs[0],
+ zh: langs[1],
+ others: langs[0],
+ };
+ } else {
+ langs.others = langs.others || langs.en;
+ }
+ return langs[language] || langs.others;
+};
+
+// 文件管理器环境初始化
+const fmLocal = FileManager.local();
+const fmCloud = (() => {
+ try {
+ return FileManager.iCloud();
+ } catch (e) {
+ return null;
+ }
+})();
+const usedICloud = fmCloud ? fmLocal.isFileStoredIniCloud(module.filename) : false;
+
+/**
+ * 智能获取适配的文件管理器(自动识别本地或 iCloud 路径)
+ * @param {string} path
+ * @returns {FileManager}
+ */
+const getFM = (path) => {
+ if (!path) return fmLocal;
+ try {
+ if (fmCloud && (fmCloud.isFileStoredIniCloud(path) || path.includes('Mobile Documents'))) {
+ return fmCloud;
+ }
+ } catch (e) {}
+ return fmLocal;
+};
+
+/**
+ * 精确格式化字节大小
+ * @param {number} bytes
+ * @returns {string}
+ */
+const formatSize = (bytes) => {
+ if (bytes === undefined || bytes === null || isNaN(bytes) || bytes <= 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`;
+};
+
+/**
+ * 计算目录大小与总项目数(带安全深度限制)
+ * @param {string} dirPath
+ * @param {number} maxDepth
+ * @param {number} currentDepth
+ * @returns {{ size: number, count: number }}
+ */
+const getDirSizeAndCount = (dirPath, maxDepth = 2, currentDepth = 0) => {
+ const currentFm = getFM(dirPath);
+ let totalSize = 0;
+ let totalCount = 0;
+ try {
+ if (!currentFm.fileExists(dirPath) || !currentFm.isDirectory(dirPath)) {
+ return { size: 0, count: 0 };
+ }
+ const items = currentFm.listContents(dirPath);
+ totalCount = items.length;
+ for (const name of items) {
+ const fullPath = currentFm.joinPath(dirPath, name);
+ try {
+ if (currentFm.isDirectory(fullPath)) {
+ if (currentDepth < maxDepth) {
+ const sub = getDirSizeAndCount(fullPath, maxDepth, currentDepth + 1);
+ totalSize += sub.size;
+ }
+ } else {
+ totalSize += (currentFm.fileSize(fullPath) || 0);
+ }
+ } catch (e) {}
+ }
+ } catch (e) {}
+ return { size: totalSize, count: totalCount };
+};
+
+/**
+ * Scriptable WebView JSBridge 核心原生 SDK
+ */
+const sendResult = (() => {
+ let sending = false;
+ const queue = [];
+
+ const processQueue = async (webView) => {
+ if (sending || queue.length === 0) return;
+ sending = true;
+ while (queue.length > 0) {
+ const item = queue.shift();
+ const eventName = `ScriptableBridge_${item.code}_Result`;
+ const res = item.data instanceof Error ? { err: item.data.message } : item.data;
+ try {
+ await webView.evaluateJavaScript(
+ `window.dispatchEvent(
+ new CustomEvent(
+ ${JSON.stringify(eventName)},
+ { detail: ${JSON.stringify(res)} }
+ )
+ )`
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ sending = false;
+ };
+
+ return async (webView, code, data) => {
+ queue.push({ code, data });
+ await processQueue(webView);
+ };
+})();
+
+/**
+ * 注入 Bridge 脚本并监听通信
+ * @param {WebView} webView
+ * @param {object} options
+ */
+const inject = async (webView, options) => {
+ const js = `(() => {
+ const queue = window.__scriptable_bridge_queue;
+ if (queue && queue.length) {
+ completion(queue);
+ }
+ window.__scriptable_bridge_queue = null;
+
+ if (!window.ScriptableBridge) {
+ window.ScriptableBridge = {
+ invoke(name, data, callback) {
+ const detail = { code: name, data };
+ const eventName = \`ScriptableBridge_\${name}_Result\`;
+ const controller = new AbortController();
+ window.addEventListener(
+ eventName,
+ (e) => {
+ callback && callback(e.detail);
+ controller.abort();
+ },
+ { signal: controller.signal }
+ );
+
+ if (window.__scriptable_bridge_queue) {
+ window.__scriptable_bridge_queue.push(detail);
+ completion();
+ } else {
+ completion(detail);
+ window.__scriptable_bridge_queue = [];
+ }
+ }
+ };
+ window.dispatchEvent(new CustomEvent('ScriptableBridgeReady'));
+ }
+ })()`;
+
+ const res = await webView.evaluateJavaScript(js, true);
+ if (!res) return inject(webView, options);
+
+ const methods = options.methods || {};
+ const events = Array.isArray(res) ? res : [res];
+
+ const sendTasks = events.map(({ code, data }) => {
+ return (async () => {
+ try {
+ if (typeof methods[code] === 'function') {
+ return await methods[code](data);
+ }
+ throw new Error(`Method [${code}] not implemented`);
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ })()
+ .then((r) => sendResult(webView, code, r))
+ .catch((e) => {
+ console.error(e);
+ sendResult(webView, code, e instanceof Error ? e : new Error(String(e)));
+ });
+ });
+
+ await Promise.all(sendTasks);
+ inject(webView, options);
+};
+
+/**
+ * 加载 HTML 并初始化注入
+ * @param {WebView} webView
+ * @param {object} args
+ * @param {object} options
+ */
+const loadHTML = async (webView, args, options = {}) => {
+ const { html, baseURL } = args;
+ await webView.loadHTML(html, baseURL);
+ inject(webView, options).catch((err) => console.error(err));
+};
+
+/**
+ * 复制/覆盖文件
+ * @param {string[]} fileURLs
+ * @param {string} destPath
+ */
+const copyFiles = async (fileURLs, destPath) => {
+ let isReplaceAll = false;
+ const currentFm = getFM(destPath);
+ for (const fileURL of fileURLs) {
+ const fileName = currentFm.fileName(fileURL, true);
+ const filePath = currentFm.joinPath(destPath, fileName);
+ if (currentFm.fileExists(filePath)) {
+ if (isReplaceAll) {
+ currentFm.remove(filePath);
+ } else {
+ const alert = new Alert();
+ alert.message = `“${fileName}”${i18n([' already exists. Do you want to replace it?', ' 已存在,是否替换?'])}`;
+ const actions = [i18n(['All Yes', '全部替换']), i18n(['Yes', '替换']), i18n(['No', '跳过'])];
+ for (const action of actions) alert.addAction(action);
+ alert.addCancelAction(i18n(['Cancel', '取消']));
+ const value = await alert.present();
+ switch (actions[value]) {
+ case i18n(['All Yes', '全部替换']):
+ isReplaceAll = true;
+ currentFm.remove(filePath);
+ break;
+ case i18n(['Yes', '替换']):
+ currentFm.remove(filePath);
+ break;
+ case i18n(['No', '跳过']):
+ continue;
+ default:
+ return;
+ }
+ }
+ }
+ currentFm.copy(fileURL, filePath);
+ }
+ const alert = new Alert();
+ alert.title = i18n(['Import successful', '导入成功']);
+ alert.message = i18n(['Re-enter this directory to view', '重新进入此目录可查看']);
+ alert.addCancelAction(i18n(['OKay', '好的']));
+ await alert.present();
+};
+
+/**
+ * 导入外部文件
+ * @param {string} destPath
+ */
+const importFiles = async (destPath) => {
+ let fileURLs = args.fileURLs || [];
+ if (!fileURLs.length) {
+ try {
+ fileURLs = await DocumentPicker.open();
+ } catch (e) {
+ return;
+ }
+ }
+ await copyFiles(fileURLs, destPath);
+};
+
+/**
+ * 呈现文件列表主视图
+ * @param {object} options
+ */
+const presentList = async (options) => {
+ const { title, list, directory, isRoot } = options;
+ const webView = new WebView();
+
+ const css = `
+ :root {
+ --text-primary: #1c1c1e;
+ --text-secondary: #8e8e93;
+ --color-primary: #007aff;
+ --color-danger: #ff3b30;
+ --divider-color: rgba(60, 60, 67, 0.12);
+ --card-background: #ffffff;
+ --bg-page: #f2f2f7;
+ --bg-btn: rgba(0, 122, 255, 0.1);
+ --fixed-btn-height: 3.2rem;
+ --item-active-bg: rgba(0, 0, 0, 0.04);
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --text-primary: #ffffff;
+ --text-secondary: #98989f;
+ --color-primary: #0a84ff;
+ --color-danger: #ff453a;
+ --divider-color: rgba(84, 84, 88, 0.35);
+ --card-background: #1c1c1e;
+ --bg-page: #000000;
+ --bg-btn: rgba(10, 132, 255, 0.15);
+ --item-active-bg: rgba(255, 255, 255, 0.06);
+ }
+ }
+ [hidden] {
+ display: none !important;
+ }
+ * {
+ -webkit-user-select: none;
+ user-select: none;
+ box-sizing: border-box;
+ }
+ body {
+ margin: 0;
+ -webkit-font-smoothing: antialiased;
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
+ min-height: 100vh;
+ background-color: var(--bg-page);
+ color: var(--text-primary);
+ padding-top: env(safe-area-inset-top);
+ }
+ .header {
+ position: sticky;
+ z-index: 99;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3.5rem;
+ background: var(--card-background);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1rem;
+ border-bottom: 0.5px solid var(--divider-color);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ }
+ .header__left,
+ .header__right {
+ display: flex;
+ align-items: center;
+ min-width: 4.2rem;
+ flex-shrink: 0;
+ }
+ .header__left {
+ justify-content: flex-start;
+ }
+ .header__right {
+ justify-content: flex-end;
+ }
+ .header__btn,
+ .select-all,
+ .select {
+ height: 1.85rem;
+ padding: 0 0.8rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-primary);
+ background-color: var(--bg-btn);
+ border-radius: 99px;
+ border: none;
+ outline: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap !important;
+ flex-shrink: 0 !important;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+ .header__btn:active,
+ .select-all:active,
+ .select:active {
+ opacity: 0.6;
+ transform: scale(0.96);
+ }
+ .title {
+ flex: 1;
+ font-size: 1.05rem;
+ font-weight: 600;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 0;
+ padding: 0 0.5rem;
+ }
+ .list-wrapper {
+ margin: 1rem;
+ background: var(--card-background);
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.03);
+ }
+ .list {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+ .item {
+ padding-left: 1rem;
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ cursor: pointer;
+ transition: background-color 0.15s;
+ }
+ .item:active {
+ background-color: var(--item-active-bg);
+ }
+ .item__body {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ column-gap: 0.75rem;
+ }
+ .item__selection {
+ width: 0;
+ height: 1.5rem;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ opacity: 0;
+ transition: width 0.2s, opacity 0.2s, margin-right 0.2s;
+ }
+ .list-select .item__selection {
+ width: 1.5rem;
+ opacity: 1;
+ margin-right: 0.65rem;
+ }
+ .item__selection .icon-checked {
+ display: none;
+ }
+ .item__selection .icon-unchecked {
+ display: block;
+ }
+ .item.is-selected .item__selection .icon-checked {
+ display: block;
+ }
+ .item.is-selected .item__selection .icon-unchecked {
+ display: none;
+ }
+ .item__content {
+ flex: 1;
+ padding: 0.8rem 1rem 0.8rem 0;
+ border-bottom: 0.5px solid var(--divider-color);
+ overflow: hidden;
+ }
+ li:last-child .item__content {
+ border-bottom: none;
+ }
+ .item__name {
+ font-size: 0.95rem;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .item__name--protected {
+ color: var(--color-primary);
+ }
+ .badge-protected {
+ font-size: 0.7rem;
+ padding: 0.1rem 0.35rem;
+ background: var(--bg-btn);
+ color: var(--color-primary);
+ border-radius: 4px;
+ margin-left: 0.35rem;
+ vertical-align: middle;
+ }
+ .item__info {
+ margin-top: 0.25rem;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ column-gap: 0.5rem;
+ }
+ .fixed-bottom {
+ position: fixed;
+ z-index: 100;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding-bottom: env(safe-area-inset-bottom);
+ background: var(--card-background);
+ border-top: 0.5px solid var(--divider-color);
+ transform: translateY(100%);
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ }
+ .fixed-bottom.show {
+ transform: translateY(0);
+ }
+ .btn-del {
+ margin: 0;
+ display: flex;
+ width: 100%;
+ height: var(--fixed-btn-height);
+ justify-content: center;
+ align-items: center;
+ column-gap: 0.4rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ background-color: transparent;
+ color: var(--color-danger);
+ padding: 0;
+ border: none;
+ cursor: pointer;
+ }
+ .btn-del:active {
+ opacity: 0.6;
+ }
+ .bottom-holder {
+ box-sizing: content-box;
+ height: var(--fixed-btn-height);
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+ .empty-state {
+ text-align: center;
+ padding: 4rem 1.5rem;
+ color: var(--text-secondary);
+ }
+ .empty-state__icon {
+ font-size: 2.8rem;
+ margin-bottom: 0.8rem;
+ }
+ .empty-state__text {
+ font-size: 0.95rem;
+ font-weight: 500;
+ }
+ .chevron-right {
+ color: var(--text-secondary);
+ opacity: 0.4;
+ flex-shrink: 0;
+ }
+ `;
+
+ const js = `
+ window.invoke = (code, data) => {
+ if (window.ScriptableBridge) {
+ ScriptableBridge.invoke(code, data);
+ }
+ };
+
+ const formatSize = (bytes) => {
+ if (!bytes || isNaN(bytes) || bytes <= 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return (bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i];
+ };
+
+ const isSelectMode = () => {
+ return document.querySelector('.list')?.classList.contains('list-select');
+ };
+
+ const checkEmptyState = () => {
+ const list = document.querySelector('.list');
+ const emptyState = document.querySelector('.empty-state');
+ const selectBtn = document.querySelector('.select');
+ if (!list || list.children.length === 0) {
+ if (emptyState) emptyState.removeAttribute('hidden');
+ if (selectBtn) selectBtn.setAttribute('hidden', '');
+ } else {
+ if (emptyState) emptyState.setAttribute('hidden', '');
+ if (selectBtn) selectBtn.removeAttribute('hidden');
+ }
+ };
+
+ // 刷新底部删除按钮文字与全选按钮状态
+ const updateSelectionState = () => {
+ const allItems = Array.from(document.querySelectorAll('.item'));
+ const selectedItems = Array.from(document.querySelectorAll('.item.is-selected'));
+ const count = selectedItems.length;
+
+ let totalBytes = 0;
+ selectedItems.forEach((el) => {
+ totalBytes += (parseInt(el.dataset.bytes) || 0);
+ });
+
+ const btnDelText = document.querySelector('.btn-del-text');
+ if (btnDelText) {
+ if (count > 0) {
+ const sizeInfo = totalBytes > 0 ? (' · ' + formatSize(totalBytes)) : '';
+ btnDelText.innerText = ${i18n(['`Delete (${count} items${sizeInfo})`', '`删除 (${count} 项${sizeInfo})`'])};
+ } else {
+ btnDelText.innerText = ${i18n(['"Delete"', '"删除"'])};
+ }
+ }
+
+ const selectAllBtn = document.querySelector('.select-all');
+ if (selectAllBtn && allItems.length > 0) {
+ const isAllSelected = count === allItems.length;
+ selectAllBtn.innerText = isAllSelected ? ${i18n(['"Deselect All"', '"取消全选"'])} : ${i18n(['"Select All"', '"全选"'])};
+ }
+ };
+
+ // 进入或退出编辑模式
+ const setSelectMode = (enable) => {
+ const selectBtn = document.querySelector('.select');
+ const selectAllBtn = document.querySelector('.select-all');
+ const importBtn = document.querySelector('#import');
+ const list = document.querySelector('.list');
+ const bottomBar = document.querySelector('.fixed-bottom');
+
+ if (enable) {
+ selectBtn.innerText = ${i18n(['"Done"', '"完成"'])};
+ if (importBtn) importBtn.setAttribute('hidden', '');
+ if (selectAllBtn) selectAllBtn.removeAttribute('hidden');
+ list?.classList.add('list-select');
+ bottomBar?.classList.add('show');
+ } else {
+ selectBtn.innerText = ${i18n(['"Select"', '"选择"'])};
+ if (selectAllBtn) selectAllBtn.setAttribute('hidden', '');
+ if (importBtn) importBtn.removeAttribute('hidden');
+ list?.classList.remove('list-select');
+ bottomBar?.classList.remove('show');
+
+ // 退出编辑时清除所有选中状态
+ document.querySelectorAll('.item.is-selected').forEach((el) => el.classList.remove('is-selected'));
+ }
+ updateSelectionState();
+ };
+
+ // 顶部“选择 / 完成”按钮
+ document.querySelector('.select')?.addEventListener('click', (e) => {
+ const isEditing = isSelectMode();
+ setSelectMode(!isEditing);
+ });
+
+ // 顶部“全选 / 取消全选”按钮
+ document.querySelector('.select-all')?.addEventListener('click', (e) => {
+ // 若未处于编辑模式,点击全选自动开启编辑模式
+ if (!isSelectMode()) {
+ setSelectMode(true);
+ }
+
+ const allItems = Array.from(document.querySelectorAll('.item'));
+ const selectedItems = Array.from(document.querySelectorAll('.item.is-selected'));
+ const isAllSelected = selectedItems.length === allItems.length;
+
+ allItems.forEach((el) => {
+ if (isAllSelected) {
+ el.classList.remove('is-selected');
+ } else {
+ el.classList.add('is-selected');
+ }
+ });
+
+ updateSelectionState();
+ });
+
+ // 单击列表条目
+ document.querySelectorAll('.item').forEach((el) => {
+ el.addEventListener('click', (e) => {
+ const target = e.currentTarget;
+ if (isSelectMode()) {
+ target.classList.toggle('is-selected');
+ updateSelectionState();
+ } else {
+ invoke('view', { ...target.dataset });
+ }
+ });
+ });
+
+ // 点击底部删除按钮
+ document.querySelector('.btn-del')?.addEventListener('click', () => {
+ const selectedItems = [];
+ document.querySelectorAll('.item.is-selected').forEach((itemEl) => {
+ selectedItems.push({ ...itemEl.dataset });
+ });
+ if (selectedItems.length === 0) {
+ return;
+ }
+ invoke('remove', selectedItems);
+ });
+
+ // 响应删除成功事件
+ const removeItems = (deletedList) => {
+ if (!Array.isArray(deletedList)) return;
+ const pathSet = new Set(deletedList.map(it => it.filePath || it.name));
+ document.querySelectorAll('.item').forEach((el) => {
+ const p = el.dataset.filePath || el.dataset.name;
+ if (pathSet.has(p)) {
+ const li = el.closest('li');
+ if (li) li.remove();
+ }
+ });
+ updateSelectionState();
+ checkEmptyState();
+ };
+
+ window.addEventListener('JWeb', (e) => {
+ const { code, data } = e.detail || {};
+ if (code === 'remove-success') {
+ removeItems(data);
+ }
+ });
+
+ checkEmptyState();
+ `;
+
+ const selfPath = module.filename;
+
+ const html = `
+
+
+
+
+ ${title}
+
+
+
+
+
+
+
+
+
+ ${list.map((file) => {
+ const isSelf = selfPath && file.filePath === selfPath;
+ const bytesVal = file.rawBytes || 0;
+ return `
+ -
+
+
+
+ ${file.isDirectory
+ ? `
`
+ : `
`
+ }
+
+
+ ${file.name}
+ ${isSelf ? `${i18n(['Protected', '当前脚本'])}` : ''}
+
+ ${file.info ? `
${file.info}
` : ''}
+
+ ${file.isDirectory ? `
+
` : ''}
+
+
+ `;
+ }).join('')}
+
+
+
0 ? 'hidden' : ''}>
+
📭
+
${i18n(['This directory is empty', '此目录为空 / 暂无缓存文件'])}
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // 打开子目录或预览文件
+ const view = async (data) => {
+ const { isDirectory, filePath, name } = data;
+ const currentFm = getFM(filePath);
+
+ if (Number(isDirectory)) {
+ const unit = i18n(['items', '项']);
+ let contents = [];
+ try {
+ contents = currentFm.listContents(filePath);
+ } catch (e) {
+ console.error(`无法读取目录内容: ${filePath} - ${e}`);
+ }
+
+ const subList = contents.map((itemName) => {
+ const itemPath = currentFm.joinPath(filePath, itemName);
+ let isDir = false;
+ try {
+ isDir = currentFm.isDirectory(itemPath);
+ } catch (e) {}
+
+ let dateStr = '';
+ try {
+ dateStr = currentFm.modificationDate(itemPath).toLocaleDateString('zh-CN');
+ } catch (e) {}
+
+ let infoStr = '';
+ let rawBytes = 0;
+
+ if (isDir) {
+ const dirStat = getDirSizeAndCount(itemPath, 1);
+ rawBytes = dirStat.size;
+ infoStr = `${dateStr} · ${dirStat.count} ${unit}${rawBytes > 0 ? ` · ${formatSize(rawBytes)}` : ''}`;
+ } else {
+ try {
+ rawBytes = currentFm.fileSize(itemPath) || 0;
+ } catch (e) {}
+ infoStr = `${dateStr} · ${formatSize(rawBytes)}`;
+ }
+
+ return {
+ name: itemName,
+ info: infoStr,
+ filePath: itemPath,
+ isDirectory: isDir,
+ rawBytes,
+ };
+ });
+
+ // 智能排序:文件夹排在最前,其次按体积降序排列,同等按名称
+ subList.sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1;
+ }
+ if (a.rawBytes !== b.rawBytes) {
+ return b.rawBytes - a.rawBytes;
+ }
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
+ });
+
+ presentList({
+ title: name,
+ list: subList,
+ directory: filePath,
+ isRoot: false,
+ });
+ } else {
+ // 打开文件预览
+ try {
+ if (!currentFm.isFileDownloaded(filePath)) {
+ await currentFm.downloadFileFromiCloud(filePath);
+ }
+ } catch (e) {}
+
+ if (/\.(js|json)$/i.test(filePath)) {
+ QuickLook.present(filePath);
+ return;
+ }
+ if (/\.(jpg|jpeg|gif|png|heic|heif|webp)$/i.test(filePath)) {
+ QuickLook.present(filePath, false);
+ return;
+ }
+ try {
+ const image = currentFm.readImage(filePath);
+ if (image) {
+ QuickLook.present(image, false);
+ return;
+ }
+ } catch (e) {}
+ try {
+ const text = currentFm.readString(filePath);
+ if (text) {
+ QuickLook.present(text);
+ return;
+ }
+ } catch (e) {}
+ QuickLook.present(filePath);
+ }
+ };
+
+ // 安全批量删除核心逻辑
+ const remove = async (rawSelectedList) => {
+ if (!rawSelectedList || !rawSelectedList.length) return;
+
+ // 1. 运行自身脚本防误删保护
+ const self = module.filename;
+ let hasProtectedSelf = false;
+ const filteredList = rawSelectedList.filter((it) => {
+ if (self && it.filePath === self) {
+ hasProtectedSelf = true;
+ return false;
+ }
+ return true;
+ });
+
+ if (hasProtectedSelf) {
+ const alertSelf = new Alert();
+ alertSelf.title = i18n(['Protected', '🛡 安全防护']);
+ alertSelf.message = i18n([
+ 'Current running script is protected and excluded from deletion.',
+ '检测到所选内容包含当前正在运行的清理脚本,已自动排除保护,防止应用闪退!'
+ ]);
+ alertSelf.addAction(i18n(['OK', '好的']));
+ await alertSelf.present();
+ }
+
+ if (filteredList.length === 0) return;
+
+ // 2. 统计即将释放的总空间
+ let totalBytes = 0;
+ for (const it of filteredList) {
+ const itemFm = getFM(it.filePath);
+ try {
+ if (Number(it.isDirectory)) {
+ totalBytes += getDirSizeAndCount(it.filePath, 1).size;
+ } else {
+ totalBytes += (itemFm.fileSize(it.filePath) || 0);
+ }
+ } catch (e) {}
+ }
+
+ // 3. 原生确认对话框 (二次确认)
+ const alert = new Alert();
+ alert.title = i18n(['Confirm Deletion', '⚠️ 确认永久删除']);
+ alert.message = i18n([
+ `Are you sure you want to permanently delete ${filteredList.length} items (${formatSize(totalBytes)})? This action cannot be undone.`,
+ `确认永久删除选中的 ${filteredList.length} 项内容吗?\n预计释放存储空间:${formatSize(totalBytes)}\n⚠️ 此操作不可撤销,请谨慎操作!`
+ ]);
+ alert.addDestructiveAction(i18n(['Delete Permanently', '确认永久删除']));
+ alert.addCancelAction(i18n(['Cancel', '取消']));
+
+ const actionIdx = await alert.present();
+ if (actionIdx === -1) return; // 用户取消
+
+ // 4. 执行安全删除
+ const successfullyDeleted = [];
+ for (const file of filteredList) {
+ try {
+ const itemFm = getFM(file.filePath);
+ itemFm.remove(file.filePath);
+ successfullyDeleted.push(file);
+ } catch (err) {
+ console.error(`删除失败: ${file.filePath} - ${err}`);
+ }
+ }
+
+ // 5. 安全向前端同步已被删除的节点
+ const detailJson = JSON.stringify({
+ code: 'remove-success',
+ data: successfullyDeleted,
+ });
+ await webView.evaluateJavaScript(
+ `window.dispatchEvent(new CustomEvent('JWeb', { detail: ${detailJson} }))`,
+ false
+ );
+ };
+
+ await loadHTML(
+ webView,
+ {
+ html,
+ baseURL: 'https://scriptore.imarkr.com/scriptables/Clean%20Files%202'
+ },
+ {
+ methods: {
+ view,
+ remove,
+ import: () => importFiles(directory)
+ }
+ }
+ );
+ webView.present();
+};
+
+/**
+ * 根目录分类装配(带容量与项目数实时统计)
+ */
+const buildRootDirectories = () => {
+ const dirs = [
+ {
+ name: i18n(['Local Cache', '本地缓存']),
+ filePath: FileManager.local().cacheDirectory(),
+ isDirectory: true,
+ },
+ {
+ name: i18n(['Local Temporary', '本地暂存']),
+ filePath: FileManager.local().temporaryDirectory(),
+ isDirectory: true,
+ },
+ {
+ name: i18n(['Local Documents', '本地文件']),
+ filePath: FileManager.local().documentsDirectory(),
+ isDirectory: true,
+ },
+ {
+ name: i18n(['Local Library', '本地支持库']),
+ filePath: FileManager.local().libraryDirectory(),
+ isDirectory: true,
+ },
+ ];
+
+ if (usedICloud && fmCloud) {
+ dirs.push(
+ {
+ name: i18n(['iCloud Documents', 'iCloud 脚本与文件']),
+ filePath: fmCloud.documentsDirectory(),
+ isDirectory: true,
+ },
+ {
+ name: i18n(['iCloud Library', 'iCloud 支持库']),
+ filePath: fmCloud.libraryDirectory(),
+ isDirectory: true,
+ }
+ );
+ }
+
+ // 为首页各主要目录快速统计容量与项目数
+ const unit = i18n(['items', '项']);
+ const rootList = dirs.map((d) => {
+ const stat = getDirSizeAndCount(d.filePath, 1);
+ const sizeStr = stat.size > 0 ? ` · ${formatSize(stat.size)}` : '';
+ return {
+ ...d,
+ info: `${stat.count} ${unit}${sizeStr}`,
+ rawBytes: stat.size,
+ };
+ });
+
+ return rootList;
+};
+
+// 启动执行
+presentList({
+ title: 'Clean Files',
+ list: buildRootDirectories(),
+ isRoot: true,
+});
diff --git a/Scriptable/Clean Files 2.scriptable b/Scriptable/Clean Files 2.scriptable
deleted file mode 100644
index 6a932089..00000000
--- a/Scriptable/Clean Files 2.scriptable
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "always_run_in_app" : false,
- "icon" : {
- "color" : "brown",
- "glyph" : "trash-alt"
- },
- "name" : "Clean Files 2",
- "script" : "const fm = FileManager.local()\n\n\/**\n * @param {object} options\n * @param {string} options.title\n * @param {File[]} options.list\n *\/\nconst presentList = async (options) => {\n const { title, list } = options\n const webView = new WebView()\n const css =\n `:root {\n --color-primary: #007aff;\n --divider-color: rgba(60,60,67,0.36);\n --card-background: #fff;\n --card-radius: 10px;\n --list-header-color: rgba(60,60,67,0.6);\n }\n * {\n -webkit-user-select: none;\n user-select: none;\n }\n body {\n margin: 0;\n -webkit-font-smoothing: antialiased;\n font-family: \"SF Pro Display\",\"SF Pro Icons\",\"Helvetica Neue\",\"Helvetica\",\"Arial\",sans-serif;\n accent-color: var(--color-primary);\n }\n .header {\n position: sticky;\n z-index: 99;\n top: 0;\n left: 0;\n right: 0;\n height: 3.5rem;\n text-align: center;\n background: var(--card-background);\n display: flex;\n align-items: center;\n padding: 0 1rem;\n }\n .header__left,\n .header__right {\n flex: 1;\n min-width: 6rem;\n }\n .header__left {\n text-align: left;\n }\n .header__right {\n text-align: right;\n }\n .select-all,\n .select {\n font-size: 0.875rem;\n }\n .title {\n font-size: 1.125rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .list {\n padding: 0;\n margin: 0;\n list-style: none;\n }\n .icon-yuan {\n color: #666;\n }\n .icon-gouxuan {\n color: var(--color-primary);\n }\n .item {\n padding-left: 1rem;\n }\n .item,\n .item__body {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n }\n .item__selection {\n font-size: 0;\n transition: all .3s;\n }\n .item__icon {\n margin-right: 0.625rem;\n font-size: 2.5rem;\n color: var(--color-primary);\n }\n .item__name {\n color: #222;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n .item[data-is-directory=\"1\"] .item__name {\n color: var(--color-primary);\n }\n .item__content {\n flex: 1;\n padding: 0.75rem 0;\n border-bottom: 0.5px solid var(--divider-color);\n }\n .item__info {\n margin-top: 0.3rem;\n font-size: 0.75rem;\n color: #666;\n }\n .list-select .item__selection {\n margin-right: 0.5rem;\n font-size: 1.5rem;\n }\n .btn-del {\n position: fixed;\n z-index: 10;\n bottom: 0;\n left: 0;\n right: 0;\n margin: 0;\n display: block;\n width: 100%;\n height: 2.75rem;\n border: none;\n font-size: 1.125rem;\n color: #fff;\n background: indianred;\n padding: 0;\n transform: translateY(100%);\n transition: all 0.25s;\n }\n .btn-del.show {\n transform: translateY(0);\n }\n .bottom-holder {\n margin-top: 2rem;\n box-sizing: content-box;\n height: 2.75rem;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --divider-color: rgba(84,84,88,0.65);\n --card-background: #1c1c1e;\n --list-header-color: rgba(235,235,245,0.6);\n }\n body {\n background: #000;\n color: #fff;\n }\n .item__name {\n color: white;\n }\n .item[data-is-directory=\"1\"] .item__name {\n color: #157EFB;\n }\n }`\n\n const js =\n `window.invoke = (code, data) => {\n window.dispatchEvent(\n new CustomEvent(\n 'JBridge',\n { detail: { code, data } }\n )\n )\n }\n\n const isSelectMode = () => {\n return document.querySelector('.list').classList.contains('list-select')\n }\n\n const removeItems = (items) => {\n const list = document.querySelector('.list')\n for (const item of items) {\n const el = document.querySelector(\\`.item[data-name=\"\\${item.name}\"]\\`)\n el.parentNode.remove()\n }\n }\n \n document.querySelector('.select').addEventListener('click', (e) => {\n \/** @type {HTMLButtonElement} *\/\n const target = e.currentTarget\n target.innerText = target.innerText === '选择' ? '完成' : '选择'\n \n document.querySelector('.select-all').toggleAttribute('hidden')\n document.querySelector('.list').classList.toggle('list-select')\n document.querySelector('.btn-del').classList.toggle('show')\n })\n \n document.querySelectorAll('.item')\n .forEach((el) => {\n el.addEventListener('click', (e) => {\n const target = e.currentTarget\n if (isSelectMode()) {\n \/** @type {HTMLElement} *\/\n const selection = target.querySelector('.item__selection')\n const isSelected = selection.classList.contains('icon-gouxuan')\n if (isSelected) {\n selection.classList.replace('icon-gouxuan', 'icon-yuan')\n } else {\n selection.classList.replace('icon-yuan', 'icon-gouxuan')\n }\n } else {\n const { name } = target.dataset\n invoke('view', target.dataset)\n }\n })\n })\n \n document.querySelector('.select-all').addEventListener('click', (e) => {\n \/** @type {HTMLButtonElement} *\/\n const target = e.currentTarget\n const isSelected = target.innerText === '取消全选'\n target.innerText = isSelected ? '全选' : '取消全选'\n document.querySelectorAll('.item__selection').forEach((e) => {\n if (isSelected) {\n e.classList.replace('icon-gouxuan', 'icon-yuan')\n } else {\n e.classList.replace('icon-yuan', 'icon-gouxuan')\n }\n })\n })\n \n document.querySelector('.btn-del').addEventListener('click', () => {\n const selectedItems = []\n for (const el of document.querySelectorAll('.icon-gouxuan')) {\n selectedItems.push({ ...el.parentNode.dataset })\n }\n invoke('remove', selectedItems)\n })\n\n window.addEventListener('JWeb', (e) => {\n const { code, data } = e.detail\n console.log('收到事件了')\n switch (code) {\n case 'remove-success':\n removeItems(JSON.parse(data))\n break;\n }\n })`\n\n const html =\n `\n \n \n \n \n ${title}<\/title>\n \n
+
+
+ ${avatarHtml}
+ ${configList}
+
+
+
+ `;
+
+ // 预览web
+ await previewWebView.loadHTML(html);
+
+ const injectListener = async () => {
+ const event = await previewWebView.evaluateJavaScript(
+ `(() => {
+ try {
+ window.addEventListener(
+ 'JBridge',
+ (e)=>{
+ completion(JSON.stringify(e.detail||{}))
+ }
+ )
+ } catch (e) {
+ alert("预览界面出错:" + e);
+ throw new Error("界面处理出错: " + e);
+ return;
+ }
+ })()`,
+ true
+ );
+
+ const { code, data } = JSON.parse(event);
+ try {
+ const actionItem = actionsConfig.find(
+ (item) => (item.name || item.val) === code
+ );
+
+ if (code === 'userInfo') await this.setUserInfo();
+
+ if (actionItem) {
+ const idName = actionItem?.name || actionItem?.val;
+ if (actionItem?.onClick) {
+ await actionItem?.onClick?.(actionItem, data, previewWebView);
+ } else if (actionItem.type == 'input') {
+ if (
+ await this.setLightAndDark(
+ actionItem['title'],
+ actionItem['desc'],
+ idName,
+ actionItem['placeholder']
+ )
+ )
+ this.insertTextByElementId(
+ previewWebView,
+ idName,
+ this.settings[idName] || ''
+ );
+ } else if (actionItem.type === 'img') {
+ const cachePath = `${actionItem.val}/${actionItem.name}`;
+ const options = ['相册选择', '清空图片', '取消'];
+ const message = '相册图片选择,请选择合适图片大小';
+ const index = await this.generateAlert(message, options);
+ switch (index) {
+ case 0:
+ const backImage = await this.chooseImg(actionItem.verify);
+ if (backImage) {
+ const cachePath = `${actionItem.val}/${actionItem.name}`;
+ await this.htmlChangeImage(backImage, cachePath, {
+ previewWebView,
+ id: idName,
+ });
+ }
+ break;
+ case 1:
+ await this.htmlChangeImage(false, cachePath, {
+ previewWebView,
+ id: idName,
+ });
+ break;
+ default:
+ break;
+ }
+ } else {
+ if (data !== undefined) {
+ this.settings[idName] = data;
+ this.saveSettings(false);
+ }
+ }
+ }
+ } catch (error) {
+ console.log('异常操作:' + error);
+ }
+ this.dismissLoading(previewWebView);
+ injectListener();
+ };
+
+ injectListener().catch((e) => {
+ console.error(e);
+ this.dismissLoading(previewWebView);
+ if (!config.runsInApp) {
+ this.notify('主界面', `🚫 ${e}`);
+ }
+ });
+
+ previewWebView.present();
+ }
+
+ initSFSymbol() {
+ const named = SFSymbol.named;
+ SFSymbol.named = (str) => {
+ const current = named(str);
+ if (!current) {
+ console.log(`图标异常,请在文中搜索并替换图标:${str}`);
+ return named('photo');
+ }
+ return current;
+ };
+ return SFSymbol;
+ }
+
+ _init(widgetFamily = config.widgetFamily) {
+ this.initSFSymbol();
+ // 组件大小:small,medium,large
+ this.widgetFamily = widgetFamily;
+ //用于配置所有的组件相关设置
+
+ // 文件管理器
+ // 提示:缓存数据不要用这个操作,这个是操作源码目录的,缓存建议存放在local temp目录中
+ this.FILE_MGR =
+ FileManager[
+ module.filename.includes('Documents/iCloud~') ? 'iCloud' : 'local'
+ ]();
+
+ this.FILE_MGR.fileExistsExtra = async (filePath) => {
+ const file = this.FILE_MGR.fileExists(filePath);
+ if (file) await this.FILE_MGR.downloadFileFromiCloud(filePath);
+ return file;
+ };
+
+ this.cacheImage = this.FILE_MGR.joinPath(
+ this.FILE_MGR.documentsDirectory(),
+ `/images/${Script.name()}`
+ );
+
+ this.baseImage = this.FILE_MGR.joinPath(
+ this.FILE_MGR.documentsDirectory(),
+ `/images/`
+ );
+
+ this.cacheImageBgPath = [
+ `${this.cacheImage}/transparentBg`,
+ `${this.cacheImage}/dayBg`,
+ `${this.cacheImage}/nightBg`,
+ `${this.baseImage}/avatar`,
+ ];
+
+ if (!this.FILE_MGR.fileExists(this.cacheImage)) {
+ this.FILE_MGR.createDirectory(this.cacheImage, true);
+ }
+
+ // 本地,用于存储图片等
+ this.FILE_MGR_LOCAL = FileManager.local();
+
+ this.settings = this.getSettings();
+
+ this.baseSettings = this.getBaseSettings();
+
+ this.settings = { ...this.defaultSettings, ...this.settings };
+
+ this.settings.lightColor = this.settings.lightColor || '#000000';
+ this.settings.darkColor = this.settings.darkColor || '#ffffff';
+ this.settings.lightBgColor = this.settings.lightBgColor || '#ffffff';
+ this.settings.darkBgColor = this.settings.darkBgColor || '#000000';
+ this.settings.boxjsDomain = this.baseSettings.boxjsDomain || 'boxjs.net';
+ this.settings.refreshAfterDate = this.settings.refreshAfterDate || '30';
+ this.settings.lightOpacity = this.settings.lightOpacity || '0.4';
+ this.settings.darkOpacity = this.settings.darkOpacity || '0.7';
+
+ this.prefix = this.settings.boxjsDomain;
+
+ config.runsInApp && this.saveSettings(false);
+
+ this.backGroundColor = Color.dynamic(
+ new Color(this.settings.lightBgColor),
+ new Color(this.settings.darkBgColor)
+ );
+
+ // const lightBgColor = this.getColors(this.settings.lightBgColor);
+ // const darkBgColor = this.getColors(this.settings.darkBgColor);
+ // if (lightBgColor.length > 1 || darkBgColor.length > 1) {
+ // this.backGroundColor = !Device.isUsingDarkAppearance()
+ // ? this.getBackgroundColor(lightBgColor)
+ // : this.getBackgroundColor(darkBgColor);
+ // } else if (lightBgColor.length > 0 && darkBgColor.length > 0) {
+ // this.backGroundColor = Color.dynamic(
+ // new Color(this.settings.lightBgColor),
+ // new Color(this.settings.darkBgColor)
+ // );
+ // }
+
+ this.widgetColor = Color.dynamic(
+ new Color(this.settings.lightColor),
+ new Color(this.settings.darkColor)
+ );
+ }
+
+ getColors = (color = '') => {
+ const colors = typeof color === 'string' ? color.split(',') : color;
+ return colors;
+ };
+
+ getBackgroundColor = (colors) => {
+ const locations = [];
+ const linearColor = new LinearGradient();
+ const cLen = colors.length;
+ linearColor.colors = colors.map((item, index) => {
+ locations.push(Math.floor(((index + 1) / cLen) * 100) / 100);
+ return new Color(item, 1);
+ });
+ linearColor.locations = locations;
+ return linearColor;
+ };
+
+ /**
+ * 注册点击操作菜单
+ * @param {string} name 操作函数名
+ * @param {func} func 点击后执行的函数
+ */
+ registerAction(name, func, icon = { name: 'gear', color: '#096dd9' }, type) {
+ if (typeof name === 'object' && !name.menu) return this._actions.push(name);
+ if (typeof name === 'object' && name.menu)
+ return this._menuActions.push(name);
+
+ const action = {
+ name,
+ type,
+ title: name,
+ onClick: func?.bind(this),
+ };
+
+ if (typeof icon === 'string') {
+ action.url = icon;
+ } else {
+ action.icon = icon;
+ }
+
+ this._actions.push(action);
+ }
+
+ /**
+ * base64 编码字符串
+ * @param {string} str 要编码的字符串
+ */
+ base64Encode(str) {
+ const data = Data.fromString(str);
+ return data.toBase64String();
+ }
+
+ /**
+ * base64解码数据 返回字符串
+ * @param {string} b64 base64编码的数据
+ */
+ base64Decode(b64) {
+ const data = Data.fromBase64String(b64);
+ return data.toRawString();
+ }
+
+ /**
+ * md5 加密字符串
+ * @param {string} str 要加密成md5的数据
+ */
+ // prettier-ignore
+ md5(str){function d(n,t){var r=(65535&n)+(65535&t);return(((n>>16)+(t>>16)+(r>>16))<<16)|(65535&r)}function f(n,t,r,e,o,u){return d(((c=d(d(t,n),d(e,u)))<<(f=o))|(c>>>(32-f)),r);var c,f}function l(n,t,r,e,o,u,c){return f((t&r)|(~t&e),n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f((t&e)|(r&~e),n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function i(n,t){var r,e,o,u;(n[t>>5]|=128<>>9)<<4)]=t);for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h>5]>>>e%32)&255);return t}function h(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e>5]|=(255&n.charCodeAt(e/8))<>>4)&15)+r.charAt(15&t));return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return a(i(h((t=r(n))),8*t.length));var t}function u(n,t){return(function(n,t){var r,e,o=h(n),u=[],c=[];for(u[15]=c[15]=void 0,16}
+ */
+
+ async generateAlert(message, options) {
+ let alert = new Alert();
+ alert.message = message;
+
+ for (const option of options) {
+ alert.addAction(option);
+ }
+ return await alert.presentAlert();
+ }
+
+ /**
+ * 弹出一个通知
+ * @param {string} title 通知标题
+ * @param {string} body 通知内容
+ * @param {string} url 点击后打开的URL
+ */
+ async notify(title, body, url, opts = {}) {
+ let n = new Notification();
+ n = Object.assign(n, opts);
+ n.title = title;
+ n.body = body;
+ if (url) n.openURL = url;
+ return await n.schedule();
+ }
+
+ /**
+ * 给图片加一层半透明遮罩
+ * @param {Image} img 要处理的图片
+ * @param {string} color 遮罩背景颜色
+ * @param {float} opacity 透明度
+ */
+ async shadowImage(img, color = '#000000', opacity = 0.7) {
+ if (!img) return;
+ if (opacity === 0) return img;
+ let ctx = new DrawContext();
+ // 获取图片的尺寸
+ ctx.size = img.size;
+
+ ctx.drawImageInRect(
+ img,
+ new Rect(0, 0, img.size['width'], img.size['height'])
+ );
+ ctx.setFillColor(new Color(color, opacity));
+ ctx.fillRect(new Rect(0, 0, img.size['width'], img.size['height']));
+ return await ctx.getImage();
+ }
+
+ /**
+ * 获取当前插件的设置
+ * @param {boolean} json 是否为json格式
+ */
+ getSettings(json = true) {
+ let res = json ? {} : '';
+ let cache = '';
+ if (Keychain.contains(this.SETTING_KEY)) {
+ cache = Keychain.get(this.SETTING_KEY);
+ }
+
+ if (json) {
+ try {
+ res = JSON.parse(cache);
+ } catch (e) {}
+ } else {
+ res = cache;
+ }
+
+ return res;
+ }
+
+ getBaseSettings(json = true) {
+ let res = json ? {} : '';
+ let cache = '';
+ if (Keychain.contains(this.BaseCacheKey)) {
+ cache = Keychain.get(this.BaseCacheKey);
+ }
+
+ if (json) {
+ try {
+ res = JSON.parse(cache);
+ } catch (e) {}
+ } else {
+ res = cache;
+ }
+
+ return res;
+ }
+
+ saveBaseSettings(res = {}, notify = true) {
+ const data = { ...(this.baseSettings || {}), ...res };
+ this.baseSettings = data;
+ Keychain.set(this.BaseCacheKey, JSON.stringify(data));
+ if (notify) this.notify('设置成功', '通用设置需重新运行脚本生效');
+ return data;
+ }
+
+ /**
+ * 存储当前设置
+ * @param {bool} notify 是否通知提示
+ */
+ saveSettings(notify = true) {
+ let res =
+ typeof this.settings === 'object'
+ ? JSON.stringify(this.settings)
+ : String(this.settings);
+ Keychain.set(this.SETTING_KEY, res);
+
+ if (notify) this.notify('设置成功', '桌面组件稍后将自动刷新');
+
+ return res;
+ }
+
+ /**
+ * 获取当前插件是否有自定义背景图片
+ * @reutrn img | false
+ */
+ async getBackgroundImage() {
+ if (await this.FILE_MGR.fileExistsExtra(this.cacheImageBgPath[0]))
+ return Image.fromFile(this.cacheImageBgPath[0]);
+
+ if (!this.isNight)
+ return (await this.FILE_MGR.fileExistsExtra(this.cacheImageBgPath[1]))
+ ? Image.fromFile(this.cacheImageBgPath[1])
+ : undefined;
+ else
+ return (await this.FILE_MGR.fileExistsExtra(this.cacheImageBgPath[2]))
+ ? Image.fromFile(this.cacheImageBgPath[2])
+ : undefined;
+ }
+
+ /**
+ * 设置当前组件的背景图片
+ * @param {Image} img
+ */
+ async setBackgroundImage(img, filePath = this.baseImage, notify = true) {
+ const cacheKey = filePath;
+ if (!img) {
+ // 移除背景
+ if (this.FILE_MGR.fileExists(cacheKey)) this.FILE_MGR.remove(cacheKey);
+ if (notify) this.notify('移除成功', '背景图片已移除,稍后刷新生效');
+ } else {
+ // 设置背景
+ this.FILE_MGR.writeImage(cacheKey, img);
+
+ if (notify) this.notify('设置成功', '背景图片已设置!稍后刷新生效');
+ return `data:image/png;base64,${Data.fromFile(
+ cacheKey
+ ).toBase64String()}`;
+ }
+ }
+
+ getRandomArrayElements(arr, count) {
+ let shuffled = arr.slice(0),
+ i = arr.length,
+ min = i - count,
+ temp,
+ index;
+ min = min > 0 ? min : 0;
+ while (i-- > min) {
+ index = Math.floor((i + 1) * Math.random());
+ temp = shuffled[index];
+ shuffled[index] = shuffled[i];
+ shuffled[i] = temp;
+ }
+ return shuffled.slice(min);
+ }
+
+ textFormat = {
+ defaultText: { size: 14, font: 'regular', color: this.widgetColor },
+ battery: { size: 10, font: 'bold', color: this.widgetColor },
+ title: { size: 16, font: 'semibold', color: this.widgetColor },
+ SFMono: { size: 12, font: 'SF Mono', color: this.widgetColor },
+ };
+
+ provideFont = (fontName, fontSize) => {
+ const fontGenerator = {
+ ultralight: function () {
+ return Font.ultraLightSystemFont(fontSize);
+ },
+ light: function () {
+ return Font.lightSystemFont(fontSize);
+ },
+ regular: function () {
+ return Font.regularSystemFont(fontSize);
+ },
+ medium: function () {
+ return Font.mediumSystemFont(fontSize);
+ },
+ semibold: function () {
+ return Font.semiboldSystemFont(fontSize);
+ },
+ bold: function () {
+ return Font.boldSystemFont(fontSize);
+ },
+ heavy: function () {
+ return Font.heavySystemFont(fontSize);
+ },
+ black: function () {
+ return Font.blackSystemFont(fontSize);
+ },
+ italic: function () {
+ return Font.italicSystemFont(fontSize);
+ },
+ };
+
+ const systemFont = fontGenerator[fontName];
+ if (systemFont) {
+ return systemFont();
+ }
+ return new Font(fontName, fontSize);
+ };
+
+ provideText = (string, container, format) => {
+ format = {
+ font: 'light',
+ size: 14,
+ color: this.widgetColor,
+ opacity: 1,
+ minimumScaleFactor: 1,
+ ...format,
+ };
+ const textItem = container.addText(string);
+ const textFont = format.font;
+ const textSize = format.size;
+ const textColor = format.color;
+
+ textItem.font = this.provideFont(textFont, textSize);
+ textItem.textColor = textColor;
+ textItem.textOpacity = format.opacity || 1;
+ textItem.minimumScaleFactor = format.minimumScaleFactor || 1;
+ return textItem;
+ };
+}
+
+// @base.end
+const Runing = async (Widget, default_args = '', isDebug = true, extra) => {
+ let M = null;
+ // 判断hash是否和当前设备匹配
+ if (config.runsInWidget) {
+ M = new Widget(args.widgetParameter || '');
+
+ if (extra) {
+ Object.keys(extra).forEach((key) => {
+ M[key] = extra[key];
+ });
+ }
+ const W = await M.render();
+ try {
+ if (M.settings.refreshAfterDate) {
+ const refreshTime = parseInt(M.settings.refreshAfterDate) * 1000 * 60;
+ const timeStr = new Date().getTime() + refreshTime;
+ W.refreshAfterDate = new Date(timeStr);
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ if (W) {
+ Script.setWidget(W);
+ Script.complete();
+ }
+ } else {
+ let { act, __arg, __size } = args.queryParameters;
+ M = new Widget(__arg || default_args || '');
+ if (extra) {
+ Object.keys(extra).forEach((key) => {
+ M[key] = extra[key];
+ });
+ }
+ if (__size) M._init(__size);
+ if (!act || !M['_actions']) {
+ // 弹出选择菜单
+ const actions = M['_actions'];
+ const onClick = async (item) => {
+ M.widgetFamily = item.val;
+ try {
+ M._init(item.val);
+ } catch (error) {
+ console.log('初始化异常:' + error);
+ }
+ w = await M.render();
+ const fnc = item.val
+ .toLowerCase()
+ .replace(/( |^)[a-z]/g, (L) => L.toUpperCase());
+ if (w) return w[`present${fnc}`]();
+ };
+
+ const preview = [],
+ lockView = [];
+ if (M.renderSmall) {
+ preview.push({
+ url: `https://raw.githubusercontent.com/dompling/Scriptable/master/images/small.png`,
+ title: '小尺寸',
+ val: 'small',
+ name: 'small',
+ dismissOnSelect: true,
+ onClick,
+ });
+ }
+
+ if (M.renderMedium) {
+ preview.push({
+ url: `https://raw.githubusercontent.com/dompling/Scriptable/master/images/medium.png`,
+ title: '中尺寸',
+ val: 'medium',
+ name: 'medium',
+ dismissOnSelect: true,
+ onClick,
+ });
+ }
+
+ if (M.renderLarge) {
+ preview.push({
+ url: `https://raw.githubusercontent.com/dompling/Scriptable/master/images/large.png`,
+ title: '大尺寸',
+ val: 'large',
+ name: 'large',
+ dismissOnSelect: true,
+ onClick,
+ });
+ }
+
+ if (M.renderAccessoryInline) {
+ lockView.push({
+ icon: {
+ color: '#4676EE',
+ name: 'list.triangle',
+ },
+ title: '锁屏列表',
+ val: 'accessoryInline',
+ name: 'accessoryInline',
+ dismissOnSelect: true,
+ onClick,
+ });
+ }
+
+ if (M.renderAccessoryRectangular) {
+ lockView.push({
+ icon: {
+ color: '#4676EE',
+ name: 'arrow.rectanglepath',
+ },
+ title: '锁屏 2x',
+ val: 'accessoryRectangular',
+ name: 'accessoryRectangular',
+ dismissOnSelect: true,
+ onClick,
+ });
+ }
+
+ if (M.renderAccessoryCircular) {
+ lockView.push({
+ icon: {
+ color: '#4676EE',
+ name: 'circle.circle',
+ },
+ title: '锁屏 1x',
+ val: 'accessoryCircular',
+ name: 'accessoryCircular',
+ dismissOnSelect: true,
+ onClick,
+ });
+ }
+
+ const menuConfig = [
+ ...(preview ? [{ title: '预览组件', menu: preview }] : []),
+ ...(lockView.length ? [{ title: '锁屏组件', menu: lockView }] : []),
+ ...M['_menuActions'],
+ ];
+
+ if (actions.length) menuConfig.push({ title: '组件配置', menu: actions });
+
+ await M.renderAppView(menuConfig, true);
+ }
+ }
+};
+// await new DmYY().setWidgetConfig();
+module.exports = { DmYY, Runing };
diff --git a/Scriptable/DmYY.scriptable b/Scriptable/DmYY.scriptable
deleted file mode 100644
index 9943777d..00000000
--- a/Scriptable/DmYY.scriptable
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "always_run_in_app" : false,
- "icon" : {
- "color" : "teal",
- "glyph" : "cogs"
- },
- "name" : "DmYY",
- "script" : "\n\/*\n * Author: 2Ya\n * Github: https:\/\/github.com\/dompling\n * UI 配置升级 感谢 @LSP 大佬提供代码\n *\/\nclass DmYY {\n constructor(arg, defaultSettings) {\n this.arg = arg;\n this.defaultSettings = defaultSettings || {};\n this._init();\n this.isNight = Device.isUsingDarkAppearance();\n }\n\n BaseCacheKey = 'DmYY';\n _actions = [];\n _menuActions = [];\n widgetColor;\n backGroundColor;\n isNight;\n\n userConfigKey = ['avatar', 'nickname', 'homePageDesc'];\n\n \/\/ 获取 Request 对象\n getRequest = (url = '') => {\n return new Request(url);\n };\n\n \/\/ 发起请求\n http = async (\n options = { headers: {}, url: '' },\n type = 'JSON',\n onError = () => {\n return SFSymbol.named('photo').image;\n }\n ) => {\n let request;\n try {\n if (type === 'IMG') {\n const fileName = `${this.cacheImage}\/${this.md5(options.url)}`;\n request = this.getRequest(options.url);\n let response;\n if (this.FILE_MGR.fileExists(fileName)) {\n request.loadImage().then((res) => {\n this.FILE_MGR.writeImage(fileName, res);\n });\n return Image.fromFile(fileName);\n } else {\n response = await request.loadImage();\n this.FILE_MGR.writeImage(fileName, response);\n }\n return response;\n }\n request = this.getRequest();\n Object.keys(options).forEach((key) => {\n request[key] = options[key];\n });\n request.headers = { ...this.defaultHeaders, ...options.headers };\n\n if (type === 'JSON') {\n return await request.loadJSON();\n }\n if (type === 'STRING') {\n return await request.loadString();\n }\n return await request.loadJSON();\n } catch (e) {\n console.log('error:' + e);\n if (type === 'IMG') return onError?.();\n }\n };\n\n \/\/request 接口请求\n $request = {\n get: (url = '', options = {}, type = 'JSON') => {\n let params = { ...options, method: 'GET' };\n if (typeof url === 'object') {\n params = { ...params, ...url };\n } else {\n params.url = url;\n }\n let _type = type;\n if (typeof options === 'string') _type = options;\n return this.http(params, _type);\n },\n post: (url = '', options = {}, type = 'JSON') => {\n let params = { ...options, method: 'POST' };\n if (typeof url === 'object') {\n params = { ...params, ...url };\n } else {\n params.url = url;\n }\n let _type = type;\n if (typeof options === 'string') _type = options;\n return this.http(params, _type);\n },\n };\n\n \/\/ 获取 boxJS 缓存\n getCache = async (key = '', notify = true) => {\n try {\n let url = 'http:\/\/' + this.prefix + '\/query\/boxdata';\n if (key) url = 'http:\/\/' + this.prefix + '\/query\/data\/' + key;\n const boxdata = await this.$request.get(\n url,\n key ? { timeoutInterval: 1 } : {}\n );\n if (key) {\n this.settings.BoxJSData = {\n ...this.settings.BoxJSData,\n [key]: boxdata.val,\n };\n this.saveSettings(false);\n }\n if (boxdata.val) return boxdata.val;\n\n return boxdata.datas;\n } catch (e) {\n if (key && this.settings.BoxJSData[key]) {\n return this.settings.BoxJSData[key];\n }\n if (notify)\n await this.notify(\n `${this.name} - BoxJS 数据读取失败`,\n '请检查 BoxJS 域名是否为代理复写的域名,如(boxjs.net 或 boxjs.com)。\\n若没有配置 BoxJS 相关模块,请点击通知查看教程',\n 'https:\/\/chavyleung.gitbook.io\/boxjs\/awesome\/videos'\n );\n return false;\n }\n };\n\n transforJSON = (str) => {\n if (typeof str == 'string') {\n try {\n return JSON.parse(str);\n } catch (e) {\n console.log(e);\n return str;\n }\n }\n console.log('It is not a string!');\n };\n\n \/\/ 选择图片并缓存\n chooseImg = async () => {\n return Photos.fromLibrary()\n .then(async (response) => {\n const bool = this.verifyImage(response);\n if (bool) return response;\n throw new Error('图片超过限制');\n })\n .catch((err) => {\n console.log('图片选择异常:' + err);\n });\n };\n\n \/\/ 设置 widget 背景图片\n getWidgetBackgroundImage = async (widget) => {\n const backgroundImage = this.getBackgroundImage();\n if (backgroundImage) {\n const opacity = Device.isUsingDarkAppearance()\n ? Number(this.settings.darkOpacity)\n : Number(this.settings.lightOpacity);\n widget.backgroundImage = await this.shadowImage(\n backgroundImage,\n '#000',\n opacity\n );\n return true;\n } else {\n if (this.backGroundColor.colors) {\n widget.backgroundGradient = this.backGroundColor;\n } else {\n widget.backgroundColor = this.backGroundColor;\n }\n return false;\n }\n };\n\n \/**\n * 验证图片尺寸: 图片像素超过 1000 左右的时候会导致背景无法加载\n * @param img Image\n *\/\n verifyImage = async (img) => {\n try {\n const { width, height } = img.size;\n const direct = true;\n if (width > 1000) {\n const options = ['取消', '打开图像处理'];\n const message =\n '您的图片像素为' +\n width +\n ' x ' +\n height +\n '\\n' +\n '请将图片' +\n (direct ? '宽度' : '高度') +\n '调整到 1000 以下\\n' +\n (!direct ? '宽度' : '高度') +\n '自动适应';\n const index = await this.generateAlert(message, options);\n if (index === 1)\n Safari.openInApp('https:\/\/www.sojson.com\/image\/change.html', false);\n return false;\n }\n return true;\n } catch (e) {\n return false;\n }\n };\n\n \/**\n * 获取截图中的组件剪裁图\n * 可用作透明背景\n * 返回图片image对象\n * 代码改自:https:\/\/gist.github.com\/mzeryck\/3a97ccd1e059b3afa3c6666d27a496c9\n * @param {string} title 开始处理前提示用户截图的信息,可选(适合用在组件自定义透明背景时提示)\n *\/\n async getWidgetScreenShot(title = null) {\n \/\/ Crop an image into the specified rect.\n function cropImage(img, rect) {\n let draw = new DrawContext();\n draw.size = new Size(rect.width, rect.height);\n\n draw.drawImageAtPoint(img, new Point(-rect.x, -rect.y));\n return draw.getImage();\n }\n\n \/\/ Pixel sizes and positions for widgets on all supported phones.\n function phoneSizes() {\n return {\n \/\/ 12 Pro Max\n 2778: {\n small: 510,\n medium: 1092,\n large: 1146,\n left: 96,\n right: 678,\n top: 246,\n middle: 882,\n bottom: 1518,\n },\n\n \/\/ 12 and 12 Pro\n 2532: {\n small: 474,\n medium: 1014,\n large: 1062,\n left: 78,\n right: 618,\n top: 231,\n middle: 819,\n bottom: 1407,\n },\n\n \/\/ 11 Pro Max, XS Max\n 2688: {\n small: 507,\n medium: 1080,\n large: 1137,\n left: 81,\n right: 654,\n top: 228,\n middle: 858,\n bottom: 1488,\n },\n\n \/\/ 11, XR\n 1792: {\n small: 338,\n medium: 720,\n large: 758,\n left: 54,\n right: 436,\n top: 160,\n middle: 580,\n bottom: 1000,\n },\n\n \/\/ 11 Pro, XS, X, 12 mini\n 2436: {\n x: {\n small: 465,\n medium: 987,\n large: 1035,\n left: 69,\n right: 591,\n top: 213,\n middle: 783,\n bottom: 1353,\n },\n\n mini: {\n small: 465,\n medium: 987,\n large: 1035,\n left: 69,\n right: 591,\n top: 231,\n middle: 801,\n bottom: 1371,\n },\n },\n\n \/\/ Plus phones\n 2208: {\n small: 471,\n medium: 1044,\n large: 1071,\n left: 99,\n right: 672,\n top: 114,\n middle: 696,\n bottom: 1278,\n },\n\n \/\/ SE2 and 6\/6S\/7\/8\n 1334: {\n small: 296,\n medium: 642,\n large: 648,\n left: 54,\n right: 400,\n top: 60,\n middle: 412,\n bottom: 764,\n },\n\n \/\/ SE1\n 1136: {\n small: 282,\n medium: 584,\n large: 622,\n left: 30,\n right: 332,\n top: 59,\n middle: 399,\n bottom: 399,\n },\n\n \/\/ 11 and XR in Display Zoom mode\n 1624: {\n small: 310,\n medium: 658,\n large: 690,\n left: 46,\n right: 394,\n top: 142,\n middle: 522,\n bottom: 902,\n },\n\n \/\/ Plus in Display Zoom mode\n 2001: {\n small: 444,\n medium: 963,\n large: 972,\n left: 81,\n right: 600,\n top: 90,\n middle: 618,\n bottom: 1146,\n },\n };\n }\n\n let message =\n title || '开始之前,请先前往桌面,截取空白界面的截图。然后回来继续';\n let exitOptions = ['我已截图', '前去截图 >'];\n let shouldExit = await this.generateAlert(message, exitOptions);\n if (shouldExit) return;\n\n \/\/ Get screenshot and determine phone size.\n let img = await Photos.fromLibrary();\n let height = img.size.height;\n let phone = phoneSizes()[height];\n if (!phone) {\n message = '好像您选择的照片不是正确的截图,请先前往桌面';\n await this.generateAlert(message, ['我已知晓']);\n return;\n }\n\n \/\/ Extra setup needed for 2436-sized phones.\n if (height === 2436) {\n const files = this.FILE_MGR_LOCAL;\n let cacheName = 'mz-phone-type';\n let cachePath = files.joinPath(files.libraryDirectory(), cacheName);\n\n \/\/ If we already cached the phone size, load it.\n if (files.fileExists(cachePath)) {\n let typeString = files.readString(cachePath);\n phone = phone[typeString];\n \/\/ Otherwise, prompt the user.\n } else {\n message = '您的📱型号是?';\n let types = ['iPhone 12 mini', 'iPhone 11 Pro, XS, or X'];\n let typeIndex = await this.generateAlert(message, types);\n let type = typeIndex === 0 ? 'mini' : 'x';\n phone = phone[type];\n files.writeString(cachePath, type);\n }\n }\n\n \/\/ Prompt for widget size and position.\n message = '截图中要设置透明背景组件的尺寸类型是?';\n let sizes = ['小尺寸', '中尺寸', '大尺寸'];\n let size = await this.generateAlert(message, sizes);\n let widgetSize = sizes[size];\n\n message = '要设置透明背景的小组件在哪个位置?';\n message +=\n height === 1136\n ? ' (备注:当前设备只支持两行小组件,所以下边选项中的「中间」和「底部」的选项是一致的)'\n : '';\n\n \/\/ Determine image crop based on phone size.\n let crop = { w: '', h: '', x: '', y: '' };\n if (widgetSize === '小尺寸') {\n crop.w = phone.small;\n crop.h = phone.small;\n let positions = [\n '左上角',\n '右上角',\n '中间左',\n '中间右',\n '左下角',\n '右下角',\n ];\n let _posotions = [\n 'Top left',\n 'Top right',\n 'Middle left',\n 'Middle right',\n 'Bottom left',\n 'Bottom right',\n ];\n let position = await this.generateAlert(message, positions);\n\n \/\/ Convert the two words into two keys for the phone size dictionary.\n let keys = _posotions[position].toLowerCase().split(' ');\n crop.y = phone[keys[0]];\n crop.x = phone[keys[1]];\n } else if (widgetSize === '中尺寸') {\n crop.w = phone.medium;\n crop.h = phone.small;\n\n \/\/ Medium and large widgets have a fixed x-value.\n crop.x = phone.left;\n let positions = ['顶部', '中间', '底部'];\n let _positions = ['Top', 'Middle', 'Bottom'];\n let position = await this.generateAlert(message, positions);\n let key = _positions[position].toLowerCase();\n crop.y = phone[key];\n } else if (widgetSize === '大尺寸') {\n crop.w = phone.medium;\n crop.h = phone.large;\n crop.x = phone.left;\n let positions = ['顶部', '底部'];\n let position = await this.generateAlert(message, positions);\n\n \/\/ Large widgets at the bottom have the \"middle\" y-value.\n crop.y = position ? phone.middle : phone.top;\n }\n\n \/\/ Crop image and finalize the widget.\n return cropImage(img, new Rect(crop.x, crop.y, crop.w, crop.h));\n }\n\n setLightAndDark = async (title, desc, val, placeholder = '') => {\n try {\n const a = new Alert();\n a.title = title;\n a.message = desc;\n a.addTextField(placeholder, `${this.settings[val] || ''}`);\n a.addAction('确定');\n a.addCancelAction('取消');\n const id = await a.presentAlert();\n if (id === -1) return false;\n this.settings[val] = a.textFieldValue(0) || '';\n this.saveSettings();\n return true;\n } catch (e) {\n console.log(e);\n }\n };\n\n \/**\n * 弹出输入框\n * @param title 标题\n * @param desc 描述\n * @param opt 属性\n * @returns {Promise}\n *\/\n setAlertInput = async (title, desc, opt = {}, isSave = true) => {\n const a = new Alert();\n a.title = title;\n a.message = !desc ? '' : desc;\n Object.keys(opt).forEach((key) => {\n a.addTextField(opt[key], this.settings[key]);\n });\n a.addAction('确定');\n a.addCancelAction('取消');\n const id = await a.presentAlert();\n if (id === -1) return;\n const data = {};\n Object.keys(opt).forEach((key, index) => {\n data[key] = a.textFieldValue(index) || '';\n });\n \/\/ 保存到本地\n if (isSave) {\n this.settings = { ...this.settings, ...data };\n return this.saveSettings();\n }\n return data;\n };\n\n setBaseAlertInput = async (title, desc, opt = {}, isSave = true) => {\n const a = new Alert();\n a.title = title;\n a.message = !desc ? '' : desc;\n Object.keys(opt).forEach((key) => {\n a.addTextField(opt[key], this.baseSettings[key] || '');\n });\n a.addAction('确定');\n a.addCancelAction('取消');\n const id = await a.presentAlert();\n if (id === -1) return;\n const data = {};\n Object.keys(opt).forEach((key, index) => {\n data[key] = a.textFieldValue(index) || '';\n });\n \/\/ 保存到本地\n if (isSave) return this.saveBaseSettings(data);\n return data;\n };\n\n \/**\n * 设置当前项目的 boxJS 缓存\n * @param opt key value\n * @returns {Promise}\n *\/\n setCacheBoxJSData = async (opt = {}) => {\n const options = ['取消', '确定'];\n const message = '代理缓存仅支持 BoxJS 相关的代理!';\n const index = await this.generateAlert(message, options);\n if (index === 0) return;\n try {\n const boxJSData = await this.getCache();\n Object.keys(opt).forEach((key) => {\n this.settings[key] = boxJSData[opt[key]] || '';\n });\n \/\/ 保存到本地\n this.saveSettings();\n } catch (e) {\n console.log(e);\n this.notify(\n this.name,\n 'BoxJS 缓存读取失败!点击查看相关教程',\n 'https:\/\/chavyleung.gitbook.io\/boxjs\/awesome\/videos'\n );\n }\n };\n\n \/**\n * 设置组件内容\n * @returns {Promise}\n *\/\n setWidgetConfig = async () => {\n const basic = [\n {\n icon: { name: 'arrow.clockwise', color: '#1890ff' },\n type: 'input',\n title: '刷新时间',\n desc: '刷新时间仅供参考,具体刷新时间由系统判断,单位:分钟',\n val: 'refreshAfterDate',\n },\n {\n icon: { name: 'sun.max.fill', color: '#d48806' },\n type: 'color',\n title: '白天字体颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)',\n val: 'lightColor',\n },\n {\n icon: { name: 'moon.stars.fill', color: '#d4b106' },\n type: 'color',\n title: '晚上字体颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)',\n val: 'darkColor',\n },\n ];\n\n return this.renderAppView([\n { title: '基础设置', menu: basic },\n {\n title: '背景设置',\n menu: [\n {\n icon: { name: 'photo', color: '#13c2c2' },\n type: 'color',\n title: '白天背景颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)\\n支持渐变色,各颜色之间以英文逗号分隔',\n val: 'lightBgColor',\n },\n {\n icon: { name: 'photo.fill', color: '#52c41a' },\n type: 'color',\n title: '晚上背景颜色',\n desc: '请自行去网站上搜寻颜色(Hex 颜色)\\n支持渐变色,各颜色之间以英文逗号分隔',\n val: 'darkBgColor',\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'photo.on.rectangle', color: '#fa8c16' },\n name: 'dayBg',\n type: 'img',\n title: '日间背景',\n val: this.cacheImage,\n },\n {\n icon: { name: 'photo.fill.on.rectangle.fill', color: '#fa541c' },\n name: 'nightBg',\n type: 'img',\n title: '夜间背景',\n val: this.cacheImage,\n },\n {\n icon: { name: 'text.below.photo', color: '#faad14' },\n type: 'img',\n name: 'transparentBg',\n title: '透明背景',\n val: this.cacheImage,\n onClick: async (item, __, previewWebView) => {\n const backImage = await this.getWidgetScreenShot();\n if (!backImage || !(await this.verifyImage(backImage))) return;\n const cachePath = `${item.val}\/${item.name}`;\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath\n );\n this.insertTextByElementId(\n previewWebView,\n item.name,\n `
`\n );\n },\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'record.circle', color: '#722ed1' },\n type: 'input',\n title: '日间蒙层',\n desc: '完全透明请设置为0',\n val: 'lightOpacity',\n },\n {\n icon: { name: 'record.circle.fill', color: '#eb2f96' },\n type: 'input',\n title: '夜间蒙层',\n desc: '完全透明请设置为0',\n val: 'darkOpacity',\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'clear', color: '#f5222d' },\n name: 'removeBackground',\n title: '清空背景图片',\n val: `${this.cacheImage}\/`,\n onClick: async (_, __, previewWebView) => {\n const options = [\n '清空日间',\n '清空夜间',\n '清空透明',\n `清空全部`,\n '取消',\n ];\n const message = '该操作不可逆,会清空背景图片!';\n const index = await this.generateAlert(message, options);\n if (index === 4) return;\n switch (index) {\n case 0:\n await this.setBackgroundImage(false, _.val + 'dayBg');\n this.insertTextByElementId(previewWebView, 'dayBg', ``);\n return;\n case 1:\n await this.setBackgroundImage(false, _.val + 'nightBg');\n this.insertTextByElementId(previewWebView, 'nightBg', ``);\n return;\n case 2:\n await this.setBackgroundImage(false, _.val + 'transparentBg');\n this.insertTextByElementId(\n previewWebView,\n 'transparentBg',\n ``\n );\n return;\n default:\n await this.setBackgroundImage(false, _.val + 'dayBg', false);\n await this.setBackgroundImage(\n false,\n _.val + 'nightBg',\n false\n );\n await this.setBackgroundImage(false, _.val + 'transparentBg');\n this.insertTextByElementId(previewWebView, 'dayBg', ``);\n this.insertTextByElementId(previewWebView, 'nightBg', ``);\n this.insertTextByElementId(\n previewWebView,\n 'transparentBg',\n ``\n );\n break;\n }\n },\n },\n ],\n },\n ]).catch((e) => {\n console.log(e);\n });\n };\n\n drawTableIcon = async (\n icon = 'square.grid.2x2',\n color = '#504ED5',\n cornerWidth = 42\n ) => {\n const sfi = SFSymbol.named(icon);\n sfi.applyFont(Font.mediumSystemFont(30));\n const imgData = Data.fromPNG(sfi.image).toBase64String();\n const html = `\n
\n
\n \n `;\n const js = `\n var canvas = document.createElement(\"canvas\");\n var sourceImg = document.getElementById(\"sourceImg\");\n var silhouetteImg = document.getElementById(\"silhouetteImg\");\n var ctx = canvas.getContext('2d');\n var size = sourceImg.width > sourceImg.height ? sourceImg.width : sourceImg.height;\n canvas.width = size;\n canvas.height = size;\n ctx.drawImage(sourceImg, (canvas.width - sourceImg.width) \/ 2, (canvas.height - sourceImg.height) \/ 2);\n var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n var pix = imgData.data;\n \/\/convert the image into a silhouette\n for (var i=0, n = pix.length; i < n; i+= 4){\n \/\/set red to 0\n pix[i] = 255;\n \/\/set green to 0\n pix[i+1] = 255;\n \/\/set blue to 0\n pix[i+2] = 255;\n \/\/retain the alpha value\n pix[i+3] = pix[i+3];\n }\n ctx.putImageData(imgData,0,0);\n silhouetteImg.src = canvas.toDataURL();\n output=canvas.toDataURL()\n `;\n\n let wv = new WebView();\n await wv.loadHTML(html);\n const base64Image = await wv.evaluateJavaScript(js);\n const iconImage = await new Request(base64Image).loadImage();\n const size = new Size(160, 160);\n const ctx = new DrawContext();\n ctx.opaque = false;\n ctx.respectScreenScale = true;\n ctx.size = size;\n const path = new Path();\n const rect = new Rect(0, 0, size.width, size.width);\n\n path.addRoundedRect(rect, cornerWidth, cornerWidth);\n path.closeSubpath();\n ctx.setFillColor(new Color(color));\n ctx.addPath(path);\n ctx.fillPath();\n const rate = 36;\n const iw = size.width - rate;\n const x = (size.width - iw) \/ 2;\n ctx.drawImageInRect(iconImage, new Rect(x, x, iw, iw));\n return ctx.getImage();\n };\n\n dismissLoading = (webView) => {\n webView.evaluateJavaScript(\n \"window.dispatchEvent(new CustomEvent('JWeb', { detail: { code: 'finishLoading' } }))\",\n false\n );\n };\n\n insertTextByElementId = (webView, elementId, text) => {\n const scripts = `document.getElementById(\"${elementId}_val\").innerHTML=\\`${text}\\`;`;\n webView.evaluateJavaScript(scripts, false);\n };\n\n loadSF2B64 = async (\n icon = 'square.grid.2x2',\n color = '#56A8D6',\n cornerWidth = 42\n ) => {\n const sfImg = await this.drawTableIcon(icon, color, cornerWidth);\n return `data:image\/png;base64,${Data.fromPNG(sfImg).toBase64String()}`;\n };\n\n setUserInfo = async () => {\n const baseOnClick = async (item, _, previewWebView) => {\n const data = await this.setBaseAlertInput(item.title, item.desc, {\n [item.val]: item.placeholder,\n });\n if (!data) return;\n this.insertTextByElementId(previewWebView, item.name, data[item.val]);\n };\n\n return this.renderAppView([\n {\n title: '个性设置',\n menu: [\n {\n icon: { name: 'person', color: '#fa541c' },\n name: this.userConfigKey[0],\n title: '首页头像',\n type: 'img',\n val: this.baseImage,\n onClick: async (_, __, previewWebView) => {\n const options = ['相册选择', '在线链接', '取消'];\n const message = '设置个性化头像';\n const index = await this.generateAlert(message, options);\n if (index === 2) return;\n const cachePath = `${_.val}\/${_.name}`;\n switch (index) {\n case 0:\n const albumOptions = ['选择图片', '清空图片', '取消'];\n\n const albumIndex = await this.generateAlert('', albumOptions);\n if (albumIndex === 2) return;\n if (albumIndex === 1) {\n await this.setBackgroundImage(false, _.name, false);\n this.insertTextByElementId(previewWebView, _.name, ``);\n return;\n }\n\n const backImage = await this.chooseImg();\n if (backImage) {\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath\n );\n\n this.insertTextByElementId(\n previewWebView,\n _.name,\n `
`\n );\n }\n\n break;\n case 1:\n const data = await this.setBaseAlertInput(\n '在线链接',\n '首页头像在线链接',\n {\n avatar: '🔗请输入 URL 图片链接',\n }\n );\n if (!data) return;\n\n if (data[_.name] !== '') {\n const backImage = await this.$request.get(\n data[_.name],\n 'IMG'\n );\n const base64Img = await this.setBackgroundImage(\n backImage,\n cachePath\n );\n\n this.insertTextByElementId(\n previewWebView,\n _.name,\n `
`\n );\n } else {\n await this.setBackgroundImage(false, cachePath);\n this.insertTextByElementId(previewWebView, 'avatar');\n }\n\n break;\n default:\n break;\n }\n },\n },\n {\n icon: { name: 'pencil', color: '#fa8c16' },\n type: 'input',\n title: '首页昵称',\n desc: '个性化首页昵称',\n placeholder: '👤请输入头像昵称',\n val: this.userConfigKey[1],\n name: this.userConfigKey[1],\n defaultValue: this.baseSettings.nickname,\n onClick: baseOnClick,\n },\n {\n icon: { name: 'lineweight', color: '#a0d911' },\n type: 'input',\n title: '首页昵称描述',\n desc: '个性化首页昵称描述',\n placeholder: '请输入描述',\n val: this.userConfigKey[2],\n name: this.userConfigKey[2],\n defaultValue: this.baseSettings.homePageDesc,\n onClick: baseOnClick,\n },\n ],\n },\n {\n menu: [\n {\n icon: { name: 'shippingbox', color: '#f7bb10' },\n type: 'input',\n title: 'BoxJS 域名',\n desc: '设置BoxJS访问域名,如:boxjs.net 或 boxjs.com',\n val: 'boxjsDomain',\n name: 'boxjsDomain',\n placeholder: 'boxjs.net',\n defaultValue: this.baseSettings.boxjsDomain,\n onClick: baseOnClick,\n },\n {\n icon: { name: 'clear', color: '#f5222d' },\n title: '恢复默认设置',\n name: 'reset',\n onClick: async () => {\n const options = ['取消', '确定'];\n const message = '确定要恢复当前所有配置吗?';\n const index = await this.generateAlert(message, options);\n if (index === 1) {\n this.settings = {};\n this.baseSettings = {};\n for (const item of this.cacheImageBgPath) {\n await this.setBackgroundImage(false, item, false);\n }\n this.saveSettings(false);\n this.saveBaseSettings();\n await this.notify(\n '重置成功',\n '请关闭窗口之后,重新运行当前脚本'\n );\n this.reopenScript();\n }\n },\n },\n ],\n },\n ]);\n };\n\n reopenScript = () => {\n Safari.open(`scriptable:\/\/\/run\/${encodeURIComponent(Script.name())}`);\n };\n\n async renderAppView(\n options = [],\n renderAvatar = false,\n previewWebView = new WebView()\n ) {\n const settingItemFontSize = 14,\n authorNameFontSize = 20,\n authorDescFontSize = 12;\n \/\/ ================== 配置界面样式 ===================\n const style = `\n :root {\n --color-primary: #007aff;\n --divider-color: rgba(60,60,67,0.16);\n --card-background: #fff;\n --card-radius: 8px;\n --list-header-color: rgba(60,60,67,0.6);\n }\n * {\n -webkit-user-select: none;\n user-select: none;\n }\n body {\n margin: 10px 0;\n -webkit-font-smoothing: antialiased;\n font-family: \"SF Pro Display\",\"SF Pro Icons\",\"Helvetica Neue\",\"Helvetica\",\"Arial\",sans-serif;\n accent-color: var(--color-primary);\n background: #f6f6f6;\n }\n .list {\n margin: 15px;\n }\n .list__header {\n margin: 0 18px;\n color: var(--list-header-color);\n font-size: 13px;\n }\n .list__body {\n margin-top: 10px;\n background: var(--card-background);\n border-radius: var(--card-radius);\n overflow: hidden;\n }\n .form-item-auth {\n display: flex;\n align-items: center;\n justify-content: space-between;\n min-height: 4em;\n padding: 0.5em 18px;\n position: relative;\n }\n .form-item-auth-name {\n margin: 0px 12px;\n font-size: ${authorNameFontSize}px;\n font-weight: 430;\n }\n .form-item-auth-desc {\n margin: 0px 12px;\n font-size: ${authorDescFontSize}px;\n font-weight: 400;\n }\n .form-label-author-avatar {\n width: 62px;\n height: 62px;\n border-radius:50%;\n border: 1px solid #F6D377;\n }\n .form-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: ${settingItemFontSize}px;\n font-weight: 400;\n min-height: 2.2em;\n padding: 0.5em 18px;\n position: relative;\n }\n .form-label {\n display: flex;\n align-items: center;\n flex-wrap:nowrap\n }\n .form-label-img {\n height: 30px;\n }\n .form-label-title {\n margin-left: 8px;\n white-space: nowrap;\n }\n .bottom-bg {\n margin: 30px 15px 15px 15px;\n }\n .form-item--link .icon-arrow-right {\n color: #86868b;\n }\n\n .form-item-right-desc {\n font-size: 13px;\n color: #86868b;\n margin: 0 4px 0 auto; \n max-width: 100px;\n overflow: hidden;\n text-overflow: ellipsis;\n display:flex;\n align-items: center;\n }\n\n .form-item-right-desc img{\n width:30px;\n height:30px;\n border-radius:3px;\n }\n\n .form-item + .form-item::before {\n content: \"\";\n position: absolute;\n top: 0;\n left: 20px;\n right: 0;\n border-top: 0.5px solid var(--divider-color);\n }\n .form-item input[type=\"checkbox\"] {\n width: 2em;\n height: 2em;\n }\n input[type='input'],select {\n width: 100%;\n height: 2.3em;\n outline-style: none;\n text-align: right;\n padding: 0px 10px;\n border: 1px solid #ddd;\n font-size: 14px;\n color: #86868b;\n border-radius:4px;\n }\n input[type='checkbox'][role='switch'] {\n position: relative;\n display: inline-block;\n appearance: none;\n width: 40px;\n height: 24px;\n border-radius: 24px;\n background: #ccc;\n transition: 0.3s ease-in-out;\n }\n input[type='checkbox'][role='switch']::before {\n content: '';\n position: absolute;\n left: 2px;\n top: 2px;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n background: #fff;\n transition: 0.3s ease-in-out;\n }\n input[type='checkbox'][role='switch']:checked {\n background: var(--color-primary);\n }\n input[type='checkbox'][role='switch']:checked::before {\n transform: translateX(16px);\n }\n .copyright {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin: 15px;\n font-size: 10px;\n color: #86868b;\n }\n .copyright a {\n color: #515154;\n text-decoration: none;\n }\n .preview.loading {\n pointer-events: none;\n }\n .icon-loading {\n display: inline-block;\n animation: 1s linear infinite spin;\n }\n .normal-loading {\n display: inline-block;\n animation: 20s linear infinite spin;\n }\n @keyframes spin {\n 0% {\n transform: rotate(0);\n }\n 100% {\n transform: rotate(1turn);\n }\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --divider-color: rgba(84,84,88,0.65);\n --card-background: #1c1c1e;\n --list-header-color: rgba(235,235,245,0.6);\n }\n body {\n background: #000;\n color: #fff;\n }\n }`;\n\n const js = `\n (() => {\n \n window.invoke = (code, data) => {\n window.dispatchEvent(\n new CustomEvent(\n 'JBridge',\n { detail: { code, data } }\n )\n )\n }\n \n \/\/ 切换ico的loading效果\n const toggleIcoLoading = (e) => {\n try{\n const target = e.currentTarget\n target.classList.add('loading')\n const icon = e.currentTarget.querySelector('.iconfont')\n const className = icon.className\n icon.className = 'iconfont icon-loading'\n const listener = (event) => {\n const { code } = event.detail\n if (code === 'finishLoading') {\n target.classList.remove('loading')\n icon.className = className\n window.removeEventListener('JWeb', listener);\n }\n }\n window.addEventListener('JWeb', listener)\n }catch(e){\n for (const loading of document.querySelectorAll('.icon-loading')) {\n loading.classList.remove('loading');\n loading.className = \"iconfont icon-arrow-right\";\n }\n }\n };\n \n for (const btn of document.querySelectorAll('.label-link')) {\n btn.addEventListener('click', (e) => {\n if(!e.target.id)return;\n toggleIcoLoading(e);\n invoke(e.target.id);\n })\n }\n \n for (const btn of document.querySelectorAll('.form-item__input')) {\n btn.addEventListener('change', (e) => {\n if(!e.target.name)return;\n invoke(e.target.name,e.target.type===\"checkbox\"?\\`\\${e.target.checked}\\`: e.target.value);\n })\n }\n\n if(${renderAvatar}){\n document.querySelectorAll('.form-item-auth')[0].addEventListener('click', (e) => {\n toggleIcoLoading(e);\n invoke(\"userInfo\");\n })\n }\n \n })()`;\n\n let configList = ``;\n let actionsConfig = [];\n\n for (const key in options) {\n const item = options[key];\n actionsConfig = [...item.menu, ...actionsConfig];\n configList += ` \n \n