feat(desktop): remember layout and personalize shortcuts and wallpaper

This commit is contained in:
Codex committed 2026-09-28 10:59:03 +09:00
1 parent 477e86f694
commit 2c5e3b4019
10 files changed
+291 -22

No files matched your search

+4 -4
View File
@@ -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 = `<script src="${src}${src === 'boot-guard.js' ? '' : `?v=${release}`}"></script>`;
if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`);
indexHtml = indexHtml.replace(tag, `<script>${code.replace(/<\/script/gi, '<\\/script')}</script>`);
}
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");
+55 -6
View File
@@ -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, /(?<![\w.])applyLanguage\s*\(/, "add-on actions must not call a missing global locale function");
assert.match(assets.indexHtml, /window\.renderIcons=renderIcons;window\.renderAll=renderAll/, "shared renderers are explicitly exposed to locale scripts");
const shippedScripts = [...assets.indexHtml.matchAll(/<script>([\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, /<script\s+src=/, "the desktop cannot silently fall back when an app script fails to load");
assert.ok(assets.indexHtml.indexOf('window.systemApp =') < assets.indexHtml.lastIndexOf("window.init()"), "locale renderers run before desktop initialization");
for (const script of shippedScripts) new vm.Script(script);
for (const script of [assets.i18nJs, assets.tasksJs, assets.calendarJs, assets.i18nRenderersJs, assets.addonsJs]) new vm.Script(script);
for (const script of [assets.i18nJs, assets.tasksJs, assets.calendarJs, assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.addonsJs]) new vm.Script(script);
assert.doesNotMatch(source, /Version 0\.1|Schema v1/);
const makeStorage = initial => {
const values = new Map(Object.entries(initial || {}));
@@ -114,8 +114,10 @@ assert.match(assets.i18nRenderersJs, /preference === 'zh'/);
assert.match(assets.i18nRenderersJs, /onclick=\"window\.setLanguage\('zh'\)\"/);
assert.doesNotMatch(assets.addonsJs, /lifeos_language_preference_v1/, "the device locale preference stays outside cloud-synced keys");
const uiNodes = new Map();
const uiNode = () => ({ 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" } }), {});