diff --git a/package.json b/package.json index f28a32d..75d4374 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.6.0", + "version": "1.7.0", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 688472b..46eadc6 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,7 +3,7 @@ const DRAFT_KEY = 'lifeos_today_draft_v1'; const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }; const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); - const APP_VERSION = '1.6.0'; + const APP_VERSION = '1.7.0'; window.LifeOSVersion = APP_VERSION; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system']; const t = (key, values) => LifeI18n.t(key, values); @@ -68,7 +68,9 @@ document.head.append(style); function exportData() { - const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys() }; + let desktopLayout = null; + try { desktopLayout = JSON.parse(localStorage.getItem('lifeos_window_layout_v1') || 'null'); } catch {} + const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys(), desktopLayout }; const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); link.download = `life-os-backup-${day()}.json`; link.click(); @@ -104,7 +106,7 @@ applyingCloud = true; Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field]))); applyingCloud = false; - todayDraft = null; window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.(); + todayDraft = null; window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.(); window.applyDesktopPreferences?.(); return mergedLocalBoard || systemNeedsMigration; } async function uploadCloudState() { diff --git a/public/desktop-personalization.js b/public/desktop-personalization.js new file mode 100644 index 0000000..59907e2 --- /dev/null +++ b/public/desktop-personalization.js @@ -0,0 +1,179 @@ +/* Device-aware desktop preferences. The app list and actual records stay in the existing stores. */ +(() => { + const APP_IDS = Object.keys(APPS); + const LAYOUT_KEY = 'lifeos_window_layout_v1'; + const cell = { width: 110, height: 98, left: 24, top: 72 }; + const desktopText = (key, values) => LifeI18n.t(`desktop.${key}`, values); + const validApps = value => Array.isArray(value) ? [...new Set(value.filter(id => APP_IDS.includes(id)))] : []; + const preference = () => settings(); + const desktopMode = () => innerWidth > 650; + const bounds = () => ({ rows: Math.max(1, Math.floor((innerHeight - 175) / cell.height)), columns: Math.max(1, Math.floor((innerWidth - 48) / cell.width)) }); + const chosenShortcuts = () => Array.isArray(preference().desktopShortcuts) ? validApps(preference().desktopShortcuts) : APP_IDS; + const savedPositions = () => preference().iconPositions || {}; + const updateSettings = patch => { const state = preference(); Object.assign(state, patch); save('lifeos_settings', state); }; + const isSlot = (value, limit) => Number.isInteger(value) && value >= 0 && value < limit; + let placed = {}, restoring = false, suppressClick = false; + + function arrangeIcons() { + const visible = chosenShortcuts(), { rows, columns } = bounds(), occupied = new Set(), result = {}; + document.querySelectorAll('#icons .app-icon').forEach(node => { if (!visible.includes(node.dataset.app)) node.remove(); }); + const nodes = [...document.querySelectorAll('#icons .app-icon')]; + if (!desktopMode()) { nodes.forEach(node => { node.style.left = ''; node.style.top = ''; node.style.position = ''; }); placed = {}; return; } + nodes.forEach((node, index) => { + const id = node.dataset.app, stored = savedPositions()[id]; + let col = stored?.col, row = stored?.row; + if (!isSlot(col, columns) || !isSlot(row, rows) || occupied.has(`${col}:${row}`)) { + const next = Array.from({ length: rows * columns }, (_, slot) => ({ col: Math.floor(slot / rows), row: slot % rows })).find(slot => !occupied.has(`${slot.col}:${slot.row}`)); + col = next?.col ?? Math.floor(index / rows); row = next?.row ?? index % rows; + } + occupied.add(`${col}:${row}`); result[id] = { col, row }; + node.style.position = 'absolute'; node.style.left = `${cell.left + col * cell.width}px`; node.style.top = `${cell.top + row * cell.height}px`; + }); + placed = result; + } + + const originalRenderIcons = window.renderIcons; + window.renderIcons = () => { originalRenderIcons(); arrangeIcons(); }; + const icons = document.querySelector('#icons'); + let dragState = null; + icons.addEventListener('pointerdown', event => { + const icon = event.target.closest('.app-icon'); + if (!desktopMode() || !icon || event.button !== 0) return; + dragState = { icon, id: icon.dataset.app, x: event.clientX, y: event.clientY, moved: false }; + icon.setPointerCapture?.(event.pointerId); + }); + icons.addEventListener('pointermove', event => { + if (!dragState) return; + const dx = event.clientX - dragState.x, dy = event.clientY - dragState.y; + if (Math.hypot(dx, dy) > 6) dragState.moved = true; + if (dragState.moved) { dragState.icon.classList.add('dragging'); dragState.icon.style.transform = `translate(${dx}px,${dy}px)`; } + }); + icons.addEventListener('pointerup', event => { + if (!dragState) return; + const { icon, id, moved, x, y } = dragState; dragState = null; + icon.classList.remove('dragging'); icon.style.transform = ''; + if (!moved) return; + suppressClick = true; + setTimeout(() => { suppressClick = false; }, 0); + const { rows, columns } = bounds(), origin = placed[id]; + const col = Math.max(0, Math.min(columns - 1, Math.round((cell.left + origin.col * cell.width + event.clientX - x - cell.left) / cell.width))); + const row = Math.max(0, Math.min(rows - 1, Math.round((cell.top + origin.row * cell.height + event.clientY - y - cell.top) / cell.height))); + const other = Object.keys(placed).find(key => key !== id && placed[key].col === col && placed[key].row === row); + const positions = { ...savedPositions(), [id]: { col, row } }; + if (other) positions[other] = origin; + updateSettings({ iconPositions: positions }); arrangeIcons(); + }); + icons.addEventListener('pointercancel', () => { if (dragState) { dragState.icon.style.transform = ''; dragState.icon.classList.remove('dragging'); dragState = null; } }); + icons.addEventListener('click', event => { if (suppressClick) { event.preventDefault(); event.stopImmediatePropagation(); } }, true); + window.addEventListener('resize', () => { arrangeIcons(); if (desktopMode()) Object.values(windows).forEach(clampWindow); }); + + const readLayout = () => { try { return JSON.parse(localStorage.getItem(LAYOUT_KEY) || '{}'); } catch { return {}; } }; + function clampWindow(node) { + if (!desktopMode() || node.classList.contains('max')) return; + const width = Math.min(node.offsetWidth || 700, Math.max(360, innerWidth - 20)); + const height = Math.min(node.offsetHeight || 590, Math.max(280, innerHeight - 85)); + node.style.width = `${width}px`; node.style.height = `${height}px`; + node.style.left = `${Math.max(0, Math.min(parseFloat(node.style.left) || 0, innerWidth - 80))}px`; + node.style.top = `${Math.max(0, Math.min(parseFloat(node.style.top) || 0, innerHeight - 95))}px`; + } + function rememberWindows() { + if (restoring || preference().rememberWindowLayout === false || !desktopMode()) return; + const entries = Object.values(windows).sort((a, b) => Number(a.style.zIndex || 0) - Number(b.style.zIndex || 0)).map(node => ({ + id: node.dataset.id, left: parseFloat(node.style.left), top: parseFloat(node.style.top), + width: parseFloat(node.style.width) || node.offsetWidth, height: parseFloat(node.style.height) || node.offsetHeight, + max: node.classList.contains('max'), min: node.classList.contains('min') + })); + localStorage.setItem(LAYOUT_KEY, JSON.stringify({ entries })); + } + function observeWindow(node) { + if (node.dataset.layoutObserved) return; + node.dataset.layoutObserved = 'true'; + if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => rememberWindows()).observe(node); + } + const originalOpenApp = window.openApp; + window.openApp = id => { if (!APP_IDS.includes(id)) return; originalOpenApp(id); observeWindow(windows[id]); rememberWindows(); }; + const originalFocus = window.focus; + window.focus = node => { originalFocus(node); rememberWindows(); }; + for (const method of ['closeWin', 'minWin', 'maxWin']) { + const original = window[method]; + window[method] = (...args) => { original(...args); rememberWindows(); }; + } + document.querySelector('#windows').addEventListener('pointerup', () => setTimeout(rememberWindows, 0)); + function restoreWindows() { + restoring = true; + try { + const remembered = desktopMode() && preference().rememberWindowLayout !== false ? readLayout().entries : []; + const entries = Array.isArray(remembered) ? remembered.filter(item => APP_IDS.includes(item.id)) : []; + const startup = validApps(preference().startupApps); + for (const id of [...new Set([...entries.map(item => item.id), ...startup])]) { + window.openApp(id); + const node = windows[id], position = entries.find(item => item.id === id); + if (!position || !desktopMode()) continue; + for (const field of ['left', 'top', 'width', 'height']) if (Number.isFinite(position[field]) && position[field] >= 0 && position[field] < 10000) node.style[field] = `${position[field]}px`; + node.classList.toggle('max', position.max === true); + node.classList.toggle('min', position.min === true); + clampWindow(node); + } + const visible = Object.values(windows).filter(node => !node.classList.contains('min')); + if (visible.length) focus(visible.at(-1)); else renderTaskbar(); + } finally { restoring = false; } + rememberWindows(); + } + const originalInit = window.init; + window.init = () => { originalInit(); restoreWindows(); }; + window.applyDesktopPreferences = () => { + window.renderIcons(); window.loadBingWallpaper(); window.applyWallpaper(); + for (const id of validApps(preference().startupApps)) if (!windows[id]) window.openApp(id); + }; + + const originalWallpaper = window.applyWallpaper; + window.applyWallpaper = () => { + const state = preference(), mode = state.wallpaperSource || 'bing', desktop = document.querySelector('#desktop'); + if (mode === 'calm') { desktop.style.backgroundImage = 'radial-gradient(ellipse at 20% 0%,#3f4a503d,transparent 40%),radial-gradient(ellipse at 93% 65%,#59604224,transparent 42%)'; desktop.title = desktopText('calm'); return; } + if (mode === 'custom') { + try { + const url = new URL(state.customWallpaperUrl); + if (url.protocol !== 'https:') throw new Error('invalid wallpaper URL'); + desktop.style.backgroundImage = `linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url("${url.href.replaceAll('"', '%22')}")`; + desktop.style.backgroundSize = 'cover'; desktop.style.backgroundPosition = 'center'; desktop.title = desktopText('custom'); return; + } catch { /* Use the built-in background until a valid URL is saved. */ } + } + if (state.wallpaper?.url) originalWallpaper(state.wallpaper); + }; + const originalLoadBing = window.loadBingWallpaper; + window.loadBingWallpaper = () => { if ((preference().wallpaperSource || 'bing') === 'bing') return originalLoadBing(); window.applyWallpaper(); }; + window.setWallpaperSource = mode => { + if (!['bing', 'calm', 'custom'].includes(mode)) return; + if (mode === 'custom') { + const value = document.querySelector('#desktop-wallpaper-url')?.value.trim() || ''; + try { const url = new URL(value); if (url.protocol !== 'https:' || value.length > 2048) throw new Error('invalid URL'); } + catch { toast(desktopText('invalidUrl')); return; } + updateSettings({ wallpaperSource: mode, customWallpaperUrl: value }); + } else updateSettings({ wallpaperSource: mode }); + window.loadBingWallpaper(); window.applyWallpaper(); window.renderAll(); + }; + window.toggleDesktopApp = (kind, id) => { + if (!APP_IDS.includes(id) || !['desktopShortcuts', 'startupApps'].includes(kind)) return; + const current = kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(preference().startupApps); + const next = current.includes(id) ? current.filter(app => app !== id) : APP_IDS.filter(app => current.includes(app) || app === id); + updateSettings({ [kind]: next }); + if (kind === 'desktopShortcuts') window.renderIcons(); + window.renderAll(); + }; + window.toggleWindowMemory = () => { const enabled = preference().rememberWindowLayout === false; updateSettings({ rememberWindowLayout: enabled }); if (enabled) rememberWindows(); window.renderAll(); }; + window.resetIconPositions = () => { updateSettings({ iconPositions: {} }); window.renderIcons(); window.renderAll(); }; + const originalSystemApp = window.systemApp; + window.systemApp = container => { + originalSystemApp(container); + const state = preference(), mode = state.wallpaperSource || 'bing'; + const appChoices = kind => `
${APP_IDS.map(id => ``).join('')}
`; + const customUrl = String(state.customWallpaperUrl || '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); + const personalization = `
${desktopText('heading')}

${desktopText('wallpaper')}

${['bing','calm'].map(value => ``).join('')}

${desktopText('urlHint')}

${desktopText('shortcuts')}

${desktopText('shortcutsHint')}

${appChoices('desktopShortcuts')}

${desktopText('windowLayout')}

${desktopText('windowHint')}

${desktopText('startup')}

${desktopText('startupHint')}

${appChoices('startupApps')}
`; + const panel = container.querySelector('.settings'); + if (panel) panel.insertAdjacentHTML('beforeend', personalization); + }; + const style = document.createElement('style'); + style.textContent = `.desktop-preferences h2{font-size:14px;margin:18px 0 9px}.desktop-preferences .desktop-app-choices{display:flex;flex-wrap:wrap;gap:8px}.desktop-preferences .chip{font-size:12px;min-height:36px}.desktop-wallpaper-custom{margin-top:13px}.desktop-wallpaper-custom label{display:block;font-size:13px;color:var(--muted);margin-bottom:7px}.desktop-wallpaper-custom>div{display:flex;gap:8px}.desktop-wallpaper-custom input{flex:1;min-width:0;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px;color:var(--ink)}.desktop-wallpaper-custom .secondary{font-size:12px;white-space:nowrap}.desktop-preferences p{font-size:13px;line-height:1.5}.desktop-text-button{margin:10px 0 2px;border:0;background:transparent;padding:5px 0;color:var(--accent);font-size:13px;text-decoration:underline}.app-icon.dragging{z-index:9;cursor:grabbing;touch-action:none}.desktop-icons .app-icon{touch-action:none}@media(max-width:650px){.desktop-icons .app-icon{position:static!important;left:auto!important;top:auto!important;touch-action:auto}.desktop-wallpaper-custom>div{flex-wrap:wrap}.desktop-wallpaper-custom input{flex-basis:100%}}`; + document.head.append(style); + window.renderIcons(); +})(); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index 5e6ed3b..4c86be1 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -110,7 +110,7 @@ window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); }; window.systemApp = container => { const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn'); - container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.6.0'})}

`; + container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.7.0'})}

`; }; window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = ``; }; window.replaceConfirm = () => { rootModal().innerHTML = ``; }; @@ -118,6 +118,7 @@ const data = importData; if (mode === 'replace') { save('lifeos_memories', data.memories); save('lifeos_museum', data.museum); save('lifeos_profile', data.profile || {}); save('lifeos_settings', data.settings || {}); save('lifeos_system', data.system || { schemaVersion: 2 }); + if (Array.isArray(data.desktopLayout?.entries)) localStorage.setItem('lifeos_window_layout_v1', JSON.stringify({ entries: data.desktopLayout.entries })); } else { const memories = mems(), ids = new Set(memories.map(item => item.id)), dates = new Set(memories.map(item => item.date)); save('lifeos_memories', [...memories, ...data.memories.filter(item => !ids.has(item.id) && !dates.has(item.date))]); @@ -132,7 +133,7 @@ } rootModal().innerHTML = ''; toast('feedback.restoring'); setTimeout(() => { renderAll(); toast('feedback.restored'); }, 500); }; - window.resetConfirm = () => { rootModal().innerHTML = ``; }; + window.resetConfirm = () => { rootModal().innerHTML = ``; }; window.toast = key => { const message = key.includes('.') ? t(key) : key; const node = document.createElement('div'); node.className = 'toast'; node.textContent = message; document.body.append(node); setTimeout(() => node.remove(), 2000); }; window.minWin = id => { const windowNode = windows[id]; if (!windowNode) return; windowNode.classList.add('min'); if (activeWindow === id) { activeWindow = Object.keys(windows).find(key => key !== id && !windows[key].classList.contains('min')) || null; if (activeWindow) focus(windows[activeWindow]); } renderTaskbar(); toast('feedback.minimized'); }; const memoryStyle = document.createElement('style'); diff --git a/public/index.html b/public/index.html index db1fd1c..3462d67 100644 --- a/public/index.html +++ b/public/index.html @@ -12,9 +12,9 @@ const lines=['Nothing remarkable happened. Maybe that was the point.','A small d function load(k,d){try{return JSON.parse(localStorage.getItem(k))||d}catch{return d}}function save(k,v){localStorage.setItem(k,JSON.stringify(v))}function mems(){return load('lifeos_memories',[])}function museum(){return load('lifeos_museum',[])}function profile(){return load('lifeos_profile',{name:'',birthday:'',startDate:new Date().toISOString().slice(0,10),motto:''})}function settings(){return{theme:'dark',lastBackup:null,language:'auto',languageMode:'auto',...load('lifeos_settings',{})}}function sys(){let value=load('lifeos_system',{schemaVersion:2});if((Number(value.schemaVersion)||1)<2)value.schemaVersion=2;return value} function browserLanguage(){let ls=navigator.languages?.length?navigator.languages:[navigator.language||navigator.userLanguage||'en'];return ls.some(x=>String(x).toLowerCase().startsWith('zh'))?'zh':'en'}function lang(){return window.LifeI18n?.language()||browserLanguage()}function i18n(key,fallback=key){return fallback} function appName(id){return i18n(APPS[id][1])}function moodName(m){return i18n(m.toUpperCase(),cap(m))}function day(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}function fmtDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}function shortDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function cap(x){return x[0].toUpperCase()+x.slice(1)}function esc(s){return (s||'').replace(/[&<>]/g,x=>({'&':'&','<':'<','>':'>'}[x]))} -function applyWallpaper(w){if(!w?.url)return;desktop.style.backgroundImage=`linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${w.url}')`;desktop.style.backgroundSize='cover';desktop.style.backgroundPosition='center';desktop.title=LifeI18n.t('shell.wallpaperTitle',{title:w.title||LifeI18n.t('wallpaper.dailyImage')})}async function loadBingWallpaper(){let s=settings();if(s.wallpaper?.date===day()&&s.wallpaper?.source==='lifeos-proxy'){applyWallpaper(s.wallpaper);return}try{let r=await fetch('/api/bing-wallpaper'),d=await r.json();if(!r.ok||!d.url)throw Error(d.error||'No Bing image');s.wallpaper={date:day(),url:'/api/bing-wallpaper-image?date='+day(),title:d.title||'Bing daily image',source:'lifeos-proxy'};save('lifeos_settings',s);applyWallpaper(s.wallpaper)}catch(e){console.info('Bing wallpaper unavailable; using the calm fallback.',e)}}function init(){let s=settings();document.documentElement.dataset.theme=s.theme==='auto'?(matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'):s.theme;renderIcons();renderTaskbar();loadBingWallpaper();setInterval(clock,1000);clock();setTimeout(()=>window.applyLanguage?.(),0);setTimeout(()=>boot.remove(),650)} -function desktopIconClick(id,e){if(innerWidth<650){openApp(id);return}selectedIcon=id;document.querySelectorAll('.app-icon').forEach(x=>x.classList.remove('selected'));e.currentTarget.classList.add('selected')}function desktopIconKey(id,e){if(e.key==='Enter'||e.key===' '){e.preventDefault();openApp(id)}}function renderIcons(){icons.innerHTML=Object.entries(APPS).map(([id,[g,n]])=>``).join('')}function taskClick(id){let w=windows[id];if(!w){openApp(id);return}if(w.classList.contains('min')){openApp(id);return}if(activeWindow===id){minWin(id);return}focus(w)}function renderTaskbar(){let ids=innerWidth<650?Object.keys(windows):['today','timeline','museum','insights','tasks','calendar'];tasks.innerHTML=ids.map(id=>{let w=windows[id],min=w?.classList.contains('min'),fg=activeWindow===id&&!min,name=window.appName(id);return``}).join('')} -function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.6.0'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) +function applyWallpaper(w){if(!w?.url)return;desktop.style.backgroundImage=`linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${w.url}')`;desktop.style.backgroundSize='cover';desktop.style.backgroundPosition='center';desktop.title=LifeI18n.t('shell.wallpaperTitle',{title:w.title||LifeI18n.t('wallpaper.dailyImage')})}async function loadBingWallpaper(){let s=settings();if(s.wallpaper?.date===day()&&s.wallpaper?.source==='lifeos-proxy'){applyWallpaper(s.wallpaper);return}try{let r=await fetch('/api/bing-wallpaper'),d=await r.json();if(!r.ok||!d.url)throw Error(d.error||'No Bing image');s=settings();if((s.wallpaperSource||'bing')!=='bing')return;s.wallpaper={date:day(),url:'/api/bing-wallpaper-image?date='+day(),title:d.title||'Bing daily image',source:'lifeos-proxy'};save('lifeos_settings',s);applyWallpaper(s.wallpaper)}catch(e){console.info('Bing wallpaper unavailable; using the calm fallback.',e)}}function init(){let s=settings();document.documentElement.dataset.theme=s.theme==='auto'?(matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'):s.theme;renderIcons();renderTaskbar();loadBingWallpaper();setInterval(clock,1000);clock();setTimeout(()=>window.applyLanguage?.(),0);setTimeout(()=>boot.remove(),650)} +function desktopIconClick(id,e){if(innerWidth<650){openApp(id);return}selectedIcon=id;document.querySelectorAll('.app-icon').forEach(x=>x.classList.remove('selected'));e.currentTarget.classList.add('selected')}function desktopIconKey(id,e){if(e.key==='Enter'||e.key===' '){e.preventDefault();openApp(id)}}function renderIcons(){icons.innerHTML=Object.entries(APPS).map(([id,[g,n]])=>``).join('')}function taskClick(id){let w=windows[id];if(!w){openApp(id);return}if(w.classList.contains('min')){openApp(id);return}if(activeWindow===id){minWin(id);return}focus(w)}function renderTaskbar(){let ids=innerWidth<650?Object.keys(windows):['today','timeline','museum','insights','tasks','calendar'];tasks.innerHTML=ids.map(id=>{let w=windows[id],min=w?.classList.contains('min'),fg=activeWindow===id&&!min,name=window.appName(id);return``}).join('')} +function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.7.0'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) function openApp(id){launcher.classList.add('hidden');let w=windows[id];if(w){w.classList.remove('min');focus(w);return}w=document.createElement('section');w.className='window';w.dataset.id=id;w.style.left=(innerWidth<650?0:90+Object.keys(windows).length*36)+'px';w.style.top=(innerWidth<650?0:72+Object.keys(windows).length*28)+'px';w.innerHTML=`
${APPS[id][0]}${appName(id)}
`;windows[id]=w;document.querySelector('#windows').append(w);let controls=w.querySelector('.controls'),bar=w.querySelector('.titlebar');controls.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation()});controls.addEventListener('click',e=>{let action=e.target.closest('button')?.dataset.windowAction;if(!action)return;e.preventDefault();e.stopPropagation();if(action==='min')minWin(id);if(action==='max')maxWin(id);if(action==='close')closeWin(id)});bar.addEventListener('dblclick',e=>{if(!e.target.closest('.controls'))maxWin(id)});w.addEventListener('pointerdown',()=>focus(w));drag(w);renderApp(id);focus(w)}function focus(w){if(!w)return;activeWindow=w.dataset.id;w.style.zIndex=++z;document.querySelectorAll('.window').forEach(x=>x.classList.toggle('active-window',x===w));renderTaskbar()}function closeWin(id){windows[id]?.remove();delete windows[id];if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar()}function minWin(id){let w=windows[id];if(!w)return;w.classList.add('min');if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>k!==id&&!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar();toast(i18n('MINIMIZED — TAP THE APP ICON TO RESTORE'))}function maxWin(id){let w=windows[id];if(!w)return;w.classList.toggle('max');focus(w)}function snapWindow(w){if(innerWidth<650||w.classList.contains('max'))return;let r=w.getBoundingClientRect(),edge=22;if(r.topinnerWidth-edge){w.style.left='calc(50vw + 9px)';w.style.top='18px';w.style.width='calc(50vw - 27px)';w.style.height='calc(100vh - 98px)'}}function drag(w){let bar=w.querySelector('.titlebar'),x,y,l,t;bar.onpointerdown=e=>{if(e.target.closest('.controls')||innerWidth<650)return;if(w.classList.contains('max'))w.classList.remove('max');x=e.clientX;y=e.clientY;l=w.offsetLeft;t=w.offsetTop;bar.setPointerCapture(e.pointerId);bar.onpointermove=e=>{w.style.left=Math.max(0,l+e.clientX-x)+'px';w.style.top=Math.max(0,t+e.clientY-y)+'px'};bar.onpointerup=()=>{bar.onpointermove=null;snapWindow(w)}}}document.addEventListener('keydown',e=>{if(e.altKey&&e.key==='F4'&&activeWindow){e.preventDefault();closeWin(activeWindow)}if(e.key==='Escape'&&activeWindow&&!document.querySelector('.modal')&&!document.querySelector('#lifeos-search')){let w=windows[activeWindow];if(w?.classList.contains('max')){e.preventDefault();maxWin(activeWindow)}}}) function renderApp(id){let c=document.querySelector('#content-'+id);if(!c)return;if(id==='today')window.today(c);if(id==='timeline')window.timeline(c);if(id==='museum')window.museumApp(c);if(id==='insights')window.insights(c);if(id==='profile')window.profileApp(c);if(id==='tasks')window.tasksApp(c);if(id==='calendar')window.calendarApp(c);if(id==='system')window.systemApp(c);setTimeout(()=>window.applyLanguage?.(),0)}function renderAll(){Object.keys(windows).forEach(renderApp);renderTaskbar();window.renderDesktopGoalCountdowns?.();setTimeout(()=>window.applyLanguage?.(),0)}document.addEventListener('click',e=>{if(e.target.closest('#content-today .chip,#content-today .energy button'))setTimeout(()=>window.applyLanguage?.(),0)}) let todayDraft=null,todayTitleDraft=null,todayTargetDate=null;function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft;c.innerHTML=`
${new Date().toLocaleDateString('en-US',{weekday:'long'}).toUpperCase()}

${fmtDate(day()).replace(/^[^,]+, /,'')}

How was your day?

`}function filmFor(m,e){const dict=LifeI18n.dictionaries[lang()==='zh'?'zh':'en'],seed=[...`${day()}|${m}|${e}`].reduce((sum,char,index)=>sum+char.charCodeAt(0)*(index+1),0),n=seed%dict.prompt.films.length;return{filmTitle:dict.prompt.films[n].title,filmSubtitle:dict.prompt.films[n].subtitle,filmLine:dict.prompt.lines[(n*3+e)%dict.prompt.lines.length]}}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)}function setMood(x){selectedMood=x;rerenderToday()}function setEnergy(x){selectedEnergy=x;rerenderToday()}function archiveToday(){let text=document.querySelector('#entry').value.trim(),a=mems(),old=a.find(x=>x.date===day()),f=old||filmFor(selectedMood,selectedEnergy),now=new Date().toISOString(),m={id:old?.id||'memory_'+crypto.randomUUID(),date:day(),text,mood:selectedMood,energy:selectedEnergy,filmTitle:f.filmTitle,filmSubtitle:f.filmSubtitle,filmLine:f.filmLine,createdAt:old?.createdAt||now,updatedAt:now};let status=document.querySelector('#saveStatus');status.textContent='ARCHIVING MEMORY...';setTimeout(()=>{save('lifeos_memories',[...a.filter(x=>x.date!==day()),m].sort((x,y)=>y.date.localeCompare(x.date)));todayDraft=null;status.textContent='MEMORY ARCHIVED';renderAll()},360)} @@ -23,6 +23,6 @@ function museumApp(c){let a=museum(),ms=mems();c.innerHTML=`
m.date===d.toISOString().slice(0,10))){s++;d.setDate(d.getDate()-1)}return s}function longest(a){let ds=a.map(x=>x.date).sort(),best=0,run=0,last='';ds.forEach(d=>{run=last&&((new Date(d)-new Date(last))/864e5===1)?run+1:1;best=Math.max(best,run);last=d});return best}function insights(c){let a=mems(),last=a.filter(m=>(Date.now()-new Date(m.date+'T12:00:00'))<2592e6),cnt={clear:0,light:0,cloudy:0,rain:0,storm:0};last.forEach(x=>cnt[x.mood]++);let avg=a.length?(a.reduce((s,x)=>s+x.energy,0)/a.length).toFixed(1):'—',days={};a.forEach(x=>{let d=new Date(x.date+'T12:00:00').toLocaleDateString('en-US',{weekday:'long'});days[d]=(days[d]||0)+1});let active=Object.entries(days).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—';c.innerHTML=`
LAST 30 DAYS

Insights, quietly.

WEATHER
${Object.entries(cnt).map(([k,v])=>`
${cap(k)} ${v} DAYS
`).join('')}
AVERAGE ENERGY
${avg} / 5
${a.slice(0,10).reverse().map(x=>``).join('')||'No entries yet'}
MOST ACTIVE DAY
${active}
CURRENT STREAK
${streak(a)} DAYS
Longest streak: ${longest(a)} days
`} function profileApp(c){let p=profile(),a=mems(),common=Object.entries(a.reduce((o,m)=>(o[m.mood]=(o[m.mood]||0)+1,o),{})).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—',avg=a.length?(a.reduce((x,m)=>x+m.energy,0)/a.length).toFixed(1):'—',started=Math.max(1,Math.floor((Date.now()-new Date(p.startDate))/864e5));c.innerHTML=`
SYSTEM PROFILE

${p.name?esc(p.name)+' · ':''}LIFE OS ONLINE

${started}DAYS ONLINE
${a.length}MEMORIES
${museum().length}MUSEUM ITEMS
${streak(a)}CURRENT STREAK
${longest(a)}LONGEST STREAK
${cap(common)}COMMON WEATHER
PERSONAL SYSTEM
AVERAGE ENERGY ${avg} / 5
`}function saveProfile(){save('lifeos_profile',{name:pname.value,birthday:pbday.value,startDate:pstart.value||day(),motto:pmotto.value});renderAll();toast('PROFILE SAVED')} function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft,cWeek=new Date().toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long'}).toUpperCase();c.innerHTML=`
${cWeek}

${fmtDate(day()).replace(/^[^,]+, /,'')}

${i18n('How was your day?')}

`}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)} -function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.6.0'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.6.0',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=``}function replaceConfirm(){modal.innerHTML=``}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} +function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.7.0'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.7.0',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=``}function replaceConfirm(){modal.innerHTML=``}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} window.renderIcons=renderIcons;window.renderAll=renderAll;window.toast=toast; - + diff --git a/public/locales/en.json b/public/locales/en.json index 985ceac..a33af5c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -330,6 +330,25 @@ "wallpaper": { "dailyImage": "Daily image" }, + "desktop": { + "heading": "DESKTOP PERSONALIZATION", + "wallpaper": "Wallpaper source", + "bing": "Bing daily image", + "calm": "Built-in background", + "custom": "Custom image URL", + "useUrl": "USE IMAGE", + "urlHint": "Use a direct HTTPS image link. Some hosts may block external images.", + "invalidUrl": "Enter a valid HTTPS image URL", + "shortcuts": "Desktop shortcuts", + "shortcutsHint": "Choose which apps appear on the desktop. All apps remain in the Life menu. Drag icons to arrange them on desktop.", + "resetPositions": "Reset icon positions", + "windowLayout": "Window layout", + "rememberOn": "Remember open windows and positions: ON", + "rememberOff": "Remember open windows and positions: OFF", + "windowHint": "Window positions and sizes are kept on this device. Mobile apps open full screen.", + "startup": "Open at startup", + "startupHint": "These apps open whenever Life OS starts, alongside any remembered windows." + }, "prompt": { "films": [ { diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 39ea0c8..3a6ee54 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -330,6 +330,25 @@ "wallpaper": { "dailyImage": "每日图片" }, + "desktop": { + "heading": "桌面个性化", + "wallpaper": "壁纸来源", + "bing": "必应每日图片", + "calm": "内置背景", + "custom": "自定义图片链接", + "useUrl": "使用图片", + "urlHint": "请输入直达图片的 HTTPS 链接;部分网站可能限制外部引用。", + "invalidUrl": "请输入有效的 HTTPS 图片链接", + "shortcuts": "桌面快捷方式", + "shortcutsHint": "选择桌面显示的 App;所有 App 仍可从 Life 菜单打开。桌面端可拖动图标排列。", + "resetPositions": "重置图标位置", + "windowLayout": "窗口布局", + "rememberOn": "记住窗口和位置:开启", + "rememberOff": "记住窗口和位置:关闭", + "windowHint": "窗口位置与大小保存在本设备;移动端仍以全屏打开。", + "startup": "开机默认打开", + "startupHint": "每次启动 Life OS 时打开这些 App,并恢复已记住的窗口。" + }, "login": { "title": "你的生活,同步在线。", "description": "登录 ChatGPT,打开你的个人 Life OS,并在不同设备间安全同步记忆。", diff --git a/public/service-worker.js b/public/service-worker.js index 4431559..a8f97da 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'life-os-shell-v1.6.0'; +const CACHE = 'life-os-shell-v1.7.0'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg' ]; diff --git a/scripts/build.mjs b/scripts/build.mjs index 20acda2..25e0f3e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,21 +7,21 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); -const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); -const release = "1.6.0"; +const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); +const release = "1.7.0"; const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`; const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`; let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`); for (const [src, code] of [ ['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs], ['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs], - ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs] + ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs] ]) { const tag = ``; if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`); indexHtml = indexHtml.replace(tag, ``); } -await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); +await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta)); console.log("Built Life OS Worker artifact"); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index ca91d3b..abcd824 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -20,7 +20,7 @@ const flatten = (value, prefix = "") => Object.entries(value).flatMap(([key, chi const enKeys = flatten(assets.localeEn), zhKeys = flatten(assets.localeZh); assert.deepEqual(enKeys, zhKeys, "English and Chinese locale keys must match"); const resolveKey = (locale, key) => key.split(".").reduce((value, part) => value?.[part], locale); -for (const script of [assets.i18nRenderersJs, assets.addonsJs, source]) { +for (const script of [assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.addonsJs, source]) { for (const match of script.matchAll(/(?:\bt|LifeI18n\.t)\(['"`]([A-Za-z][\w.]+)['"`]/g)) { assert.notEqual(resolveKey(assets.localeEn, match[1]), undefined, `Missing locale key: ${match[1]}`); assert.notEqual(resolveKey(assets.localeZh, match[1]), undefined, `Missing Chinese locale key: ${match[1]}`); @@ -45,11 +45,11 @@ assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/); assert.doesNotMatch(assets.addonsJs, /(?([\s\S]*?)<\/script>/g)].map(match => match[1]); -assert.equal(shippedScripts.length, 10, "the complete desktop script chain is present in the served HTML"); +assert.equal(shippedScripts.length, 11, "the complete desktop script chain is present in the served HTML"); assert.doesNotMatch(assets.indexHtml, / ({ innerHTML: "", textContent: "", style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, remove() {}, append() {}, querySelector: () => null, querySelectorAll: () => [] }); +const uiNode = () => ({ innerHTML: "", textContent: "", style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, remove() {}, append() {}, querySelector: () => null, querySelectorAll: () => [], addEventListener() {} }); for (const id of ["icons", "tasks", "launcher", "calendar", "desktop", "boot", "modal", "windows", "clock", "date", "desktop-brand", "life-launcher", "content-system", "content-insights"]) uiNodes.set(`#${id}`, uiNode()); +const systemNode = uiNodes.get("#content-system"); +systemNode.querySelector = selector => selector === ".settings" ? { insertAdjacentHTML: (_, markup) => { systemNode.innerHTML += markup; } } : null; const uiDocument = { documentElement: { dataset: {}, lang: "en" }, title: "", head: { insertAdjacentHTML() {}, append() {} }, body: { append() {} }, querySelector: selector => uiNodes.get(selector) || null, getElementById: id => uiNodes.get(`#${id}`) || null, @@ -124,21 +126,23 @@ const uiDocument = { const uiStorage = makeStorage(); const uiContext = vm.createContext({ document: uiDocument, navigator: { languages: ["en-US"], language: "en-US" }, localStorage: uiStorage, - Blob, crypto: globalThis.crypto, innerWidth: 1000, innerHeight: 800, + Blob, URL, crypto: globalThis.crypto, innerWidth: 1000, innerHeight: 800, addEventListener() {}, matchMedia: () => ({ matches: false }), setTimeout: () => 0, setInterval: () => 0, fetch: async () => ({ ok: false, json: async () => ({}) }), console, ...Object.fromEntries(["icons", "tasks", "launcher", "calendar", "desktop", "boot", "modal"].map(id => [id, uiNodes.get(`#${id}`)])) }); uiContext.window = uiContext; -for (const index of [0, 2, 3, 4, 8]) vm.runInContext(shippedScripts[index], uiContext); +for (const index of [0, 2, 3, 4, 8, 9]) vm.runInContext(shippedScripts[index], uiContext); vm.runInContext("windows.system={classList:{contains(){return false}}}", uiContext); uiContext.setLanguage("zh"); assert.equal(uiContext.LifeI18n.language(), "zh"); assert.match(uiNodes.get("#content-system").innerHTML, /系统设置/); +assert.match(uiNodes.get("#content-system").innerHTML, /桌面个性化/); assert.match(uiNodes.get("#icons").innerHTML, /今日/); assert.equal(uiDocument.documentElement.lang, "zh-CN"); uiContext.setLanguage("en"); assert.match(uiNodes.get("#content-system").innerHTML, /SYSTEM SETTINGS/); +assert.match(uiNodes.get("#content-system").innerHTML, /DESKTOP PERSONALIZATION/); assert.match(uiNodes.get("#icons").innerHTML, /TODAY/); uiContext.navigator.languages = ["zh-CN"]; uiContext.setLanguage("auto"); @@ -178,6 +182,51 @@ uiStorage.setItem("lifeos_system", JSON.stringify({ schemaVersion: 2, tasksBoard uiContext.insights(uiNodes.get("#content-insights")); assert.match(uiNodes.get("#content-insights").innerHTML, /No weather recorded in this period/); assert.match(uiNodes.get("#content-insights").innerHTML, /No saved or tagged memories in this period/); +uiContext.toggleDesktopApp("desktopShortcuts", "tasks"); +assert.equal(JSON.parse(uiStorage.getItem("lifeos_settings")).desktopShortcuts.includes("tasks"), false, "removing a desktop shortcut preserves app access elsewhere"); +uiContext.toggleDesktopApp("startupApps", "calendar"); +assert.deepEqual(JSON.parse(uiStorage.getItem("lifeos_settings")).startupApps, ["calendar"]); +uiContext.setWallpaperSource("calm"); +assert.equal(JSON.parse(uiStorage.getItem("lifeos_settings")).wallpaperSource, "calm"); +assert.match(uiNodes.get("#desktop").style.backgroundImage, /radial-gradient/); +uiNodes.set("#desktop-wallpaper-url", { value: "javascript:alert(1)" }); +uiContext.setWallpaperSource("custom"); +assert.equal(JSON.parse(uiStorage.getItem("lifeos_settings")).wallpaperSource, "calm", "unsafe image URL is rejected"); +uiNodes.get("#desktop-wallpaper-url").value = "https://example.com/photo.jpg"; +uiContext.setWallpaperSource("custom"); +assert.equal(JSON.parse(uiStorage.getItem("lifeos_settings")).wallpaperSource, "custom"); +assert.match(uiNodes.get("#desktop").style.backgroundImage, /example\.com\/photo\.jpg/); +vm.runInContext("delete windows.system", uiContext); +uiContext.toggleWindowMemory(); +assert.equal(JSON.parse(uiStorage.getItem("lifeos_settings")).rememberWindowLayout, false); +uiContext.toggleWindowMemory(); +assert.equal(JSON.parse(uiStorage.getItem("lifeos_settings")).rememberWindowLayout, true); +const layoutStorage = makeStorage({ + lifeos_settings: JSON.stringify({ wallpaperSource: "calm", startupApps: ["calendar"] }), + lifeos_window_layout_v1: JSON.stringify({ entries: [{ id: "tasks", left: 204, top: 137, width: 520, height: 410, max: false, min: true }] }) +}); +const layoutWindows = {}, layoutIcons = { addEventListener() {} }, layoutHost = { addEventListener() {} }; +const layoutContext = vm.createContext({ + APPS: { today: [], timeline: [], museum: [], insights: [], profile: [], tasks: [], calendar: [], system: [] }, + windows: layoutWindows, localStorage: layoutStorage, innerWidth: 1200, innerHeight: 800, + document: { querySelector: selector => selector === "#icons" ? layoutIcons : selector === "#windows" ? layoutHost : { style: {} }, querySelectorAll: () => [], head: { append() {} }, createElement: () => ({ textContent: "" }) }, + settings: () => ({ ...JSON.parse(layoutStorage.getItem("lifeos_settings")) }), + save: (key, value) => layoutStorage.setItem(key, JSON.stringify(value)), + LifeI18n: { t: key => key }, appName: id => id, + renderIcons() {}, systemApp() {}, applyWallpaper() {}, loadBingWallpaper() {}, init() {}, + openApp(id) { const flags = new Set(); layoutWindows[id] = { dataset: { id }, style: { left: "20px", top: "20px" }, offsetWidth: 700, offsetHeight: 590, classList: { contains: key => flags.has(key), toggle: (key, state) => state ? flags.add(key) : flags.delete(key) } }; }, + closeWin(id) { delete layoutWindows[id]; }, minWin() {}, maxWin() {}, focus() {}, renderTaskbar() {}, renderAll() {}, toast() {}, + addEventListener() {}, setTimeout() {} +}); +layoutContext.window = layoutContext; +vm.runInContext(assets.desktopPersonalizationJs, layoutContext); +layoutContext.init(); +assert.deepEqual(Object.keys(layoutWindows), ["tasks", "calendar"], "remembered and startup apps both reopen once"); +assert.equal(layoutWindows.tasks.style.left, "204px"); +assert.equal(layoutWindows.tasks.style.top, "137px"); +assert.equal(layoutWindows.tasks.classList.contains("min"), true); +layoutContext.closeWin("tasks"); +assert.deepEqual(JSON.parse(layoutStorage.getItem("lifeos_window_layout_v1")).entries.map(entry => entry.id), ["calendar"], "closed windows stay closed on the next launch"); const zhLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "zh-CN,zh;q=0.9" } }), {}); assert.match(await zhLogin.text(), /登录 ChatGPT/); const enLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "en-US,en;q=0.9" } }), {});