/* ============ 应用注册表 + 核心应用(访达/终端/文本编辑/备忘录/计算器/日历) ============ */ 'use strict'; const Apps = { registry: {}, register(def) { def.w = def.w || 720; def.h = def.h || 480; def.minW = def.minW || 320; def.minH = def.minH || 240; def.about = def.about || `${def.name} — macOS 网页版内置应用`; this.registry[def.id] = def; }, get(id) { return this.registry[id]; }, open(id, args) { const app = this.get(id); if (!app) { console.warn('[apps] 未注册:', id); return null; } if (app.singleton !== false) { const ex = WM.windowsForApp(id)[0]; if (ex) { if (args && app.onArgs) { try { app.onArgs(args, ex); } catch (e) { console.error(e); } } WM.focus(ex); return ex; } } const win = WM.openWindow({ app, title: app.name, icon: app.icon, w: app.w, h: app.h, minW: app.minW, minH: app.minH, data: args }); try { app.render(win, args || {}); } catch (e) { console.error('[apps] 渲染失败:', id, e); win.body.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '⚠️' }), el('div', { text: `${app.name} 打开失败` }))); } return win; }, quit(id) { WM.windowsForApp(id).slice().forEach(w => WM.close(w)); }, openPath(path) { const n = FS.node(path); if (!n) { UI.alert('找不到项目', `“${FS.baseName(path)}”不存在。`, 'assets/icons/finder.png'); return; } if (n.t === 'd') return this.open('finder', { path }); if (n.t === 'a') return this.open(n.app); const ext = (path.split('.').pop() || '').toLowerCase(); if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || ext === 'pdf') return this.open('preview', { path }); return this.open('textedit', { path }); } }; /* 标准菜单构造 */ function stdMenus(app, { file = [], edit = [], view = [], format = [], store = [] } = {}) { const menus = []; menus.push({ label: '文件', items: () => [ ...file, file.length ? { sep: true } : null, { label: '关闭窗口', key: '⌘W', disabled: !WM.activeWin, action: () => WM.activeWin && WM.close(WM.activeWin) }, ].filter(Boolean) }); const editBase = () => { const a = document.activeElement; const isText = a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable); const doCmd = cmd => { if (!isText) return; if (cmd === 'selectAll') { a.select ? a.select() : document.execCommand('selectAll'); return; } if (cmd === 'cut' || cmd === 'copy') { const sel = a.value?.slice(a.selectionStart, a.selectionEnd) ?? ''; if (cmd === 'copy' && sel) navigator.clipboard?.writeText(sel); if (cmd === 'cut' && sel) { navigator.clipboard?.writeText(sel); a.setRangeText('', a.selectionStart, a.selectionEnd, 'end'); a.dispatchEvent(new Event('input', { bubbles: true })); } } if (cmd === 'paste') navigator.clipboard?.readText().then(t => { a.setRangeText(t, a.selectionStart, a.selectionEnd, 'end'); a.dispatchEvent(new Event('input', { bubbles: true })); }).catch(() => {}); }; return [ ...edit, edit.length ? { sep: true } : null, { label: '剪切', key: '⌘X', disabled: !isText, action: () => doCmd('cut') }, { label: '拷贝', key: '⌘C', disabled: !isText, action: () => doCmd('copy') }, { label: '粘贴', key: '⌘V', disabled: !isText, action: () => doCmd('paste') }, { label: '全选', key: '⌘A', disabled: !isText, action: () => doCmd('selectAll') }, ].filter(Boolean); }; menus.push({ label: '编辑', items: editBase }); menus.push({ label: '显示', items: () => [ ...view, view.length ? { sep: true } : null, { label: WM.activeWin?.state === 'fullscreen' ? '退出全屏' : '进入全屏', key: '⌃⌘F', disabled: !WM.activeWin, action: () => WM.activeWin && WM.toggleFullscreen(WM.activeWin) }, ].filter(Boolean) }); if (format.length) menus.push({ label: '格式', items: () => format }); if (store.length) menus.push({ label: '商店', items: () => store }); menus.push({ label: '窗口', items: () => { const wins = WM.windows; return [ { label: '最小化', key: '⌘M', disabled: !WM.activeWin, action: () => WM.activeWin && WM.minimize(WM.activeWin) }, { label: '缩放', disabled: !WM.activeWin, action: () => WM.activeWin && WM.toggleZoom(WM.activeWin) }, { sep: true }, ...wins.map(w => ({ label: w.title, checked: w === WM.activeWin, action: () => WM.focus(w) })), wins.length ? { sep: true } : null, { label: '全部前置', disabled: !wins.length, action: () => wins.forEach(w => WM.restore(w)) }, ].filter(Boolean); } }); menus.push({ label: '帮助', items: () => [ { label: `${app.name}帮助`, action: () => UI.dialog({ icon: app.icon, title: `${app.name}帮助`, msg: app.about, buttons: ['好'] }) }, ] }); return menus; } /* ==================== 访达 ==================== */ const FinderApp = { id: 'finder', name: '访达', icon: 'assets/icons/finder.png', w: 860, h: 540, minW: 560, minH: 340, singleton: false, menus(win) { const st = win?.appState; return stdMenus(this, { file: [ { label: '新建 Finder 窗口', key: '⌘N', action: () => Apps.open('finder', { path: st?.path }) }, { label: '新建文件夹', key: '⇧⌘N', action: () => st?.newFolder() }, { sep: true }, { label: '移到废纸篓', key: '⌘⌫', disabled: !st?.selection.size, action: () => st?.trashSelection() }, { label: '显示简介', key: '⌘I', disabled: st?.selection.size !== 1, action: () => st?.showInfo() }, ], edit: [ { label: '拷贝', key: '⌘C', disabled: !st?.selection.size, action: () => st?.copySel('copy') }, { label: '粘贴', key: '⌘V', disabled: !st?.clipboard, action: () => st?.paste() }, ], view: [ { label: '按图标显示', key: '⌘1', checked: st?.view === 'icon', action: () => st?.setView('icon') }, { label: '按列表显示', key: '⌘2', checked: st?.view === 'list', action: () => st?.setView('list') }, { sep: true }, { label: '排序方式', submenu: [ { label: '名称', checked: st?.sortBy === 'name', action: () => st?.setSort('name') }, { label: '种类', checked: st?.sortBy === 'kind', action: () => st?.setSort('kind') }, { label: '修改日期', checked: st?.sortBy === 'date', action: () => st?.setSort('date') }, ] }, ] }); }, render(win, args) { const st = win.appState = { path: args.path || FS.HOME + '/Desktop', history: [], hi: -1, anchor: null, view: Sys.settings.finderView, sortBy: Sys.settings.finderSort, selection: new Set(), clipboard: FinderApp.clipboard || null, search: '', }; // 历史模型:history 保存完整访问路径(含当前),hi 指向当前位置。 // back 只 hi--,forward 只 hi++;普通导航删除 forward 分支、追加并移动 hi。 st.navigate = (p, push = true) => { if (!FS.isDir(p)) return; p = FS.normalize(p); if (push && st.history[st.hi] !== p) { st.history = st.history.slice(0, st.hi + 1); st.history.push(p); st.hi = st.history.length - 1; } st.path = p; st.selection.clear(); st.anchor = null; st.search = ''; searchInput.value = ''; render(); }; st.back = () => { if (st.hi > 0) { st.hi--; st.navigate(st.history[st.hi], false); } }; st.fwd = () => { if (st.hi < st.history.length - 1) { st.hi++; st.navigate(st.history[st.hi], false); } }; // 初始路径只入列一次;args.path 打开时不产生重复历史 st.history = [FS.normalize(st.path)]; st.hi = 0; st.setView = v => { st.view = v; Sys.settings.finderView = v; Sys.save(); render(); }; st.setSort = s => { st.sortBy = s; Sys.settings.finderSort = s; Sys.save(); render(); }; st.newFolder = async () => { const name = await UI.prompt('新建文件夹', '请输入文件夹名称:', '未命名文件夹'); if (!name) return; try { FS.mkdir(FS.join(st.path, name)); } catch (e) { UI.alert('无法创建', e.message, this.icon); } }; st.trashSelection = () => { [...st.selection].forEach(p => { try { FS.trash(p); } catch (e) { UI.alert('无法移到废纸篓', e.message, this.icon); } }); st.selection.clear(); }; st.copySel = op => { st.clipboard = FinderApp.clipboard = { op, paths: [...st.selection], from: st.path }; }; st.paste = () => { const cb = st.clipboard; if (!cb) return; for (const p of cb.paths) { try { cb.op === 'copy' ? FS.copy(p, st.path) : FS.move(p, st.path); } catch (e) { UI.alert('粘贴失败', e.message, this.icon); } } if (cb.op === 'cut') { st.clipboard = FinderApp.clipboard = null; } }; st.showInfo = () => { const p = [...st.selection][0]; if (!p) return; const n = FS.node(p); const size = n.t === 'f' ? fmtBytes((n.data || '').length) : (n.t === 'd' ? `${Object.keys(n.c).length} 个项目` : '—'); UI.dialog({ icon: FS.iconFor(p), title: FS.baseName(p) + ' 简介', buttons: ['好'], msg: `种类:${FS.kindOf(p)}\n大小:${size}\n位置:${FS.dirName(p).replace(FS.HOME, '~')}\n修改时间:${new Date(n.mtime || 0).toLocaleString('zh-CN')}` }); }; st.startRename = p => { const itemEl = content.querySelector(`[data-path="${CSS.escape(p)}"]`); if (!itemEl) return; const nameEl = itemEl.querySelector('.fi-name'); const old = FS.baseName(p); const input = el('input', { class: 'text-input fi-rename', value: old }); nameEl.replaceWith(input); input.focus(); const dot = old.lastIndexOf('.'); input.setSelectionRange(0, dot > 0 ? dot : old.length); let done = false; const commit = () => { if (done) return; done = true; const v = input.value.trim(); if (v && v !== old) { try { FS.rename(p, v); } catch (e) { UI.alert('无法重命名', e.message, this.icon); } } render(); content.focus({ preventScroll: true }); }; input.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') commit(); if (e.key === 'Escape') { done = true; render(); } }); input.addEventListener('blur', commit); }; // ===== 布局 ===== win.body.classList.add('finder'); const backBtn = el('button', { class: 'fb-btn', title: '后退', html: '‹' }); const fwdBtn = el('button', { class: 'fb-btn', title: '前进', html: '›' }); const crumb = el('div', { class: 'fb-crumb' }); const viewSeg = el('div', { class: 'segmented' }, el('button', { text: '图标', title: '图标视图', onclick: () => st.setView('icon') }), el('button', { text: '列表', title: '列表视图', onclick: () => st.setView('list') })); const sortSel = el('select', { class: 'text-input fb-sort', title: '排序方式' }, el('option', { value: 'name', text: '按名称' }), el('option', { value: 'kind', text: '按种类' }), el('option', { value: 'date', text: '按日期' })); sortSel.value = st.sortBy; sortSel.addEventListener('change', () => st.setSort(sortSel.value)); const newBtn = el('button', { class: 'fb-btn', title: '新建文件夹', html: '+' }); newBtn.addEventListener('click', () => st.newFolder()); const searchInput = el('input', { class: 'text-input fb-search', type: 'search', placeholder: '搜索' }); searchInput.addEventListener('input', debounce(() => { st.search = searchInput.value.trim(); renderList(); }, 200)); const toolbar = el('div', { class: 'fb-toolbar' }, backBtn, fwdBtn, crumb, viewSeg, sortSel, newBtn, searchInput); const side = el('div', { class: 'fb-sidebar' }); const content = el('div', { class: 'fb-content', tabindex: '0' }); const main = el('div', { class: 'fb-main' }, side, content); win.body.append(toolbar, main); const favs = [ ['桌面', FS.HOME + '/Desktop', '🖥'], ['文稿', FS.HOME + '/Documents', '📄'], ['下载', FS.HOME + '/Downloads', '⬇️'], ['图片', FS.HOME + '/Pictures', '🖼'], ['音乐', FS.HOME + '/Music', '🎵'], ['应用程序', FS.HOME + '/Applications', '📦'], ]; const locs = [['iCloud 云盘', '__icloud', '☁️'], ['废纸篓', FS.TRASH, '🗑']]; const renderSide = () => { side.innerHTML = ''; const group = (title, items) => { side.append(el('div', { class: 'fb-side-title', text: title })); for (const [name, p, ico] of items) { const row = el('div', { class: 'fb-side-item' + (st.path === p ? ' sel' : '') }, el('span', { class: 'fb-side-ico', text: ico }), el('span', { text: name })); row.addEventListener('click', () => { if (p === '__icloud') { UI.dialog({ icon: 'assets/icons/finder.png', title: 'iCloud 云盘', msg: 'iCloud 在离线环境下不可用。你的文件都保存在本机的虚拟磁盘中。', buttons: ['好'] }); return; } st.navigate(p); }); side.append(row); } }; group('个人收藏', favs); group('位置', locs); }; const sortItems = items => { const by = st.sortBy; return items.sort((a, b) => { if ((a.node.t === 'd') !== (b.node.t === 'd')) return a.node.t === 'd' ? -1 : 1; if (by === 'kind') return FS.kindOf(a.path).localeCompare(FS.kindOf(b.path), 'zh') || a.name.localeCompare(b.name, 'zh-Hans-CN'); if (by === 'date') return (b.node.mtime || 0) - (a.node.mtime || 0); return a.name.localeCompare(b.name, 'zh-Hans-CN'); }); }; const renderCrumb = () => { crumb.innerHTML = ''; const rel = st.path === FS.HOME ? ['~'] : st.path.replace(FS.HOME, '~').split('/').filter(Boolean); let acc = ''; rel.forEach((seg, i) => { acc += (i === 0 && seg === '~') ? '' : '/' + seg; const target = seg === '~' ? FS.HOME : FS.join(FS.HOME, acc); const b = el('button', { class: 'fb-crumb-item' + (i === rel.length - 1 ? ' cur' : ''), text: seg === '~' ? '客人用户' : seg }); b.addEventListener('click', () => st.navigate(target)); crumb.append(b); if (i < rel.length - 1) crumb.append(el('span', { class: 'fb-crumb-sep', text: '›' })); }); }; /* 仅选择变化:切换 class,不重建列表 DOM(保证双击、重命名输入、拖拽不被打断) */ const updateSel = () => { content.querySelectorAll('.fb-item').forEach(n => n.classList.toggle('sel', st.selection.has(n.dataset.path))); }; const renderList = () => { content.innerHTML = ''; content.className = 'fb-content ' + (st.view === 'icon' ? 'icon-view' : 'list-view'); let items = []; try { items = FS.list(st.path); } catch (e) { content.append(el('div', { class: 'empty-state', text: '无法读取此文件夹' })); return; } if (st.search) items = items.filter(it => it.name.toLowerCase().includes(st.search.toLowerCase())); items = sortItems(items); if (!items.length) { content.append(el('div', { class: 'empty-state' }, el('div', { class: 'es-icon', text: '📂' }), el('div', { text: st.search ? '没有匹配的结果' : '文件夹为空' }))); } for (const it of items) { const selected = st.selection.has(it.path); const itemEl = el('div', { class: 'fb-item' + (selected ? ' sel' : ''), dataset: { path: it.path }, draggable: 'true', tabindex: '0', }, iconImg(FS.iconFor(it.path), 'fi-icon', ''), el('div', { class: 'fi-name', text: it.name })); if (st.view === 'list') { itemEl.append( el('span', { class: 'fi-col', text: FS.kindOf(it.path) }), el('span', { class: 'fi-col', text: it.node.mtime ? new Date(it.node.mtime).toLocaleDateString('zh-CN') : '—' }), el('span', { class: 'fi-col', text: it.node.t === 'f' ? fmtBytes((it.node.data || '').length) : '—' })); } itemEl.addEventListener('click', e => { e.stopPropagation(); if (e.metaKey || e.ctrlKey) { st.selection.has(it.path) ? st.selection.delete(it.path) : st.selection.add(it.path); st.anchor = it.path; } else if (e.shiftKey && st.anchor) { const arr = items.map(x => x.path); const a = arr.indexOf(st.anchor), b = arr.indexOf(it.path); if (a >= 0 && b >= 0) st.selection = new Set(arr.slice(Math.min(a, b), Math.max(a, b) + 1)); } else { st.selection = new Set([it.path]); st.anchor = it.path; } updateSel(); content.focus({ preventScroll: true }); }); itemEl.addEventListener('dblclick', e => { e.stopPropagation(); it.node.t === 'd' ? st.navigate(it.path) : Apps.openPath(it.path); }); itemEl.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); if (!st.selection.has(it.path)) { st.selection = new Set([it.path]); st.anchor = it.path; updateSel(); } UI.contextMenu(this.itemMenu(st, it), e); }); itemEl.addEventListener('dragstart', e => { e.dataTransfer.setData('text/x-fspath', it.path); }); if (it.node.t === 'd') { itemEl.addEventListener('dragover', e => { e.preventDefault(); itemEl.classList.add('drop-hint'); }); itemEl.addEventListener('dragleave', () => itemEl.classList.remove('drop-hint')); itemEl.addEventListener('drop', e => { e.preventDefault(); itemEl.classList.remove('drop-hint'); const src = e.dataTransfer.getData('text/x-fspath'); if (src && src !== it.path) { try { FS.move(src, it.path); } catch (err) { UI.alert('无法移动', err.message, this.icon); } } }); } content.append(itemEl); } }; content.addEventListener('click', () => { if (st.selection.size) { st.selection.clear(); st.anchor = null; updateSel(); } }); content.addEventListener('contextmenu', e => { if (e.target.closest('.fb-item')) return; e.preventDefault(); UI.contextMenu([ { label: '新建文件夹', action: () => st.newFolder() }, { sep: true }, { label: '按图标显示', checked: st.view === 'icon', action: () => st.setView('icon') }, { label: '按列表显示', checked: st.view === 'list', action: () => st.setView('list') }, { sep: true }, { label: '粘贴', disabled: !st.clipboard, action: () => st.paste() }, { label: '显示简介', action: () => { const n = FS.node(st.path); UI.dialog({ icon: 'assets/icons/folder.svg', title: FS.baseName(st.path) + ' 简介', buttons: ['好'], msg: `种类:文件夹\n项目数:${Object.keys(n.c).length}\n位置:${st.path.replace(FS.HOME, '~')}` }); } }, ], e); }); content.addEventListener('keydown', e => { if (e.key === 'Backspace' && (e.metaKey || e.ctrlKey)) { st.trashSelection(); e.preventDefault(); } else if (e.key === 'Enter' && st.selection.size === 1) { st.startRename([...st.selection][0]); e.preventDefault(); } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') { st.copySel('copy'); } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') { st.paste(); } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); st.selection = new Set(FS.list(st.path).map(i => i.path)); updateSel(); } else if (e.key === 'Delete' && st.selection.size) { st.trashSelection(); } }); backBtn.addEventListener('click', () => { if (st.hi >= 0) { const p = st.history[st.hi--]; st.navigate(p, false); } }); fwdBtn.addEventListener('click', () => { if (st.hi < st.history.length - 1) { const p = st.history[++st.hi]; st.navigate(p, false); } }); const render = () => { WM.setTitle(win, st.path === FS.TRASH ? '废纸篓' : FS.baseName(st.path) || '访达'); renderSide(); renderCrumb(); renderList(); backBtn.disabled = st.hi <= 0; fwdBtn.disabled = st.hi >= st.history.length - 1; viewSeg.children[0].classList.toggle('on', st.view === 'icon'); viewSeg.children[1].classList.toggle('on', st.view === 'list'); sortSel.value = st.sortBy; }; win._fsUnsub = Bus.on('fs:changed', () => { if (document.body.contains(win.el)) render(); }); win.onClose = () => { win._fsUnsub && win._fsUnsub(); }; render(); if (args.path) st.navigate(args.path); }, itemMenu(st, it) { const inTrash = st.path === FS.TRASH || st.path.startsWith(FS.TRASH + '/'); if (inTrash) return [ { label: '放回原处', action: () => { try { FS.restore(it.name); } catch (e) { UI.alert('无法还原', e.message, 'assets/icons/finder.png'); } } }, { label: '立即删除…', action: async () => { if (await UI.confirm('确定要永久删除吗?', `“${it.name}”将被永久删除,此操作无法撤销。`, { ok: '删除', danger: true })) FS.remove(it.path); } }, { sep: true }, { label: '清空废纸篓…', action: async () => { if (await UI.confirm('确定要清空废纸篓吗?', '此操作无法撤销。', { ok: '清空', danger: true })) FS.emptyTrash(); } }, ]; return [ { label: '打开', action: () => it.node.t === 'd' ? st.navigate(it.path) : Apps.openPath(it.path) }, { sep: true }, { label: '显示简介', action: () => { st.selection = new Set([it.path]); st.showInfo(); } }, { label: '重命名', action: () => st.startRename(it.path) }, { label: '复制“' + it.name + '”', action: () => { try { FS.copy(it.path, st.path); } catch (e) { UI.alert('无法复制', e.message, 'assets/icons/finder.png'); } } }, { sep: true }, { label: '移到废纸篓', action: () => { st.selection = new Set([it.path]); st.trashSelection(); } }, ]; }, clipboard: null, }; Apps.register(FinderApp); window.Apps = Apps; window.stdMenus = stdMenus; /* ==================== 终端 ==================== */ Apps.register({ id: 'terminal', name: '终端', icon: 'assets/icons/terminal.png', w: 640, h: 420, minW: 420, minH: 260, singleton: false, menus(win) { const st = win?.appState; return stdMenus(this, { file: [{ label: '新建窗口', key: '⌘N', action: () => Apps.open('terminal') }], edit: [{ label: '清屏', key: '⌘K', action: () => st?.clear() }], }); }, render(win) { const st = win.appState = { cwd: FS.HOME, hist: [], hi: -1 }; win.body.classList.add('term-body'); const out = el('div', { class: 'term-out' }); const inputRow = el('div', { class: 'term-input-row' }); const promptEl = el('span', { class: 'term-prompt' }); const input = el('input', { class: 'term-input', spellcheck: 'false', autocomplete: 'off', 'aria-label': '终端输入' }); inputRow.append(promptEl, input); win.body.append(out, inputRow); const short = p => p.replace(FS.HOME, '~') || '/'; const setPrompt = () => { promptEl.textContent = `guest@MacBook-Pro ${short(st.cwd)} % `; }; const print = (text, cls = '') => { out.append(el('div', { class: 'term-line ' + cls, text })); out.scrollTop = out.scrollHeight; }; const printHtml = html => { out.append(el('div', { class: 'term-line', html })); out.scrollTop = out.scrollHeight; }; st.clear = () => { out.innerHTML = ''; }; const resolve = p => { if (!p) return st.cwd; if (p.startsWith('~')) p = FS.HOME + p.slice(1); return FS.normalize(p.startsWith('/') ? p : st.cwd + '/' + p); }; /* tokenizer:支持单引号(原样)、双引号(内可用 \" \\ 转义)、反斜杠转义(引号外转任意字符) */ const tokenize = s => { const out = []; let cur = ''; let q = null; let esc = false; let started = false; for (const ch of s) { if (esc) { cur += ch; esc = false; started = true; continue; } if (q === "'") { if (ch === "'") { q = null; } else cur += ch; continue; } if (q === '"') { if (ch === '"') q = null; else if (ch === '\\') esc = true; else cur += ch; continue; } if (ch === '\\') { esc = true; started = true; continue; } if (ch === "'" || ch === '"') { q = ch; started = true; continue; } if (/\s/.test(ch)) { if (started) { out.push(cur); cur = ''; started = false; } continue; } cur += ch; started = true; } if (esc) cur += '\\'; if (q) throw new Error('未闭合的引号'); if (started) out.push(cur); return out; }; const run = cmdline => { print(promptEl.textContent + cmdline, 'term-echo'); let args; try { args = tokenize(cmdline.trim()); } catch (te) { print('zsh: ' + te.message, 'term-err'); return; } if (!args.length) return; const [cmd, ...rest] = args; const err = m => print(m, 'term-err'); const flagsOf = () => rest.filter(a => a.startsWith('-')).join(''); const operands = () => rest.filter(a => !a.startsWith('-')); try { switch (cmd) { case 'help': print(['可用命令:', ' help pwd ls [路径] cd <路径> cat <文件>', ' touch <文件> mkdir <目录> rm [-r] <路径> mv <源> <目标> cp [-r] <源> <目标>', ' open <路径|应用> echo <文本> clear date whoami uname', '路径支持 ~ 、相对路径与引号(如 cd "~/Sample Folder"),与访达共享同一文件系统。'].join('\n')); break; case 'pwd': print(st.cwd); break; case 'whoami': print('guest'); break; case 'uname': print('Darwin MacBook-Pro.local 24.5.0 Darwin Kernel Version 24.5.0 (Web) arm64'); break; case 'date': print(new Date().toLocaleString('zh-CN', { hour12: false })); break; case 'clear': st.clear(); break; case 'echo': print(rest.join(' ')); break; case 'ls': { const p = resolve(operands()[0]); const items = FS.list(p, { showHidden: rest.includes('-a') }); if (rest.includes('-l')) items.forEach(it => print(`${it.node.t === 'd' ? 'd' : '-'}rw-r--r-- ${it.node.t === 'f' ? String((it.node.data || '').length).padStart(6) : ' -'} ${it.name}`)); else print(items.map(it => it.name + (it.node.t === 'd' ? '/' : '')).join(' ') || ''); break; } case 'cd': { const p = resolve(operands()[0] || FS.HOME); if (!FS.isDir(p)) return err(`cd: 不是目录: ${operands()[0] || ''}`); st.cwd = p; setPrompt(); break; } case 'cat': { if (!operands()[0]) return err('cat: 缺少文件'); print(FS.read(resolve(operands()[0]))); break; } case 'touch': { if (!operands()[0]) return err('touch: 缺少文件'); const p = resolve(operands()[0]); if (FS.exists(p)) { const n = FS.node(p); n.mtime = Date.now(); FS.save(); } // 已存在:仅更新 mtime else FS.write(p, ''); break; } case 'mkdir': { if (!operands()[0]) return err('mkdir: 缺少目录'); FS.mkdir(resolve(operands()[0])); break; } case 'rm': { const ops = operands(); if (!ops.length) return err('rm: 缺少路径'); const recursive = flagsOf().includes('r') || flagsOf().includes('R'); const force = flagsOf().includes('f'); for (const t of ops) { const rp = resolve(t); try { if (!FS.exists(rp)) { if (!force) err(`rm: ${t}: 没有那个文件或目录`); continue; } if (FS.isDir(rp) && !recursive) { err(`rm: ${t}: 是一个目录(递归删除请用 rm -r)`); continue; } FS.remove(rp); } catch (e2) { err(`rm: ${t}: ${e2.message}`); } } break; } case 'mv': { const ops = operands(); if (ops.length < 2) return err('mv: 用法 mv <源> <目标>'); const s = resolve(ops[0]), d = resolve(ops[1]); try { if (!FS.exists(s)) return err(`mv: ${ops[0]}: 没有那个文件或目录`); if (FS.isDir(d)) FS.move(s, d); // 目标是目录:保留原名移入 else { if (FS.exists(d)) return err(`mv: ${ops[1]}: 目标已存在`); const dir = FS.dirName(d); if (!FS.isDir(dir)) return err(`mv: ${ops[1]}: 目标目录不存在`); const np = FS.move(s, dir); // 目标是文件路径:移动并改名(只执行一次) FS.rename(np, FS.baseName(d)); } } catch (e2) { err(`mv: ${e2.message}`); } break; } case 'cp': { const ops = operands(); if (ops.length < 2) return err('cp: 用法 cp [-r] <源> <目标>'); const s = resolve(ops[0]), d = resolve(ops[1]); try { if (!FS.exists(s)) return err(`cp: ${ops[0]}: 没有那个文件或目录`); if (FS.isDir(s) && !(flagsOf().includes('r') || flagsOf().includes('R'))) return err(`cp: ${ops[0]} 是一个目录(复制目录请用 cp -r)`); if (FS.isDir(d)) FS.copy(s, d); // 目标是目录:保留原名 else { if (FS.exists(d)) return err(`cp: ${ops[1]}: 目标已存在`); const dir = FS.dirName(d); if (!FS.isDir(dir)) return err(`cp: ${ops[1]}: 目标目录不存在`); const np = FS.copy(s, dir); // 目标是文件路径:复制并改名 FS.rename(np, FS.baseName(d)); } } catch (e2) { err(`cp: ${e2.message}`); } break; } case 'open': { if (!operands()[0]) return err('open: 缺少路径'); const p = resolve(operands()[0]); if (FS.exists(p)) Apps.openPath(p); else if (Apps.get(operands()[0])) Apps.open(operands()[0]); else err(`open: 找不到: ${operands()[0]}`); break; } default: err(`zsh: command not found: ${cmd}`); } } catch (e2) { err(`${cmd}: ${e2.message}`); } }; input.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { const v = input.value; input.value = ''; if (v.trim()) { st.hist.push(v); st.hi = st.hist.length; } run(v); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (st.hi > 0) { st.hi--; input.value = st.hist[st.hi] || ''; } } else if (e.key === 'ArrowDown') { e.preventDefault(); if (st.hi < st.hist.length - 1) { st.hi++; input.value = st.hist[st.hi]; } else { st.hi = st.hist.length; input.value = ''; } } else if (e.ctrlKey && e.key.toLowerCase() === 'l') { e.preventDefault(); st.clear(); } else if (e.ctrlKey && e.key.toLowerCase() === 'c') { print(promptEl.textContent + input.value + ' ^C', 'term-echo'); input.value = ''; } }); win.body.addEventListener('click', () => input.focus()); print('Last login: ' + new Date().toLocaleString('zh-CN') + ' on ttys000', 'term-dim'); setPrompt(); setTimeout(() => input.focus(), 60); } }); /* ==================== 文本编辑 ==================== */ Apps.register({ id: 'textedit', name: '文本编辑', icon: 'assets/icons/textedit.svg', w: 680, h: 520, minW: 380, minH: 260, singleton: false, menus(win) { const st = win?.appState; return stdMenus(this, { file: [ { label: '新建', key: '⌘N', action: () => Apps.open('textedit') }, { label: '打开…', key: '⌘O', action: () => st?.openPicker() }, { sep: true }, { label: '保存', key: '⌘S', action: () => st?.save() }, { label: '另存为…', key: '⇧⌘S', action: () => st?.saveAs() }, ], format: [ { label: '放大字体', key: '⌘+', action: () => st?.setFont(1) }, { label: '缩小字体', key: '⌘−', action: () => st?.setFont(-1) }, { label: '恢复默认字号', key: '⌘0', action: () => st?.setFont(0) }, ] }); }, render(win, args) { const st = win.appState = { path: args.path || null, dirty: false, fontSize: 14 }; win.body.classList.add('te-body'); const ta = el('textarea', { class: 'te-area', placeholder: '开始输入…', 'aria-label': '文本内容' }); win.body.append(ta); const name = () => st.path ? FS.baseName(st.path) : '未命名'; const refreshTitle = () => WM.setTitle(win, name(), st.dirty); if (st.path) { try { ta.value = FS.read(st.path); } catch (e) { UI.alert('无法打开', e.message, this.icon); st.path = null; } } refreshTitle(); ta.addEventListener('input', () => { if (!st.dirty) { st.dirty = true; refreshTitle(); } }); st.setFont = d => { st.fontSize = d === 0 ? 14 : clamp(st.fontSize + d, 10, 28); ta.style.fontSize = st.fontSize + 'px'; }; st.save = () => { if (!st.path) return st.saveAs(); try { FS.write(st.path, ta.value); st.dirty = false; refreshTitle(); } catch (e) { UI.alert('保存失败', e.message, this.icon); } }; st.saveAs = async () => { const v = await UI.prompt('另存为', '输入保存路径(相对于文稿文件夹):', (st.path ? FS.baseName(st.path) : '未命名.txt')); if (!v) return false; const p = v.startsWith('/') ? FS.normalize(v) : FS.join(FS.HOME + '/Documents', v); try { FS.write(p, ta.value); st.path = p; st.dirty = false; refreshTitle(); return true; } catch (e) { await UI.alert('保存失败', e.message, this.icon); return false; } }; st.openPicker = async () => { const files = []; FS.walk(FS.HOME, (p, n) => { if (n.t === 'f' && !p.startsWith(FS.TRASH)) files.push(p); }); if (!files.length) return UI.alert('没有可打开的文件', '', this.icon); const v = await UI.prompt('打开文件', '输入文件路径:\n' + files.slice(0, 8).map(f => f.replace(FS.HOME, '~')).join('\n'), files[0]); if (!v) return; const p = v.startsWith('~') ? v.replace('~', FS.HOME) : v; if (FS.node(p)?.t === 'f') { st.path = FS.normalize(p); ta.value = FS.read(st.path); st.dirty = false; refreshTitle(); } else UI.alert('无法打开', '文件不存在或不是文本文件。', this.icon); }; win.el.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); e.shiftKey ? st.saveAs() : st.save(); } }); win.confirmClose = (done, cancel) => { if (!st.dirty) return done(); UI.dialog({ icon: this.icon, title: '要存储更改吗?', msg: `“${name()}”有未存储的更改。`, buttons: ['不存储', '取消', '存储'] }).then(r => { if (r.index === 0) done(); else if (r.index === 2) { if (st.path) { try { FS.write(st.path, ta.value); st.dirty = false; done(); } catch (e) { UI.alert('保存失败', e.message).then(cancel); } } else { st.saveAs().then(saved => { (saved === false || st.dirty) ? cancel() : done(); }); } } else cancel(); // 取消 / Esc:中止关闭 }); }; setTimeout(() => ta.focus(), 60); } }); /* ==================== 备忘录 ==================== */ Apps.register({ id: 'notes', name: '备忘录', icon: 'assets/icons/notes.png', w: 780, h: 520, minW: 520, minH: 320, menus(win) { const st = win?.appState; return stdMenus(this, { file: [ { label: '新建备忘录', key: '⌘N', action: () => st?.add() }, { label: '删除备忘录', key: '⌘⌫', disabled: !st?.cur, action: () => st?.remove() }, ] }); }, store: { get() { return Store.get('notes', null) || [{ id: uid(), body: '欢迎使用备忘录\n\n· 点击左上角 + 新建备忘录\n· 标题自动取自第一行\n· 内容会自动保存\n\n试试在搜索框中查找内容。', updated: Date.now() }]; }, set(v) { Store.set('notes', v); } }, render(win) { const st = win.appState = { notes: this.store.get(), cur: null, q: '' }; win.body.classList.add('notes-body'); const search = el('input', { class: 'text-input notes-search', type: 'search', placeholder: '搜索所有备忘录' }); const addBtn = el('button', { class: 'fb-btn', title: '新建备忘录', html: '+' }); const delBtn = el('button', { class: 'fb-btn', title: '删除备忘录', html: '🗑' }); const listEl = el('div', { class: 'notes-list' }); const side = el('div', { class: 'notes-side' }, el('div', { class: 'notes-side-bar' }, search, addBtn, delBtn), listEl); const dateEl = el('div', { class: 'notes-date' }); const editor = el('textarea', { class: 'notes-editor', placeholder: '开始记录…', 'aria-label': '备忘录内容' }); const main = el('div', { class: 'notes-main' }, dateEl, editor); win.body.append(side, main); const titleOf = n => (n.body.split('\n')[0] || '新备忘录').trim().slice(0, 40) || '新备忘录'; const save = () => { this.store.set(st.notes); }; const renderList = () => { listEl.innerHTML = ''; const items = st.notes.filter(n => !st.q || n.body.toLowerCase().includes(st.q.toLowerCase())) .sort((a, b) => b.updated - a.updated); if (!items.length) listEl.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: st.q ? '无结果' : '没有备忘录' })); for (const n of items) { const row = el('div', { class: 'note-row' + (n === st.cur ? ' sel' : '') }, el('div', { class: 'note-title', text: titleOf(n) }), el('div', { class: 'note-sub', text: `${relTime(n.updated)} ${(n.body.split('\n')[1] || '').trim().slice(0, 26)}` })); row.addEventListener('click', () => { st.cur = n; sync(); }); listEl.append(row); } }; const sync = () => { renderList(); if (!st.cur) { dateEl.textContent = ''; editor.value = ''; editor.disabled = true; return; } editor.disabled = false; dateEl.textContent = new Date(st.cur.updated).toLocaleString('zh-CN'); if (editor.value !== st.cur.body) editor.value = st.cur.body; }; st.add = () => { const n = { id: uid(), body: '', updated: Date.now() }; st.notes.push(n); st.cur = n; save(); sync(); editor.focus(); }; st.remove = async () => { if (!st.cur) return; if (!await UI.confirm('删除备忘录?', `“${titleOf(st.cur)}”将被删除。`, { ok: '删除', danger: true })) return; st.notes = st.notes.filter(n => n !== st.cur); st.cur = st.notes[0] || null; save(); sync(); }; editor.addEventListener('input', debounce(() => { if (st.cur) { st.cur.body = editor.value; st.cur.updated = Date.now(); save(); renderList(); } }, 350)); search.addEventListener('input', debounce(() => { st.q = search.value.trim(); renderList(); }, 200)); addBtn.addEventListener('click', () => st.add()); delBtn.addEventListener('click', () => st.remove()); st.cur = st.notes[0] || null; sync(); } }); /* ==================== 计算器 ==================== */ Apps.register({ id: 'calculator', name: '计算器', icon: 'assets/icons/calculator.png', w: 260, h: 400, minW: 240, minH: 380, noResize: false, menus() { return stdMenus(this); }, render(win) { const st = win.appState = { disp: '0', acc: null, op: null, fresh: true, err: false }; win.body.classList.add('calc-body'); const disp = el('div', { class: 'calc-disp', text: '0', role: 'status' }); const grid = el('div', { class: 'calc-grid' }); win.body.append(disp, grid); const show = v => { st.disp = v; disp.textContent = v; disp.style.fontSize = (v.length > 9 ? '30px' : v.length > 6 ? '42px' : '54px'); }; const calc = (a, b, op) => { switch (op) { case '+': return a + b; case '-': return a - b; case '*': return a * b; case '/': return b === 0 ? NaN : a / b; } }; const fmt = n => { if (!isFinite(n)) return '错误'; const r = Math.round(n * 1e10) / 1e10; return String(r).slice(0, 12); }; const inputDigit = d => { if (st.err) { st.acc = null; st.op = null; st.err = false; } if (st.fresh) { show(d === '.' ? '0.' : d); st.fresh = false; } else if (st.disp.replace(/[-.]/g, '').length < 12) { if (d === '.' && st.disp.includes('.')) return; show(st.disp === '0' && d !== '.' ? d : st.disp + d); } }; const setOp = op => { if (st.err) return; if (st.op && !st.fresh) equals(); st.acc = parseFloat(st.disp); st.op = op; st.fresh = true; }; const equals = () => { if (st.op == null || st.acc == null) return; const r = calc(st.acc, parseFloat(st.disp), st.op); show(fmt(r)); st.acc = null; st.op = null; st.fresh = true; if (!isFinite(r)) st.err = true; }; const clearAll = () => { st.acc = null; st.op = null; st.err = false; st.fresh = true; show('0'); }; const negate = () => { if (!st.err && st.disp !== '0') show(st.disp.startsWith('-') ? st.disp.slice(1) : '-' + st.disp); }; const percent = () => { if (!st.err) { show(fmt(parseFloat(st.disp) / 100)); st.fresh = true; } }; const keys = [ ['AC', 'fn', clearAll], ['±', 'fn', negate], ['%', 'fn', percent], ['÷', 'op', () => setOp('/')], ['7', '', () => inputDigit('7')], ['8', '', () => inputDigit('8')], ['9', '', () => inputDigit('9')], ['×', 'op', () => setOp('*')], ['4', '', () => inputDigit('4')], ['5', '', () => inputDigit('5')], ['6', '', () => inputDigit('6')], ['−', 'op', () => setOp('-')], ['1', '', () => inputDigit('1')], ['2', '', () => inputDigit('2')], ['3', '', () => inputDigit('3')], ['+', 'op', () => setOp('+')], ['0', 'zero', () => inputDigit('0')], ['.', '', () => inputDigit('.')], ['=', 'op', equals], ]; const btns = []; for (const [label, cls, fn] of keys) { const b = el('button', { class: 'calc-key ' + cls, text: label }); b.addEventListener('click', () => { fn(); grid.querySelectorAll('.calc-key').forEach(x => x.classList.remove('press')); }); btns.push(b); grid.append(b); } win.el.addEventListener('keydown', e => { if (e.target.matches('input,textarea')) return; const k = e.key; if (/^[0-9.]$/.test(k)) inputDigit(k); else if (k === '+') setOp('+'); else if (k === '-') setOp('-'); else if (k === '*') setOp('*'); else if (k === '/') { e.preventDefault(); setOp('/'); } else if (k === 'Enter' || k === '=') { e.preventDefault(); equals(); } else if (k === 'Escape' || k.toLowerCase() === 'c') clearAll(); else if (k === 'Backspace') { if (!st.fresh && !st.err) show(st.disp.length > 1 ? st.disp.slice(0, -1) : '0'); } else if (k === '%') percent(); else return; e.stopPropagation(); }); win.body.tabIndex = 0; } }); /* ==================== 日历 ==================== */ Apps.register({ id: 'calendar', name: '日历', icon: 'assets/icons/calendar.png', w: 760, h: 560, minW: 480, minH: 400, menus(win) { const st = win?.appState; return stdMenus(this, { file: [{ label: '新建事件', key: '⌘N', action: () => st?.addEvent() }], view: [{ label: '前往今天', key: '⌘T', action: () => st?.goToday() }], }); }, render(win) { const now = new Date(); const st = win.appState = { year: now.getFullYear(), month: now.getMonth(), sel: localDateKey(now), events: Store.get('calendar', {}), }; win.body.classList.add('cal-body'); const titleEl = el('div', { class: 'cal-title' }); const prev = el('button', { class: 'fb-btn', html: '‹', title: '上个月' }); const next = el('button', { class: 'fb-btn', html: '›', title: '下个月' }); const todayBtn = el('button', { class: 'btn', text: '今天' }); const head = el('div', { class: 'cal-head' }, titleEl, el('div', { class: 'cal-head-btns' }, todayBtn, prev, next)); const weekRow = el('div', { class: 'cal-week' }); (Sys.settings.firstDayMonday ? ['一', '二', '三', '四', '五', '六', '日'] : ['日', '一', '二', '三', '四', '五', '六']) .forEach(d => weekRow.append(el('span', { text: '周' + d }))); const gridEl = el('div', { class: 'cal-grid' }); const detail = el('div', { class: 'cal-detail' }); win.body.append(head, weekRow, gridEl, detail); const save = () => Store.set('calendar', st.events); const key = (y, m, d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; st.goToday = () => { const t = new Date(); st.year = t.getFullYear(); st.month = t.getMonth(); st.sel = localDateKey(t); render(); }; const render = () => { titleEl.textContent = `${st.year}年${st.month + 1}月`; gridEl.innerHTML = ''; const first = new Date(st.year, st.month, 1); let startCol = first.getDay(); if (Sys.settings.firstDayMonday) startCol = (startCol + 6) % 7; const days = new Date(st.year, st.month + 1, 0).getDate(); const prevDays = new Date(st.year, st.month, 0).getDate(); const todayStr = localDateKey(); for (let i = 0; i < 42; i++) { const dayNum = i - startCol + 1; let cellDate, other = false, y = st.year, m = st.month, d = dayNum; if (dayNum < 1) { other = true; d = prevDays + dayNum; m = st.month - 1; if (m < 0) { m = 11; y--; } } else if (dayNum > days) { other = true; d = dayNum - days; m = st.month + 1; if (m > 11) { m = 0; y++; } } const k = key(y, m, d); const cell = el('div', { class: 'cal-cell' + (other ? ' other' : '') + (k === todayStr ? ' today' : '') + (k === st.sel ? ' sel' : ''), }, el('span', { class: 'cal-num', text: String(d) })); const evs = st.events[k] || []; if (evs.length) { const dots = el('div', { class: 'cal-dots' }); evs.slice(0, 3).forEach(() => dots.append(el('span', { class: 'cal-dot' }))); cell.append(dots); } cell.addEventListener('click', () => { st.sel = k; if (other) { st.year = y; st.month = m; } render(); }); gridEl.append(cell); } renderDetail(); }; const renderDetail = () => { detail.innerHTML = ''; const [y, m, d] = st.sel.split('-').map(Number); detail.append(el('div', { class: 'cal-detail-title', text: `${m}月${d}日 ${WEEK_CN[new Date(y, m - 1, d).getDay()]}` })); const evs = (st.events[st.sel] || []).slice().sort((a, b) => (a.time || '').localeCompare(b.time || '')); const list = el('div', { class: 'cal-events' }); if (!evs.length) list.append(el('div', { class: 'cal-no-events', text: '没有事件' })); for (const ev of evs) { const row = el('div', { class: 'cal-event' }, el('span', { class: 'cal-event-dot' }), el('span', { class: 'cal-event-time', text: ev.time || '全天' }), el('span', { class: 'cal-event-title', text: ev.title }), el('button', { class: 'cal-event-del', text: '✕', title: '删除事件', onclick: () => { st.events[st.sel] = st.events[st.sel].filter(x => x.id !== ev.id); if (!st.events[st.sel].length) delete st.events[st.sel]; save(); render(); } })); list.append(row); } const timeIn = el('input', { class: 'text-input', type: 'time', style: { width: '96px' } }); const titleIn = el('input', { class: 'text-input', type: 'text', placeholder: '事件标题', style: { flex: '1' } }); const addBtn = el('button', { class: 'btn primary', text: '添加事件' }); const doAdd = () => { const t = titleIn.value.trim(); if (!t) { titleIn.focus(); return; } (st.events[st.sel] ||= []).push({ id: uid(), title: t, time: timeIn.value || '' }); save(); titleIn.value = ''; render(); Notify.send({ appId: 'calendar', title: '日历', body: `已添加事件:${t}`, silent: true }); }; addBtn.addEventListener('click', doAdd); titleIn.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); }); detail.append(list, el('div', { class: 'cal-add' }, timeIn, titleIn, addBtn)); }; st.addEvent = () => { renderDetail(); detail.querySelector('input[type=text]')?.focus(); }; prev.addEventListener('click', () => { st.month--; if (st.month < 0) { st.month = 11; st.year--; } render(); }); next.addEventListener('click', () => { st.month++; if (st.month > 11) { st.month = 0; st.year++; } render(); }); todayBtn.addEventListener('click', () => st.goToday()); render(); } });