macos-web/js/apps.js
2026-07-17 16:52:13 +08:00

880 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============ 应用注册表 + 核心应用(访达/终端/文本编辑/备忘录/计算器/日历) ============ */
'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();
}
});