效果图
/**
- @version 1.0.2
- @author Honye
*/
/**
- Thanks @mzeryck
- @param {number} [height] The screen height measured in pixels
/
const phoneSize = (height) => {
const phones = {
/* 14 Pro Max /
2796: {
small: 510,
medium: 1092,
large: 1146,
left: 99,
right: 681,
top: 282,
middle: 918,
bottom: 1554
},
/* 14 Pro /
2556: {
small: 474,
medium: 1014,
large: 1062,
left: 82,
right: 622,
top: 270,
middle: 858,
bottom: 1446
},
/* 13 Pro Max, 12 Pro Max /
2778: {
small: 510,
medium: 1092,
large: 1146,
left: 96,
right: 678,
top: 246,
middle: 882,
bottom: 1518
},
/* 13, 13 Pro, 12, 12 Pro /
2532: {
small: 474,
medium: 1014,
large: 1062,
left: 78,
right: 618,
top: 231,
middle: 819,
bottom: 1407
},
/* 11 Pro Max, XS Max /
2688: {
small: 507,
medium: 1080,
large: 1137,
left: 81,
right: 654,
top: 228,
middle: 858,
bottom: 1488
},
/* 11, XR /
1792: {
small: 338,
medium: 720,
large: 758,
left: 55,
right: 437,
top: 159,
middle: 579,
bottom: 999
},
/* 13 mini, 12 mini / 11 Pro, XS, X /
2436: {
small: 465,
medium: 987,
large: 1035,
x: {
left: 69,
right: 591,
top: 213,
middle: 783,
bottom: 1353
},
mini: {
left: 69,
right: 591,
top: 231,
middle: 801,
bottom: 1371
}
},
/* Plus phones /
2208: {
small: 471,
medium: 1044,
large: 1071,
left: 99,
right: 672,
top: 114,
middle: 696,
bottom: 1278
},
/* SE2 and 6/6S/7/8 /
1334: {
small: 296,
medium: 642,
large: 648,
left: 54,
right: 400,
top: 60,
middle: 412,
bottom: 764
},
/* SE1 /
1136: {
small: 282,
medium: 584,
large: 622,
left: 30,
right: 332,
top: 59,
middle: 399,
bottom: 399
},
/* 11 and XR in Display Zoom mode /
1624: {
small: 310,
medium: 658,
large: 690,
left: 46,
right: 394,
top: 142,
middle: 522,
bottom: 902
},
/* Plus in Display Zoom mode */
2001: {
small: 444,
medium: 963,
large: 972,
left: 81,
right: 600,
top: 90,
middle: 618,
bottom: 1146
}
};
height = height || Device.screenResolution().height;
const scale = Device.screenScale();
const phone = phones[height];
if (phone) {
return phone
}
if (config.runsInWidget) {
const pc = {
small: 164 * scale,
medium: 344 * scale,
large: 354 * scale
};
return pc
}
// in app screen fixed 375x812 pt
return {
small: 155 * scale,
medium: 329 * scale,
large: 345 * scale
}
};
/**
- 多语言国际化
- @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
};
/**
- 获取网络图片
- @param {string} url
*/
const getImage = async (url) => {
const request = new Request(url);
const image = await request.loadImage();
return image
};
/**
- @param {…string} paths
*/
const joinPath = (…paths) => {
const fm = FileManager.local();
return paths.reduce((prev, curr) => {
return fm.joinPath(prev, curr)
}, ‘’)
};
/**
- 规范使用 FileManager。每个脚本使用独立文件夹
- 注意:桌面组件无法写入 cacheDirectory 和 temporaryDirectory
- @param {object} options
- @param {boolean} [options.useICloud]
- @param {string} [options.basePath]
/
const useFileManager = (options = {}) => {
const { useICloud, basePath } = options;
const fm = useICloud ? FileManager.iCloud() : FileManager.local();
const paths = [fm.documentsDirectory(), Script.name()];
if (basePath) {
paths.push(basePath);
}
const cacheDirectory = joinPath(…paths);
/*- 删除路径末尾所有的 /
- @param {string} filePath
/
const safePath = (filePath) => {
return fm.joinPath(cacheDirectory, filePath).replace(//+$/, ‘’)
};
/* - 如果上级文件夹不存在,则先创建文件夹
- @param {string} filePath
*/
const preWrite = (filePath) => {
const i = filePath.lastIndexOf(’/’);
const directory = filePath.substring(0, i);
if (!fm.fileExists(directory)) {
fm.createDirectory(directory, true);
}
};
const writeString = (filePath, content) => {
const nextPath = safePath(filePath);
preWrite(nextPath);
fm.writeString(nextPath, content);
};
/**
- @param {string} filePath
- @param {*} jsonData
/
const writeJSON = (filePath, jsonData) => writeString(filePath, JSON.stringify(jsonData));
/* - @param {string} filePath
- @param {Image} image
*/
const writeImage = (filePath, image) => {
const nextPath = safePath(filePath);
preWrite(nextPath);
return fm.writeImage(nextPath, image)
};
/**
- 文件不存在时返回 null
- @param {string} filePath
- @returns {string|null}
*/
const readString = (filePath) => {
const fullPath = fm.joinPath(cacheDirectory, filePath);
if (fm.fileExists(fullPath)) {
return fm.readString(
fm.joinPath(cacheDirectory, filePath)
)
}
return null
};
/**
- @param {string} filePath
*/
const readJSON = (filePath) => JSON.parse(readString(filePath));
/**
- @param {string} filePath
*/
const readImage = (filePath) => {
return fm.readImage(fm.joinPath(cacheDirectory, filePath))
};
return {
cacheDirectory,
writeString,
writeJSON,
writeImage,
readString,
readJSON,
readImage
}
};
/** 规范使用文件缓存。每个脚本使用独立文件夹 */
const useCache = () => useFileManager({ basePath: ‘cache’ });
/**
- @param {string} data
*/
const hashCode = (data) => {
return Array.from(data).reduce((accumulator, currentChar) => Math.imul(31, accumulator) + currentChar.charCodeAt(0), 0)
};
/**
- @file Scriptable WebView JSBridge native SDK
- @version 1.0.3
- @author Honye
*/
/**
- @typedef Options
- @property {Record<string, () => void>} methods
*/
const sendResult = (() => {
let sending = false;
/** @type {{ code: string; data: any }[]} */
const list = [];
/**
- @param {WebView} webView
- @param {string} code
- @param {any} data
*/
return async (webView, code, data) => {
if (sending) return
sending = true;
list.push({ code, data });
const arr = list.splice(0, list.length);
for (const { code, data } of arr) {
const eventName = `ScriptableBridge_${code}_Result`;
const res = data instanceof Error ? { err: data.message } : data;
await webView.evaluateJavaScript(
`window.dispatchEvent(
new CustomEvent(
'${eventName}',
{ detail: ${JSON.stringify(res)} }
)
)`
);
}
if (list.length) {
const { code, data } = list.shift();
sendResult(webView, code, data);
} else {
sending = false;
}
}
})();
/**
- @param {WebView} webView
- @param {Options} 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];
// 同时执行多次 webView.evaluateJavaScript Scriptable 存在问题
// 可能是因为 JavaScript 是单线程导致的
const sendTasks = events.map(({ code, data }) => {
return (() => {
try {
return Promise.resolve(methodscode)
} catch (e) {
return Promise.reject(e)
}
})()
.then((res) => sendResult(webView, code, res))
.catch((e) => {
console.error(e);
sendResult(webView, code, e instanceof Error ? e : new Error(e));
})
});
await Promise.all(sendTasks);
inject(webView, options);
};
/**
- @param {WebView} webView
- @param {object} args
- @param {string} args.html
- @param {string} [args.baseURL]
- @param {Options} options
*/
const loadHTML = async (webView, args, options = {}) => {
const { html, baseURL } = args;
await webView.loadHTML(html, baseURL);
inject(webView, options).catch((err) => console.error(err));
};
/**
- 轻松实现桌面组件可视化配置
-
- 颜色选择器及更多表单控件
-
- 快速预览
- GitHub: https://github.com/honye
- @version 1.6.1
- @author Honye
*/
const fm = FileManager.local();
const fileName = ‘settings.json’;
const toast = (message) => {
const notification = new Notification();
notification.title = Script.name();
notification.body = message;
notification.schedule();
};
const isUseICloud = () => {
const ifm = useFileManager({ useICloud: true });
const filePath = fm.joinPath(ifm.cacheDirectory, fileName);
return fm.fileExists(filePath)
};
/**
- @returns {Promise}
*/
const readSettings = async () => {
const useICloud = isUseICloud();
console.log([info] use ${useICloud ? 'iCloud' : 'local'} settings
);
const fm = useFileManager({ useICloud });
const settings = fm.readJSON(fileName);
return settings
};
/**
- @param {Record<string, unknown>} data
- @param {{ useICloud: boolean; }} options
*/
const writeSettings = async (data, { useICloud }) => {
const fm = useFileManager({ useICloud });
fm.writeJSON(fileName, data);
};
const removeSettings = async (settings) => {
const cache = useFileManager({ useICloud: settings.useICloud });
fm.remove(
fm.joinPath(cache.cacheDirectory, fileName)
);
};
const moveSettings = (useICloud, data) => {
const localFM = useFileManager();
const iCloudFM = useFileManager({ useICloud: true });
const [i, l] = [
fm.joinPath(iCloudFM.cacheDirectory, fileName),
fm.joinPath(localFM.cacheDirectory, fileName)
];
try {
// 移动文件需要创建父文件夹,写入操作会自动创建文件夹
writeSettings(data, { useICloud });
if (useICloud) {
if (fm.fileExists(l)) fm.remove(l);
} else {
if (fm.fileExists(i)) fm.remove(i);
}
} catch (e) {
console.error(e);
}
};
/**
- @typedef {object} NormalFormItem
- @property {string} name
- @property {string} label
- @property {‘text’|‘number’|‘color’|‘select’|‘date’|‘cell’} [type]
-
- HTML type 属性
-
'cell'
: 可点击的
- @property {’(prefers-color-scheme: light)’|’(prefers-color-scheme: dark)’} [media]
- @property {{ label: string; value: unknown }[]} [options]
- @property {unknown} [default]
/
/* - @typedef {Pick<NormalFormItem, ‘label’|‘name’> & { type: ‘group’, items: FormItem[] }} GroupFormItem
/
/* - @typedef {Omit<NormalFormItem, ‘type’> & { type: ‘page’ } & Pick<Options, ‘formItems’|‘onItemClick’>} PageFormItem 单独的页面
/
/* - @typedef {NormalFormItem|GroupFormItem|PageFormItem} FormItem
/
/* - @typedef {object} CommonSettings
- @property {boolean} useICloud
- @property {string} [backgroundImage] 背景图路径
- @property {string} [backgroundColorLight]
- @property {string} [backgroundColorDark]
/
/* - @typedef {CommonSettings & Record<string, unknown>} Settings
/
/* - @typedef {object} Options
- @property {(data: {
- settings: Settings;
- family?: typeof config.widgetFamily;
- }) => ListWidget | Promise} render
- @property {string} [head] 顶部插入 HTML
- @property {FormItem[]} [formItems]
- @property {(item: FormItem) => void} [onItemClick]
- @property {string} [homePage] 右上角分享菜单地址
- @property {(data: any) => void} [onWebEvent]
/
/* - @template T
- @typedef {T extends infer O ? {[K in keyof O]: O[K]} : never} Expand
*/
const previewsHTML =
`
i 18 n ( [ ′ S m a l l ′ , ′ 预 览 小 号 ′ ] ) < / b u t t o n > < b u t t o n c l a s s = " p r e v i e w " d a t a − s i z e = " m e d i u m " > < i c l a s s = " i c o n f o n t i c o n − d a l i e b i a o " > < / i > {i18n(['Small', '预览小号'])}</button> <button class="preview" data-size="medium"><i class="iconfont icon-daliebiao"></i> i18n([′Small′,′预览小号′])</button><buttonclass="preview"data−size="medium"><iclass="iconfonticon−daliebiao"></i>{i18n([‘Medium’, ‘预览中号’])}
${i18n([‘Large’, ‘预览大号’])}
const copyrightHTML =
`
/**
- @param {Expand} options
- @param {boolean} [isFirstPage]
- @param {object} [others]
- @param {Settings} [others.settings]
- @returns {Promise<ListWidget|undefined>} 仅在 Widget 中运行时返回 ListWidget
*/
const present = async (options, isFirstPage, others = {}) => {
const {
formItems = [],
onItemClick,
render,
head,
homePage = ‘https://www.imarkr.com’,
onWebEvent
} = options;
const cache = useCache();
const settings = others.settings || await readSettings() || {};
/**
- @param {Parameters<Options[‘render’]>[0]} param
*/
const getWidget = async (param) => {
const widget = await render(param);
const { backgroundImage, backgroundColorLight, backgroundColorDark } = settings;
if (backgroundImage && fm.fileExists(backgroundImage)) {
widget.backgroundImage = fm.readImage(backgroundImage);
}
if (!widget.backgroundColor || backgroundColorLight || backgroundColorDark) {
widget.backgroundColor = Color.dynamic(
new Color(backgroundColorLight || ‘#ffffff’),
new Color(backgroundColorDark || ‘#242426’)
);
}
return widget
};
if (config.runsInWidget) {
const widget = await getWidget({ settings });
Script.setWidget(widget);
return widget
}
// ====== web start =======
const style =
`:root {
–color-primary: #007aff;
–divider-color: rgba(60,60,67,0.36);
–card-background: #fff;
–card-radius: 10px;
–list-header-color: rgba(60,60,67,0.6);
}
-
{
-webkit-user-select: none;
user-select: none;
}
body {
margin: 10px 0;
-webkit-font-smoothing: antialiased;
font-family: “SF Pro Display”,“SF Pro Icons”,“Helvetica Neue”,“Helvetica”,“Arial”,sans-serif;
accent-color: var(–color-primary);
}
input {
-webkit-user-select: auto;
user-select: auto;
}
body {
background: #f2f2f7;
}
button {
font-size: 16px;
background: var(–color-primary);
color: #fff;
border-radius: 8px;
border: none;
padding: 0.24em 0.5em;
}
button .iconfont {
margin-right: 6px;
}
.list {
margin: 15px;
}
.list__header {
margin: 0 20px;
color: var(–list-header-color);
font-size: 13px;
}
.list__body {
margin-top: 10px;
background: var(–card-background);
border-radius: var(–card-radius);
border-radius: 12px;
overflow: hidden;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 1em;
font-size: 16px;
min-height: 2em;
padding: 0.5em 20px;
position: relative;
}
.form-item[media*=“prefers-color-scheme”] {
display: none;
}
.form-item–link .icon-arrow_right {
color: #86868b;
}
.form-item + .form-item::before {
content: “”;
position: absolute;
top: 0;
left: 20px;
right: 0;
border-top: 0.5px solid var(–divider-color);
}
.form-item__input-wrapper {
flex: 1;
overflow: hidden;
text-align: right;
}
.form-item__input {
max-width: 100%;
}
.form-item .iconfont {
margin-right: 4px;
}
.form-item input,
.form-item select {
font-size: 14px;
text-align: right;
}
.form-item input[type=“checkbox”] {
width: 1.25em;
height: 1.25em;
}
input[type=“number”] {
width: 4em;
}
input[type=“date”] {
min-width: 6.4em;
}
input[type=‘checkbox’][role=‘switch’] {
position: relative;
display: inline-block;
appearance: none;
width: 40px;
height: 24px;
border-radius: 24px;
background: #ccc;
transition: 0.3s ease-in-out;
}
input[type=‘checkbox’][role=‘switch’]::before {
content: ‘’;
position: absolute;
left: 2px;
top: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: 0.3s ease-in-out;
}
input[type=‘checkbox’][role=‘switch’]:checked {
background: var(–color-primary);
}
input[type=‘checkbox’][role=‘switch’]:checked::before {
transform: translateX(16px);
}
.actions {
margin: 15px;
}
.copyright {
margin: 15px;
margin-inline: 18px;
font-size: 12px;
color: #86868b;
}
.copyright a {
color: #515154;
text-decoration: none;
}
.preview.loading {
pointer-events: none;
}
.icon-loading {
display: inline-block;
animation: 1s linear infinite spin;
}
@keyframes spin {
0% {
transform: rotate(0);
}
100% {
transform: rotate(1turn);
}
}
@media (prefers-color-scheme: light) {
.form-item[media="(prefers-color-scheme: light)"] {
display: flex;
}
}
@media (prefers-color-scheme: dark) {
:root {
–divider-color: rgba(84,84,88,0.65);
–card-background: #1c1c1e;
–list-header-color: rgba(235,235,245,0.6);
}
body {
background: #000;
color: #fff;
}
input {
background-color: rgb(58, 57, 57);
color: var(–color-primary);
}
input[type=‘checkbox’][role=‘switch’] {
background-color: rgb(56, 56, 60);
}
input[type=‘checkbox’][role=‘switch’]::before {
background-color: rgb(206, 206, 206);
}
select {
background-color: rgb(82, 82, 82);
border: none;
}
.form-item[media="(prefers-color-scheme: dark)"] {
display: flex;
}
}
`;const js =
`(() => {
const settings = ${JSON.stringify({
…settings,
useICloud: isUseICloud()
})}
const formItems = ${JSON.stringify(formItems)}window.invoke = (code, data, cb) => {
ScriptableBridge.invoke(code, data, cb)
}const formData = {}
const createFormItem = (item) => {
const value = settings[item.name] ?? item.default ?? null
formData[item.name] = value;
const label = document.createElement(“label”);
label.className = “form-item”;
if (item.media) {
label.setAttribute(‘media’, item.media)
}
const div = document.createElement(“div”);
div.innerText = item.label;
label.appendChild(div);
if (/^(select|multi-select)$/.test(item.type)) {
const wrapper = document.createElement(‘div’)
wrapper.className = ‘form-item__input-wrapper’
const select = document.createElement(‘select’)
select.className = ‘form-item__input’
select.name = item.name
select.multiple = item.type === ‘multi-select’
const map = (options, parent) => {
for (const opt of (options || [])) {
if (opt.children?.length) {
const elGroup = document.createElement(‘optgroup’)
elGroup.label = opt.label
map(opt.children, elGroup)
parent.appendChild(elGroup)
} else {
const option = document.createElement(‘option’)
option.value = opt.value
option.innerText = opt.label
option.selected = Array.isArray(value) ? value.includes(opt.value) : (value === opt.value)
parent.appendChild(option)
}
}
}
map(item.options || [], select)
select.addEventListener(‘change’, ({ target }) => {
let { value } = target
if (item.type === ‘multi-select’) {
value = Array.from(target.selectedOptions).map(({ value }) => value)
}
formData[item.name] = value
invoke(‘changeSettings’, formData)
})
wrapper.appendChild(select)
label.appendChild(wrapper)
} else if (
item.type === ‘cell’ ||
item.type === ‘page’
) {
label.classList.add(‘form-item–link’)
const icon = document.createElement(‘i’)
icon.className = ‘iconfont icon-arrow_right’
label.appendChild(icon)
label.addEventListener(‘click’, () => {
const { name } = item
switch (name) {
case ‘backgroundImage’:
invoke(‘chooseBgImg’)
break
case ‘clearBackgroundImage’:
invoke(‘clearBgImg’)
break
case ‘reset’:
reset()
break
default:
invoke(‘itemClick’, item)
}
})
} else {
const input = document.createElement(“input”)
input.className = ‘form-item__input’
input.name = item.name
input.type = item.type || “text”;
input.enterKeyHint = ‘done’
input.value = value
// Switch
if (item.type === ‘switch’) {
input.type = ‘checkbox’
input.role = ‘switch’
input.checked = value
if (item.name === ‘useICloud’) {
input.addEventListener(‘change’, (e) => {
invoke(‘moveSettings’, e.target.checked)
})
}
}
if (item.type === ‘number’) {
input.inputMode = ‘decimal’
}
if (input.type === ‘text’) {
input.size = 12
}
input.addEventListener(“change”, (e) => {
formData[item.name] =
item.type === ‘switch’
? e.target.checked
: item.type === ‘number’
? Number(e.target.value)
: e.target.value;
invoke(‘changeSettings’, formData)
});
label.appendChild(input);
}
return label
}const createList = (list, title) => {
const fragment = document.createDocumentFragment()let elBody;
for (const item of list) {
if (item.type === ‘group’) {
const grouped = createList(item.items, item.label)
fragment.appendChild(grouped)
} else {
if (!elBody) {
const groupDiv = fragment.appendChild(document.createElement(‘div’))
groupDiv.className = ‘list’
if (title) {
const elTitle = groupDiv.appendChild(document.createElement(‘div’))
elTitle.className = ‘list__header’
elTitle.textContent = title
}
elBody = groupDiv.appendChild(document.createElement(‘div’))
elBody.className = ‘list__body’
}
const label = createFormItem(item)
elBody.appendChild(label)
}
}
return fragment
}const fragment = createList(formItems)
document.getElementById(‘settings’).appendChild(fragment)for (const btn of document.querySelectorAll(’.preview’)) {
btn.addEventListener(‘click’, (e) => {
const target = e.currentTarget
target.classList.add(‘loading’)
const icon = e.currentTarget.querySelector(’.iconfont’)
const className = icon.className
icon.className = ‘iconfont icon-loading’
invoke(
‘preview’,
e.currentTarget.dataset.size,
() => {
target.classList.remove(‘loading’)
icon.className = className
}
)
})
}const setFieldValue = (name, value) => {
const input = document.querySelector(`.form-item__input[name="${name}"]`)
if (!input) return
if (input.type === ‘checkbox’) {
input.checked = value
} else {
input.value = value
}
}const reset = (items = formItems) => {
for (const item of items) {
if (item.type === ‘group’) {
reset(item.items)
} else if (item.type === ‘page’) {
continue;
} else {
setFieldValue(item.name, item.default)
}
}
invoke(‘removeSettings’, formData)
}
})()`;const html =
${head || ''} ${isFirstPage ? (previewsHTML + copyrightHTML) : ''}
`
const webView = new WebView();
const methods = {
async preview (data) {
const widget = await getWidget({ settings, family: data });
widgetpresent${data.replace(data[0], data[0].toUpperCase())}
;
},
safari (data) {
Safari.openInApp(data, true);
},
changeSettings (data) {
Object.assign(settings, data);
writeSettings(settings, { useICloud: settings.useICloud });
},
moveSettings (data) {
settings.useICloud = data;
moveSettings(data, settings);
},
removeSettings (data) {
Object.assign(settings, data);
clearBgImg();
removeSettings(settings);
},
chooseBgImg (data) {
chooseBgImg();
},
clearBgImg () {
clearBgImg();
},
async itemClick (data) {
if (data.type === ‘page’) {
// data
经传到 HTML 后丢失了不可序列化的数据,因为需要从源数据查找
const item = (() => {
const find = (items) => {
for (const el of items) {
if (el.name === data.name) return el
if (el.type === 'group') {
const r = find(el.items);
if (r) return r
}
}
return null
};
return find(formItems)
})();
await present(item, false, { settings });
} else {
await onItemClick?.(data, { settings });
}
},
native (data) {
onWebEvent?.(data);
}
};
await loadHTML(
webView,
{ html, baseURL: homePage },
{ methods }
);
const clearBgImg = () => {
const { backgroundImage } = settings;
delete settings.backgroundImage;
if (backgroundImage && fm.fileExists(backgroundImage)) {
fm.remove(backgroundImage);
}
writeSettings(settings, { useICloud: settings.useICloud });
toast(i18n([‘Cleared success!’, ‘背景已清除’]));
};
const chooseBgImg = async () => {
try {
const image = await Photos.fromLibrary();
cache.writeImage(‘bg.png’, image);
const imgPath = fm.joinPath(cache.cacheDirectory, ‘bg.png’);
settings.backgroundImage = imgPath;
writeSettings(settings, { useICloud: settings.useICloud });
} catch (e) {
console.log(’[info] 用户取消选择图片’);
}
};
webView.present();
// ======= web end =========
};
/**
- @param {Options} options
*/
const withSettings = async (options) => {
const { formItems, onItemClick, …restOptions } = options;
return present({
formItems: [
{
label: i18n([‘Common’, ‘通用’]),
type: ‘group’,
items: [
{
label: i18n([‘Sync with iCloud’, ‘iCloud 同步’]),
type: ‘switch’,
name: ‘useICloud’,
default: false
},
{
label: i18n([‘Background’, ‘背景’]),
type: ‘page’,
name: ‘background’,
formItems: [
{
label: i18n([‘Background’, ‘背景’]),
type: ‘group’,
items: [
{
name: ‘backgroundColorLight’,
type: ‘color’,
label: i18n([‘Background color’, ‘背景色’]),
media: ‘(prefers-color-scheme: light)’,
default: ‘#ffffff’
},
{
name: ‘backgroundColorDark’,
type: ‘color’,
label: i18n([‘Background color’, ‘背景色’]),
media: ‘(prefers-color-scheme: dark)’,
default: ‘#242426’
},
{
label: i18n([‘Background image’, ‘背景图’]),
type: ‘cell’,
name: ‘backgroundImage’
}
]
},
{
type: ‘group’,
items: [
{
label: i18n([‘Clear background image’, ‘清除背景图’]),
type: ‘cell’,
name: ‘clearBackgroundImage’
}
]
}
]
},
{
label: i18n([‘Reset’, ‘重置’]),
type: ‘cell’,
name: ‘reset’
}
]
},
{
label: i18n([‘Settings’, ‘设置’]),
type: ‘group’,
items: formItems
}
],
onItemClick: (item, …args) => {
onItemClick?.(item, …args);
},
…restOptions
}, true)
};
/**
- @param {WidgetStack} stack
- @param {object} options
- @param {number} [options.column] column count
- @param {number | [number, number]} [options.gap]
- @param {‘row’ | ‘column’} [options.direction]
*/
const useGrid = async (stack, options) => {
const {
column,
gap = 0,
direction = ‘row’
} = options;
const [columnGap, rowGap] = typeof gap === ‘number’ ? [gap, gap] : gap;
if (direction === ‘row’) {
stack.layoutVertically();
} else {
stack.layoutHorizontally();
}
let i = -1;
const rows = [];
const add = async (fn) => {
i++;
const r = Math.floor(i / column);
if (i % column === 0) {
if (r > 0) {
stack.addSpacer(rowGap);
}
const rowStack = stack.addStack();
if (direction === ‘row’) {
rowStack.layoutHorizontally();
} else {
rowStack.layoutVertically();
}
rows.push(rowStack);
}
if (i % column > 0) {
rows[r].addSpacer(columnGap);
}
await fn(rows[r]);
};
return { add }
};
const paddingVertical = 10;
const preference = {
/** @type {‘light’|‘dark’|‘system’} */
colorScheme: ‘system’,
fontSize: 14,
useShadow: false,
lightColor: ‘#333333’,
darkColor: ‘#ffffff’,
indexLightColor: ‘’,
indexDarkColor: ‘’,
timeColor: ‘#666666’,
logoSize: 30,
padding: [NaN, 12, NaN, 14],
gap: 8,
columns: ‘1’
};
const conf = {};
const screen = Device.screenResolution();
const scale = Device.screenScale();
const phone = phoneSize(screen.height);
const cache = useCache();
if (config.runsInWidget) {
const [colorScheme] = (args.widgetParameter || ‘’).split(’;’).map(text => text.trim());
preference.colorScheme = colorScheme || preference.colorScheme;
}
const fetchData = async () => {
const url = ‘https://edith.xiaohongshu.com/api/sns/v1/search/hot_list’;
const request = new Request(url);
request.headers = {
‘User-Agent’: ‘discover/8.27 (iPhone; iOS 17.4; Scale/2.00) Resolution/828*1792 Version/8.27 Build/8270555 Device/(Apple Inc.;iPhone12,1) NetType/CellNetwork’,
‘xy-direction’: ‘11’,
‘Accept-Language’: ‘en-CN;q=1, zh-Hans-CN;q=0.9’,
shield: ‘XYAAAAAQAAAAEAAABTAAAAUzUWEe4xG1IYD9/c+qCLOlKGmTtFa+lG434LeOBXRqtCwIG0n8AVP53/i+Ytz8QursR+2dM1ZAw9FWKBZM6A31hp8uUzKCEBoUmKRKKeHM3/tlWY’,
‘xy-platform-info’: ‘platform=iOS&version=8.54.1&build=8541126&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&bundle=com.xingin.discover’,
‘xy-common-params’: ‘app_id=ECFAAF02&build=8541126&channel=AppStore&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&device_fingerprint=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_fingerprint1=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_model=phone&fid=1608005629-0-0-eed79a78f1907a1d38d9d05dcddae6bc&gid=7c4ed0bf25ed547a1267ed41d1df9eaf51de4a284735941f77a554c8&identifier_flag=1&is_mac=0&lang=en&launch_id=748588941&overseas_channel=0&platform=iOS&project_id=ECFAAF&sid=session.1726768676545524226817&t=1726896157&teenager=0&tz=Asia/Shanghai&uis=light&version=8.54.1’,
Referer: ‘https://app.xhs.cn/’
};
try {
const res = await request.loadJSON();
console.log(res);
const df = new DateFormatter();
df.dateFormat = ‘HH:mm’;
const timeString = df.string(new Date());
const data = {
data: res,
updatedAt: timeString
};
cache.writeJSON(‘trending.json’, data);
return data
} catch (e) {
const data = cache.readJSON(‘trending.json’);
return data
}
};
/**
- 优先使用缓存的 Logo,如果不存在缓存则使用线上 Logo 并缓存
*/
const getLogoImage = async () => {
try {
const image = cache.readImage(‘logo.png’);
if (!image) {
throw new Error(‘no cache’)
}
return image
} catch (e) {
const image = await getImage(‘https://cdn.jsdelivr.net/gh/Honye/scriptable-scripts@master/docs/assets/logo_xhs.png’);
cache.writeImage(‘logo.png’, image);
return image
}
};
const createWidget = async ({ data, updatedAt }) => {
const {
fontSize,
logoSize,
padding,
gap,
columns
} = preference;
const { widgetFamily } = config;
const heightPX = widgetFamily === ‘medium’ ? phone.small : phone[widgetFamily];
let height = heightPX / scale;
if (columns > 1) {
// 当列数大于 1 时 Logo 和时间占满一行
height -= logoSize;
}
conf.count = Math.floor((height - paddingVertical * 2 + gap) / (fontSize + gap));
if (widgetFamily === ‘small’) {
padding[1] = padding[3] = 6;
}
let stackBottom;
let widgetBottom;
const widget = new ListWidget();
widget.url = ‘xhsdiscover://home’;
const paddingY = paddingVertical - (gap / 2);
widget.setPadding(paddingY, padding[1], paddingY, padding[3]);
const max = Math.min(conf.count, data.data.items.length);
const logoLines = logoSize ? Math.ceil((logoSize + gap) / (fontSize + gap)) : 0;
if (columns > 1) {
await addLogoTime(widget, { time: updatedAt });
const stackItems = widget.addStack();
const { add } = await useGrid(stackItems, { column: columns });
for (let i = 0; i < max * columns; ++i) {
await add((stack) => addItem(stack, data.data.items[i]), i);
}
} else {
for (let i = 0; i < max; ++i) {
const item = data.data.items[i];
if (i === 0) {
const stack = widget.addStack();
await addItem(stack, item, i);
stack.addSpacer();
await addTime(stack, updatedAt);
} else if (i < max - logoLines) {
await addItem(widget, item, i);
} else {
if (!widgetBottom) {
stackBottom = widget.addStack();
stackBottom.bottomAlignContent();
widgetBottom = stackBottom.addStack();
widgetBottom.layoutVertically();
addItem(widgetBottom, item, i);
} else {
await addItem(widgetBottom, item, i);
}
widgetBottom.length = (widgetBottom.length || 0) + 1;
if (widgetBottom.length === logoLines) {
stackBottom.addSpacer();
await addLogo(stackBottom);
}
}
}
}
return widget
};
/**
- 优先使用线上最新 Icon 并缓存,请求失败时使用缓存
*/
const getIcon = async (src) => {
const hash =${hashCode(src)}
;
try {
const image = await getImage(src);
cache.writeImage(hash, image);
return image
} catch (e) {
return cache.readImage(hash)
}
};
/**
- @param {WidgetStack} widget
- @param {*} item
- @param {number} i
*/
const addItem = async (widget, item, i) => {
const {
fontSize,
useShadow,
lightColor,
darkColor,
indexLightColor,
indexDarkColor,
gap
} = preference;
i += 1;
const { title, icon } = item;
const stack = widget.addStack();
stack.url =xhsdiscover://search/result?keyword=${encodeURIComponent(title)}
;
stack.centerAlignContent();
stack.size = new Size(-1, fontSize + gap);
const stackIndex = stack.addStack();
stackIndex.size = new Size(fontSize * 1.4, -1);
const textIndex = stackIndex.addText(${i}
);
textIndex.rightAlignText();
let colors;
if (indexLightColor) {
colors = [new Color(indexLightColor), new Color(indexLightColor)];
}
if (indexDarkColor) {
colors = colors || [new Color(indexDarkColor)];
colors[1] = new Color(indexDarkColor);
}
textIndex.textColor = colors
? Color.dynamic(…colors)
: i > 3 ? new Color(’#f5c94c’) : new Color(’#fe4f67’);
textIndex.font = Font.boldSystemFont(fontSize);
stack.addSpacer(4);
const textTitle = stack.addText(title);
textTitle.font = Font.systemFont(fontSize);
textTitle.textColor = Color.dynamic(new Color(lightColor), new Color(darkColor));
textTitle.lineLimit = 1;
if (useShadow) {
textTitle.shadowColor = Color.dynamic(
new Color(lightColor, 0.2),
new Color(darkColor, 0.2)
);
textTitle.shadowOffset = new Point(1, 1);
textTitle.shadowRadius = 0.5;
}
if (icon) {
stack.addSpacer(4);
const imageIcon = stack.addImage(await getIcon(icon));
imageIcon.imageSize = new Size(12, 12);
}
stack.addSpacer();
};
/**
- @param {WidgetStack} container
*/
const addLogo = async (container) => {
const { logoSize } = preference;
const image = container.addImage(await getLogoImage());
image.imageSize = new Size(logoSize, logoSize);
return image
};
/**
- @param {WidgetStack} container
- @param {string} time
*/
const addTime = async (container, time) => {
const { fontSize, timeColor } = preference;
const textTime = container.addText(更新于 ${time}
);
textTime.font = Font.systemFont(fontSize * 0.7);
textTime.textColor = new Color(timeColor);
return textTime
};
/**
- @param {WidgetStack} container
- @param {object} data
- @param {string} data.time
*/
const addLogoTime = async (container, { time }) => {
const stack = container.addStack();
stack.centerAlignContent();
await addLogo(stack);
stack.addSpacer();
await addTime(stack, time);
return stack
};
const main = async () => {
const data = await fetchData();
await withSettings({
homePage: ‘https://github.com/Honye/scriptable-scripts’,
formItems: [
{
name: ‘lightColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.lightColor
},
{
name: ‘darkColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.darkColor
},
{
name: ‘indexLightColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.indexLightColor
},
{
name: ‘indexDarkColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.indexDarkColor
},
{
name: ‘useShadow’,
label: i18n([‘Text shadow’, ‘文字阴影’]),
type: ‘switch’,
default: preference.useShadow
},
{
name: ‘fontSize’,
label: i18n([‘Font size’, ‘字体大小’]),
type: ‘number’,
default: preference.fontSize
},
{
name: ‘timeColor’,
label: i18n([‘Time color’, ‘时间颜色’]),
type: ‘color’,
default: preference.timeColor
},
{
name: ‘logoSize’,
label: i18n([‘Logo size (0: hidden)’, ‘Logo 大小(0:隐藏)’]),
type: ‘number’,
default: preference.logoSize
},
{
name: ‘columns’,
label: i18n([‘Column count’, ‘列数’]),
type: ‘select’,
options: [
{ label: ‘1’, value: ‘1’ },
{ label: ‘2’, value: ‘2’ },
{ label: ‘3’, value: ‘3’ }
],
default: preference.columns
}
],
render: async ({ family, settings }) => {
family && (config.widgetFamily = family);
Object.assign(preference, settings);
try {
return await createWidget(data)
} catch (e) {
console.error(e);
}
}
});
Script.complete();
};
await main();
/**
- @version 1.0.2
- @author Honye
*/
/**
- Thanks @mzeryck
- @param {number} [height] The screen height measured in pixels
/
const phoneSize = (height) => {
const phones = {
/* 14 Pro Max /
2796: {
small: 510,
medium: 1092,
large: 1146,
left: 99,
right: 681,
top: 282,
middle: 918,
bottom: 1554
},
/* 14 Pro /
2556: {
small: 474,
medium: 1014,
large: 1062,
left: 82,
right: 622,
top: 270,
middle: 858,
bottom: 1446
},
/* 13 Pro Max, 12 Pro Max /
2778: {
small: 510,
medium: 1092,
large: 1146,
left: 96,
right: 678,
top: 246,
middle: 882,
bottom: 1518
},
/* 13, 13 Pro, 12, 12 Pro /
2532: {
small: 474,
medium: 1014,
large: 1062,
left: 78,
right: 618,
top: 231,
middle: 819,
bottom: 1407
},
/* 11 Pro Max, XS Max /
2688: {
small: 507,
medium: 1080,
large: 1137,
left: 81,
right: 654,
top: 228,
middle: 858,
bottom: 1488
},
/* 11, XR /
1792: {
small: 338,
medium: 720,
large: 758,
left: 55,
right: 437,
top: 159,
middle: 579,
bottom: 999
},
/* 13 mini, 12 mini / 11 Pro, XS, X /
2436: {
small: 465,
medium: 987,
large: 1035,
x: {
left: 69,
right: 591,
top: 213,
middle: 783,
bottom: 1353
},
mini: {
left: 69,
right: 591,
top: 231,
middle: 801,
bottom: 1371
}
},
/* Plus phones /
2208: {
small: 471,
medium: 1044,
large: 1071,
left: 99,
right: 672,
top: 114,
middle: 696,
bottom: 1278
},
/* SE2 and 6/6S/7/8 /
1334: {
small: 296,
medium: 642,
large: 648,
left: 54,
right: 400,
top: 60,
middle: 412,
bottom: 764
},
/* SE1 /
1136: {
small: 282,
medium: 584,
large: 622,
left: 30,
right: 332,
top: 59,
middle: 399,
bottom: 399
},
/* 11 and XR in Display Zoom mode /
1624: {
small: 310,
medium: 658,
large: 690,
left: 46,
right: 394,
top: 142,
middle: 522,
bottom: 902
},
/* Plus in Display Zoom mode */
2001: {
small: 444,
medium: 963,
large: 972,
left: 81,
right: 600,
top: 90,
middle: 618,
bottom: 1146
}
};
height = height || Device.screenResolution().height;
const scale = Device.screenScale();
const phone = phones[height];
if (phone) {
return phone
}
if (config.runsInWidget) {
const pc = {
small: 164 * scale,
medium: 344 * scale,
large: 354 * scale
};
return pc
}
// in app screen fixed 375x812 pt
return {
small: 155 * scale,
medium: 329 * scale,
large: 345 * scale
}
};
/**
- 多语言国际化
- @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
};
/**
- 获取网络图片
- @param {string} url
*/
const getImage = async (url) => {
const request = new Request(url);
const image = await request.loadImage();
return image
};
/**
- @param {…string} paths
*/
const joinPath = (…paths) => {
const fm = FileManager.local();
return paths.reduce((prev, curr) => {
return fm.joinPath(prev, curr)
}, ‘’)
};
/**
- 规范使用 FileManager。每个脚本使用独立文件夹
- 注意:桌面组件无法写入 cacheDirectory 和 temporaryDirectory
- @param {object} options
- @param {boolean} [options.useICloud]
- @param {string} [options.basePath]
/
const useFileManager = (options = {}) => {
const { useICloud, basePath } = options;
const fm = useICloud ? FileManager.iCloud() : FileManager.local();
const paths = [fm.documentsDirectory(), Script.name()];
if (basePath) {
paths.push(basePath);
}
const cacheDirectory = joinPath(…paths);
/*- 删除路径末尾所有的 /
- @param {string} filePath
/
const safePath = (filePath) => {
return fm.joinPath(cacheDirectory, filePath).replace(//+$/, ‘’)
};
/* - 如果上级文件夹不存在,则先创建文件夹
- @param {string} filePath
*/
const preWrite = (filePath) => {
const i = filePath.lastIndexOf(’/’);
const directory = filePath.substring(0, i);
if (!fm.fileExists(directory)) {
fm.createDirectory(directory, true);
}
};
const writeString = (filePath, content) => {
const nextPath = safePath(filePath);
preWrite(nextPath);
fm.writeString(nextPath, content);
};
/**
- @param {string} filePath
- @param {*} jsonData
/
const writeJSON = (filePath, jsonData) => writeString(filePath, JSON.stringify(jsonData));
/* - @param {string} filePath
- @param {Image} image
*/
const writeImage = (filePath, image) => {
const nextPath = safePath(filePath);
preWrite(nextPath);
return fm.writeImage(nextPath, image)
};
/**
- 文件不存在时返回 null
- @param {string} filePath
- @returns {string|null}
*/
const readString = (filePath) => {
const fullPath = fm.joinPath(cacheDirectory, filePath);
if (fm.fileExists(fullPath)) {
return fm.readString(
fm.joinPath(cacheDirectory, filePath)
)
}
return null
};
/**
- @param {string} filePath
*/
const readJSON = (filePath) => JSON.parse(readString(filePath));
/**
- @param {string} filePath
*/
const readImage = (filePath) => {
return fm.readImage(fm.joinPath(cacheDirectory, filePath))
};
return {
cacheDirectory,
writeString,
writeJSON,
writeImage,
readString,
readJSON,
readImage
}
};
/** 规范使用文件缓存。每个脚本使用独立文件夹 */
const useCache = () => useFileManager({ basePath: ‘cache’ });
/**
- @param {string} data
*/
const hashCode = (data) => {
return Array.from(data).reduce((accumulator, currentChar) => Math.imul(31, accumulator) + currentChar.charCodeAt(0), 0)
};
/**
- @file Scriptable WebView JSBridge native SDK
- @version 1.0.3
- @author Honye
*/
/**
- @typedef Options
- @property {Record<string, () => void>} methods
*/
const sendResult = (() => {
let sending = false;
/** @type {{ code: string; data: any }[]} */
const list = [];
/**
- @param {WebView} webView
- @param {string} code
- @param {any} data
*/
return async (webView, code, data) => {
if (sending) return
sending = true;
list.push({ code, data });
const arr = list.splice(0, list.length);
for (const { code, data } of arr) {
const eventName = `ScriptableBridge_${code}_Result`;
const res = data instanceof Error ? { err: data.message } : data;
await webView.evaluateJavaScript(
`window.dispatchEvent(
new CustomEvent(
'${eventName}',
{ detail: ${JSON.stringify(res)} }
)
)`
);
}
if (list.length) {
const { code, data } = list.shift();
sendResult(webView, code, data);
} else {
sending = false;
}
}
})();
/**
- @param {WebView} webView
- @param {Options} 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];
// 同时执行多次 webView.evaluateJavaScript Scriptable 存在问题
// 可能是因为 JavaScript 是单线程导致的
const sendTasks = events.map(({ code, data }) => {
return (() => {
try {
return Promise.resolve(methodscode)
} catch (e) {
return Promise.reject(e)
}
})()
.then((res) => sendResult(webView, code, res))
.catch((e) => {
console.error(e);
sendResult(webView, code, e instanceof Error ? e : new Error(e));
})
});
await Promise.all(sendTasks);
inject(webView, options);
};
/**
- @param {WebView} webView
- @param {object} args
- @param {string} args.html
- @param {string} [args.baseURL]
- @param {Options} options
*/
const loadHTML = async (webView, args, options = {}) => {
const { html, baseURL } = args;
await webView.loadHTML(html, baseURL);
inject(webView, options).catch((err) => console.error(err));
};
/**
- 轻松实现桌面组件可视化配置
-
- 颜色选择器及更多表单控件
-
- 快速预览
- GitHub: https://github.com/honye
- @version 1.6.1
- @author Honye
*/
const fm = FileManager.local();
const fileName = ‘settings.json’;
const toast = (message) => {
const notification = new Notification();
notification.title = Script.name();
notification.body = message;
notification.schedule();
};
const isUseICloud = () => {
const ifm = useFileManager({ useICloud: true });
const filePath = fm.joinPath(ifm.cacheDirectory, fileName);
return fm.fileExists(filePath)
};
/**
- @returns {Promise}
*/
const readSettings = async () => {
const useICloud = isUseICloud();
console.log([info] use ${useICloud ? 'iCloud' : 'local'} settings
);
const fm = useFileManager({ useICloud });
const settings = fm.readJSON(fileName);
return settings
};
/**
- @param {Record<string, unknown>} data
- @param {{ useICloud: boolean; }} options
*/
const writeSettings = async (data, { useICloud }) => {
const fm = useFileManager({ useICloud });
fm.writeJSON(fileName, data);
};
const removeSettings = async (settings) => {
const cache = useFileManager({ useICloud: settings.useICloud });
fm.remove(
fm.joinPath(cache.cacheDirectory, fileName)
);
};
const moveSettings = (useICloud, data) => {
const localFM = useFileManager();
const iCloudFM = useFileManager({ useICloud: true });
const [i, l] = [
fm.joinPath(iCloudFM.cacheDirectory, fileName),
fm.joinPath(localFM.cacheDirectory, fileName)
];
try {
// 移动文件需要创建父文件夹,写入操作会自动创建文件夹
writeSettings(data, { useICloud });
if (useICloud) {
if (fm.fileExists(l)) fm.remove(l);
} else {
if (fm.fileExists(i)) fm.remove(i);
}
} catch (e) {
console.error(e);
}
};
/**
- @typedef {object} NormalFormItem
- @property {string} name
- @property {string} label
- @property {‘text’|‘number’|‘color’|‘select’|‘date’|‘cell’} [type]
-
- HTML type 属性
-
'cell'
: 可点击的
- @property {’(prefers-color-scheme: light)’|’(prefers-color-scheme: dark)’} [media]
- @property {{ label: string; value: unknown }[]} [options]
- @property {unknown} [default]
/
/* - @typedef {Pick<NormalFormItem, ‘label’|‘name’> & { type: ‘group’, items: FormItem[] }} GroupFormItem
/
/* - @typedef {Omit<NormalFormItem, ‘type’> & { type: ‘page’ } & Pick<Options, ‘formItems’|‘onItemClick’>} PageFormItem 单独的页面
/
/* - @typedef {NormalFormItem|GroupFormItem|PageFormItem} FormItem
/
/* - @typedef {object} CommonSettings
- @property {boolean} useICloud
- @property {string} [backgroundImage] 背景图路径
- @property {string} [backgroundColorLight]
- @property {string} [backgroundColorDark]
/
/* - @typedef {CommonSettings & Record<string, unknown>} Settings
/
/* - @typedef {object} Options
- @property {(data: {
- settings: Settings;
- family?: typeof config.widgetFamily;
- }) => ListWidget | Promise} render
- @property {string} [head] 顶部插入 HTML
- @property {FormItem[]} [formItems]
- @property {(item: FormItem) => void} [onItemClick]
- @property {string} [homePage] 右上角分享菜单地址
- @property {(data: any) => void} [onWebEvent]
/
/* - @template T
- @typedef {T extends infer O ? {[K in keyof O]: O[K]} : never} Expand
*/
const previewsHTML =
`
i 18 n ( [ ′ S m a l l ′ , ′ 预 览 小 号 ′ ] ) < / b u t t o n > < b u t t o n c l a s s = " p r e v i e w " d a t a − s i z e = " m e d i u m " > < i c l a s s = " i c o n f o n t i c o n − d a l i e b i a o " > < / i > {i18n(['Small', '预览小号'])}</button> <button class="preview" data-size="medium"><i class="iconfont icon-daliebiao"></i> i18n([′Small′,′预览小号′])</button><buttonclass="preview"data−size="medium"><iclass="iconfonticon−daliebiao"></i>{i18n([‘Medium’, ‘预览中号’])}
${i18n([‘Large’, ‘预览大号’])}
const copyrightHTML =
`
/**
- @param {Expand} options
- @param {boolean} [isFirstPage]
- @param {object} [others]
- @param {Settings} [others.settings]
- @returns {Promise<ListWidget|undefined>} 仅在 Widget 中运行时返回 ListWidget
*/
const present = async (options, isFirstPage, others = {}) => {
const {
formItems = [],
onItemClick,
render,
head,
homePage = ‘https://www.imarkr.com’,
onWebEvent
} = options;
const cache = useCache();
const settings = others.settings || await readSettings() || {};
/**
- @param {Parameters<Options[‘render’]>[0]} param
*/
const getWidget = async (param) => {
const widget = await render(param);
const { backgroundImage, backgroundColorLight, backgroundColorDark } = settings;
if (backgroundImage && fm.fileExists(backgroundImage)) {
widget.backgroundImage = fm.readImage(backgroundImage);
}
if (!widget.backgroundColor || backgroundColorLight || backgroundColorDark) {
widget.backgroundColor = Color.dynamic(
new Color(backgroundColorLight || ‘#ffffff’),
new Color(backgroundColorDark || ‘#242426’)
);
}
return widget
};
if (config.runsInWidget) {
const widget = await getWidget({ settings });
Script.setWidget(widget);
return widget
}
// ====== web start =======
const style =
`:root {
–color-primary: #007aff;
–divider-color: rgba(60,60,67,0.36);
–card-background: #fff;
–card-radius: 10px;
–list-header-color: rgba(60,60,67,0.6);
}
-
{
-webkit-user-select: none;
user-select: none;
}
body {
margin: 10px 0;
-webkit-font-smoothing: antialiased;
font-family: “SF Pro Display”,“SF Pro Icons”,“Helvetica Neue”,“Helvetica”,“Arial”,sans-serif;
accent-color: var(–color-primary);
}
input {
-webkit-user-select: auto;
user-select: auto;
}
body {
background: #f2f2f7;
}
button {
font-size: 16px;
background: var(–color-primary);
color: #fff;
border-radius: 8px;
border: none;
padding: 0.24em 0.5em;
}
button .iconfont {
margin-right: 6px;
}
.list {
margin: 15px;
}
.list__header {
margin: 0 20px;
color: var(–list-header-color);
font-size: 13px;
}
.list__body {
margin-top: 10px;
background: var(–card-background);
border-radius: var(–card-radius);
border-radius: 12px;
overflow: hidden;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 1em;
font-size: 16px;
min-height: 2em;
padding: 0.5em 20px;
position: relative;
}
.form-item[media*=“prefers-color-scheme”] {
display: none;
}
.form-item–link .icon-arrow_right {
color: #86868b;
}
.form-item + .form-item::before {
content: “”;
position: absolute;
top: 0;
left: 20px;
right: 0;
border-top: 0.5px solid var(–divider-color);
}
.form-item__input-wrapper {
flex: 1;
overflow: hidden;
text-align: right;
}
.form-item__input {
max-width: 100%;
}
.form-item .iconfont {
margin-right: 4px;
}
.form-item input,
.form-item select {
font-size: 14px;
text-align: right;
}
.form-item input[type=“checkbox”] {
width: 1.25em;
height: 1.25em;
}
input[type=“number”] {
width: 4em;
}
input[type=“date”] {
min-width: 6.4em;
}
input[type=‘checkbox’][role=‘switch’] {
position: relative;
display: inline-block;
appearance: none;
width: 40px;
height: 24px;
border-radius: 24px;
background: #ccc;
transition: 0.3s ease-in-out;
}
input[type=‘checkbox’][role=‘switch’]::before {
content: ‘’;
position: absolute;
left: 2px;
top: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: 0.3s ease-in-out;
}
input[type=‘checkbox’][role=‘switch’]:checked {
background: var(–color-primary);
}
input[type=‘checkbox’][role=‘switch’]:checked::before {
transform: translateX(16px);
}
.actions {
margin: 15px;
}
.copyright {
margin: 15px;
margin-inline: 18px;
font-size: 12px;
color: #86868b;
}
.copyright a {
color: #515154;
text-decoration: none;
}
.preview.loading {
pointer-events: none;
}
.icon-loading {
display: inline-block;
animation: 1s linear infinite spin;
}
@keyframes spin {
0% {
transform: rotate(0);
}
100% {
transform: rotate(1turn);
}
}
@media (prefers-color-scheme: light) {
.form-item[media="(prefers-color-scheme: light)"] {
display: flex;
}
}
@media (prefers-color-scheme: dark) {
:root {
–divider-color: rgba(84,84,88,0.65);
–card-background: #1c1c1e;
–list-header-color: rgba(235,235,245,0.6);
}
body {
background: #000;
color: #fff;
}
input {
background-color: rgb(58, 57, 57);
color: var(–color-primary);
}
input[type=‘checkbox’][role=‘switch’] {
background-color: rgb(56, 56, 60);
}
input[type=‘checkbox’][role=‘switch’]::before {
background-color: rgb(206, 206, 206);
}
select {
background-color: rgb(82, 82, 82);
border: none;
}
.form-item[media="(prefers-color-scheme: dark)"] {
display: flex;
}
}
`;const js =
`(() => {
const settings = ${JSON.stringify({
…settings,
useICloud: isUseICloud()
})}
const formItems = ${JSON.stringify(formItems)}window.invoke = (code, data, cb) => {
ScriptableBridge.invoke(code, data, cb)
}const formData = {}
const createFormItem = (item) => {
const value = settings[item.name] ?? item.default ?? null
formData[item.name] = value;
const label = document.createElement(“label”);
label.className = “form-item”;
if (item.media) {
label.setAttribute(‘media’, item.media)
}
const div = document.createElement(“div”);
div.innerText = item.label;
label.appendChild(div);
if (/^(select|multi-select)$/.test(item.type)) {
const wrapper = document.createElement(‘div’)
wrapper.className = ‘form-item__input-wrapper’
const select = document.createElement(‘select’)
select.className = ‘form-item__input’
select.name = item.name
select.multiple = item.type === ‘multi-select’
const map = (options, parent) => {
for (const opt of (options || [])) {
if (opt.children?.length) {
const elGroup = document.createElement(‘optgroup’)
elGroup.label = opt.label
map(opt.children, elGroup)
parent.appendChild(elGroup)
} else {
const option = document.createElement(‘option’)
option.value = opt.value
option.innerText = opt.label
option.selected = Array.isArray(value) ? value.includes(opt.value) : (value === opt.value)
parent.appendChild(option)
}
}
}
map(item.options || [], select)
select.addEventListener(‘change’, ({ target }) => {
let { value } = target
if (item.type === ‘multi-select’) {
value = Array.from(target.selectedOptions).map(({ value }) => value)
}
formData[item.name] = value
invoke(‘changeSettings’, formData)
})
wrapper.appendChild(select)
label.appendChild(wrapper)
} else if (
item.type === ‘cell’ ||
item.type === ‘page’
) {
label.classList.add(‘form-item–link’)
const icon = document.createElement(‘i’)
icon.className = ‘iconfont icon-arrow_right’
label.appendChild(icon)
label.addEventListener(‘click’, () => {
const { name } = item
switch (name) {
case ‘backgroundImage’:
invoke(‘chooseBgImg’)
break
case ‘clearBackgroundImage’:
invoke(‘clearBgImg’)
break
case ‘reset’:
reset()
break
default:
invoke(‘itemClick’, item)
}
})
} else {
const input = document.createElement(“input”)
input.className = ‘form-item__input’
input.name = item.name
input.type = item.type || “text”;
input.enterKeyHint = ‘done’
input.value = value
// Switch
if (item.type === ‘switch’) {
input.type = ‘checkbox’
input.role = ‘switch’
input.checked = value
if (item.name === ‘useICloud’) {
input.addEventListener(‘change’, (e) => {
invoke(‘moveSettings’, e.target.checked)
})
}
}
if (item.type === ‘number’) {
input.inputMode = ‘decimal’
}
if (input.type === ‘text’) {
input.size = 12
}
input.addEventListener(“change”, (e) => {
formData[item.name] =
item.type === ‘switch’
? e.target.checked
: item.type === ‘number’
? Number(e.target.value)
: e.target.value;
invoke(‘changeSettings’, formData)
});
label.appendChild(input);
}
return label
}const createList = (list, title) => {
const fragment = document.createDocumentFragment()let elBody;
for (const item of list) {
if (item.type === ‘group’) {
const grouped = createList(item.items, item.label)
fragment.appendChild(grouped)
} else {
if (!elBody) {
const groupDiv = fragment.appendChild(document.createElement(‘div’))
groupDiv.className = ‘list’
if (title) {
const elTitle = groupDiv.appendChild(document.createElement(‘div’))
elTitle.className = ‘list__header’
elTitle.textContent = title
}
elBody = groupDiv.appendChild(document.createElement(‘div’))
elBody.className = ‘list__body’
}
const label = createFormItem(item)
elBody.appendChild(label)
}
}
return fragment
}const fragment = createList(formItems)
document.getElementById(‘settings’).appendChild(fragment)for (const btn of document.querySelectorAll(’.preview’)) {
btn.addEventListener(‘click’, (e) => {
const target = e.currentTarget
target.classList.add(‘loading’)
const icon = e.currentTarget.querySelector(’.iconfont’)
const className = icon.className
icon.className = ‘iconfont icon-loading’
invoke(
‘preview’,
e.currentTarget.dataset.size,
() => {
target.classList.remove(‘loading’)
icon.className = className
}
)
})
}const setFieldValue = (name, value) => {
const input = document.querySelector(`.form-item__input[name="${name}"]`)
if (!input) return
if (input.type === ‘checkbox’) {
input.checked = value
} else {
input.value = value
}
}const reset = (items = formItems) => {
for (const item of items) {
if (item.type === ‘group’) {
reset(item.items)
} else if (item.type === ‘page’) {
continue;
} else {
setFieldValue(item.name, item.default)
}
}
invoke(‘removeSettings’, formData)
}
})()`;const html =
${head || ''} ${isFirstPage ? (previewsHTML + copyrightHTML) : ''}
`
const webView = new WebView();
const methods = {
async preview (data) {
const widget = await getWidget({ settings, family: data });
widgetpresent${data.replace(data[0], data[0].toUpperCase())}
;
},
safari (data) {
Safari.openInApp(data, true);
},
changeSettings (data) {
Object.assign(settings, data);
writeSettings(settings, { useICloud: settings.useICloud });
},
moveSettings (data) {
settings.useICloud = data;
moveSettings(data, settings);
},
removeSettings (data) {
Object.assign(settings, data);
clearBgImg();
removeSettings(settings);
},
chooseBgImg (data) {
chooseBgImg();
},
clearBgImg () {
clearBgImg();
},
async itemClick (data) {
if (data.type === ‘page’) {
// data
经传到 HTML 后丢失了不可序列化的数据,因为需要从源数据查找
const item = (() => {
const find = (items) => {
for (const el of items) {
if (el.name === data.name) return el
if (el.type === 'group') {
const r = find(el.items);
if (r) return r
}
}
return null
};
return find(formItems)
})();
await present(item, false, { settings });
} else {
await onItemClick?.(data, { settings });
}
},
native (data) {
onWebEvent?.(data);
}
};
await loadHTML(
webView,
{ html, baseURL: homePage },
{ methods }
);
const clearBgImg = () => {
const { backgroundImage } = settings;
delete settings.backgroundImage;
if (backgroundImage && fm.fileExists(backgroundImage)) {
fm.remove(backgroundImage);
}
writeSettings(settings, { useICloud: settings.useICloud });
toast(i18n([‘Cleared success!’, ‘背景已清除’]));
};
const chooseBgImg = async () => {
try {
const image = await Photos.fromLibrary();
cache.writeImage(‘bg.png’, image);
const imgPath = fm.joinPath(cache.cacheDirectory, ‘bg.png’);
settings.backgroundImage = imgPath;
writeSettings(settings, { useICloud: settings.useICloud });
} catch (e) {
console.log(’[info] 用户取消选择图片’);
}
};
webView.present();
// ======= web end =========
};
/**
- @param {Options} options
*/
const withSettings = async (options) => {
const { formItems, onItemClick, …restOptions } = options;
return present({
formItems: [
{
label: i18n([‘Common’, ‘通用’]),
type: ‘group’,
items: [
{
label: i18n([‘Sync with iCloud’, ‘iCloud 同步’]),
type: ‘switch’,
name: ‘useICloud’,
default: false
},
{
label: i18n([‘Background’, ‘背景’]),
type: ‘page’,
name: ‘background’,
formItems: [
{
label: i18n([‘Background’, ‘背景’]),
type: ‘group’,
items: [
{
name: ‘backgroundColorLight’,
type: ‘color’,
label: i18n([‘Background color’, ‘背景色’]),
media: ‘(prefers-color-scheme: light)’,
default: ‘#ffffff’
},
{
name: ‘backgroundColorDark’,
type: ‘color’,
label: i18n([‘Background color’, ‘背景色’]),
media: ‘(prefers-color-scheme: dark)’,
default: ‘#242426’
},
{
label: i18n([‘Background image’, ‘背景图’]),
type: ‘cell’,
name: ‘backgroundImage’
}
]
},
{
type: ‘group’,
items: [
{
label: i18n([‘Clear background image’, ‘清除背景图’]),
type: ‘cell’,
name: ‘clearBackgroundImage’
}
]
}
]
},
{
label: i18n([‘Reset’, ‘重置’]),
type: ‘cell’,
name: ‘reset’
}
]
},
{
label: i18n([‘Settings’, ‘设置’]),
type: ‘group’,
items: formItems
}
],
onItemClick: (item, …args) => {
onItemClick?.(item, …args);
},
…restOptions
}, true)
};
/**
- @param {WidgetStack} stack
- @param {object} options
- @param {number} [options.column] column count
- @param {number | [number, number]} [options.gap]
- @param {‘row’ | ‘column’} [options.direction]
*/
const useGrid = async (stack, options) => {
const {
column,
gap = 0,
direction = ‘row’
} = options;
const [columnGap, rowGap] = typeof gap === ‘number’ ? [gap, gap] : gap;
if (direction === ‘row’) {
stack.layoutVertically();
} else {
stack.layoutHorizontally();
}
let i = -1;
const rows = [];
const add = async (fn) => {
i++;
const r = Math.floor(i / column);
if (i % column === 0) {
if (r > 0) {
stack.addSpacer(rowGap);
}
const rowStack = stack.addStack();
if (direction === ‘row’) {
rowStack.layoutHorizontally();
} else {
rowStack.layoutVertically();
}
rows.push(rowStack);
}
if (i % column > 0) {
rows[r].addSpacer(columnGap);
}
await fn(rows[r]);
};
return { add }
};
const paddingVertical = 10;
const preference = {
/** @type {‘light’|‘dark’|‘system’} */
colorScheme: ‘system’,
fontSize: 14,
useShadow: false,
lightColor: ‘#333333’,
darkColor: ‘#ffffff’,
indexLightColor: ‘’,
indexDarkColor: ‘’,
timeColor: ‘#666666’,
logoSize: 30,
padding: [NaN, 12, NaN, 14],
gap: 8,
columns: ‘1’
};
const conf = {};
const screen = Device.screenResolution();
const scale = Device.screenScale();
const phone = phoneSize(screen.height);
const cache = useCache();
if (config.runsInWidget) {
const [colorScheme] = (args.widgetParameter || ‘’).split(’;’).map(text => text.trim());
preference.colorScheme = colorScheme || preference.colorScheme;
}
const fetchData = async () => {
const url = ‘https://edith.xiaohongshu.com/api/sns/v1/search/hot_list’;
const request = new Request(url);
request.headers = {
‘User-Agent’: ‘discover/8.27 (iPhone; iOS 17.4; Scale/2.00) Resolution/828*1792 Version/8.27 Build/8270555 Device/(Apple Inc.;iPhone12,1) NetType/CellNetwork’,
‘xy-direction’: ‘11’,
‘Accept-Language’: ‘en-CN;q=1, zh-Hans-CN;q=0.9’,
shield: ‘XYAAAAAQAAAAEAAABTAAAAUzUWEe4xG1IYD9/c+qCLOlKGmTtFa+lG434LeOBXRqtCwIG0n8AVP53/i+Ytz8QursR+2dM1ZAw9FWKBZM6A31hp8uUzKCEBoUmKRKKeHM3/tlWY’,
‘xy-platform-info’: ‘platform=iOS&version=8.54.1&build=8541126&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&bundle=com.xingin.discover’,
‘xy-common-params’: ‘app_id=ECFAAF02&build=8541126&channel=AppStore&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&device_fingerprint=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_fingerprint1=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_model=phone&fid=1608005629-0-0-eed79a78f1907a1d38d9d05dcddae6bc&gid=7c4ed0bf25ed547a1267ed41d1df9eaf51de4a284735941f77a554c8&identifier_flag=1&is_mac=0&lang=en&launch_id=748588941&overseas_channel=0&platform=iOS&project_id=ECFAAF&sid=session.1726768676545524226817&t=1726896157&teenager=0&tz=Asia/Shanghai&uis=light&version=8.54.1’,
Referer: ‘https://app.xhs.cn/’
};
try {
const res = await request.loadJSON();
console.log(res);
const df = new DateFormatter();
df.dateFormat = ‘HH:mm’;
const timeString = df.string(new Date());
const data = {
data: res,
updatedAt: timeString
};
cache.writeJSON(‘trending.json’, data);
return data
} catch (e) {
const data = cache.readJSON(‘trending.json’);
return data
}
};
/**
- 优先使用缓存的 Logo,如果不存在缓存则使用线上 Logo 并缓存
*/
const getLogoImage = async () => {
try {
const image = cache.readImage(‘logo.png’);
if (!image) {
throw new Error(‘no cache’)
}
return image
} catch (e) {
const image = await getImage(‘https://cdn.jsdelivr.net/gh/Honye/scriptable-scripts@master/docs/assets/logo_xhs.png’);
cache.writeImage(‘logo.png’, image);
return image
}
};
const createWidget = async ({ data, updatedAt }) => {
const {
fontSize,
logoSize,
padding,
gap,
columns
} = preference;
const { widgetFamily } = config;
const heightPX = widgetFamily === ‘medium’ ? phone.small : phone[widgetFamily];
let height = heightPX / scale;
if (columns > 1) {
// 当列数大于 1 时 Logo 和时间占满一行
height -= logoSize;
}
conf.count = Math.floor((height - paddingVertical * 2 + gap) / (fontSize + gap));
if (widgetFamily === ‘small’) {
padding[1] = padding[3] = 6;
}
let stackBottom;
let widgetBottom;
const widget = new ListWidget();
widget.url = ‘xhsdiscover://home’;
const paddingY = paddingVertical - (gap / 2);
widget.setPadding(paddingY, padding[1], paddingY, padding[3]);
const max = Math.min(conf.count, data.data.items.length);
const logoLines = logoSize ? Math.ceil((logoSize + gap) / (fontSize + gap)) : 0;
if (columns > 1) {
await addLogoTime(widget, { time: updatedAt });
const stackItems = widget.addStack();
const { add } = await useGrid(stackItems, { column: columns });
for (let i = 0; i < max * columns; ++i) {
await add((stack) => addItem(stack, data.data.items[i]), i);
}
} else {
for (let i = 0; i < max; ++i) {
const item = data.data.items[i];
if (i === 0) {
const stack = widget.addStack();
await addItem(stack, item, i);
stack.addSpacer();
await addTime(stack, updatedAt);
} else if (i < max - logoLines) {
await addItem(widget, item, i);
} else {
if (!widgetBottom) {
stackBottom = widget.addStack();
stackBottom.bottomAlignContent();
widgetBottom = stackBottom.addStack();
widgetBottom.layoutVertically();
addItem(widgetBottom, item, i);
} else {
await addItem(widgetBottom, item, i);
}
widgetBottom.length = (widgetBottom.length || 0) + 1;
if (widgetBottom.length === logoLines) {
stackBottom.addSpacer();
await addLogo(stackBottom);
}
}
}
}
return widget
};
/**
- 优先使用线上最新 Icon 并缓存,请求失败时使用缓存
*/
const getIcon = async (src) => {
const hash =${hashCode(src)}
;
try {
const image = await getImage(src);
cache.writeImage(hash, image);
return image
} catch (e) {
return cache.readImage(hash)
}
};
/**
- @param {WidgetStack} widget
- @param {*} item
- @param {number} i
*/
const addItem = async (widget, item, i) => {
const {
fontSize,
useShadow,
lightColor,
darkColor,
indexLightColor,
indexDarkColor,
gap
} = preference;
i += 1;
const { title, icon } = item;
const stack = widget.addStack();
stack.url =xhsdiscover://search/result?keyword=${encodeURIComponent(title)}
;
stack.centerAlignContent();
stack.size = new Size(-1, fontSize + gap);
const stackIndex = stack.addStack();
stackIndex.size = new Size(fontSize * 1.4, -1);
const textIndex = stackIndex.addText(${i}
);
textIndex.rightAlignText();
let colors;
if (indexLightColor) {
colors = [new Color(indexLightColor), new Color(indexLightColor)];
}
if (indexDarkColor) {
colors = colors || [new Color(indexDarkColor)];
colors[1] = new Color(indexDarkColor);
}
textIndex.textColor = colors
? Color.dynamic(…colors)
: i > 3 ? new Color(’#f5c94c’) : new Color(’#fe4f67’);
textIndex.font = Font.boldSystemFont(fontSize);
stack.addSpacer(4);
const textTitle = stack.addText(title);
textTitle.font = Font.systemFont(fontSize);
textTitle.textColor = Color.dynamic(new Color(lightColor), new Color(darkColor));
textTitle.lineLimit = 1;
if (useShadow) {
textTitle.shadowColor = Color.dynamic(
new Color(lightColor, 0.2),
new Color(darkColor, 0.2)
);
textTitle.shadowOffset = new Point(1, 1);
textTitle.shadowRadius = 0.5;
}
if (icon) {
stack.addSpacer(4);
const imageIcon = stack.addImage(await getIcon(icon));
imageIcon.imageSize = new Size(12, 12);
}
stack.addSpacer();
};
/**
- @param {WidgetStack} container
*/
const addLogo = async (container) => {
const { logoSize } = preference;
const image = container.addImage(await getLogoImage());
image.imageSize = new Size(logoSize, logoSize);
return image
};
/**
- @param {WidgetStack} container
- @param {string} time
*/
const addTime = async (container, time) => {
const { fontSize, timeColor } = preference;
const textTime = container.addText(更新于 ${time}
);
textTime.font = Font.systemFont(fontSize * 0.7);
textTime.textColor = new Color(timeColor);
return textTime
};
/**
- @param {WidgetStack} container
- @param {object} data
- @param {string} data.time
*/
const addLogoTime = async (container, { time }) => {
const stack = container.addStack();
stack.centerAlignContent();
await addLogo(stack);
stack.addSpacer();
await addTime(stack, time);
return stack
};
const main = async () => {
const data = await fetchData();
await withSettings({
homePage: ‘https://github.com/Honye/scriptable-scripts’,
formItems: [
{
name: ‘lightColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.lightColor
},
{
name: ‘darkColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.darkColor
},
{
name: ‘indexLightColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.indexLightColor
},
{
name: ‘indexDarkColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.indexDarkColor
},
{
name: ‘useShadow’,
label: i18n([‘Text shadow’, ‘文字阴影’]),
type: ‘switch’,
default: preference.useShadow
},
{
name: ‘fontSize’,
label: i18n([‘Font size’, ‘字体大小’]),
type: ‘number’,
default: preference.fontSize
},
{
name: ‘timeColor’,
label: i18n([‘Time color’, ‘时间颜色’]),
type: ‘color’,
default: preference.timeColor
},
{
name: ‘logoSize’,
label: i18n([‘Logo size (0: hidden)’, ‘Logo 大小(0:隐藏)’]),
type: ‘number’,
default: preference.logoSize
},
{
name: ‘columns’,
label: i18n([‘Column count’, ‘列数’]),
type: ‘select’,
options: [
{ label: ‘1’, value: ‘1’ },
{ label: ‘2’, value: ‘2’ },
{ label: ‘3’, value: ‘3’ }
],
default: preference.columns
}
],
render: async ({ family, settings }) => {
family && (config.widgetFamily = family);
Object.assign(preference, settings);
try {
return await createWidget(data)
} catch (e) {
console.error(e);
}
}
});
Script.complete();
};
await main();