dsh-remote-plugin 0.5.9 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +133 -0
- package/index.mjs +68 -3
- package/package.json +1 -1
- package/public/admin.html +17 -0
- package/public/admin.js +9 -0
- package/public/app.js +459 -17
- package/public/desktop/desktop.html +160 -15
- package/public/desktop/desktop.js +275 -4
- package/public/donate.png +0 -0
- package/public/index.html +271 -85
- package/public/update.json +4 -3
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -57,6 +57,8 @@ const state = {
|
|
|
57
57
|
jobs: {}, // sessionId -> jobs
|
|
58
58
|
history: emptyHistory(),
|
|
59
59
|
errCount: 0,
|
|
60
|
+
streamMode: 'ws', // 'ws' | 'poll'
|
|
61
|
+
pollSeq: { mux: 0, host: 0 },
|
|
60
62
|
refreshTimer: null,
|
|
61
63
|
fs: { path: null, initial: null, loaded: false, upload: null },
|
|
62
64
|
models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
|
|
@@ -267,11 +269,15 @@ function renderStats(days) {
|
|
|
267
269
|
}).join('')
|
|
268
270
|
}
|
|
269
271
|
async function rpc(method, payload = {}) {
|
|
270
|
-
const
|
|
272
|
+
const opts = {
|
|
271
273
|
method: 'POST',
|
|
272
274
|
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
273
275
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
274
|
-
}
|
|
276
|
+
}
|
|
277
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
278
|
+
opts.signal = AbortSignal.timeout(20000)
|
|
279
|
+
}
|
|
280
|
+
const res = await fetch(apiUrl('/api/' + method), opts)
|
|
275
281
|
if (res.status === 401) throw new Error('AUTH')
|
|
276
282
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
277
283
|
const full = await res.json()
|
|
@@ -449,6 +455,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
449
455
|
if (srv) state.groupActive[state.activeGroup] = srv.id
|
|
450
456
|
}
|
|
451
457
|
saveServers()
|
|
458
|
+
syncBgConfig()
|
|
452
459
|
if (!silent) {
|
|
453
460
|
if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
|
|
454
461
|
else toast(t('speed.switchedOrigin'), 'ok')
|
|
@@ -697,12 +704,16 @@ function deleteGroup(name) {
|
|
|
697
704
|
if (state.token) selectFastestServer({ silent: true })
|
|
698
705
|
}
|
|
699
706
|
|
|
700
|
-
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
707
|
+
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
701
708
|
const streams = {}
|
|
702
709
|
state.streamsOk = { mux: false, host: false }
|
|
710
|
+
let pollTimer = null
|
|
711
|
+
let wsRetryTimer = null
|
|
703
712
|
|
|
704
713
|
function openStreams() {
|
|
705
714
|
if (!state.token) return
|
|
715
|
+
if (state.streamMode === 'poll') stopPolling()
|
|
716
|
+
state.streamMode = 'ws'
|
|
706
717
|
openStream('mux', onMuxFrame, true)
|
|
707
718
|
openStream('host', onHostFrame, false)
|
|
708
719
|
}
|
|
@@ -723,6 +734,8 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
723
734
|
ws.onopen = () => {
|
|
724
735
|
state.streamsOk[kind] = true
|
|
725
736
|
state.errCount = 0
|
|
737
|
+
// 重连成功:切回 WS 并停止轮询
|
|
738
|
+
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
726
739
|
updateConn()
|
|
727
740
|
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
728
741
|
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
@@ -746,7 +759,9 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
746
759
|
state.streamsOk[kind] = false
|
|
747
760
|
state.errCount++
|
|
748
761
|
updateConn()
|
|
749
|
-
if (state.
|
|
762
|
+
if (state.streamMode === 'poll') return
|
|
763
|
+
// 连续失败 3 次 -> 降级为轮询
|
|
764
|
+
if (state.errCount >= 3) { enterPollMode(); return }
|
|
750
765
|
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
751
766
|
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
752
767
|
// 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
@@ -755,6 +770,78 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
755
770
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
756
771
|
}
|
|
757
772
|
|
|
773
|
+
/* ---------------- 轮询降级模式 ---------------- */
|
|
774
|
+
function enterPollMode() {
|
|
775
|
+
if (state.streamMode === 'poll') return
|
|
776
|
+
state.streamMode = 'poll'
|
|
777
|
+
state.pollSeq = { mux: 0, host: 0 }
|
|
778
|
+
state.streamsOk = { mux: false, host: false }
|
|
779
|
+
try { streams.mux?.close() } catch {}
|
|
780
|
+
try { streams.host?.close() } catch {}
|
|
781
|
+
streams.mux = null
|
|
782
|
+
streams.host = null
|
|
783
|
+
refreshAll()
|
|
784
|
+
startPolling()
|
|
785
|
+
updateConn()
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function stopPolling() {
|
|
789
|
+
clearInterval(pollTimer)
|
|
790
|
+
pollTimer = null
|
|
791
|
+
clearTimeout(wsRetryTimer)
|
|
792
|
+
wsRetryTimer = null
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function startPolling() {
|
|
796
|
+
stopPolling()
|
|
797
|
+
pollTimer = setInterval(pollOnce, 4000)
|
|
798
|
+
wsRetryTimer = setInterval(tryRestoreWs, 30000)
|
|
799
|
+
pollOnce()
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let pollInFlight = false
|
|
803
|
+
async function pollOnce() {
|
|
804
|
+
if (state.streamMode !== 'poll' || pollInFlight) return
|
|
805
|
+
pollInFlight = true
|
|
806
|
+
try {
|
|
807
|
+
await Promise.all([pollKind('mux'), pollKind('host')])
|
|
808
|
+
} finally {
|
|
809
|
+
pollInFlight = false
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function pollKind(kind) {
|
|
814
|
+
if (state.streamMode !== 'poll') return
|
|
815
|
+
const since = state.pollSeq[kind] || 0
|
|
816
|
+
let res
|
|
817
|
+
try {
|
|
818
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
|
|
819
|
+
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
820
|
+
res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
|
|
821
|
+
} catch { return }
|
|
822
|
+
if (res.status === 401) { authFailure(); return }
|
|
823
|
+
if (!res.ok) return
|
|
824
|
+
let data
|
|
825
|
+
try { data = await res.json() } catch { return }
|
|
826
|
+
if (!data || !Array.isArray(data.events)) return
|
|
827
|
+
// 网关重启后 seq 会重置:落后就从头拉当前缓冲
|
|
828
|
+
if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
|
|
829
|
+
for (const item of data.events) {
|
|
830
|
+
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
831
|
+
state.pollSeq[kind] = item.seq
|
|
832
|
+
if (kind === 'mux') onMuxFrame(item.event)
|
|
833
|
+
else onHostFrame(item.event)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function tryRestoreWs() {
|
|
839
|
+
if (state.streamMode !== 'poll' || !state.token) return
|
|
840
|
+
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
841
|
+
openStream('mux', onMuxFrame, true)
|
|
842
|
+
openStream('host', onHostFrame, false)
|
|
843
|
+
}
|
|
844
|
+
|
|
758
845
|
/* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
|
|
759
846
|
function onResume() {
|
|
760
847
|
if (document.visibilityState !== 'visible') return
|
|
@@ -894,6 +981,10 @@ function applyProjection(sessionId, key, value, seq) {
|
|
|
894
981
|
else renderSessions()
|
|
895
982
|
}function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
|
|
896
983
|
function short(id) { return '…' + String(id).slice(-8) }
|
|
984
|
+
const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
|
|
985
|
+
function isGoalTerminal(goal) {
|
|
986
|
+
return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
|
|
987
|
+
}
|
|
897
988
|
function goalOf(s) {
|
|
898
989
|
const p = proj(s, 'goal')
|
|
899
990
|
if (!p) return null
|
|
@@ -946,8 +1037,10 @@ async function openSession(id) {
|
|
|
946
1037
|
state.history = emptyHistory()
|
|
947
1038
|
document.body.classList.add('in-session')
|
|
948
1039
|
showView('view-session')
|
|
1040
|
+
$('session-cards').innerHTML = ''
|
|
949
1041
|
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
950
1042
|
$('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
|
|
1043
|
+
restoreCachedHistory()
|
|
951
1044
|
await loadHistory(true)
|
|
952
1045
|
renderSessionCards()
|
|
953
1046
|
refreshSessions()
|
|
@@ -967,7 +1060,7 @@ function bindNativeBack() {
|
|
|
967
1060
|
try {
|
|
968
1061
|
CAP.Plugins?.App?.addListener?.('backButton', () => {
|
|
969
1062
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
970
|
-
if (openModal) { openModal.classList.add('hidden'); return } // 先关弹窗
|
|
1063
|
+
if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else openModal.classList.add('hidden'); return } // 先关弹窗
|
|
971
1064
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
972
1065
|
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
973
1066
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
@@ -1110,8 +1203,12 @@ async function loadHistory(reset) {
|
|
|
1110
1203
|
trimVisible()
|
|
1111
1204
|
state.history.hasMore = !!v.hasMore
|
|
1112
1205
|
state.history.loading = false
|
|
1113
|
-
|
|
1114
|
-
|
|
1206
|
+
try {
|
|
1207
|
+
if (reset) renderHistory(true)
|
|
1208
|
+
else if (added) renderHistory(false, 'keep')
|
|
1209
|
+
} catch (e) {
|
|
1210
|
+
console.error('renderHistory failed', e)
|
|
1211
|
+
}
|
|
1115
1212
|
if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
|
|
1116
1213
|
$('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
|
|
1117
1214
|
scheduleHistoryCacheSave()
|
|
@@ -1256,6 +1353,13 @@ function shouldShowEvent(type) {
|
|
|
1256
1353
|
if (INTERESTING_EVENTS.has(type)) return true
|
|
1257
1354
|
return false
|
|
1258
1355
|
}
|
|
1356
|
+
function systemReminderText(blocks) {
|
|
1357
|
+
if (!Array.isArray(blocks)) return ''
|
|
1358
|
+
return blocks
|
|
1359
|
+
.filter(b => b && typeof b === 'object' && b.type === 'text' && String(b.text ?? '').trimStart().startsWith('<system-reminder>'))
|
|
1360
|
+
.map(b => String(b.text ?? ''))
|
|
1361
|
+
.join('\n')
|
|
1362
|
+
}
|
|
1259
1363
|
function eventHtml(entry, ctx = {}) {
|
|
1260
1364
|
const seq = entry.seq
|
|
1261
1365
|
const ev = entry.event || {}
|
|
@@ -1268,7 +1372,12 @@ function eventHtml(entry, ctx = {}) {
|
|
|
1268
1372
|
const msg = data.message || {}
|
|
1269
1373
|
const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
|
|
1270
1374
|
const blocks = msg.content || data.content || []
|
|
1271
|
-
|
|
1375
|
+
const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
|
|
1376
|
+
if (sysText) {
|
|
1377
|
+
inner = `<details class="event" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 400))}</pre></details>`
|
|
1378
|
+
} else {
|
|
1379
|
+
inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
|
|
1380
|
+
}
|
|
1272
1381
|
} else if (type === 'tool/call') {
|
|
1273
1382
|
const name = data.name || data.toolName || t('tool.default')
|
|
1274
1383
|
const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
|
|
@@ -1354,12 +1463,13 @@ async function renderSessionCards() {
|
|
|
1354
1463
|
const box = $('session-cards')
|
|
1355
1464
|
const statsBox = $('stats-body')
|
|
1356
1465
|
if (!s) { box.innerHTML = ''; if (statsBox) statsBox.innerHTML = ''; return }
|
|
1357
|
-
if (statsBox) statsBox.innerHTML = statsHtml(s)
|
|
1466
|
+
if (statsBox) { try { statsBox.innerHTML = statsHtml(s) } catch {} }
|
|
1467
|
+
box.innerHTML = ''
|
|
1358
1468
|
const goal = goalOf(s)
|
|
1359
1469
|
const todos = proj(s, 'todos')
|
|
1360
1470
|
let html = ''
|
|
1361
1471
|
|
|
1362
|
-
if (goal) {
|
|
1472
|
+
if (goal && !isGoalTerminal(goal)) {
|
|
1363
1473
|
html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
|
|
1364
1474
|
<div class="goal-obj">${esc(goal.objective || '')}</div>
|
|
1365
1475
|
<div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
|
|
@@ -1394,6 +1504,16 @@ async function renderSessionCards() {
|
|
|
1394
1504
|
}
|
|
1395
1505
|
}
|
|
1396
1506
|
|
|
1507
|
+
function setGoalPhaseLocal(phase) {
|
|
1508
|
+
const s = state.byId.get(state.current)
|
|
1509
|
+
const p = s && proj(s, 'goal')
|
|
1510
|
+
const goal = p && typeof p === 'object' && p.goal && typeof p.goal === 'object' ? p.goal : p
|
|
1511
|
+
if (!goal) return
|
|
1512
|
+
goal.phase = phase
|
|
1513
|
+
renderSessions()
|
|
1514
|
+
renderSessionCards()
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1397
1517
|
async function goalAction(kind) {
|
|
1398
1518
|
const s = state.byId.get(state.current)
|
|
1399
1519
|
const goal = goalOf(s)
|
|
@@ -1406,6 +1526,8 @@ async function goalAction(kind) {
|
|
|
1406
1526
|
if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
|
|
1407
1527
|
if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
|
|
1408
1528
|
await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
|
|
1529
|
+
if (kind === 'complete') setGoalPhaseLocal('complete')
|
|
1530
|
+
if (kind === 'clear') setGoalPhaseLocal('cleared')
|
|
1409
1531
|
toast(t('goal.actionSubmitted'), 'ok')
|
|
1410
1532
|
scheduleRefresh()
|
|
1411
1533
|
}
|
|
@@ -1418,9 +1540,34 @@ async function interruptSubagent(childId) {
|
|
|
1418
1540
|
}
|
|
1419
1541
|
|
|
1420
1542
|
/* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
|
|
1543
|
+
async function runSlashCommand(text) {
|
|
1544
|
+
const clean = String(text || '').trim()
|
|
1545
|
+
if (!clean.startsWith('/') || !state.current) return false
|
|
1546
|
+
try {
|
|
1547
|
+
const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
1548
|
+
? AbortSignal.timeout(20000)
|
|
1549
|
+
: undefined
|
|
1550
|
+
const res = await fetch(apiUrl('/remote/api/command'), {
|
|
1551
|
+
method: 'POST',
|
|
1552
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
|
|
1553
|
+
body: JSON.stringify({ sessionId: state.current, line: clean }),
|
|
1554
|
+
...(signal ? { signal } : {})
|
|
1555
|
+
})
|
|
1556
|
+
if (res.status === 401) { authFailure(); return true }
|
|
1557
|
+
if (!res.ok) return false
|
|
1558
|
+
const data = await res.json().catch(() => null)
|
|
1559
|
+
if (data?.ok === false) { toast(data.message || t('send.failed'), 'err'); return true }
|
|
1560
|
+
if (data?.ok && data.executed === true) { toast(t('send.commandExecuted'), 'ok'); return true }
|
|
1561
|
+
} catch (e) {
|
|
1562
|
+
console.error('slash command bridge failed', e)
|
|
1563
|
+
}
|
|
1564
|
+
return false
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1421
1567
|
async function sendSessionText(text) {
|
|
1422
1568
|
const clean = String(text || '').trim()
|
|
1423
1569
|
if (!clean || !state.current) return false
|
|
1570
|
+
if (await runSlashCommand(clean)) return true
|
|
1424
1571
|
$('btn-send').disabled = true
|
|
1425
1572
|
const v = await safeRpc('session.prompt', {
|
|
1426
1573
|
sessionId: state.current,
|
|
@@ -2158,6 +2305,54 @@ async function loadLocalVersion() {
|
|
|
2158
2305
|
$('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
|
|
2159
2306
|
}
|
|
2160
2307
|
|
|
2308
|
+
/* ---------------- 更新内容弹窗 ---------------- */
|
|
2309
|
+
const NOTES_KEY = 'seenNotesVersion'
|
|
2310
|
+
let notesVersion = ''
|
|
2311
|
+
let notesPages = []
|
|
2312
|
+
let notesPage = 0
|
|
2313
|
+
function splitNotes(notes) {
|
|
2314
|
+
return String(notes || '').split(/[;;]/).map(s => s.trim()).filter(Boolean)
|
|
2315
|
+
}
|
|
2316
|
+
function renderNotesPages(items) {
|
|
2317
|
+
const box = $('notes-pages')
|
|
2318
|
+
if (!box) return
|
|
2319
|
+
const pages = []
|
|
2320
|
+
for (let i = 0; i < items.length; i += 3) pages.push(items.slice(i, i + 3))
|
|
2321
|
+
notesPages = pages
|
|
2322
|
+
notesPage = 0
|
|
2323
|
+
box.innerHTML = pages.map(page => `<div class="notes-page" style="flex:0 0 100%;scroll-snap-align:start;box-sizing:border-box;min-width:0;">${page.map(item => `<div class="notes-item" style="padding:6px 0;line-height:1.5;">${esc(item)}</div>`).join('')}</div>`).join('')
|
|
2324
|
+
box.scrollLeft = 0
|
|
2325
|
+
updateNotesPage()
|
|
2326
|
+
}
|
|
2327
|
+
function updateNotesPage() {
|
|
2328
|
+
const box = $('notes-pages')
|
|
2329
|
+
const pageEl = $('notes-page')
|
|
2330
|
+
if (!box || !pageEl) return
|
|
2331
|
+
const total = notesPages.length || 1
|
|
2332
|
+
const idx = Math.min(Math.max(0, Math.round(box.scrollLeft / Math.max(1, box.clientWidth))), total - 1)
|
|
2333
|
+
notesPage = idx
|
|
2334
|
+
pageEl.textContent = t('notes.page', { current: idx + 1, total })
|
|
2335
|
+
}
|
|
2336
|
+
function scrollNotes(dir) {
|
|
2337
|
+
const box = $('notes-pages')
|
|
2338
|
+
if (box) box.scrollBy({ left: dir * box.clientWidth, behavior: 'smooth' })
|
|
2339
|
+
}
|
|
2340
|
+
function openNotesModal(info) {
|
|
2341
|
+
if (!info?.version || String(info.version).includes('-rc')) return
|
|
2342
|
+
if (LS.get(NOTES_KEY) === info.version) return
|
|
2343
|
+
const items = splitNotes(info.notes)
|
|
2344
|
+
if (!items.length) return
|
|
2345
|
+
notesVersion = info.version
|
|
2346
|
+
const vEl = $('notes-version')
|
|
2347
|
+
if (vEl) vEl.textContent = 'v' + info.version
|
|
2348
|
+
renderNotesPages(items)
|
|
2349
|
+
$('modal-notes').classList.remove('hidden')
|
|
2350
|
+
}
|
|
2351
|
+
function closeNotesModal() {
|
|
2352
|
+
$('modal-notes').classList.add('hidden')
|
|
2353
|
+
if (notesVersion) { LS.set(NOTES_KEY, notesVersion); notesVersion = '' }
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2161
2356
|
async function checkUpdate(silent) {
|
|
2162
2357
|
if (!state.localVersion) {
|
|
2163
2358
|
$('update-desc').textContent = t('update.noVersion')
|
|
@@ -2177,6 +2372,7 @@ async function checkUpdate(silent) {
|
|
|
2177
2372
|
const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
|
|
2178
2373
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
2179
2374
|
const info = await res.json()
|
|
2375
|
+
openNotesModal(info)
|
|
2180
2376
|
if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
|
|
2181
2377
|
state.updateInfo = info
|
|
2182
2378
|
const hasNotes = !!(info.notes && String(info.notes).trim())
|
|
@@ -2201,13 +2397,64 @@ async function checkUpdate(silent) {
|
|
|
2201
2397
|
}
|
|
2202
2398
|
}
|
|
2203
2399
|
|
|
2204
|
-
function
|
|
2400
|
+
async function sha256Hex(buffer) {
|
|
2401
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
|
2402
|
+
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* 下载 APK 并用 update.json 的 sha256 校验。
|
|
2407
|
+
* 返回 { ok, skipped } 或 { ok:false, status | corrupted | network }。
|
|
2408
|
+
* 老产物没有 sha256 时跳过校验;crypto.subtle 不可用也跳过(不阻塞老 WebView)。
|
|
2409
|
+
*/
|
|
2410
|
+
async function verifyUpdateApk(info, url) {
|
|
2411
|
+
const expected = String(info.sha256 || '').trim().toLowerCase()
|
|
2412
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true }
|
|
2413
|
+
let res
|
|
2414
|
+
try {
|
|
2415
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(120000) : undefined
|
|
2416
|
+
res = signal ? await fetch(url, { signal }) : await fetch(url)
|
|
2417
|
+
} catch (err) {
|
|
2418
|
+
return { ok: false, network: true, msg: err?.message || '' }
|
|
2419
|
+
}
|
|
2420
|
+
if (!res.ok) return { ok: false, status: res.status }
|
|
2421
|
+
let buf
|
|
2422
|
+
try {
|
|
2423
|
+
buf = await res.arrayBuffer()
|
|
2424
|
+
} catch (err) {
|
|
2425
|
+
return { ok: false, network: true, msg: err?.message || '' }
|
|
2426
|
+
}
|
|
2427
|
+
let actual
|
|
2428
|
+
try {
|
|
2429
|
+
actual = await sha256Hex(buf)
|
|
2430
|
+
} catch {
|
|
2431
|
+
return { ok: true, skipped: true }
|
|
2432
|
+
}
|
|
2433
|
+
if (actual.toLowerCase() !== expected) return { ok: false, corrupted: true }
|
|
2434
|
+
return { ok: true, skipped: false }
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
async function downloadUpdate() {
|
|
2205
2438
|
const info = state.updateInfo
|
|
2206
2439
|
if (!info) return
|
|
2207
2440
|
const base = state.server || ''
|
|
2208
2441
|
let url
|
|
2209
2442
|
try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
|
|
2210
2443
|
catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
|
|
2444
|
+
|
|
2445
|
+
// 先下载校验再交给原生/浏览器安装;校验失败不进入安装
|
|
2446
|
+
const verify = await verifyUpdateApk(info, url)
|
|
2447
|
+
if (!verify.ok) {
|
|
2448
|
+
if (verify.corrupted) {
|
|
2449
|
+
toast(t('update.corrupted'), 'err')
|
|
2450
|
+
} else if (verify.status) {
|
|
2451
|
+
toast(t('update.serverFileMissing'), 'err')
|
|
2452
|
+
} else {
|
|
2453
|
+
toast(t('update.downloadFailed', { msg: verify.msg || t('fs.networkError') }), 'err')
|
|
2454
|
+
}
|
|
2455
|
+
return
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2211
2458
|
if (CAP?.isNativePlatform?.()) {
|
|
2212
2459
|
// Android WebView 原生桥(不依赖 Capacitor 插件路由)
|
|
2213
2460
|
if (window.NativeUpdate?.downloadAndInstall) {
|
|
@@ -2268,6 +2515,108 @@ function notify(title, body) {
|
|
|
2268
2515
|
} catch {}
|
|
2269
2516
|
}
|
|
2270
2517
|
|
|
2518
|
+
/* ---------------- 后台轮询(Android 前台服务) ---------------- */
|
|
2519
|
+
function bgBridge() { return window.NativeBackground }
|
|
2520
|
+
function bgBase() { return (state.server || location.origin || '').replace(/\/+$/, '') }
|
|
2521
|
+
function applyBgConfigFromNative() {
|
|
2522
|
+
const b = bgBridge()
|
|
2523
|
+
if (!b?.getBackgroundConfig) return
|
|
2524
|
+
try {
|
|
2525
|
+
const cfg = JSON.parse(b.getBackgroundConfig() || '{}')
|
|
2526
|
+
$('opt-bg-poll').checked = !!cfg.enabled
|
|
2527
|
+
const v = String(cfg.intervalMin ?? 1)
|
|
2528
|
+
const opts = Array.from($('bg-interval')?.options || [])
|
|
2529
|
+
if (opts.some(o => o.value === v)) $('bg-interval').value = v
|
|
2530
|
+
if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
|
|
2531
|
+
$('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
|
|
2532
|
+
} catch {}
|
|
2533
|
+
}
|
|
2534
|
+
function saveBgConfig(enabled) {
|
|
2535
|
+
const b = bgBridge()
|
|
2536
|
+
if (!b?.saveBackgroundConfig) return false
|
|
2537
|
+
const base = bgBase()
|
|
2538
|
+
const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
|
|
2539
|
+
const notifyTaskDone = $('opt-task-done')?.checked !== false
|
|
2540
|
+
b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
|
|
2541
|
+
if (enabled) $('bg-auth-status')?.classList.add('hidden')
|
|
2542
|
+
return true
|
|
2543
|
+
}
|
|
2544
|
+
function syncBgConfig() {
|
|
2545
|
+
if ($('opt-bg-poll')?.checked && state.token) saveBgConfig(true)
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/* ---------------- 预设提示词 ---------------- */
|
|
2549
|
+
const PRESETS_KEY = 'dshPromptPresets'
|
|
2550
|
+
const PRESET_NAME_MAX = 20
|
|
2551
|
+
const PRESET_TEXT_MAX = 2000
|
|
2552
|
+
const PRESET_LIMIT = 20
|
|
2553
|
+
function readPresets() {
|
|
2554
|
+
try {
|
|
2555
|
+
const v = JSON.parse(LS.get(PRESETS_KEY, '[]') || '[]')
|
|
2556
|
+
return Array.isArray(v) ? v.filter(p => p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.text === 'string') : []
|
|
2557
|
+
} catch { return [] }
|
|
2558
|
+
}
|
|
2559
|
+
function writePresets(list) {
|
|
2560
|
+
LS.set(PRESETS_KEY, JSON.stringify(list))
|
|
2561
|
+
renderPresets()
|
|
2562
|
+
renderPresetMenu()
|
|
2563
|
+
}
|
|
2564
|
+
function renderPresets() {
|
|
2565
|
+
const box = $('preset-list')
|
|
2566
|
+
if (!box) return
|
|
2567
|
+
const list = readPresets()
|
|
2568
|
+
if (!list.length) {
|
|
2569
|
+
box.innerHTML = `<div class="server-empty">${esc(t('presets.empty'))}</div>`
|
|
2570
|
+
return
|
|
2571
|
+
}
|
|
2572
|
+
box.innerHTML = list.map(p => `<div class="server-row">
|
|
2573
|
+
<div class="server-main"><div class="server-note">${esc(p.name)}</div><div class="server-url">${esc((p.text || '').slice(0, 60))}</div></div>
|
|
2574
|
+
<button class="mini-btn" data-preset-edit="${esc(p.id)}">${t('presets.edit')}</button>
|
|
2575
|
+
<button class="mini-btn" data-preset-del="${esc(p.id)}">${t('presets.delete')}</button>
|
|
2576
|
+
</div>`).join('')
|
|
2577
|
+
box.querySelectorAll('[data-preset-edit]').forEach(b => b.addEventListener('click', () => editPreset(b.dataset.presetEdit)))
|
|
2578
|
+
box.querySelectorAll('[data-preset-del]').forEach(b => b.addEventListener('click', () => deletePreset(b.dataset.presetDel)))
|
|
2579
|
+
}
|
|
2580
|
+
function renderPresetMenu() {
|
|
2581
|
+
const group = $('preset-menu-group')
|
|
2582
|
+
const listBox = $('preset-menu-list')
|
|
2583
|
+
if (!group || !listBox) return
|
|
2584
|
+
const list = readPresets()
|
|
2585
|
+
group.classList.toggle('hidden', !list.length)
|
|
2586
|
+
listBox.innerHTML = list.map(p => `<button class="menu-chip" data-preset="${esc(p.id)}">${esc(p.name)}</button>`).join('')
|
|
2587
|
+
}
|
|
2588
|
+
function promptPreset(id) {
|
|
2589
|
+
const list = readPresets()
|
|
2590
|
+
const existing = id ? list.find(p => p.id === id) : null
|
|
2591
|
+
const name = prompt(t('presets.namePrompt'), existing?.name || '')
|
|
2592
|
+
if (name == null) return
|
|
2593
|
+
const text = prompt(t('presets.textPrompt'), existing?.text || '')
|
|
2594
|
+
if (text == null) return
|
|
2595
|
+
const n = (name || '').trim()
|
|
2596
|
+
if (!n) return toast(t('presets.nameEmpty'), 'err')
|
|
2597
|
+
if (n.length > PRESET_NAME_MAX) return toast(t('presets.nameTooLong'), 'err')
|
|
2598
|
+
if (text.length > PRESET_TEXT_MAX) return toast(t('presets.textTooLong'), 'err')
|
|
2599
|
+
if (existing) {
|
|
2600
|
+
existing.name = n
|
|
2601
|
+
existing.text = text
|
|
2602
|
+
} else {
|
|
2603
|
+
if (list.length >= PRESET_LIMIT) return toast(t('presets.limit'), 'err')
|
|
2604
|
+
list.push({ id: 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name: n, text })
|
|
2605
|
+
}
|
|
2606
|
+
writePresets(list)
|
|
2607
|
+
toast(existing ? t('presets.saved') : t('presets.added'), 'ok')
|
|
2608
|
+
}
|
|
2609
|
+
function addPreset() { promptPreset(null) }
|
|
2610
|
+
function editPreset(id) { promptPreset(id) }
|
|
2611
|
+
function deletePreset(id) {
|
|
2612
|
+
const list = readPresets()
|
|
2613
|
+
const p = list.find(x => x.id === id)
|
|
2614
|
+
if (!p) return
|
|
2615
|
+
if (!confirm(t('presets.confirmDelete', { name: p.name }))) return
|
|
2616
|
+
writePresets(list.filter(x => x.id !== id))
|
|
2617
|
+
toast(t('presets.deleted'), 'ok')
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2271
2620
|
/* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
|
|
2272
2621
|
const PEAK_REMIND_NOTIFS = [
|
|
2273
2622
|
{ id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
|
|
@@ -2313,17 +2662,40 @@ function showView(id) {
|
|
|
2313
2662
|
window.scrollTo(0, 0)
|
|
2314
2663
|
if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
|
|
2315
2664
|
if (id === 'view-stats') loadStats()
|
|
2665
|
+
if (id === 'view-settings') showSettingsHome()
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
const SETTINGS_GROUPS = ['general', 'servers', 'notify', 'theme', 'about']
|
|
2669
|
+
function showSettingsHome() {
|
|
2670
|
+
const home = $('settings-home')
|
|
2671
|
+
if (!home) return
|
|
2672
|
+
home.classList.remove('hidden')
|
|
2673
|
+
for (const name of SETTINGS_GROUPS) $('settings-page-' + name)?.classList.add('hidden')
|
|
2674
|
+
window.scrollTo(0, 0)
|
|
2675
|
+
}
|
|
2676
|
+
function showSettingsPage(name) {
|
|
2677
|
+
const home = $('settings-home')
|
|
2678
|
+
if (!home || !SETTINGS_GROUPS.includes(name)) return
|
|
2679
|
+
home.classList.add('hidden')
|
|
2680
|
+
for (const g of SETTINGS_GROUPS) $('settings-page-' + g)?.classList.toggle('hidden', g !== name)
|
|
2681
|
+
window.scrollTo(0, 0)
|
|
2316
2682
|
}
|
|
2317
2683
|
|
|
2318
2684
|
function updateConn() {
|
|
2319
|
-
const ok = !!state.streamsOk?.mux
|
|
2320
2685
|
const el = $('conn-badge')
|
|
2321
|
-
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
2322
|
-
el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
|
|
2323
2686
|
const cur = state.servers.find(s => s.url === state.server)
|
|
2324
2687
|
const ms = state.serverLatency[state.server]
|
|
2325
2688
|
const curGroup = cur ? cur.group : state.activeGroup
|
|
2326
2689
|
const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
|
|
2690
|
+
if (state.streamMode === 'poll') {
|
|
2691
|
+
el.textContent = t('conn.poll')
|
|
2692
|
+
el.className = 'topbar-btn conn-badge off'
|
|
2693
|
+
el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
2694
|
+
return
|
|
2695
|
+
}
|
|
2696
|
+
const ok = !!state.streamsOk?.mux
|
|
2697
|
+
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
2698
|
+
el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
|
|
2327
2699
|
el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
2328
2700
|
}
|
|
2329
2701
|
|
|
@@ -2350,6 +2722,7 @@ function applyPairUrl(url) {
|
|
|
2350
2722
|
saveServers()
|
|
2351
2723
|
renderServers()
|
|
2352
2724
|
$('token-desc').textContent = t('token.savedScan')
|
|
2725
|
+
syncBgConfig()
|
|
2353
2726
|
return true
|
|
2354
2727
|
} catch {
|
|
2355
2728
|
return false
|
|
@@ -2490,6 +2863,11 @@ function openThemePanel() {
|
|
|
2490
2863
|
$('modal-theme').classList.remove('hidden')
|
|
2491
2864
|
}
|
|
2492
2865
|
|
|
2866
|
+
function openDonateModal() {
|
|
2867
|
+
const m = $('modal-donate')
|
|
2868
|
+
if (m) m.classList.remove('hidden')
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2493
2871
|
function bindUi() {
|
|
2494
2872
|
renderLangBtn()
|
|
2495
2873
|
renderThemeBtn()
|
|
@@ -2510,6 +2888,17 @@ function bindUi() {
|
|
|
2510
2888
|
})
|
|
2511
2889
|
$('btn-theme').addEventListener('click', openThemePanel)
|
|
2512
2890
|
$('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
|
|
2891
|
+
$('btn-donate').addEventListener('click', openDonateModal)
|
|
2892
|
+
$('donate-close').addEventListener('click', () => $('modal-donate').classList.add('hidden'))
|
|
2893
|
+
document.addEventListener('click', (e) => {
|
|
2894
|
+
if (e.target.closest('[data-donate-open]')) openDonateModal()
|
|
2895
|
+
})
|
|
2896
|
+
// 更新内容弹窗
|
|
2897
|
+
$('notes-close').addEventListener('click', closeNotesModal)
|
|
2898
|
+
$('notes-prev').addEventListener('click', () => scrollNotes(-1))
|
|
2899
|
+
$('notes-next').addEventListener('click', () => scrollNotes(1))
|
|
2900
|
+
$('notes-pages').addEventListener('scroll', updateNotesPage)
|
|
2901
|
+
$('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
|
|
2513
2902
|
renderServers()
|
|
2514
2903
|
// 底部导航
|
|
2515
2904
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
@@ -2552,9 +2941,27 @@ function bindUi() {
|
|
|
2552
2941
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
2553
2942
|
$('btn-send').addEventListener('click', sendMessage)
|
|
2554
2943
|
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
2555
|
-
$('composer-menu').addEventListener('click', (e) => {
|
|
2944
|
+
$('composer-menu').addEventListener('click', async (e) => {
|
|
2556
2945
|
const chip = e.target.closest('[data-cmd]')
|
|
2557
|
-
if (chip) {
|
|
2946
|
+
if (chip) {
|
|
2947
|
+
const input = $('composer-input')
|
|
2948
|
+
input.value = chip.dataset.cmd + ' '
|
|
2949
|
+
input.focus()
|
|
2950
|
+
autosize(input)
|
|
2951
|
+
hideComposerMenu()
|
|
2952
|
+
return
|
|
2953
|
+
}
|
|
2954
|
+
const preset = e.target.closest('[data-preset]')
|
|
2955
|
+
if (preset) {
|
|
2956
|
+
const found = readPresets().find(x => x.id === preset.dataset.preset)
|
|
2957
|
+
if (found) {
|
|
2958
|
+
const input = $('composer-input')
|
|
2959
|
+
input.value = found.text
|
|
2960
|
+
input.focus()
|
|
2961
|
+
autosize(input)
|
|
2962
|
+
}
|
|
2963
|
+
hideComposerMenu()
|
|
2964
|
+
}
|
|
2558
2965
|
})
|
|
2559
2966
|
$('btn-model-refresh').addEventListener('click', loadSessionModels)
|
|
2560
2967
|
const input = $('composer-input')
|
|
@@ -2579,10 +2986,15 @@ function bindUi() {
|
|
|
2579
2986
|
$('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
|
|
2580
2987
|
$('goal-edit').addEventListener('click', submitGoalEdit)
|
|
2581
2988
|
// 设置
|
|
2989
|
+
$('view-settings').addEventListener('click', (e) => {
|
|
2990
|
+
const group = e.target.closest('[data-settings-group]')
|
|
2991
|
+
if (group) { showSettingsPage(group.dataset.settingsGroup); return }
|
|
2992
|
+
if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
|
|
2993
|
+
})
|
|
2582
2994
|
$('btn-scan-pair').addEventListener('click', scanPair)
|
|
2583
2995
|
$('btn-change-token').addEventListener('click', () => {
|
|
2584
2996
|
const input = prompt(t('token.prompt'), state.token)
|
|
2585
|
-
if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll() }
|
|
2997
|
+
if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
|
|
2586
2998
|
})
|
|
2587
2999
|
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
2588
3000
|
$('btn-server-add').addEventListener('click', addServer)
|
|
@@ -2607,6 +3019,7 @@ function bindUi() {
|
|
|
2607
3019
|
$('btn-reset').addEventListener('click', () => {
|
|
2608
3020
|
if (!confirm(t('settings.confirmReset'))) return
|
|
2609
3021
|
LS.del('token'); LS.del('notify'); LS.del('server')
|
|
3022
|
+
if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
|
|
2610
3023
|
location.reload()
|
|
2611
3024
|
})
|
|
2612
3025
|
$('opt-notify').checked = LS.get('notify', '0') === '1'
|
|
@@ -2636,6 +3049,35 @@ function bindUi() {
|
|
|
2636
3049
|
})
|
|
2637
3050
|
// 已开启则启动时重新调度, 防止系统清理后丢失
|
|
2638
3051
|
if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
|
|
3052
|
+
applyBgConfigFromNative()
|
|
3053
|
+
$('opt-bg-poll').addEventListener('change', async (e) => {
|
|
3054
|
+
if (e.target.checked) {
|
|
3055
|
+
if (!CAP?.isNativePlatform?.() || !bgBridge()?.saveBackgroundConfig) {
|
|
3056
|
+
e.target.checked = false
|
|
3057
|
+
return toast(t('settings.bgNativeOnly'), 'err')
|
|
3058
|
+
}
|
|
3059
|
+
if (!state.token) {
|
|
3060
|
+
e.target.checked = false
|
|
3061
|
+
return toast(t('settings.bgNeedToken'), 'err')
|
|
3062
|
+
}
|
|
3063
|
+
const ok = await ensureNotify()
|
|
3064
|
+
if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
|
|
3065
|
+
saveBgConfig(true)
|
|
3066
|
+
toast(t('settings.bgOn'), 'ok')
|
|
3067
|
+
} else {
|
|
3068
|
+
saveBgConfig(false)
|
|
3069
|
+
toast(t('settings.bgOff'), 'ok')
|
|
3070
|
+
}
|
|
3071
|
+
})
|
|
3072
|
+
$('bg-interval').addEventListener('change', () => {
|
|
3073
|
+
if ($('opt-bg-poll')?.checked) saveBgConfig(true)
|
|
3074
|
+
})
|
|
3075
|
+
$('opt-task-done')?.addEventListener('change', () => {
|
|
3076
|
+
if (bgBridge()?.saveBackgroundConfig) saveBgConfig($('opt-bg-poll')?.checked)
|
|
3077
|
+
})
|
|
3078
|
+
renderPresets()
|
|
3079
|
+
renderPresetMenu()
|
|
3080
|
+
$('btn-preset-add').addEventListener('click', addPreset)
|
|
2639
3081
|
$('opt-tools').checked = LS.get('showTools', '1') !== '0'
|
|
2640
3082
|
$('opt-tools').addEventListener('change', (e) => {
|
|
2641
3083
|
LS.set('showTools', e.target.checked ? '1' : '0')
|