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

876 lines
45 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.

/* ============ 系统层:设置 / 电源 / 菜单栏 / Dock / 控制中心 / 通知 / Spotlight / 屏保 ============ */
'use strict';
const WALLPAPERS = [
{ id: 'monterey', name: 'Monterey 抽象', src: 'assets/wallpapers/monterey.jpg' },
{ id: 'sonoma', name: 'Sonoma 落日', src: 'assets/wallpapers/sonoma.jpg' },
{ id: 'ventura', name: 'Ventura 霞光', src: 'assets/wallpapers/ventura.jpg' },
{ id: 'bigsur', name: 'Big Sur 海岸', src: 'assets/wallpapers/big-sur-day.jpg', dark: 'assets/wallpapers/big-sur-night.jpg' },
{ id: 'sequoia', name: 'Sequoia 山谷', src: 'assets/wallpapers/sequoia.jpg' },
{ id: 'galaxy', name: '银河', src: 'assets/wallpapers/galaxy.jpg' },
];
const DEFAULT_SETTINGS = {
appearance: 'light', wallpaper: 'monterey', accent: '#0a84ff',
reduceTransparency: false, reduceMotion: false, increaseContrast: false,
dockSize: 54, dockMagnify: true, dockMagnifyLevel: 1.6, dockPosition: 'bottom', dockAutohide: false,
brightness: 1, nightShift: false, nightShiftStrength: 0.4,
volume: 0.6, muted: false,
wifi: true, bluetooth: true, airdrop: false, focus: false, vpn: false,
userName: '客人用户', avatar: 'assets/icons/avatar.svg',
passwordEnabled: false, password: '',
screensaverType: 'off', screensaverDelay: 5,
h24: false, language: 'zh-Hans', region: '中国', firstDayMonday: true,
searchEngine: 'bing', computerName: 'MacBook Pro',
siriApps: true, siriFiles: true, siriSettings: true,
loginItems: {}, notifAllow: {},
notificationsEnabled: true, // 通知总开关notifAllow[appId]===false 时该 App 完全静默
timezone: 'local', // 'local' 或 IANA 时区名(如 Asia/Shanghai
wifiNetwork: '家庭网络 5G', // 已知网络的连接状态(模拟,但持久化)
btDevices: {}, // 蓝牙设备连接状态(模拟,但持久化)
kbRepeat: 7, kbDelay: 4, mouseSpeed: 5, // 键盘/鼠标参数(模拟,但持久化)
finderView: 'icon', finderSort: 'name',
};
const Sys = {
settings: null,
unlocked: false,
fullscreenWin: null,
activeApp: 'finder',
mediaEls: new Set(),
_dockHideT: null,
_saverT: null,
_clockT: null,
init() {
this.settings = Object.assign(structuredClone(DEFAULT_SETTINGS), Store.get('settings', {}));
this.applyAll();
this.buildMenubar();
this.buildDock();
this.initIdleWatch();
addEventListener('resize', debounce(() => { WM.clampAll(); this.layoutDock(); }, 120));
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.applyAppearance());
document.addEventListener('keydown', e => this.globalKeys(e));
// 全屏时鼠标触及顶部唤出菜单栏
document.addEventListener('pointermove', e => {
if (this.fullscreenWin) {
$('#menubar').classList.toggle('autohide', e.clientY > 2 && !e.target.closest('#menubar'));
}
});
},
save() { Store.set('settings', this.settings); },
set(k, v) { this.settings[k] = v; this.save(); this.applyAll(); },
applyAll() {
this.applyAppearance();
this.applyWallpaper();
this.applyBrightness();
this.applyNightShift();
this.applyVolume();
document.documentElement.style.setProperty('--accent', this.settings.accent);
},
isDark() {
const a = this.settings.appearance;
if (a === 'dark') return true;
if (a === 'auto') return matchMedia('(prefers-color-scheme: dark)').matches;
return false;
},
applyAppearance() {
const b = document.body;
b.classList.toggle('dark', this.isDark());
b.classList.toggle('reduce-transparency', !!this.settings.reduceTransparency);
b.classList.toggle('reduce-motion', !!this.settings.reduceMotion);
b.classList.toggle('increase-contrast', !!this.settings.increaseContrast);
this.applyWallpaper();
},
wallpaperSrc() {
const w = WALLPAPERS.find(w => w.id === this.settings.wallpaper) || WALLPAPERS[0];
return (this.isDark() && w.dark) ? w.dark : w.src;
},
applyWallpaper() {
const src = this.wallpaperSrc();
const desk = $('#desktop'), lock = $('#lockscreen-bg');
desk.style.backgroundImage = `url("${src}")`;
lock.style.backgroundImage = `url("${src}")`;
// 壁纸缺失时回退到 Big Sur 日落渐变
const probe = new Image();
probe.onerror = () => {
const grad = 'linear-gradient(160deg,#4a6fa5 0%,#c86b85 45%,#f0a35e 100%)';
desk.style.backgroundImage = grad; lock.style.backgroundImage = grad;
};
probe.src = src;
},
applyBrightness() { $('#overlay-brightness').style.opacity = String((1 - this.settings.brightness) * 0.55); },
applyNightShift() { $('#overlay-nightshift').style.opacity = this.settings.nightShift ? String(this.settings.nightShiftStrength * 0.32) : '0'; },
applyVolume() {
const v = this.settings.muted ? 0 : this.settings.volume;
this.mediaEls.forEach(m => { try { m.volume = clamp(v, 0, 1); } catch (e) {} });
Bus.emit('volume:changed', v); // App 内音量滑块监听此事件与全局保持一致
},
/* 媒体注册:切换 src 后元素仍保留在集合中;仅在窗口关闭时由应用注销 */
registerMedia(m) {
this.mediaEls.add(m);
try { m.volume = this.settings.muted ? 0 : this.settings.volume; } catch (e) {}
return m;
},
unregisterMedia(m) { this.mediaEls.delete(m); },
/* ================= 电源流程 ================= */
boot() {
$('#poweroff').classList.add('hidden');
$('#lockscreen').classList.add('hidden');
$('#desktop').classList.add('hidden');
$('#menubar').classList.add('hidden');
$('#dock').classList.add('hidden');
const boot = $('#bootscreen'), fill = $('#boot-progress-fill');
boot.classList.remove('hidden');
fill.style.transition = 'none'; fill.style.width = '0%';
requestAnimationFrame(() => requestAnimationFrame(() => {
fill.style.transition = 'width 2.3s cubic-bezier(.25,.6,.35,1)';
fill.style.width = '100%';
}));
setTimeout(() => {
boot.classList.add('hidden');
this.showLock();
}, 3100);
},
/* 关闭菜单、控制中心、通知中心、Spotlight、Launchpad 等临时浮层 */
closeOverlays() {
UI.closeAllMenus();
$('#control-center').classList.add('hidden');
Notify.hideCenter();
Spotlight.close();
if (globalThis.Launchpad) Launchpad.hide();
if (!$('#screensaver').classList.contains('hidden')) this.hideScreensaver();
},
/* 依次请求关闭所有窗口并等待脏文档确认;任一取消则整体中止。
* 确认对话框z-index 7000在此过程中始终位于锁屏(9500)/关机层(10000)之下显示之前,故不会被遮住。 */
async closeAllWindowsGracefully() {
for (const w of WM.windows.slice()) {
const r = await WM.close(w);
if (r !== 'closed') return false; // cancelled → 中止会话结束流程
}
return true;
},
showLock() {
this.closeOverlays();
this.unlocked = false;
// 锁屏不再关闭任何窗口:窗口、层级、最小化与全屏状态全部保留,解锁后原样恢复
const ls = $('#lockscreen');
ls.classList.remove('hidden', 'unlocking', 'shake');
$('#lock-password-row').classList.add('hidden');
$('#lock-error').classList.add('hidden');
$('#lock-hint').textContent = '点按或按 Enter 键解锁';
$('#lock-username').textContent = this.settings.userName;
$('#lock-avatar').src = this.settings.avatar;
this.applyWallpaper();
this.tickLockClock();
clearInterval(this._lockT);
this._lockT = setInterval(() => this.tickLockClock(), 1000);
ls.onclick = () => this.lockAdvance();
$('#lock-password').onkeydown = e => {
e.stopPropagation();
if (e.key === 'Enter') this.tryUnlock();
};
},
tickLockClock() {
const d = new Date();
$('#lock-date').textContent = fmtDateCN(d, this.settings.timezone);
$('#lock-time').textContent = fmtTime(d, this.settings.h24, this.settings.timezone);
},
lockAdvance() {
if (this.unlocked) return;
if (this.settings.passwordEnabled && $('#lock-password-row').classList.contains('hidden')) {
$('#lock-password-row').classList.remove('hidden');
$('#lock-hint').textContent = '按 Enter 键解锁';
$('#lock-password').value = '';
setTimeout(() => $('#lock-password').focus(), 50);
return;
}
if (this.settings.passwordEnabled) { this.tryUnlock(); return; }
this.unlock();
},
tryUnlock() {
const v = $('#lock-password').value;
if (v === this.settings.password) this.unlock();
else {
const ls = $('#lockscreen');
$('#lock-error').classList.remove('hidden');
ls.classList.remove('shake'); void ls.offsetWidth; ls.classList.add('shake');
$('#lock-password').value = '';
}
},
unlock() {
this.unlocked = true;
clearInterval(this._lockT);
const ls = $('#lockscreen');
ls.classList.add('unlocking');
setTimeout(() => {
ls.classList.add('hidden'); ls.classList.remove('unlocking');
$('#desktop').classList.remove('hidden');
$('#menubar').classList.remove('hidden');
this.layoutDock();
$('#dock').classList.remove('hidden');
this.renderDesktopIcons();
this.setActiveApp('finder');
// 登录项
for (const [appId, on] of Object.entries(this.settings.loginItems)) {
if (on && Apps.get(appId)) setTimeout(() => Apps.open(appId), 400);
}
this.resetIdle();
}, 420);
},
/* 会话结束统一流程:系统确认 → 等待全部脏文档确认(可中止)→ 执行 */
async _sessionEnd(confirmTitle, confirmMsg, okLabel, danger, act) {
if (this._sessionBusy) return;
this._sessionBusy = true;
try {
if (!await UI.confirm(confirmTitle, confirmMsg, { ok: okLabel, danger, icon: 'assets/icons/settings.png' })) return;
this.closeOverlays();
if (!await this.closeAllWindowsGracefully()) return; // 用户取消脏文档确认 → 中止
this.unlocked = false;
act();
} finally { this._sessionBusy = false; }
},
powerOff() {
return this._sessionEnd('确定要关机吗?', '关机前将请你确认所有未保存的更改。', '关机', true, () => {
['#desktop', '#menubar', '#dock', '#lockscreen', '#notification-center'].forEach(s => $(s).classList.add('hidden'));
$('#poweroff').classList.remove('hidden');
$('#poweroff').onclick = () => { $('#poweroff').classList.add('hidden'); this.boot(); };
});
},
restart() {
return this._sessionEnd('确定要重新启动吗?', '重新启动前将请你确认所有未保存的更改。', '重新启动', false, () => this.boot());
},
logout() {
return this._sessionEnd(`确定要注销“${this.settings.userName}”吗?`, '注销前将请你确认所有未保存的更改。', '注销', false, () => this.showLock());
},
sleep() {
this.closeOverlays();
this.unlocked = false;
// 睡眠不关闭窗口:仅熄灭显示,唤醒后经锁屏恢复原状
['#desktop', '#menubar', '#dock'].forEach(s => $(s).classList.add('hidden'));
const black = el('div', { style: { position: 'fixed', inset: '0', background: '#000', zIndex: '9999' } });
document.body.append(black);
const wake = () => { black.remove(); document.removeEventListener('keydown', wake); this.showLock(); };
setTimeout(() => { black.addEventListener('pointerdown', wake); document.addEventListener('keydown', wake); }, 600);
},
globalKeys(e) {
// Spotlight
if ((e.ctrlKey || e.metaKey) && e.code === 'Space') { e.preventDefault(); if (this.unlocked) Spotlight.toggle(); return; }
if (!this.unlocked) {
if (e.key === 'Enter' && !$('#lockscreen').classList.contains('hidden') && document.activeElement !== $('#lock-password')) this.lockAdvance();
return;
}
// Ctrl+Cmd+F 全屏
if (e.ctrlKey && e.metaKey && (e.key === 'f' || e.key === 'F')) {
e.preventDefault();
if (WM.activeWin) WM.toggleFullscreen(WM.activeWin);
return;
}
// ⌘W 关闭窗口 / ⌘M 最小化meta 在多数浏览器难捕获,兼容 ctrl+alt
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === 'w' && WM.activeWin) {
if (e.metaKey) { e.preventDefault(); WM.close(WM.activeWin); }
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'm' && WM.activeWin && e.metaKey) {
e.preventDefault(); WM.minimize(WM.activeWin);
}
// ⌘Q 退出前台应用
if (e.metaKey && e.key.toLowerCase() === 'q') {
e.preventDefault();
if (this.activeApp && this.activeApp !== 'finder') Apps.quit(this.activeApp);
}
},
/* ================= 屏保 ================= */
initIdleWatch() {
const reset = () => this.resetIdle();
['pointermove', 'pointerdown', 'keydown', 'wheel'].forEach(ev =>
document.addEventListener(ev, reset, { passive: true, capture: true }));
},
resetIdle() {
if (!$('#screensaver').classList.contains('hidden')) this.hideScreensaver();
clearTimeout(this._saverT);
if (!this.unlocked || this.settings.screensaverType === 'off') return;
this._saverT = setTimeout(() => this.showScreensaver(), this.settings.screensaverDelay * 60000);
},
showScreensaver() {
const ss = $('#screensaver');
ss.innerHTML = '';
ss.classList.remove('hidden');
if (this.settings.screensaverType === 'clock') {
const t = el('div', { class: 't' }), d = el('div', { class: 'd' });
ss.append(el('div', { class: 'ss-clock' }, t, d));
const tick = () => { const n = new Date(); t.textContent = fmtTime(n, this.settings.h24); d.textContent = fmtDateCN(n); };
tick(); ss._timer = setInterval(tick, 1000);
} else {
const srcs = WALLPAPERS.map(w => w.src);
let i = 0;
const slides = srcs.map(s => el('div', { class: 'ss-slide', style: { backgroundImage: `url("${s}")` } }));
slides.forEach(s => ss.append(s));
const cycle = () => { slides.forEach((s, j) => s.style.opacity = j === i ? '1' : '0'); i = (i + 1) % slides.length; };
cycle(); ss._timer = setInterval(cycle, 5000);
}
},
hideScreensaver() {
const ss = $('#screensaver');
clearInterval(ss._timer);
ss.classList.add('hidden'); ss.innerHTML = '';
},
/* ================= 菜单栏 ================= */
buildMenubar() {
const left = $('#menubar-left'), right = $('#menubar-right');
left.innerHTML = ''; right.innerHTML = '';
// Apple 菜单
const apple = el('div', { class: 'mb-item mb-apple', role: 'button', 'aria-label': 'Apple 菜单' },
el('svg', { viewBox: '0 0 384 512', html: '<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>' }));
apple.addEventListener('click', e => { e.stopPropagation(); UI.closeAllMenus(); this.openAppleMenu(apple); });
left.append(apple);
this._appMenuItems = [];
// 右侧状态项
const mk = (cls, html, title) => { const d = el('div', { class: 'mb-item ' + cls, title: title || '' }); if (html != null) d.innerHTML = html; return d; };
this._mbBattery = mk('mb-battery', `<span>100%</span><svg viewBox="0 0 27 12"><rect x="0.5" y="0.5" width="22" height="11" rx="3.5" fill="none" stroke="currentColor" opacity=".5"/><rect x="2" y="2" width="19" height="8" rx="2" fill="currentColor"/><path d="M25 4 v4 c1.2-.3 2-1 2-2 s-.8-1.7-2-2z" fill="currentColor" opacity=".5"/></svg>`, '电池电量100%(电源适配器)');
this._mbWifi = mk('mb-wifi', '', 'Wi-Fi');
this._mbWifi.append(this.wifiSvg());
this._mbSpot = mk('mb-spot', '<svg viewBox="0 0 24 24" width="15" height="15"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2.2"/><line x1="15.3" y1="15.3" x2="20.5" y2="20.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>', '聚焦搜索 (Ctrl+空格)');
this._mbCC = mk('mb-cc', '<svg viewBox="0 0 24 24" width="15" height="15"><rect x="2" y="4" width="20" height="7" rx="3.5" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="17.5" cy="7.5" r="2.2" fill="currentColor"/><rect x="2" y="13" width="20" height="7" rx="3.5" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="6.5" cy="16.5" r="2.2" fill="currentColor"/></svg>', '控制中心');
this._mbClock = mk('', '', '');
this._mbClock.id = 'mb-clock';
right.append(this._mbBattery, this._mbWifi, this._mbSpot, this._mbCC, this._mbClock);
this._mbSpot.addEventListener('click', () => Spotlight.toggle());
this._mbCC.addEventListener('click', e => { e.stopPropagation(); this.toggleControlCenter(); });
this._mbWifi.addEventListener('click', e => { e.stopPropagation(); this.toggleControlCenter(); });
this._mbClock.addEventListener('click', e => { e.stopPropagation(); this.toggleNotificationCenter(); });
this.tickClock();
clearInterval(this._clockT);
this._clockT = setInterval(() => this.tickClock(), 1000);
this.initDockMag();
},
wifiSvg() {
const on = this.settings.wifi;
const color = 'currentColor';
const svg = el('span', { style: { display: 'inline-flex', opacity: on ? '1' : '.45' } });
svg.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16"><g fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"><path d="M4 9.5 C8.5 5 15.5 5 20 9.5"/><path d="M7 13 C10 10.2 14 10.2 17 13"/><path d="M9.8 16.3 C11.2 15 12.8 15 14.2 16.3"/><circle cx="12" cy="19" r="1.4" fill="${color}" stroke="none"/></g>${on ? '' : '<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" stroke-width="2"/>'}</svg>`;
return svg;
},
tickClock() {
if (this._mbClock) this._mbClock.textContent = fmtMenuClock(new Date(), this.settings.h24, this.settings.timezone);
// 闹钟检查
globalThis.ClockApp && ClockApp.tick();
},
openAppleMenu(anchor) {
const r = anchor.getBoundingClientRect();
UI.menu([
{ label: '关于本机', action: () => Apps.open('settings', { pane: 'about' }) },
{ sep: true },
{ label: '系统设置…', action: () => Apps.open('settings') },
{ label: 'App Store…', action: () => Apps.open('appstore') },
{ sep: true },
{ label: '强制退出…', key: '⌥⌘⎋', action: () => this.forceQuitDialog() },
{ sep: true },
{ label: '睡眠', action: () => this.sleep() },
{ label: '重新启动…', action: () => this.restart() },
{ label: '关机…', action: () => this.powerOff() },
{ sep: true },
{ label: '锁定屏幕', key: '⌃⌘Q', action: () => this.showLock() },
{ label: `注销“${this.settings.userName}”…`, action: () => this.logout() },
], r.left, r.bottom + 4);
},
forceQuitDialog() {
const running = [...new Set(WM.windows.map(w => w.appId))].map(id => Apps.get(id)).filter(Boolean);
const mask = el('div', { class: 'modal-mask' });
const list = el('div', { style: { margin: '10px 0', maxHeight: '180px', overflowY: 'auto', textAlign: 'left' } });
let sel = null;
const render = () => {
list.innerHTML = '';
if (!running.length) list.append(el('div', { class: 'empty-state', style: { height: '80px' }, text: '没有正在运行的应用' }));
running.forEach(app => {
const row = el('div', {
class: 'menu-item enabled', style: { borderRadius: '6px' },
onclick: () => { sel = app; $$('.menu-item', list).forEach(n => n.style.background = ''); row.style.background = 'var(--selection)'; }
}, iconImg(app.icon, '', ''), el('span', { text: app.name }));
row.querySelector('img').style.width = '20px'; row.querySelector('img').style.height = '20px';
list.append(row);
});
};
render();
const dlg = el('div', { class: 'dialog', style: { width: '300px' } },
el('div', { class: 'dlg-title', text: '强制退出应用程序' }),
el('div', { class: 'dlg-msg', text: '选择要强制退出的应用,未保存的更改将会丢失。' }),
list
);
const btns = el('div', { class: 'dlg-btns row' },
el('button', { class: 'btn', text: '取消', onclick: () => mask.remove() }),
el('button', {
class: 'btn danger', text: '强制退出', onclick: () => {
if (sel) { WM.windowsForApp(sel.id).slice().forEach(w => WM.close(w)); running.splice(running.indexOf(sel), 1); sel = null; render(); }
}
})
);
dlg.append(btns); mask.append(dlg); document.body.append(mask);
mask.addEventListener('pointerdown', e => { if (e.target === mask) mask.remove(); });
},
setActiveApp(appId) {
this.activeApp = appId || 'finder';
const app = Apps.get(this.activeApp);
// 重建应用菜单区
$$('.mb-appmenu', $('#menubar-left')).forEach(n => n.remove());
const left = $('#menubar-left');
const menus = app ? app.menus(WM.activeWin && WM.activeWin.appId === app.id ? WM.activeWin : null) : [];
const nameItem = el('div', { class: 'mb-item mb-appmenu app-name', text: app ? app.name : '访达' });
nameItem.addEventListener('click', e => {
e.stopPropagation();
UI.closeAllMenus();
const r = nameItem.getBoundingClientRect();
const nm = app ? app.name : '访达';
UI.menu([
{ label: `关于${nm}`, action: () => UI.dialog({ icon: app?.icon, title: nm, msg: app?.about || 'macOS 网页版内置应用', buttons: ['好'] }) },
{ sep: true },
{ label: '设置…', action: () => Apps.open('settings') },
{ sep: true },
{ label: `退出${nm}`, key: '⌘Q', disabled: this.activeApp === 'finder', action: () => Apps.quit(this.activeApp) },
], r.left, r.bottom + 4);
});
left.append(nameItem);
for (const m of menus) {
const item = el('div', { class: 'mb-item mb-appmenu', text: m.label });
item.addEventListener('click', e => {
e.stopPropagation();
if (item.classList.contains('open')) { UI.closeAllMenus(); item.classList.remove('open'); return; }
UI.closeAllMenus(); $$('.mb-item.open').forEach(n => n.classList.remove('open'));
item.classList.add('open');
const r = item.getBoundingClientRect();
// 展开瞬间按当前 activeWin.appState 重新生成菜单,确保 disabled/checked/action 为最新状态
const curApp = Apps.get(this.activeApp);
const curWin = curApp && WM.activeWin && WM.activeWin.appId === curApp.id ? WM.activeWin : null;
const freshMenus = curApp ? curApp.menus(curWin) : [];
const fm = freshMenus.find(x => x.label === m.label) || m;
const pop = UI.menu(typeof fm.items === 'function' ? fm.items() : fm.items, r.left, r.bottom + 4);
const obs = new MutationObserver(() => { if (!document.body.contains(pop)) item.classList.remove('open'); obs.disconnect(); });
obs.observe(document.body, { childList: true });
});
item.addEventListener('pointerenter', () => {
if ($$('.menu-pop').length && !item.classList.contains('open') && $$('.mb-item.open').length) item.click();
});
left.append(item);
}
},
/* ================= Dock ================= */
dockPinned() {
return ['finder', 'launchpad', 'safari', 'mail', 'messages', 'facetime', 'maps', 'notes', 'textedit', 'calendar', 'photos', 'music', 'calculator', 'terminal', 'activitymonitor', 'appstore', 'settings'];
},
buildDock() {
Bus.on('wm:changed', () => this.updateDockDots());
Bus.on('trash:changed', () => this.renderDock());
},
dockItems() {
const pinned = this.dockPinned();
const running = [...new Set(WM.windows.map(w => w.appId))].filter(id => !pinned.includes(id));
return [...pinned, ...running];
},
renderDock() {
const dock = $('#dock');
dock.innerHTML = '';
const s = this.settings;
for (const id of this.dockItems()) {
const app = Apps.get(id);
if (!app) continue;
const ic = el('div', { class: 'dock-icon', dataset: { app: id }, title: '' },
iconImg(app.icon, '', app.name),
el('span', { class: 'dock-tip', text: app.name }),
el('span', { class: 'run-dot hidden' })
);
const badge = Notify.badgeCount(id);
if (badge > 0) ic.append(el('span', { class: 'badge dock-badge', text: String(badge) }));
ic.style.width = ic.style.height = this.effDockSize() + 'px';
ic.addEventListener('click', () => this.dockClick(id, ic));
ic.addEventListener('contextmenu', e => this.dockIconMenu(e, id));
dock.append(ic);
}
const sep = el('div', { class: 'dock-sep' });
dock.append(sep);
// 废纸篓
const trashEmpty = FS.list(FS.TRASH).length === 0;
const trash = el('div', { class: 'dock-icon', dataset: { app: '__trash' }, title: '废纸篓' },
iconImg(trashEmpty ? 'assets/icons/trash.svg' : 'assets/icons/trash-full.svg', '', '废纸篓'),
el('span', { class: 'dock-tip', text: '废纸篓' })
);
trash.style.width = trash.style.height = this.effDockSize() + 'px';
trash.addEventListener('click', () => Apps.open('finder', { path: FS.TRASH }));
trash.addEventListener('contextmenu', e => {
e.preventDefault(); e.stopPropagation();
UI.contextMenu([
{ label: '打开', action: () => Apps.open('finder', { path: FS.TRASH }) },
{ label: '清空废纸篓', disabled: trashEmpty, action: async () => { if (await UI.confirm('确定要清空废纸篓吗?', '此操作无法撤销。', { ok: '清空', danger: true })) FS.emptyTrash(); } },
], e);
});
dock.append(trash);
dock.addEventListener('contextmenu', e => {
if (e.target.closest('.dock-icon')) return;
e.preventDefault();
this.dockBgMenu(e);
});
this.layoutDock();
this.updateDockDots();
},
/* 窄屏自动缩小 Dock 图标 */
effDockSize() {
const s = this.settings.dockSize;
if (innerWidth <= 640) return Math.min(s, 40);
if (innerWidth <= 1100) return Math.min(s, 48);
return s;
},
layoutDock() {
const dock = $('#dock'), hz = $('#dock-hotzone'), s = this.settings;
dock.className = dock.className.replace(/pos-\w+|hidden-\w+/g, '').trim();
dock.classList.add('pos-' + s.dockPosition);
hz.className = s.dockPosition;
// 尺寸
$$('.dock-icon', dock).forEach(ic => { ic.style.width = ic.style.height = this.effDockSize() + 'px'; });
// 自动隐藏
const hidden = s.dockAutohide && !this._dockPeeking;
dock.classList.toggle('hidden-' + s.dockPosition, !!hidden && this.unlocked);
if (this.fullscreenWin) dock.classList.add('hidden-' + s.dockPosition);
// 绑定热区
hz.onpointerenter = () => { if (s.dockAutohide) this.dockPeek(true); };
dock.onpointerenter = () => { clearTimeout(this._dockHideT); if (s.dockMagnify) dock.addEventListener('pointermove', this._dockMag); };
dock.onpointerleave = () => {
dock.removeEventListener('pointermove', this._dockMag);
this.dockResetMag();
if (s.dockAutohide) { clearTimeout(this._dockHideT); this._dockHideT = setTimeout(() => this.dockPeek(false), 450); }
};
},
dockPeek(show) {
this._dockPeeking = show;
const dock = $('#dock'), s = this.settings;
clearTimeout(this._dockHideT);
dock.classList.toggle('hidden-' + s.dockPosition, !show);
if (!show) this._dockPeeking = false;
},
dockHide(force) {
const dock = $('#dock'), s = this.settings;
dock.classList.toggle('hidden-' + s.dockPosition, force || (s.dockAutohide && !this._dockPeeking));
},
_dockMag: null,
dockResetMag() {
const s = this.settings;
$$('.dock-icon', $('#dock')).forEach(ic => { ic.style.width = ic.style.height = this.effDockSize() + 'px'; ic.style.margin = '0'; });
},
initDockMag() {
this._dockMag = e => {
const s = this.settings;
if (!s.dockMagnify) return;
const base = this.effDockSize(), max = base * s.dockMagnifyLevel;
const vertical = s.dockPosition !== 'bottom';
for (const ic of $$('.dock-icon', $('#dock'))) {
const r = ic.getBoundingClientRect();
const c = vertical ? r.top + r.height / 2 : r.left + r.width / 2;
const m = vertical ? e.clientY : e.clientX;
const d = Math.abs(m - c);
const range = base * 2.4;
const scale = d < range ? base + (max - base) * Math.cos((d / range) * Math.PI / 2) : base;
ic.style.width = ic.style.height = scale + 'px';
}
};
},
updateDockDots() {
for (const ic of $$('.dock-icon', $('#dock'))) {
const id = ic.dataset.app;
if (!id || id === '__trash') continue;
const running = WM.windows.some(w => w.appId === id);
ic.querySelector('.run-dot')?.classList.toggle('hidden', !running);
// 同步 dock 项(未固定应用退出后移除)
}
const want = this.dockItems();
const have = $$('.dock-icon', $('#dock')).map(n => n.dataset.app).filter(a => a && a !== '__trash');
if (want.join() !== have.join()) this.renderDock();
},
dockClick(id, ic) {
const wins = WM.windowsForApp(id);
if (!wins.length) {
ic.classList.add('bouncing');
setTimeout(() => ic.classList.remove('bouncing'), 1700);
Apps.open(id);
return;
}
const min = wins.filter(w => w.state === 'minimized');
if (min.length) { min.forEach(w => WM.restore(w)); return; }
if (WM.activeWin && WM.activeWin.appId === id && wins.length > 1) {
// 轮换窗口
const i = wins.indexOf(WM.activeWin);
WM.focus(wins[(i + 1) % wins.length]);
} else WM.focus(wins[wins.length - 1]);
},
dockIconMenu(e, id) {
e.preventDefault(); e.stopPropagation();
const app = Apps.get(id);
const running = WM.windowsForApp(id).length > 0;
UI.contextMenu([
{ label: running ? '显示所有窗口' : '打开', action: () => { running ? WM.windowsForApp(id).forEach(w => WM.restore(w)) : Apps.open(id); } },
{ sep: true },
{ label: '选项', submenu: [
{ label: '登录时打开', checked: !!this.settings.loginItems[id], action: () => { this.settings.loginItems[id] = !this.settings.loginItems[id]; this.save(); } },
] },
{ label: running ? '退出' : '在访达中显示', action: () => { running ? Apps.quit(id) : Apps.open('finder', { path: FS.HOME + '/Applications' }); } },
], e);
},
dockBgMenu(e) {
const s = this.settings;
UI.contextMenu([
{ label: '自动隐藏', checked: s.dockAutohide, action: () => { s.dockAutohide = !s.dockAutohide; this.save(); this.layoutDock(); } },
{ label: '位置', submenu: ['bottom', 'left', 'right'].map(p => ({
label: { bottom: '置于底部', left: '置于左边', right: '置于右边' }[p], checked: s.dockPosition === p,
action: () => { s.dockPosition = p; this.save(); this.layoutDock(); }
})) },
{ sep: true },
{ label: '程序坞设置…', action: () => Apps.open('settings', { pane: 'dock' }) },
], e);
},
/* ================= 控制中心 ================= */
toggleControlCenter() {
const cc = $('#control-center');
if (!cc.classList.contains('hidden')) { cc.classList.add('hidden'); return; }
Notify.hideCenter();
this.renderControlCenter();
cc.classList.remove('hidden');
const close = e => { if (!e.target.closest('#control-center') && !e.target.closest('.mb-cc') && !e.target.closest('.mb-wifi')) { cc.classList.add('hidden'); document.removeEventListener('pointerdown', close); } };
setTimeout(() => document.addEventListener('pointerdown', close), 0);
},
ccToggle(iconSvg, label, sub, key, onClick) {
const s = this.settings;
const ico = el('div', { class: 'cc-ico' + (s[key] ? ' on' : '') }); ico.innerHTML = iconSvg;
const row = el('div', { class: 'cc-row', style: { cursor: 'pointer' } }, ico,
el('div', { class: 'cc-lab' }, label, el('small', { text: s[key] ? '打开' : '关闭' })));
row.addEventListener('click', () => {
if (onClick) onClick(!s[key]);
else { s[key] = !s[key]; this.save(); }
this.applyAll(); this.renderControlCenter();
if (key === 'wifi') { this._mbWifi.innerHTML = ''; this._mbWifi.append(this.wifiSvg()); }
});
return row;
},
renderControlCenter() {
const cc = $('#control-center'), s = this.settings;
cc.innerHTML = '';
const svgWifi = '<svg viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 9.5 C8.5 5 15.5 5 20 9.5"/><path d="M7 13 C10 10.2 14 10.2 17 13"/><circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/></g></svg>';
const svgBt = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 12 l5 -6 v12 z M12 8 l4 4 -4 4 M12 8 l4 4"/></svg>';
const svgDrop = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4.5" fill="currentColor"/></svg>';
const svgMoon = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M20 15.5 A8.5 8.5 0 1 1 8.5 4 A7 7 0 0 0 20 15.5z"/></svg>';
const svgSun = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor"/><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="12" y1="2.5" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="21.5"/><line x1="2.5" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="21.5" y2="12"/><line x1="5" y1="5" x2="6.8" y2="6.8"/><line x1="17.2" y1="17.2" x2="19" y2="19"/><line x1="5" y1="19" x2="6.8" y2="17.2"/><line x1="17.2" y1="6.8" x2="19" y2="5"/></g></svg>';
const svgVol = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 9 v6 h4 l5 4 V5 L8 9 z"/><path d="M16 9 a4.5 4.5 0 0 1 0 6 M18 6.5 a8 8 0 0 1 0 11" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
cc.append(
el('div', { class: 'cc-card' },
this.ccToggle(svgWifi, 'Wi-Fi', '', 'wifi'),
this.ccToggle(svgBt, '蓝牙', '', 'bluetooth'),
this.ccToggle(svgDrop, '隔空投送', '', 'airdrop')),
el('div', { class: 'cc-card' },
this.ccToggle(svgMoon, '专注模式', '', 'focus'),
this.ccToggle('<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 20 h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', 'VPN', '', 'vpn')),
el('div', { class: 'cc-card span2' },
el('div', { class: 'cc-title', text: '显示器' }),
el('div', { class: 'cc-slider-row' }, (() => { const i = el('span'); i.innerHTML = svgSun; return i; })(),
(() => {
const r = el('input', { type: 'range', class: 'slider', min: '20', max: '100', value: String(Math.round(s.brightness * 100)) });
r.style.setProperty('--fill', (s.brightness * 100) + '%');
r.addEventListener('input', () => { s.brightness = r.value / 100; r.style.setProperty('--fill', r.value + '%'); Sys.applyBrightness(); });
r.addEventListener('change', () => Sys.save());
return r;
})())),
el('div', { class: 'cc-card span2' },
el('div', { class: 'cc-title', text: '声音' }),
el('div', { class: 'cc-slider-row' }, (() => { const i = el('span'); i.innerHTML = svgVol; return i; })(),
(() => {
const r = el('input', { type: 'range', class: 'slider', min: '0', max: '100', value: String(Math.round(s.volume * 100)) });
r.style.setProperty('--fill', (s.volume * 100) + '%');
r.addEventListener('input', () => { s.volume = r.value / 100; s.muted = r.value == 0; r.style.setProperty('--fill', r.value + '%'); Sys.applyVolume(); });
r.addEventListener('change', () => Sys.save());
return r;
})())),
el('div', { class: 'cc-card span2' },
this.ccToggle('<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5" fill="currentColor"/><path d="M12 2 v3 M12 19 v3 M2 12 h3 M19 12 h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>', '夜览', '', 'nightShift'))
);
},
/* ================= 通知中心 ================= */
toggleNotificationCenter() {
const nc = $('#notification-center');
const open = nc.classList.contains('nc-hidden') || nc.classList.contains('hidden');
if (open) { $('#control-center').classList.add('hidden'); Notify.showCenter(); }
else Notify.hideCenter();
},
/* 还原所有设置 */
async resetAll() {
const ok = await UI.confirm('还原所有设置?', '将清除本应用保存在此浏览器中的全部数据(设置、文件、备忘录等)并恢复默认。', { ok: '还原', danger: true, icon: 'assets/icons/settings.png' });
if (!ok) return false;
Store.clearAll();
location.reload();
return true;
}
};
/* ============ 通知 API ============ */
const Notify = {
list: [],
init() { this.list = Store.get('notifs', []); },
save() { Store.set('notifs', this.list.slice(-60)); },
badgeCount(appId) { return this.list.filter(n => n.appId === appId && !n.read).length; },
/* 判断某 App 是否允许发通知(总开关 + 每 App 开关,默认允许) */
allowed(appId) {
const s = Sys.settings;
if (!s.notificationsEnabled) return false;
return s.notifAllow[appId] !== false;
},
/* breakthrough: 允许突破专注模式(闹钟等关键提醒使用) */
send({ appId, title, body, silent = false, breakthrough = false }) {
if (!this.allowed(appId)) return null; // 开关关闭:无横幅、无记录、无徽标
const app = Apps.get(appId) || { name: appId, icon: 'assets/icons/settings.png' };
const n = { id: uid(), appId, title, body, ts: Date.now(), read: false, icon: app.icon };
this.list.push(n); this.save();
// 专注模式仍保存记录与未读仅不弹横幅breakthrough 除外)
if ((!Sys.settings.focus || breakthrough) && !silent) this.banner(n, app);
if (!$('#notification-center').classList.contains('hidden') && !$('#notification-center').classList.contains('nc-hidden')) this.renderCenter();
this.updateBadges();
return n;
},
banner(n, app) {
const b = el('div', { class: 'banner' }, iconImg(n.icon, '', ''),
el('div', null, el('div', { class: 'n-title', text: n.title }), el('div', { class: 'n-text', text: n.body })));
// 只有点击横幅才标记已读;横幅自然消失不改变未读状态
b.addEventListener('click', () => { this.markRead(n.id); b.remove(); if (Apps.get(n.appId)) Apps.open(n.appId); });
$('#banner-container').append(b);
setTimeout(() => { b.classList.add('out'); setTimeout(() => b.remove(), 320); }, 5200);
},
markRead(id) { const n = this.list.find(x => x.id === id); if (n && !n.read) { n.read = true; this.save(); this.updateBadges(); } },
remove(id) { this.list = this.list.filter(x => x.id !== id); this.save(); this.renderCenter(); this.updateBadges(); },
clearAll() { this.list = []; this.save(); this.renderCenter(); this.updateBadges(); },
/* 局部更新 Dock 徽标,不重建整个 Dock */
updateBadges() {
$$('#dock .dock-icon').forEach(ic => {
const id = ic.dataset.app;
const cnt = id === 'trash' ? 0 : this.badgeCount(id);
let badge = ic.querySelector('.dock-badge');
if (cnt > 0) {
if (!badge) { badge = el('span', { class: 'badge dock-badge' }); ic.append(badge); }
badge.textContent = String(cnt);
} else if (badge) badge.remove();
});
},
showCenter() {
const nc = $('#notification-center');
nc.classList.remove('hidden', 'nc-hidden');
this.renderCenter();
},
hideCenter() { $('#notification-center').classList.add('nc-hidden'); },
renderCenter() {
const nc = $('#notification-center');
if (nc.classList.contains('hidden')) return;
nc.innerHTML = '';
const d = new Date();
nc.append(el('div', { class: 'nc-date' }, el('div', { class: 'd1', text: WEEK_CN[d.getDay()] }), el('div', { class: 'd2', text: `${d.getMonth() + 1}${d.getDate()}` })));
// 天气小组件
const w = globalThis.WeatherApp ? WeatherApp.widgetData() : null;
if (w) nc.append(el('div', { class: 'nc-widget' },
el('div', { class: 'w-city', text: w.city }),
el('div', { class: 'w-temp', text: w.temp + '°' }),
el('div', { class: 'w-desc', text: `${w.desc} 最高${w.hi}° 最低${w.lo}°` })));
const list = this.list.slice().reverse();
if (!list.length) nc.append(el('div', { class: 'nc-empty', text: '没有新通知' }));
for (const n of list.slice(0, 30)) {
const item = el('div', { class: 'nc-notif' },
el('button', { class: 'n-close', text: '✕', 'aria-label': '清除通知', onclick: e => { e.stopPropagation(); this.remove(n.id); } }),
iconImg(n.icon, '', ''),
el('div', { class: 'n-body' },
el('div', { class: 'n-title' }, el('span', { text: n.title }), el('time', { text: relTime(n.ts) })),
el('div', { class: 'n-text', text: n.body })));
item.addEventListener('click', () => { this.markRead(n.id); if (Apps.get(n.appId)) Apps.open(n.appId); });
nc.append(item);
}
if (list.length) nc.append(el('button', { class: 'nc-clear-all', text: '清除所有通知', onclick: () => this.clearAll() }));
}
};
/* ============ Spotlight ============ */
const Spotlight = {
sel: 0, items: [],
toggle() { $('#spotlight').classList.contains('hidden') ? this.open() : this.close(); },
open() {
const sp = $('#spotlight');
sp.classList.remove('hidden');
const input = $('#spotlight-input');
input.value = ''; this.render('');
setTimeout(() => input.focus(), 30);
input.oninput = () => { this.sel = 0; this.render(input.value.trim()); };
input.onkeydown = e => {
if (e.key === 'Escape') this.close();
else if (e.key === 'ArrowDown') { e.preventDefault(); this.moveSel(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); this.moveSel(-1); }
else if (e.key === 'Enter') { const it = this.items[this.sel]; if (it) { this.close(); it.action(); } }
};
sp.onclick = e => { if (e.target === sp) this.close(); };
},
close() { $('#spotlight').classList.add('hidden'); },
moveSel(d) {
if (!this.items.length) return;
this.sel = (this.sel + d + this.items.length) % this.items.length;
$$('.sp-item').forEach((n, i) => n.classList.toggle('sel', i === this.sel));
$$('.sp-item')[this.sel]?.scrollIntoView({ block: 'nearest' });
},
render(q) {
const box = $('#spotlight-results');
box.innerHTML = ''; this.items = [];
if (!q) return;
const s = Sys.settings;
const addSection = name => box.append(el('div', { class: 'sp-section', text: name }));
const addItem = (section, icon, label, sub, action) => {
this.items.push({ action });
const item = el('div', { class: 'sp-item' + (this.items.length - 1 === this.sel ? ' sel' : '') },
typeof icon === 'string' ? iconImg(icon, '', '') : icon,
el('span', { text: label }), sub ? el('span', { class: 'sp-sub', text: sub }) : null);
item.addEventListener('click', () => { this.close(); action(); });
item.addEventListener('pointerenter', () => { this.sel = this.items.length - 1; $$('.sp-item').forEach((n, i) => n.classList.toggle('sel', i === this.sel)); });
if (section) { const secs = $$('.sp-section', box).map(n => n.textContent); if (!secs.includes(section)) addSection(section); }
box.append(item);
};
// 计算器
if (/^[\d\s+\-*/().%×÷^]+$/.test(q) && /\d/.test(q) && /[+\-*/×÷^%]/.test(q)) {
try {
const expr = q.replace(/×/g, '*').replace(/÷/g, '/').replace(/\^/g, '**').replace(/%/g, '/100');
const val = Function('"use strict";return (' + expr + ')')();
if (isFinite(val)) {
const c = el('div', { class: 'sp-calc', text: `${q} = ${+val.toFixed(8)}` });
box.append(c);
this.items.push({ action: () => navigator.clipboard?.writeText(String(+val.toFixed(8))) });
}
} catch (e) {}
}
const ql = q.toLowerCase();
if (s.siriApps) {
const apps = Object.values(Apps.registry)
.filter(a => (!a.storeApp || (globalThis.AppStoreApp && AppStoreApp.isInstalled(a.id))))
.filter(a => a.name.toLowerCase().includes(ql) || a.id.includes(ql)).slice(0, 5);
apps.forEach(a => addItem('应用程序', a.icon, a.name, '应用程序', () => Apps.open(a.id)));
}
if (s.siriFiles) {
const files = FS.search(q).slice(0, 6);
files.forEach(f => addItem('文件与文件夹', FS.iconFor(f.path), FS.baseName(f.path), FS.dirName(f.path).replace(FS.HOME, '~'), () => Apps.openPath(f.path)));
}
if (s.siriSettings && window.SettingsApp) {
const panes = SettingsApp.panes.filter(p => p.name.includes(q)).slice(0, 4);
panes.forEach(p => addItem('系统设置', 'assets/icons/settings.png', p.name, '系统设置', () => Apps.open('settings', { pane: p.id })));
}
if (!this.items.length && !box.querySelector('.sp-calc')) box.append(el('div', { class: 'sp-item disabled', text: `未找到“${q}”相关结果` }));
}
};
window.Sys = Sys; window.Notify = Notify; window.Spotlight = Spotlight; window.WALLPAPERS = WALLPAPERS; window.DEFAULT_SETTINGS = DEFAULT_SETTINGS;