// Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-glyph: frog; icon-color: green; /** * 在 App 内运行时会请求最新的货币列表并缓存 * * 其他情况优先使用缓存中货币 ID 去请求数据 * * @version 1.2.2 * @author Honye */ /** * @param {object} options * @param {string} [options.title] * @param {string} [options.message] * @param {Array<{ title: string; [key: string]: any }>} options.options * @param {boolean} [options.showCancel = true] * @param {string} [options.cancelText = 'Cancel'] */ const presentSheet = async (options) => { options = { showCancel: true, cancelText: 'Cancel', ...options }; const alert = new Alert(); if (options.title) { alert.title = options.title; } if (options.message) { alert.message = options.message; } if (!options.options) { throw new Error('The "options" property of the parameter cannot be empty') } for (const option of options.options) { alert.addAction(option.title); } if (options.showCancel) { alert.addCancelAction(options.cancelText); } const value = await alert.presentSheet(); return { value, option: options.options[value] } }; const getImage = async (url) => { const request = new Request(url); const image = await request.loadImage(); return image }; const useCache$1 = () => { const fm = FileManager.local(); const cacheDirectory = fm.joinPath(fm.documentsDirectory(), `${Script.name()}/cache`); /** * 删除路径末尾所有的 / * @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); }; 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) }; const readString = (filePath) => { return fm.readString( fm.joinPath(cacheDirectory, 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 } }; /** * @param {string} data */ const hashCode = (data) => { return Array.from(data).reduce((accumulator, currentChar) => Math.imul(31, accumulator) + currentChar.charCodeAt(0), 0) }; const cache$1 = useCache$1(); const useCache = (useICloud) => { const fm = FileManager[useICloud ? 'iCloud' : 'local'](); const cacheDirectory = fm.joinPath(fm.documentsDirectory(), Script.name()); const writeString = (filePath, content) => { const safePath = fm.joinPath(cacheDirectory, filePath).replace(/\/+$/, ''); const i = safePath.lastIndexOf('/'); const directory = safePath.substring(0, i); if (!fm.fileExists(directory)) { fm.createDirectory(directory, true); } fm.writeString(safePath, content); }; const writeJSON = (filePath, jsonData) => writeString(filePath, JSON.stringify(jsonData)); const readString = (filePath) => { return fm.readString( fm.joinPath(cacheDirectory, filePath) ) }; const readJSON = (filePath) => JSON.parse(readString(filePath)); return { cacheDirectory, writeString, writeJSON, readString, readJSON } }; const readSettings = async () => { const localFM = useCache(); let settings = localFM.readJSON('settings.json'); if (settings) { console.log('[info] use local settings'); return settings } const iCloudFM = useCache(true); settings = iCloudFM.readJSON('settings.json'); if (settings) { console.log('[info] use iCloud settings'); } return settings }; /** * @param {Record<string, unknown>} data * @param {{ useICloud: boolean; }} options */ const writeSettings = async (data, { useICloud }) => { const fm = useCache(useICloud); fm.writeJSON('settings.json', data); }; const removeSettings = async (settings) => { const cache = useCache(settings.useICloud); FileManager.local().remove( FileManager.local().joinPath( cache.cacheDirectory, 'settings.json' ) ); }; const moveSettings = (useICloud, data) => { const localFM = useCache(); const iCloudFM = useCache(true); const [i, l] = [ FileManager.local().joinPath( iCloudFM.cacheDirectory, 'settings.json' ), FileManager.local().joinPath( localFM.cacheDirectory, 'settings.json' ) ]; try { writeSettings(data, { useICloud }); if (useICloud) { FileManager.local().remove(l); } else { FileManager.iCloud().remove(i); } } catch (e) { console.error(e); } }; /** * @param {object} options * @param {{ * name: string; * label: string; * type: string; * default: unknow; * }[]} options.formItems * @param {(data: { * settings: Record<string, string>; * family: string; * }) => Promise<ListWidget>} options.render * @param {string} [options.homePage] * @returns {Promise<ListWidget|undefined>} 在 Widget 中运行时返回 ListWidget,其它无返回 */ const withSettings = async (options = {}) => { const { formItems = [], render, homePage = 'https://www.imarkr.com' } = options; /** @type {{ backgroundImage?: string; [key: string]: unknown }} */ let settings = await readSettings() || {}; const imgPath = FileManager.local().joinPath( cache$1.cacheDirectory, 'bg.png' ); if (config.runsInWidget) { const widget = await render({ settings }); if (settings.backgroundImage) { widget.backgroundImage = FileManager.local().readImage(imgPath); } 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; font-size: 16px; min-height: 2em; padding: 0.5em 20px; position: relative; } .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 .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='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; 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: 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; } }`; const js = `(() => { const settings = JSON.parse('${JSON.stringify(settings)}') const formItems = JSON.parse('${JSON.stringify(formItems)}') window.invoke = (code, data) => { window.dispatchEvent( new CustomEvent( 'JBridge', { detail: { code, data } } ) ) } const iCloudInput = document.querySelector('input[name="useICloud"]') iCloudInput.checked = settings.useICloud iCloudInput .addEventListener('change', (e) => { invoke('moveSettings', e.target.checked) }) const formData = {}; const fragment = document.createDocumentFragment() for (const item of formItems) { const value = settings[item.name] ?? item.default ?? null formData[item.name] = value; const label = document.createElement("label"); label.className = "form-item"; const div = document.createElement("div"); div.innerText = item.label; label.appendChild(div); if (item.type === 'select') { const select = document.createElement('select') select.className = 'form-item__input' select.name = item.name select.value = value for (const opt of (item.options || [])) { const option = document.createElement('option') option.value = opt.value option.innerText = opt.label option.selected = value === opt.value select.appendChild(option) } select.addEventListener('change', (e) => { formData[item.name] = e.target.value invoke('changeSettings', formData) }) label.appendChild(select) } 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.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); } fragment.appendChild(label); } document.getElementById('form').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' const listener = (event) => { const { code } = event.detail if (code === 'previewStart') { target.classList.remove('loading') icon.className = className window.removeEventListener('JWeb', listener); } } window.addEventListener('JWeb', listener) invoke('preview', e.currentTarget.dataset.size) }) } const reset = () => { for (const item of formItems) { const el = document.querySelector(\`.form-item__input[name="\${item.name}"]\`) formData[item.name] = item.default if (item.type === 'switch') { el.checked = item.default } else { el.value = item.default } } invoke('removeSettings', formData) } document.getElementById('reset').addEventListener('click', () => reset()) document.getElementById('chooseBgImg') .addEventListener('click', () => invoke('chooseBgImg')) })()`; const html = `<html> <head> <meta name='viewport' content='width=device-width, user-scalable=no'> <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3772663_kmo790s3yfq.css" type="text/css"> <style>${style}</style> </head> <body> <div class="list"> <div class="list__header">Common</div> <form class="list__body" action="javascript:void(0);"> <label class="form-item"> <div>Sync with iCloud</div> <input name="useICloud" type="checkbox" role="switch"> </label> <label id="chooseBgImg" class="form-item form-item--link"> <div>Background image</div> <i class="iconfont icon-arrow_right"></i> </label> <label id='reset' class="form-item form-item--link"> <div>Reset</div> <i class="iconfont icon-arrow_right"></i> </label> </form> </div> <div class="list"> <div class="list__header">Settings</div> <form id="form" class="list__body" action="javascript:void(0);"></form> </div> <div class="actions"> <button class="preview" data-size="small"><i class="iconfont icon-yingyongzhongxin"></i>Small</button> <button class="preview" data-size="medium"><i class="iconfont icon-daliebiao"></i>Medium</button> <button class="preview" data-size="large"><i class="iconfont icon-dantupailie"></i>Large</button> </div> <footer> <div class="copyright">Copyright © 2022 <a href="javascript:invoke('safari','https://www.imarkr.com');">iMarkr</a> All rights reserved.</div> </footer> <script>${js}</script> </body> </html>`; const webView = new WebView(); await webView.loadHTML(html, homePage); const clearBgImg = () => { delete settings.backgroundImage; const fm = FileManager.local(); if (fm.fileExists(imgPath)) { fm.remove(imgPath); } }; const chooseBgImg = async () => { const { option } = await presentSheet({ options: [ { key: 'choose', title: 'Choose photo' }, { key: 'clear', title: 'Clear background image' } ] }); switch (option?.key) { case 'choose': { try { const image = await Photos.fromLibrary(); cache$1.writeImage('bg.png', image); settings.backgroundImage = imgPath; writeSettings(settings, { useICloud: settings.useICloud }); } catch (e) {} break } case 'clear': clearBgImg(); writeSettings(settings, { useICloud: settings.useICloud }); break } }; const injectListener = async () => { const event = await webView.evaluateJavaScript( `(() => { const controller = new AbortController() const listener = (e) => { completion(e.detail) controller.abort() } window.addEventListener( 'JBridge', listener, { signal: controller.signal } ) })()`, true ).catch((err) => { console.error(err); throw err }); const { code, data } = event; switch (code) { case 'preview': { const widget = await render({ settings, family: data }); const { backgroundImage } = settings; if (backgroundImage) { widget.backgroundImage = FileManager.local().readImage(backgroundImage); } webView.evaluateJavaScript( 'window.dispatchEvent(new CustomEvent(\'JWeb\', { detail: { code: \'previewStart\' } }))', false ); widget[`present${data.replace(data[0], data[0].toUpperCase())}`](); break } case 'safari': Safari.openInApp(data, true); break case 'changeSettings': settings = { ...settings, ...data }; writeSettings(data, { useICloud: settings.useICloud }); break case 'moveSettings': settings.useICloud = data; moveSettings(data, settings); break case 'removeSettings': settings = { ...settings, ...data }; clearBgImg(); removeSettings(settings); break case 'chooseBgImg': await chooseBgImg(); break } injectListener(); }; injectListener().catch((e) => { console.error(e); throw e }); webView.present(); // ======= web end ========= }; /** * 是否缓存请求响应数据 * * - `true`:网络异常时显示历史缓存数据 * - `false`:当网络异常时组件会显示红色异常信息 */ let cacheData = true; const API_BASE = 'https://api.coingecko.com/api/v3'; const cache = useCache$1(); /** 只支持中英文 */ const language = Device.language() === 'zh' ? 'zh' : 'en'; const fetchCoinList = async () => { if (!config.runsInApp) { try { const list = cache.readJSON('coins-list.json'); if (list && list.length) { return list } } catch (e) {} } const url = `${API_BASE}/coins/list`; const request = new Request(url); const json = await request.loadJSON(); cache.writeJSON('coins-list.json', json); return json }; const findCoins = async (symbols) => { const list = await fetchCoinList(); const result = []; for (const symbol of symbols) { const coin = list.find((item) => item.symbol.toLowerCase() === symbol.toLowerCase()); result.push(coin); } return result }; const fetchMarkets = async (params = {}) => { const query = Object.entries({ vs_currency: 'USD', ...params }) .map(([k, v]) => `${k}=${v || ''}`) .join('&'); const url = `${API_BASE}/coins/markets?${query}`; const request = new Request(url); try { const json = await request.loadJSON(); if (cacheData) { cache.writeJSON('data.json', json); } return json } catch (e) { if (cacheData) { return cache.readJSON('data.json') } throw e } }; /** * @param {string} url * @returns {Image} */ const getIcon = async (url) => { const hash = `${hashCode(url)}`; try { const icon = cache.readImage(hash); if (!icon) { throw new Error('no cached icon') } return icon } catch (e) { const icon = await getImage(url); cache.writeImage(hash, icon); return icon } }; const detailURL = (market) => { return `https://www.coingecko.com/${language}/${language === 'zh' ? encodeURIComponent('数字货币') : 'coins'}/${market.id}` }; const getSmallBg = async (url) => { const webview = new WebView(); const js = `const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const { width, height } = img canvas.width = width canvas.height = height ctx.globalAlpha = 0.3 ctx.drawImage( img, -width / 2 + 50, -height / 2 + 50, width, height ) const uri = canvas.toDataURL() completion(uri); }; img.src = '${url}'`; const uri = await webview.evaluateJavaScript(js, true); const base64str = uri.replace(/^data:image\/\w+;base64,/, ''); const image = Image.fromData(Data.fromBase64String(base64str)); return image }; const addListItem = async (widget, market) => { const item = widget.addStack(); item.url = detailURL(market); const left = item.addStack(); left.centerAlignContent(); const image = left.addImage(await getIcon(market.image)); image.imageSize = new Size(28, 28); left.addSpacer(8); const coin = left.addStack(); coin.layoutVertically(); const symbol = coin.addText(market.symbol.toUpperCase()); symbol.font = Font.semiboldSystemFont(16); const name = coin.addText(market.name); name.font = Font.systemFont(10); name.textColor = Color.gray(); const right = item.addStack(); const price = right.addStack(); price.layoutVertically(); price.centerAlignContent(); const cuWrap = price.addStack(); cuWrap.addSpacer(); const currency = cuWrap.addText(`$ ${market.current_price}`); currency.font = Font.semiboldSystemFont(15); const timeWrap = price.addStack(); timeWrap.addSpacer(); const dfm = new DateFormatter(); dfm.dateFormat = 'hh:mm'; const time = timeWrap.addText(dfm.string(new Date(market.last_updated))); time.font = Font.systemFont(10); time.textColor = Color.gray(); right.addSpacer(8); const perWrap = right.addStack(); perWrap.size = new Size(72, 28); perWrap.cornerRadius = 4; const per = market.price_change_percentage_24h; perWrap.backgroundColor = per > 0 ? Color.green() : Color.red(); perWrap.centerAlignContent(); const percent = perWrap.addText(`${per > 0 ? '+' : ''}${per.toFixed(2)}%`); percent.font = Font.semiboldSystemFont(14); percent.textColor = Color.white(); percent.lineLimit = 1; percent.minimumScaleFactor = 0.1; }; const addList = async (widget, data) => { widget.url = `https://www.coingecko.com/${language}`; widget.setPadding(5, 15, 5, 15); await Promise.all( data.map((item) => { const add = async () => { widget.addSpacer(); await addListItem(widget, item); }; return add() }) ); widget.addSpacer(); }; const render = async (data) => { const market = data[0]; const widget = new ListWidget(); widget.backgroundColor = Color.dynamic(new Color('#fff'), new Color('#242426')); if (config.widgetFamily === 'small') { widget.url = detailURL(market); const image = await getIcon(market.image); const obase64str = Data.fromPNG(image).toBase64String(); widget.backgroundColor = Color.dynamic(new Color('#fff'), new Color('#242426')); const bg = await getSmallBg(`data:image/png;base64,${obase64str}`); widget.backgroundImage = bg; widget.setPadding(12, 12, 12, 12); const coin = widget.addText(market.symbol.toUpperCase()); coin.font = Font.heavySystemFont(24); coin.rightAlignText(); const name = widget.addText(market.name); name.font = Font.systemFont(10); name.textColor = Color.gray(); name.rightAlignText(); widget.addSpacer(); const changePercent = market.price_change_percentage_24h || NaN; const trend = widget.addText(`${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%`); trend.font = Font.semiboldSystemFont(16); trend.textColor = changePercent >= 0 ? Color.green() : Color.red(); trend.rightAlignText(); const price = widget.addText(`$ ${market.current_price}`); price.font = Font.boldSystemFont(28); price.rightAlignText(); price.lineLimit = 1; price.minimumScaleFactor = 0.1; const history = widget.addText(`H: ${market.high_24h}, L: ${market.low_24h}`); history.font = Font.systemFont(10); history.textColor = Color.gray(); history.rightAlignText(); history.lineLimit = 1; history.minimumScaleFactor = 0.1; } else if (config.widgetFamily === 'medium') { await addList(widget, data.slice(0, 3)); } else if (config.widgetFamily === 'large') { await addList(widget, data.slice(0, 6)); } return widget }; const main = async () => { const [symbols] = (args.widgetParameter || '').split(';').map((item) => item.trim()); // symbols = 'btc,eth' let ids = ''; if (symbols) { const list = await findCoins( symbols.split(',').map((item) => item.trim()) ); ids = list.filter((item) => item) .map((item) => item.id) .join(','); } const widget = await withSettings({ formItems: [ { name: 'cacheData', type: 'switch', label: 'Cache data', default: true } ], render: async ({ settings, family }) => { config.widgetFamily = family ?? config.widgetFamily; cacheData = settings.cacheData ?? cacheData; const markets = await fetchMarkets({ ids }); const widget = await render(markets); return widget } }); if (config.runsInWidget) { Script.setWidget(widget); } }; await main();