Restore default template binding across browser sessions
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-10-09T16:12+08:00
|
||||
2026-10-10T10:18+08:00
|
||||
|
||||
@@ -5,6 +5,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from html import escape
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
@@ -801,7 +802,11 @@ def create_app(
|
||||
@app.get("/")
|
||||
def index() -> HTMLResponse:
|
||||
network.dismiss_for_boot()
|
||||
return HTMLResponse(index_html)
|
||||
return HTMLResponse(index_html.replace(
|
||||
"</head>",
|
||||
f'<meta name="device-boot-id" content="{escape(wifi_store.boot_id, quote=True)}"></head>',
|
||||
1,
|
||||
))
|
||||
|
||||
if ui_copy_store is not None:
|
||||
@app.get("/api/dev/ui-copy")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js";
|
||||
import "./interaction-feedback.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js";
|
||||
import { setBoardWorkspace } from "./scene-board.js";
|
||||
import { setBoardWorkspace, initializeStartupBoard } from "./scene-board.js";
|
||||
import { renderUiCopy } from "./ui-copy-runtime.js";
|
||||
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
|
||||
import {
|
||||
@@ -343,6 +343,7 @@ window.addEventListener("keydown", (event) => {
|
||||
});
|
||||
|
||||
buildNavigation();
|
||||
initializeStartupBoard();
|
||||
setWorkspaceOrderToggleLabel();
|
||||
navigate(resolveWorkspaceId(), false);
|
||||
loadWorkspaceOrder().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export const BOARD_SESSION_KEY = "matrix-board-session-v1";
|
||||
|
||||
function validContext(context) {
|
||||
return context === null || (
|
||||
context && ["template", "animation"].includes(context.type)
|
||||
&& ["id", "name", "revision", "baseline"].every((key) => typeof context[key] === "string" && context[key])
|
||||
&& typeof context.readOnly === "boolean"
|
||||
&& (context.type !== "animation" || context.readOnly)
|
||||
);
|
||||
}
|
||||
|
||||
export function readBoardSession(storage, draft, bootId) {
|
||||
const raw = storage?.getItem(BOARD_SESSION_KEY);
|
||||
if (!raw) return { bootId: null, context: null };
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
if (value.version !== 1 || typeof value.bootId !== "string" || !validContext(value.context)) {
|
||||
throw new Error("invalid session");
|
||||
}
|
||||
return {
|
||||
bootId: value.bootId,
|
||||
context: value.bootId === bootId && value.draft === draft ? value.context : null,
|
||||
};
|
||||
} catch {
|
||||
throw new Error("画板保存绑定损坏或来自未来版本;原值已保留,请另存当前内容后处理浏览器存储。");
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBoardSession(storage, bootId, draft, context) {
|
||||
storage.setItem(BOARD_SESSION_KEY, JSON.stringify({ version: 1, bootId, draft, context }));
|
||||
}
|
||||
|
||||
// Resolve only editable static defaults and the read-only demo animation.
|
||||
// Ordinary animations retain their existing explicit frame editing workflow.
|
||||
export async function loadDefaultBoard(apiJson) {
|
||||
const reference = await apiJson("/api/display/default-content");
|
||||
if (reference.type === "template") {
|
||||
return { ...await apiJson(`/api/templates/${reference.id}`), type: "template" };
|
||||
}
|
||||
if (reference.type !== "animation" || !reference.is_demo) return null;
|
||||
const animation = await apiJson(`/api/animations/${reference.id}/frames`);
|
||||
const first = animation.frames[0];
|
||||
if (!first) throw new Error("默认演示动图没有帧");
|
||||
const frame = await apiJson(`/api/animations/${reference.id}/frames/${first.id}`);
|
||||
return { ...animation, type: "animation", read_only: true, scene: frame.scene };
|
||||
}
|
||||
|
||||
export async function initializeDefaultBoard({ canLoad, load, apply, retain }) {
|
||||
if (!canLoad()) return false;
|
||||
const content = await load();
|
||||
if (!canLoad()) {
|
||||
retain();
|
||||
return false;
|
||||
}
|
||||
if (content) apply(content);
|
||||
else retain();
|
||||
return Boolean(content);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
|
||||
import { announce, apiBlob, isAppStale } from "./core.js";
|
||||
import { announce, apiBlob, apiJson, isAppStale } from "./core.js";
|
||||
import { readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "./board-startup-model.js";
|
||||
import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js";
|
||||
import {
|
||||
RGB_BYTE_LENGTH,
|
||||
@@ -102,10 +103,61 @@ let lastExactRgb = null;
|
||||
let sceneSource = null;
|
||||
let draftConflict = false;
|
||||
let externalDraftValue = null;
|
||||
let externalDraftContext = null;
|
||||
let draftPersistenceBlocked = !draftLoad.canPersist;
|
||||
let draftProtectionNotice = (
|
||||
draftPersistenceBlocked || draftLoad.status === "cleanup-warning"
|
||||
) ? draftLoad.notice : "";
|
||||
const deviceBootId = document.querySelector('meta[name="device-boot-id"]')?.content || "";
|
||||
let boardBootId = "";
|
||||
let boardMutation = 0;
|
||||
let startupBoardPromise = null;
|
||||
if (draftLoad.canPersist && deviceBootId) {
|
||||
try {
|
||||
const session = readBoardSession(draftStorage, serializeScene(scene), deviceBootId);
|
||||
boardBootId = session.bootId || "";
|
||||
templateEdit = session.context;
|
||||
if (templateEdit && !deserializeScene(templateEdit.baseline)) throw new Error("画板保存基线无效;原草稿已保留。");
|
||||
if (templateEdit && !sceneNeedsTemplateSave(templateEdit, serializeScene(scene)) && templateEdit.type === "template") {
|
||||
sceneSource = { type: "template", id: templateEdit.id, revision: templateEdit.revision };
|
||||
}
|
||||
} catch (error) {
|
||||
templateEdit = null;
|
||||
draftPersistenceBlocked = true;
|
||||
draftProtectionNotice = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeStartupBoard() {
|
||||
if (startupBoardPromise) return startupBoardPromise;
|
||||
const mutation = boardMutation;
|
||||
const canLoad = () => Boolean(deviceBootId) && boardBootId !== deviceBootId
|
||||
&& !draftPersistenceBlocked && !draftConflict && !animationEdit && boardMutation === mutation;
|
||||
const retain = () => {
|
||||
if (draftPersistenceBlocked || draftConflict || animationEdit) return;
|
||||
boardBootId = deviceBootId;
|
||||
persistScene();
|
||||
};
|
||||
startupBoardPromise = initializeDefaultBoard({
|
||||
canLoad,
|
||||
load: () => loadDefaultBoard(apiJson),
|
||||
apply: (content) => {
|
||||
// Validate before changing either the session marker or the current draft.
|
||||
if (!deserializeScene(content.scene)) throw new Error("默认画面场景无效");
|
||||
boardBootId = deviceBootId;
|
||||
replaceSceneFromStorage(content.scene, {
|
||||
reason: "startup-default-load",
|
||||
source: content.type === "template" ? { type: "template", id: content.id, revision: content.revision } : null,
|
||||
templateEditContext: content,
|
||||
});
|
||||
},
|
||||
retain,
|
||||
}).catch((error) => {
|
||||
announce(`默认画面读取失败,已保留当前草稿:${error.message}`, true);
|
||||
return false;
|
||||
});
|
||||
return startupBoardPromise;
|
||||
}
|
||||
|
||||
const draftConflictNotice = document.getElementById("draft-conflict-notice");
|
||||
const loadExternalDraftButton = document.getElementById("load-external-draft");
|
||||
@@ -122,6 +174,7 @@ function blockDraftPersistence(message) {
|
||||
}
|
||||
|
||||
export function persistScene() {
|
||||
boardMutation += 1;
|
||||
if (animationEdit) {
|
||||
animationEdit.dirty = serializeScene(scene) !== animationEdit.baseline;
|
||||
updateBoardUi();
|
||||
@@ -131,7 +184,10 @@ export function persistScene() {
|
||||
if (draftConflict || draftPersistenceBlocked) return false;
|
||||
try {
|
||||
if (!draftStorage) throw new Error("browser storage unavailable");
|
||||
draftStorage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
|
||||
const draft = serializeScene(scene);
|
||||
// Commit metadata first: a torn pair never restores the wrong binding.
|
||||
writeBoardSession(draftStorage, boardBootId, draft, templateEdit);
|
||||
draftStorage.setItem(SCENE_STORAGE_KEY, draft);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
blockDraftPersistence("浏览器无法保存统一画板草稿;原草稿已保留,本页不会继续覆盖或应用它。");
|
||||
@@ -157,6 +213,12 @@ window.addEventListener("storage", (event) => {
|
||||
return;
|
||||
}
|
||||
if (external.status !== "valid") return;
|
||||
try {
|
||||
externalDraftContext = readBoardSession(draftStorage, event.newValue, deviceBootId).context;
|
||||
} catch (error) {
|
||||
blockDraftPersistence(error.message);
|
||||
return;
|
||||
}
|
||||
externalDraftValue = event.newValue;
|
||||
setDraftConflict(true);
|
||||
announce("另一标签页修改了画板草稿,请选择要保留的版本", true);
|
||||
@@ -164,11 +226,12 @@ window.addEventListener("storage", (event) => {
|
||||
|
||||
loadExternalDraftButton.addEventListener("click", async () => {
|
||||
const value = externalDraftValue;
|
||||
const context = externalDraftContext;
|
||||
if (!value) return;
|
||||
if (animationEdit && !await sceneReplacementGuard()) return;
|
||||
externalDraftValue = null;
|
||||
setDraftConflict(false);
|
||||
replaceSceneFromStorage(value, { persist: false, reason: "tab-sync" });
|
||||
replaceSceneFromStorage(value, { persist: false, reason: "tab-sync", templateEditContext: context });
|
||||
announce("已载入另一标签页的最新画板草稿");
|
||||
});
|
||||
|
||||
@@ -670,6 +733,7 @@ export function replaceSceneFromStorage(
|
||||
templateEditContext = null,
|
||||
} = {},
|
||||
) {
|
||||
boardMutation += 1;
|
||||
const restored = deserializeScene(value);
|
||||
if (!restored) throw new Error("模板场景无效,无法载入");
|
||||
textLayers.forEach((entry) => {
|
||||
@@ -719,6 +783,8 @@ export function markTemplateSaved(template) {
|
||||
clearEditHistory();
|
||||
templateEdit = createTemplateEditContext(template, serializeScene(scene));
|
||||
sceneSource = { type: "template", id: template.id, revision: template.revision };
|
||||
boardBootId = deviceBootId;
|
||||
persistScene();
|
||||
updateBoardUi();
|
||||
document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() }));
|
||||
return true;
|
||||
|
||||
@@ -1663,7 +1663,8 @@ dd {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.template-card-actions .demo-restricted-action[aria-disabled="true"] {
|
||||
.template-card-actions .demo-restricted-action[aria-disabled="true"],
|
||||
.template-save-choice-actions .demo-restricted-action[aria-disabled="true"] {
|
||||
border-color: #000;
|
||||
background: #050505;
|
||||
color: #9aa1ab;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export function createTemplateEditContext(template, baseline) {
|
||||
return {
|
||||
type: template.type || "template",
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
revision: template.revision,
|
||||
readOnly: Boolean(template.read_only ?? template.readOnly),
|
||||
readOnly: template.type === "animation" || Boolean(template.read_only ?? template.readOnly),
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +12,7 @@ export function createTemplateEditContext(template, baseline) {
|
||||
export function templateEditSnapshot(context, serializedScene) {
|
||||
if (!context) return null;
|
||||
return {
|
||||
type: context.type || "template",
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
revision: context.revision,
|
||||
|
||||
@@ -558,6 +558,10 @@ async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
|
||||
}
|
||||
|
||||
async function saveSceneToCurrentTemplate(context) {
|
||||
if (context.readOnly || context.type !== "template") {
|
||||
announce("演示案例不得覆盖,请另存为新模板", true);
|
||||
return;
|
||||
}
|
||||
await mutate(async () => {
|
||||
const result = await apiJson(`/api/templates/${context.id}`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -23,6 +23,16 @@ def solid_scene(color=(12, 34, 56)):
|
||||
}
|
||||
|
||||
|
||||
def test_homepage_exposes_escaped_boot_session_without_changing_rest_contract(tmp_path):
|
||||
boot_id = 'boot-"<unsafe>&'
|
||||
with TestClient(create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id=boot_id)) as client:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert '<meta name="device-boot-id" content="boot-"<unsafe>&">' in response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert 'name="device-boot-id"' not in client.get("/api/status").text
|
||||
|
||||
|
||||
def test_status_config_and_display_api(tmp_path):
|
||||
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { BOARD_SESSION_KEY, readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "../app/static/board-startup-model.js";
|
||||
|
||||
import {
|
||||
createTemplateEditContext,
|
||||
@@ -22,6 +23,7 @@ test("template edit baseline distinguishes clean, dirty, and unbound scenes", ()
|
||||
assert.equal(sceneNeedsTemplateSave(context, "scene-a"), false);
|
||||
assert.equal(sceneNeedsTemplateSave(context, "scene-b"), true);
|
||||
assert.deepEqual(templateEditSnapshot(context, "scene-a"), {
|
||||
type: "template",
|
||||
id: "template-1",
|
||||
name: "测试模板",
|
||||
revision: "revision-1",
|
||||
@@ -31,6 +33,71 @@ test("template edit baseline distinguishes clean, dirty, and unbound scenes", ()
|
||||
assert.equal(templateEditSnapshot(context, "scene-b").dirty, true);
|
||||
});
|
||||
|
||||
test("same boot restores binding and original dirty baseline; new boot or mismatched draft does not", () => {
|
||||
const values = new Map();
|
||||
const storage = { getItem: (key) => values.get(key), setItem: (key, value) => values.set(key, value) };
|
||||
const context = createTemplateEditContext({ id: "saved", name: "默认画面", revision: "original" }, "clean-scene");
|
||||
writeBoardSession(storage, "boot-a", "dirty-scene", context);
|
||||
const restored = readBoardSession(storage, "dirty-scene", "boot-a");
|
||||
assert.deepEqual(restored.context, context);
|
||||
assert.equal(templateEditSnapshot(restored.context, "dirty-scene").dirty, true);
|
||||
assert.equal(restored.context.revision, "original");
|
||||
assert.equal(readBoardSession(storage, "dirty-scene", "boot-b").context, null);
|
||||
assert.equal(readBoardSession(storage, "another-scene", "boot-a").context, null);
|
||||
assert.equal(readBoardSession(storage, "another-scene", "boot-a").bootId, "boot-a");
|
||||
values.set(BOARD_SESSION_KEY, '{"version":99}');
|
||||
assert.throws(() => readBoardSession(storage, "dirty-scene", "boot-a"), /原值已保留/);
|
||||
assert.equal(values.get(BOARD_SESSION_KEY), '{"version":99}');
|
||||
});
|
||||
|
||||
test("default static loads its complete latest scene and both demos keep read-only ownership", async () => {
|
||||
const staticDetail = { id: "static", name: "默认静态", revision: "r2", scene: "complete-scene" };
|
||||
const calls = [];
|
||||
const normal = await loadDefaultBoard(async (url) => {
|
||||
calls.push(url);
|
||||
return url.endsWith("default-content") ? { type: "template", id: "static" } : staticDetail;
|
||||
});
|
||||
assert.deepEqual(normal, { ...staticDetail, type: "template" });
|
||||
assert.deepEqual(calls, ["/api/display/default-content", "/api/templates/static"]);
|
||||
const staticDemo = createTemplateEditContext({ ...normal, read_only: true }, "baseline");
|
||||
assert.equal(staticDemo.readOnly, true);
|
||||
const demo = await loadDefaultBoard(async (url) => {
|
||||
if (url.endsWith("default-content")) return { type: "animation", id: "demo", is_demo: true };
|
||||
if (url.endsWith("/frames")) return { id: "demo", name: "演示动图", revision: "r1", frames: [{ id: "first" }, { id: "second" }] };
|
||||
assert.equal(url, "/api/animations/demo/frames/first");
|
||||
return { scene: "first-scene" };
|
||||
});
|
||||
assert.equal(demo.scene, "first-scene");
|
||||
const context = createTemplateEditContext(demo, demo.scene);
|
||||
assert.equal(context.type, "animation");
|
||||
assert.equal(context.readOnly, true);
|
||||
assert.equal(context.id, "demo");
|
||||
assert.equal(await loadDefaultBoard(async () => ({ type: "animation", is_demo: false })), null);
|
||||
});
|
||||
|
||||
test("startup skips protected drafts and never replaces intervening edits or failed reads", async () => {
|
||||
let allowed = false, applied = 0, retained = 0, loaded = 0;
|
||||
const options = {
|
||||
canLoad: () => allowed,
|
||||
load: async () => { loaded++; return { scene: "default" }; },
|
||||
apply: () => applied++, retain: () => retained++,
|
||||
};
|
||||
assert.equal(await initializeDefaultBoard(options), false);
|
||||
assert.equal(loaded, 0);
|
||||
allowed = true;
|
||||
assert.equal(await initializeDefaultBoard(options), true);
|
||||
assert.equal(applied, 1);
|
||||
options.load = async () => { allowed = false; return { scene: "late-default" }; };
|
||||
assert.equal(await initializeDefaultBoard(options), false);
|
||||
assert.equal(applied, 1);
|
||||
assert.equal(retained, 1);
|
||||
allowed = true;
|
||||
options.load = async () => { throw Error("deleted or unavailable"); };
|
||||
await assert.rejects(initializeDefaultBoard(options), /deleted or unavailable/);
|
||||
assert.equal(applied, 1);
|
||||
assert.equal(retained, 1);
|
||||
});
|
||||
|
||||
test("template manager uses a monitor and unified four-row card actions", async () => {
|
||||
const [html, board, templates, style] = await Promise.all([
|
||||
readFile(new URL("index.html", staticRoot), "utf8"),
|
||||
@@ -54,6 +121,7 @@ test("template manager uses a monitor and unified four-row card actions", async
|
||||
assert.match(html, /id="template-save-choice-dialog"/);
|
||||
assert.match(html, /value="current">保存到当前模板/);
|
||||
assert.match(html, /value="new">另存为新模板/);
|
||||
assert.match(style, /\.template-save-choice-actions \.demo-restricted-action\[aria-disabled="true"\][\s\S]*background: #050505/);
|
||||
});
|
||||
|
||||
test("animation editing backs up and restores template edit ownership", async () => {
|
||||
|
||||
Reference in New Issue
Block a user