Просмотр исходного кода

fix Windows disk stats

On Windows hardware info, replaced per-property logical disk queries with grouped row-based WMI/CIM reads to prevent cross-column misalignment when devices appear/disappear or fields are null, and added focused tests for row shape and logical disk output.
Toby Chui 5 дней назад
Родитель
Сommit
6f2b725668

+ 82 - 0
src/mod/info/hardwareinfo/hardwareinfo.go

@@ -190,3 +190,85 @@ func filterGrepResults(result string, sep string) string {
 	resultString := tmp[1]
 	return strings.TrimSpace(resultString)
 }
+
+// cimGetinfoRows reads multiple WMI properties of the same class in a single
+// query, keeping the values of one instance together on one row. Properties
+// that are null (or missing on that instance) come back as an empty string so
+// every row always has len(itemNames) columns.
+func cimGetinfoRows(wmicName string, itemNames []string) [][]string {
+	className := wmicClassName(wmicName)
+	psClass := strings.ReplaceAll(className, "'", "''")
+	quotedItems := make([]string, 0, len(itemNames))
+	for _, itemName := range itemNames {
+		quotedItems = append(quotedItems, "'"+strings.ReplaceAll(itemName, "'", "''")+"'")
+	}
+	script := fmt.Sprintf(
+		"Get-CimInstance -ClassName '%s' | ForEach-Object { $o = $_; (@(%s) | ForEach-Object { $p = $o.PSObject.Properties[$_]; if ($null -ne $p -and $null -ne $p.Value) { [string]$p.Value } else { '' } }) -join \"`t\" }",
+		psClass, strings.Join(quotedItems, ","),
+	)
+	cmd := exec.Command("powershell.exe",
+		"-NoProfile",
+		"-NonInteractive",
+		"-WindowStyle", "Hidden",
+		"-ExecutionPolicy", "Bypass",
+		"-Command", script,
+	)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil
+	}
+	var rows [][]string
+	for _, line := range strings.Split(string(out), "\n") {
+		line = strings.ReplaceAll(line, "\r", "")
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+		cols := strings.Split(line, "\t")
+		if len(cols) != len(itemNames) {
+			//Unexpected output shape, discard this row instead of misaligning it
+			continue
+		}
+		for i := range cols {
+			cols[i] = strings.TrimSpace(cols[i])
+		}
+		rows = append(rows, cols)
+	}
+	return rows
+}
+
+/*
+wmicGetinfoRows returns the requested properties grouped per instance, so the
+caller never has to zip together separately queried slices. Querying each
+property on its own is racy: hardware can appear or disappear (e.g. a USB drive
+being unplugged) between two queries, and null values are skipped by CIM, both
+of which used to misalign the resulting slices.
+*/
+func wmicGetinfoRows(wmicName string, itemNames ...string) [][]string {
+	if len(itemNames) == 0 || runtime.GOOS != "windows" {
+		return nil
+	}
+	if rows := cimGetinfoRows(wmicName, itemNames); len(rows) > 0 {
+		return rows
+	}
+
+	//CIM unavailable, fall back to the legacy per-property wmic queries and
+	//trim everything down to the shortest result to stay in bounds.
+	columns := make([][]string, 0, len(itemNames))
+	shortest := -1
+	for _, itemName := range itemNames {
+		values := legacyWmicGetinfo(wmicName, itemName)
+		if shortest == -1 || len(values) < shortest {
+			shortest = len(values)
+		}
+		columns = append(columns, values)
+	}
+	var rows [][]string
+	for i := 0; i < shortest; i++ {
+		row := make([]string, 0, len(columns))
+		for _, column := range columns {
+			row = append(row, column[i])
+		}
+		rows = append(rows, row)
+	}
+	return rows
+}

+ 58 - 0
src/mod/info/hardwareinfo/hardwareinfo_test.go

@@ -257,3 +257,61 @@ func TestWmicGetinfoWindowsCpuAndDisk(t *testing.T) {
 		t.Fatalf("wmicGetinfo(Win32_USBHub, Description) returned empty")
 	}
 }
+
+// ---------------------------------------------------------------------------
+// wmicGetinfoRows – grouped multi property query
+// ---------------------------------------------------------------------------
+
+// TestWmicGetinfoRowsShape verifies that every returned row carries exactly one
+// column per requested property, so callers can index them without bound
+// checks even when a device disappears mid-query.
+func TestWmicGetinfoRowsShape(t *testing.T) {
+	tests := []struct {
+		name      string
+		className string
+		items     []string
+	}{
+		{"no items", "logicaldisk", nil},
+		{"single item", "logicaldisk", []string{"DeviceID"}},
+		{"drive stat", "logicaldisk", []string{"DeviceID", "FileSystem", "FreeSpace"}},
+		{"unknown class", "definitely_not_a_wmi_class", []string{"DeviceID"}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			rows := wmicGetinfoRows(tt.className, tt.items...)
+			if runtime.GOOS != "windows" && rows != nil {
+				t.Fatalf("wmicGetinfoRows() = %v, want nil on non-Windows", rows)
+			}
+			if len(tt.items) == 0 && rows != nil {
+				t.Fatalf("wmicGetinfoRows() = %v, want nil when no property is requested", rows)
+			}
+			for i, row := range rows {
+				if len(row) != len(tt.items) {
+					t.Errorf("row %d has %d columns, want %d", i, len(row), len(tt.items))
+				}
+			}
+		})
+	}
+}
+
+// TestWmicGetinfoRowsLogicalDisk checks that the logical disk query used by
+// GetDriveStat actually returns usable, aligned rows on Windows.
+func TestWmicGetinfoRowsLogicalDisk(t *testing.T) {
+	if runtime.GOOS != "windows" {
+		t.Skip("windows only")
+	}
+	rows := wmicGetinfoRows("logicaldisk", "DeviceID", "FileSystem", "FreeSpace")
+	if len(rows) == 0 {
+		t.Fatal("wmicGetinfoRows(logicaldisk, ...) returned no row, want at least the system drive")
+	}
+	foundDriveLetter := false
+	for _, row := range rows {
+		if strings.Contains(row[0], ":") {
+			foundDriveLetter = true
+		}
+	}
+	if !foundDriveLetter {
+		t.Errorf("no drive letter found in %v", rows)
+	}
+}

+ 11 - 8
src/mod/info/hardwareinfo/sysinfo_window.go

@@ -47,16 +47,19 @@ func Ifconfig(w http.ResponseWriter, r *http.Request) {
 
 func GetDriveStat(w http.ResponseWriter, r *http.Request) {
 
-	var DeviceID []string = wmicGetinfo("logicaldisk", "DeviceID")
-	var FileSystem []string = wmicGetinfo("logicaldisk", "FileSystem")
-	var FreeSpace []string = wmicGetinfo("logicaldisk", "FreeSpace")
-
+	//Query all three properties in one go, otherwise a drive appearing or
+	//disappearing (e.g. an USB drive being unplugged) in between the queries
+	//would misalign the results
 	var arr []LogicalDisk
-	for i, info := range DeviceID {
+	for _, disk := range wmicGetinfoRows("logicaldisk", "DeviceID", "FileSystem", "FreeSpace") {
+		if disk[0] == "" {
+			//Drive without a device id, skip it
+			continue
+		}
 		LogicalDisk := LogicalDisk{
-			DriveLetter: info,
-			FileSystem:  FileSystem[i],
-			FreeSpace:   FreeSpace[i],
+			DriveLetter: disk[0],
+			FileSystem:  disk[1],
+			FreeSpace:   disk[2],
 		}
 		arr = append(arr, LogicalDisk)
 	}

+ 11 - 7
src/web/Web Builder/init.agi

@@ -1,21 +1,25 @@
 /*
 	Web Builder initiation script
+
+	Registers the visual site builder with the ArozOS desktop.
+	The module name is kept as "Web Builder" on purpose: module permissions and
+	user shortcuts are keyed by this name, so renaming it would silently drop
+	every existing permission group entry.
 */
 
 //Setup the module information
 var moduleLaunchInfo = {
-    Name: "Web Builder",
-	Desc: "The basic Web builder for writing HTML code",
+	Name: "Web Builder",
+	Desc: "Visual website builder that publishes to your personal site",
 	Group: "Office",
 	IconPath: "Web Builder/img/module_icon.png",
-	Version: "0.1.0",
+	Version: "1.0.0",
 	StartDir: "Web Builder/index.html",
 	SupportFW: true,
 	LaunchFWDir: "Web Builder/index.html",
-	InitFWSize: [1080, 580],
-	SupportedExt: [".html",".htm"]
+	InitFWSize: [1440, 900],
+	SupportedExt: [".wbsite", ".html", ".htm"]
 }
 
-
 //Register the module
-registerModule(JSON.stringify(moduleLaunchInfo));
+registerModule(JSON.stringify(moduleLaunchInfo));

+ 860 - 0
src/web/Web Builder/js/app.js

@@ -0,0 +1,860 @@
+/*
+    app.js
+
+    Boots the builder and wires everything together: the icon rail, the top bar,
+    the status bar, keyboard shortcuts, node level commands (insert, duplicate,
+    wrap, delete), autosave, and the ArozOS integration (input files, window
+    title, desktop theme).
+
+    Module load order matters and is fixed in index.html:
+        icons - schema - model - render - ui - canvas - panels - fileio -
+        publish - settings - app
+*/
+
+var WBApp = (function () {
+
+    var activePanel = "add";
+    var dockCollapsed = false;
+    var autosave = true;
+    var autosaveTimer = null;
+    var styleClipboard = null;
+    var suppressPanelRefresh = false;
+
+    /* ------------------------------------------------------------ boot -- */
+
+    function init() {
+        applyTheme();
+
+        WBModel.newProject("My Website");
+
+        WBCanvas.init({
+            onSelect: function (id) { selectNode(id, { fromCanvas: true }); },
+            onHover: function (id) { WBLayers.setHover(id); },
+            onChange: onCanvasChange,
+            onEditStateChange: onEditStateChange,
+            onRequestMenu: function (id, pos) { showElementMenu(id, pos); }
+        });
+
+        WBInspector.init();
+        WBPalette.init();
+        WBPages.init();
+        WBLayers.init();
+        WBDesign.init();
+        WBSettings.init();
+
+        bindRail();
+        bindTopbar();
+        bindStatusbar();
+        bindTextToolbar();
+        bindShortcuts();
+        bindHelp();
+
+        WBModel.on("change", onModelChange);
+        WBModel.on("restore", onModelRestore);
+        WBModel.on("clean", updateSaveState);
+
+        WBCanvas.setDevice("base");
+        WBCanvas.render();
+        setTimeout(function () { WBCanvas.zoomToFit(); updateZoomLabel(); }, 120);
+
+        selectNode(null);
+        refreshTitles();
+        updateSaveState();
+        loadInputFile();
+    }
+
+    /* Open the file the desktop launched us with, when there is one. */
+    function loadInputFile() {
+        var inputFiles = null;
+        try { inputFiles = ao_module_loadInputFiles(); } catch (e) { inputFiles = null; }
+        if (!inputFiles || !inputFiles.length) { return; }
+        var f = inputFiles[0];
+        WBFileIO.openPath(f.filepath, function (ok) {
+            if (!ok) { return; }
+            afterProjectLoaded();
+        });
+    }
+
+    function afterProjectLoaded() {
+        WBCanvas.render();
+        selectNode(null);
+        WBPages.render();
+        WBLayers.render();
+        WBDesign.render();
+        WBSettings.render();
+        refreshTitles();
+        updateSaveState();
+        setTimeout(function () { WBCanvas.zoomToFit(); updateZoomLabel(); }, 100);
+    }
+
+    /* ------------------------------------------------------------ rail -- */
+
+    function bindRail() {
+        var btns = document.querySelectorAll("#wb-rail .wb-rail-btn");
+        for (var i = 0; i < btns.length; i++) {
+            (function (btn) {
+                btn.addEventListener("click", function () {
+                    var panel = btn.getAttribute("data-panel");
+                    if (panel === activePanel && !dockCollapsed) {
+                        dockCollapsed = true;
+                        document.getElementById("wb-leftdock").classList.add("collapsed");
+                    } else {
+                        dockCollapsed = false;
+                        document.getElementById("wb-leftdock").classList.remove("collapsed");
+                        showPanel(panel);
+                    }
+                    setTimeout(function () { WBCanvas.scheduleOverlayUpdate(); }, 30);
+                });
+            })(btns[i]);
+        }
+    }
+
+    function showPanel(name) {
+        activePanel = name;
+        var panels = document.querySelectorAll(".wb-panel");
+        for (var i = 0; i < panels.length; i++) {
+            panels[i].classList.toggle("active", panels[i].id === "wb-panel-" + name);
+        }
+        var btns = document.querySelectorAll("#wb-rail .wb-rail-btn");
+        for (var j = 0; j < btns.length; j++) {
+            btns[j].classList.toggle("active", btns[j].getAttribute("data-panel") === name);
+        }
+        if (name === "layers") { WBLayers.render(); WBLayers.revealSelected(); }
+        if (name === "pages") { WBPages.render(); }
+        if (name === "design") { WBDesign.render(); }
+        if (name === "settings") { WBSettings.render(); }
+    }
+
+    /* ---------------------------------------------------------- topbar -- */
+
+    function bindTopbar() {
+        document.getElementById("wb-project-btn").addEventListener("click", function (e) {
+            WBUI.menu(e.currentTarget, [
+                { header: "Project" },
+                { label: "New Site", icon: "new-file", action: newSite },
+                { label: "Open...", icon: "open", key: "Ctrl+O", action: openSite },
+                { label: "Save", icon: "save", key: "Ctrl+S", action: save },
+                { label: "Save As...", icon: "duplicate", action: saveAs },
+                { separator: true },
+                { label: "Rename Site", icon: "pencil", action: renameSite },
+                { label: "Import HTML As Page", icon: "download", action: function () {
+                    WBFileIO.importHtmlAsPage();
+                } }
+            ]);
+        });
+
+        var devBtns = document.querySelectorAll("[data-device]");
+        for (var i = 0; i < devBtns.length; i++) {
+            (function (b) {
+                b.addEventListener("click", function () { setDevice(b.getAttribute("data-device")); });
+            })(devBtns[i]);
+        }
+
+        document.getElementById("wb-undo").addEventListener("click", undo);
+        document.getElementById("wb-redo").addEventListener("click", redo);
+
+        document.getElementById("wb-preview-btn").addEventListener("click", function () {
+            WBPublish.togglePreview();
+        });
+        document.getElementById("wb-exit-preview").addEventListener("click", function () {
+            WBPublish.togglePreview(false);
+        });
+        document.getElementById("wb-publish-btn").addEventListener("click", function () {
+            WBPublish.publish();
+        });
+
+        document.getElementById("wb-more-btn").addEventListener("click", function (e) {
+            WBUI.menu(e.currentTarget, [
+                { label: "Preview In New Tab", icon: "external", action: WBPublish.openInNewTab },
+                { label: "Publish...", icon: "publish", action: WBPublish.publish },
+                { label: "Export To Folder...", icon: "download", action: WBPublish.exportToFolder },
+                { separator: true },
+                { label: "Site Settings", icon: "gear", action: function () { showPanel("settings"); openDock(); } },
+                { label: "Personal Site Settings", icon: "globe", action: function () {
+                    try { ao_module_openSetting("Network", "Personal Page"); }
+                    catch (err) { WBUI.toast("Open ArozOS Settings > Network > Personal Page", "err"); }
+                } },
+                { separator: true },
+                { label: "Zoom To Fit", icon: "resize", key: "Ctrl+0", action: function () {
+                    WBCanvas.zoomToFit(); updateZoomLabel();
+                } },
+                { label: "Keyboard Shortcuts", icon: "help", action: showShortcuts },
+                { label: "About Site Builder", icon: "info", action: showAbout }
+            ], { alignRight: true });
+        });
+    }
+
+    function openDock() {
+        dockCollapsed = false;
+        document.getElementById("wb-leftdock").classList.remove("collapsed");
+    }
+
+    function setDevice(key) {
+        WBCanvas.setDevice(key);
+        var all = document.querySelectorAll("[data-device]");
+        for (var i = 0; i < all.length; i++) {
+            all[i].classList.toggle("active", all[i].getAttribute("data-device") === key);
+        }
+        updateZoomLabel();
+        WBInspector.render();
+    }
+
+    /* ------------------------------------------------------- status bar -- */
+
+    function bindStatusbar() {
+        document.getElementById("wb-zoom-out").addEventListener("click", function () {
+            WBCanvas.setZoom(WBCanvas.getZoom() - 0.1);
+            updateZoomLabel();
+        });
+        document.getElementById("wb-zoom-in").addEventListener("click", function () {
+            WBCanvas.setZoom(WBCanvas.getZoom() + 0.1);
+            updateZoomLabel();
+        });
+        document.getElementById("wb-zoom-label").addEventListener("click", function () {
+            WBCanvas.setZoom(1);
+            updateZoomLabel();
+        });
+    }
+
+    function updateZoomLabel() {
+        document.getElementById("wb-zoom-label").textContent =
+            Math.round(WBCanvas.getZoom() * 100) + "%";
+    }
+
+    function renderBreadcrumb() {
+        var el = document.getElementById("wb-breadcrumb");
+        WBUI.clear(el);
+        var id = WBCanvas.getSelection();
+        var chain = id ? (WBModel.pathTo(id) || []) : [];
+        if (!chain.length) {
+            el.appendChild(WBUI.el("span", { class: "wb-crumb", text: WBModel.activePage().name }));
+            return;
+        }
+        chain.forEach(function (n, i) {
+            if (i) { el.appendChild(WBUI.el("span", { class: "wb-crumb-sep", html: WBIcon("caret-right", 9) })); }
+            el.appendChild(WBUI.el("button", {
+                class: "wb-crumb" + (i === chain.length - 1 ? " current" : ""),
+                type: "button",
+                text: WBModel.displayName(n),
+                onclick: function () { selectNode(n.id); }
+            }));
+        });
+    }
+
+    /* ------------------------------------------------- canvas callbacks -- */
+
+    function onCanvasChange(evt) {
+        switch (evt.action) {
+        case "insert":
+            insertElement(evt.type, evt.parentId, evt.index);
+            break;
+        case "move":
+            if (WBModel.moveNode(evt.id, evt.parentId, evt.index)) {
+                WBModel.commit("Move element");
+                rerenderCanvas();
+                selectNode(evt.id);
+            }
+            break;
+        case "delete":
+            deleteNode(evt.id);
+            break;
+        case "text":
+        case "resize":
+            WBLayers.render();
+            WBInspector.render();
+            updateSaveState();
+            break;
+        default:
+            break;
+        }
+    }
+
+    function onEditStateChange(editing) {
+        var bar = document.getElementById("wb-text-toolbar");
+        bar.classList.toggle("editing", editing);
+        updateTextToolbarState();
+    }
+
+    function onModelChange() {
+        updateSaveState();
+        updateHistoryButtons();
+        scheduleAutosave();
+    }
+
+    function onModelRestore() {
+        rerenderCanvas();
+        var id = WBCanvas.getSelection();
+        if (id && !WBModel.findNode(id)) { id = null; }
+        selectNode(id, { silentCanvas: false });
+        WBPages.render();
+        WBDesign.render();
+        refreshTitles();
+        updateSaveState();
+        updateHistoryButtons();
+    }
+
+    /* --------------------------------------------------- node commands -- */
+
+    /*
+        Insert a new element. When no explicit parent is given the element goes
+        into the current selection if it can hold children, otherwise right
+        after the selection, otherwise at the end of the page.
+    */
+    function insertElement(type, parentId, index) {
+        var node = WBModel.createNode(type);
+
+        if (parentId === undefined || parentId === null) {
+            var selId = WBCanvas.getSelection();
+            var sel = selId ? WBModel.findNode(selId) : null;
+            if (sel && (wbIsContainer(sel.type) || sel.type === "body")) {
+                parentId = sel.id;
+                index = sel.children.length;
+            } else if (sel) {
+                var parent = WBModel.findParent(sel.id);
+                parentId = parent ? parent.id : WBModel.activePage().root.id;
+                index = WBModel.indexOfNode(sel.id) + 1;
+            } else {
+                parentId = WBModel.activePage().root.id;
+                index = null;
+            }
+        }
+
+        if (!WBModel.insertNode(parentId, node, index)) {
+            WBUI.toast("That element cannot hold other elements", "err");
+            return;
+        }
+        WBModel.commit("Add " + wbDef(type).name);
+        rerenderCanvas();
+        selectNode(node.id);
+        WBCanvas.scrollToNode(node.id);
+        if (wbIsTextEditable(type)) {
+            setTimeout(function () { WBCanvas.startTextEdit(node.id); }, 120);
+        }
+    }
+
+    function deleteNode(id) {
+        var node = WBModel.findNode(id);
+        if (!node || node.type === "body") { return; }
+        var parent = WBModel.findParent(id);
+        var idx = WBModel.indexOfNode(id);
+        WBCanvas.stopTextEdit();
+        WBModel.removeNode(id);
+        WBModel.commit("Delete " + WBModel.displayName(node));
+        rerenderCanvas();
+        var next = null;
+        if (parent) {
+            next = parent.children[Math.min(idx, parent.children.length - 1)];
+        }
+        selectNode(next ? next.id : (parent ? parent.id : null));
+    }
+
+    function duplicateNode(id) {
+        var copy = WBModel.duplicateNode(id);
+        if (!copy) { return; }
+        WBModel.commit("Duplicate element");
+        rerenderCanvas();
+        selectNode(copy.id);
+    }
+
+    function wrapInContainer(id) {
+        var node = WBModel.findNode(id);
+        var parent = WBModel.findParent(id);
+        if (!node || !parent) { return; }
+        var idx = WBModel.indexOfNode(id);
+        var box = WBModel.createNode("container");
+        box.styles.base = { maxWidth: "none", marginLeft: "0", marginRight: "0", width: "100%" };
+        parent.children.splice(idx, 1, box);
+        box.children.push(node);
+        WBModel.commit("Wrap in container");
+        rerenderCanvas();
+        selectNode(box.id);
+    }
+
+    function unwrapNode(id) {
+        var node = WBModel.findNode(id);
+        var parent = WBModel.findParent(id);
+        if (!node || !parent || node.type === "body") { return; }
+        var idx = WBModel.indexOfNode(id);
+        var args = [idx, 1].concat(node.children);
+        Array.prototype.splice.apply(parent.children, args);
+        WBModel.commit("Unwrap element");
+        rerenderCanvas();
+        selectNode(node.children.length ? node.children[0].id : parent.id);
+    }
+
+    function copyStyles(id) {
+        var node = WBModel.findNode(id);
+        if (!node) { return; }
+        styleClipboard = WBModel.clone(node.styles);
+        WBUI.toast("Styles copied - select another element and press Ctrl+Shift+V");
+    }
+
+    function pasteStyles(id) {
+        if (!styleClipboard) { WBUI.toast("No styles copied yet", "err"); return; }
+        var node = WBModel.findNode(id);
+        if (!node) { return; }
+        node.styles = WBModel.clone(styleClipboard);
+        WBModel.commit("Paste styles");
+        WBCanvas.refreshStyles();
+        WBInspector.render();
+    }
+
+    function showElementMenu(id, pos) {
+        var node = WBModel.findNode(id);
+        if (!node) { return; }
+        var parent = WBModel.findParent(id);
+        WBUI.menu(pos || { x: 200, y: 200 }, [
+            { label: "Duplicate", icon: "duplicate", key: "Ctrl+D", action: function () { duplicateNode(id); } },
+            { label: "Copy Styles", icon: "copy", action: function () { copyStyles(id); } },
+            { label: "Paste Styles", icon: "palette", disabled: !styleClipboard,
+              action: function () { pasteStyles(id); } },
+            { separator: true },
+            { label: "Select Parent", icon: "caret-up", disabled: !parent,
+              action: function () { selectNode(parent.id); } },
+            { label: "Wrap In Container", icon: "container", action: function () { wrapInContainer(id); } },
+            { separator: true },
+            { label: "Delete", icon: "trash", danger: true, key: "Del",
+              disabled: node.type === "body", action: function () { deleteNode(id); } }
+        ]);
+    }
+
+    /* ------------------------------------------------------- selection -- */
+
+    function selectNode(id, opts) {
+        opts = opts || {};
+        if (!opts.fromCanvas) { WBCanvas.select(id, { silent: true }); }
+        WBInspector.setTarget(id);
+        renderBreadcrumb();
+        if (!suppressPanelRefresh) {
+            WBLayers.render();
+            if (!opts.fromLayers) { WBLayers.revealSelected(); }
+        }
+    }
+
+    function highlightNode(id) {
+        WBLayers.setHover(id);
+    }
+
+    /* ---------------------------------------------------------- canvas -- */
+
+    function rerenderCanvas(opts) {
+        opts = opts || {};
+        if (opts.soft) { WBCanvas.refreshStyles(); }
+        else { WBCanvas.render(); }
+        WBCanvas.select(WBCanvas.getSelection(), { silent: true, force: true });
+        suppressPanelRefresh = true;
+        WBLayers.render();
+        suppressPanelRefresh = false;
+        renderBreadcrumb();
+    }
+
+    function refreshInspector() { WBInspector.render(); }
+
+    /* ----------------------------------------------------------- pages -- */
+
+    function switchPage(id) {
+        WBCanvas.stopTextEdit();
+        WBModel.setActivePage(id);
+        WBCanvas.render();
+        selectNode(null);
+        WBPages.render();
+        WBLayers.render();
+        WBDesign.render();
+        refreshTitles();
+        setTimeout(function () { WBCanvas.syncFrameHeight(); WBCanvas.updateOverlay(); }, 120);
+    }
+
+    /* ------------------------------------------------------ file state -- */
+
+    function refreshTitles() {
+        var project = WBModel.get();
+        document.getElementById("wb-project-name").textContent = project.name;
+        var page = WBModel.activePage();
+        try {
+            ao_module_setWindowTitle(project.name + " - " + page.name + " - Site Builder");
+        } catch (e) { /* not running inside the ArozOS desktop */ }
+        document.title = project.name + " - Site Builder";
+        WBPages.renderSettings();
+    }
+
+    function updateSaveState() {
+        var dirty = WBModel.isDirty();
+        var st = document.getElementById("wb-save-state");
+        st.classList.toggle("dirty", dirty);
+        st.querySelector(".wb-save-icon").innerHTML = WBIcon(dirty ? "cloud-off" : "cloud-check", 15);
+        st.querySelector(".wb-save-text").textContent = dirty ? "Unsaved" : "Saved";
+
+        document.getElementById("wb-status-dot").classList.toggle("dirty", dirty);
+        document.getElementById("wb-status-saved").textContent =
+            dirty ? "Unsaved changes" : (WBFileIO.currentPath() ? "Saved to " + WBFileIO.fileName() : "Not saved yet");
+    }
+
+    function updateHistoryButtons() {
+        document.getElementById("wb-undo").disabled = !WBModel.canUndo();
+        document.getElementById("wb-redo").disabled = !WBModel.canRedo();
+    }
+
+    function scheduleAutosave() {
+        if (!autosave || !WBFileIO.currentPath()) { return; }
+        if (autosaveTimer) { clearTimeout(autosaveTimer); }
+        autosaveTimer = setTimeout(function () {
+            if (!WBModel.isDirty()) { return; }
+            WBFileIO.save(function (ok) {
+                if (ok) { updateSaveState(); }
+            });
+        }, 4000);
+    }
+
+    function getAutosave() { return autosave; }
+    function setAutosave(v) { autosave = v; }
+
+    function save() {
+        WBFileIO.save(function (ok) {
+            if (ok) {
+                WBUI.toast("Saved", "ok");
+                updateSaveState();
+                WBSettings.render();
+            }
+        });
+    }
+
+    function saveAs() {
+        WBFileIO.saveAs(function (ok) {
+            if (ok) {
+                WBUI.toast("Saved as " + WBFileIO.fileName(), "ok");
+                updateSaveState();
+                WBSettings.render();
+            }
+        });
+    }
+
+    function openSite() {
+        confirmDiscard(function () {
+            WBFileIO.openDialog();
+            /* openDialog resolves asynchronously through openPath */
+            var poll = setInterval(function () {
+                if (WBModel.get() && !WBModel.isDirty() && WBFileIO.currentPath()) {
+                    clearInterval(poll);
+                    afterProjectLoaded();
+                }
+            }, 250);
+            setTimeout(function () { clearInterval(poll); }, 60000);
+        });
+    }
+
+    function newSite() {
+        confirmDiscard(function () {
+            WBUI.prompt("New Site", "Site name", "My Website").then(function (name) {
+                if (name === null) { return; }
+                WBModel.newProject(name || "My Website");
+                WBFileIO.setPath("");
+                afterProjectLoaded();
+                WBUI.toast("New site created");
+            });
+        });
+    }
+
+    function renameSite() {
+        WBUI.prompt("Rename Site", "Site name", WBModel.get().name).then(function (name) {
+            if (!name) { return; }
+            WBModel.get().name = name;
+            WBModel.commit("Rename site");
+            refreshTitles();
+            WBSettings.render();
+        });
+    }
+
+    function confirmDiscard(next) {
+        if (!WBModel.isDirty()) { next(); return; }
+        WBUI.modal({
+            title: "Unsaved Changes",
+            body: WBUI.el("div", { text: "This site has changes that have not been saved to a file yet." }),
+            buttons: [
+                { label: "Cancel", value: null },
+                { label: "Discard", value: "discard", danger: true },
+                { label: "Save First", value: "save", primary: true }
+            ]
+        }).then(function (v) {
+            if (v === "discard") { next(); }
+            else if (v === "save") { WBFileIO.save(function (ok) { if (ok) { next(); } }); }
+        });
+    }
+
+    /* ------------------------------------------------------ text tools -- */
+
+    function bindTextToolbar() {
+        var bar = document.getElementById("wb-text-toolbar");
+
+        bar.querySelector("[data-tt='edit']").addEventListener("click", function () {
+            var id = WBCanvas.getSelection();
+            if (id) { WBCanvas.startTextEdit(id); }
+        });
+
+        ["left", "center", "right"].forEach(function (a) {
+            bar.querySelector("[data-tt='align-" + a + "']").addEventListener("click", function () {
+                var id = WBCanvas.getSelection();
+                if (!id) { return; }
+                WBModel.setStyle(id, "textAlign", a, WBCanvas.getDevice());
+                WBModel.commit("Align text");
+                WBCanvas.refreshStyles();
+                WBInspector.render();
+                updateTextToolbarState();
+            });
+        });
+
+        bar.querySelector("[data-tt='bold']").addEventListener("click", function () {
+            requireEditing(function () { WBCanvas.execTextCommand("bold"); updateTextToolbarState(); });
+        });
+        bar.querySelector("[data-tt='italic']").addEventListener("click", function () {
+            requireEditing(function () { WBCanvas.execTextCommand("italic"); updateTextToolbarState(); });
+        });
+        bar.querySelector("[data-tt='link']").addEventListener("click", function () {
+            requireEditing(function () {
+                WBUI.prompt("Insert Link", "Link address", "", "https:// or about.html").then(function (v) {
+                    if (!v) { return; }
+                    WBCanvas.execTextCommand("createLink", v);
+                });
+            });
+        });
+        bar.querySelector("[data-tt='more']").addEventListener("click", function (e) {
+            var id = WBCanvas.getSelection();
+            var node = id ? WBModel.findNode(id) : null;
+            var items = [
+                { label: "Underline", icon: "underline", action: function () {
+                    requireEditing(function () { WBCanvas.execTextCommand("underline"); });
+                } },
+                { label: "Remove Link", icon: "unlink", action: function () {
+                    requireEditing(function () { WBCanvas.execTextCommand("unlink"); });
+                } },
+                { label: "Clear Formatting", icon: "close", action: function () {
+                    requireEditing(function () { WBCanvas.execTextCommand("removeFormat"); });
+                } },
+                { separator: true }
+            ];
+            if (node && node.type === "heading") {
+                items.push({ header: "Heading level" });
+                ["h1", "h2", "h3", "h4", "h5", "h6"].forEach(function (h) {
+                    items.push({
+                        label: h.toUpperCase(),
+                        checked: node.tag === h,
+                        action: function () {
+                            WBModel.setTag(node.id, h);
+                            WBModel.commit("Change heading level");
+                            rerenderCanvas();
+                            WBInspector.render();
+                        }
+                    });
+                });
+            } else {
+                items.push({ label: "Open in inspector", icon: "gear", action: function () {
+                    WBInspector.render();
+                } });
+            }
+            WBUI.menu(e.currentTarget, items, { alignRight: true });
+        });
+    }
+
+    function requireEditing(fn) {
+        if (!WBCanvas.isEditingText()) {
+            var id = WBCanvas.getSelection();
+            if (!id) { return; }
+            WBCanvas.startTextEdit(id);
+            setTimeout(fn, 60);
+            return;
+        }
+        fn();
+    }
+
+    function updateTextToolbarState() {
+        var bar = document.getElementById("wb-text-toolbar");
+        var id = WBCanvas.getSelection();
+        var node = id ? WBModel.findNode(id) : null;
+        if (!node) { return; }
+        var align = WBModel.effectiveStyle(node, "textAlign", WBCanvas.getDevice()) || "left";
+        ["left", "center", "right"].forEach(function (a) {
+            bar.querySelector("[data-tt='align-" + a + "']").classList.toggle("active", align === a);
+        });
+        bar.querySelector("[data-tt='bold']").classList.toggle("active", WBCanvas.queryTextState("bold"));
+        bar.querySelector("[data-tt='italic']").classList.toggle("active", WBCanvas.queryTextState("italic"));
+    }
+
+    /* ------------------------------------------------------- shortcuts -- */
+
+    function bindShortcuts() {
+        document.addEventListener("keydown", function (e) {
+            var mod = e.ctrlKey || e.metaKey;
+            var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : "";
+            var typing = tag === "input" || tag === "textarea" || e.target.isContentEditable;
+
+            if (mod && e.key.toLowerCase() === "s") { e.preventDefault(); save(); return; }
+            if (mod && e.key.toLowerCase() === "o") { e.preventDefault(); openSite(); return; }
+            if (mod && e.key === "0") { e.preventDefault(); WBCanvas.zoomToFit(); updateZoomLabel(); return; }
+
+            if (mod && e.key.toLowerCase() === "z" && !e.shiftKey) { e.preventDefault(); undo(); return; }
+            if (mod && (e.key.toLowerCase() === "y" || (e.shiftKey && e.key.toLowerCase() === "z"))) {
+                e.preventDefault(); redo(); return;
+            }
+
+            if (typing && !WBCanvas.isEditingText()) { return; }
+
+            if (mod && e.shiftKey && e.key.toLowerCase() === "v") {
+                e.preventDefault();
+                var sel = WBCanvas.getSelection();
+                if (sel) { pasteStyles(sel); }
+                return;
+            }
+            if (mod && e.key.toLowerCase() === "d") {
+                e.preventDefault();
+                var d = WBCanvas.getSelection();
+                if (d) { duplicateNode(d); }
+                return;
+            }
+            if (e.key === "Escape") {
+                if (WBCanvas.isEditingText()) { WBCanvas.stopTextEdit(); }
+                else if (WBPublish.isPreview()) { WBPublish.togglePreview(false); }
+                else { selectNode(null); }
+                return;
+            }
+            if ((e.key === "Delete" || e.key === "Backspace") && !WBCanvas.isEditingText()) {
+                var id = WBCanvas.getSelection();
+                if (id) { e.preventDefault(); deleteNode(id); }
+                return;
+            }
+            if (e.key === "Enter" && !WBCanvas.isEditingText()) {
+                var s2 = WBCanvas.getSelection();
+                var n2 = s2 ? WBModel.findNode(s2) : null;
+                if (n2 && wbIsTextEditable(n2.type)) { e.preventDefault(); WBCanvas.startTextEdit(s2); }
+                return;
+            }
+        });
+
+        window.addEventListener("beforeunload", function (e) {
+            if (WBModel.isDirty()) {
+                e.preventDefault();
+                e.returnValue = "";
+                return "";
+            }
+        });
+    }
+
+    function undo() {
+        if (WBModel.undo()) { WBUI.toast("Undo"); }
+    }
+
+    function redo() {
+        if (WBModel.redo()) { WBUI.toast("Redo"); }
+    }
+
+    /* ------------------------------------------------------------ help -- */
+
+    function bindHelp() {
+        var body = document.querySelector("#wb-panel-help .wb-panel-bd");
+        var items = [
+            ["Add elements", "Drag a card from the Add panel onto the canvas, or click it to drop it inside whatever is selected."],
+            ["Edit text", "Double-click any text on the canvas, or press Enter with it selected. The floating bar handles bold, italic and links."],
+            ["Layers", "The Layers panel is the element tree of this page. Drag rows to reorder or re-parent; drop on the middle of a container to move inside it."],
+            ["Responsive design", "Switch to Tablet or Mobile in the top bar, then edit. Those changes only apply at that width and narrower."],
+            ["Publish", "Publish writes a static site into a folder of its own inside your ArozOS Personal Site web root, then serves it at /www/&lt;user&gt;/&lt;folder&gt;/."],
+            ["Project files", "Sites are saved as .wbsite files anywhere in your file system. Publishing is a separate step and never overwrites your project file."]
+        ];
+        items.forEach(function (it) {
+            body.appendChild(WBUI.el("div", { class: "wb-help-item" }, [
+                WBUI.el("h4", { text: it[0] }),
+                WBUI.el("p", { html: it[1] })
+            ]));
+        });
+        body.appendChild(WBUI.el("button", {
+            class: "wb-btn wb-btn-block wb-btn-sm",
+            style: "margin-top:14px",
+            type: "button",
+            html: WBIcon("help", 13) + "<span>Keyboard shortcuts</span>",
+            onclick: showShortcuts
+        }));
+    }
+
+    function showShortcuts() {
+        var rows = [
+            ["Ctrl + S", "Save project"],
+            ["Ctrl + O", "Open project"],
+            ["Ctrl + Z", "Undo"],
+            ["Ctrl + Shift + Z", "Redo"],
+            ["Ctrl + D", "Duplicate selection"],
+            ["Ctrl + Shift + V", "Paste copied styles"],
+            ["Ctrl + 0", "Zoom to fit"],
+            ["Enter", "Edit the selected text"],
+            ["Delete", "Delete selection"],
+            ["Escape", "Stop editing / deselect / leave preview"]
+        ];
+        var list = WBUI.el("div");
+        rows.forEach(function (r) {
+            list.appendChild(WBUI.el("div", {
+                style: "display:flex;gap:12px;padding:6px 0;border-bottom:1px solid var(--wb-border)"
+            }, [
+                WBUI.el("span", { class: "wb-kbd", text: r[0] }),
+                WBUI.el("span", { text: r[1], style: "color:var(--wb-text-dim)" })
+            ]));
+        });
+        WBUI.modal({ title: "Keyboard Shortcuts", body: list });
+    }
+
+    function showAbout() {
+        WBUI.modal({
+            title: "ArozOS Site Builder",
+            body: WBUI.el("div", {
+                html: "<p>A visual website builder for ArozOS. Sites are saved as <code>.wbsite</code> " +
+                      "project files and published as plain static HTML into your Personal Site web root " +
+                      "- no runtime, no database, nothing to keep running.</p>" +
+                      "<p style='margin-top:10px;color:var(--wb-text-dim)'>Part of the ArozOS web desktop. " +
+                      "Licensed under GPLv3.</p>"
+            })
+        });
+    }
+
+    /* ----------------------------------------------------------- theme -- */
+
+    function applyTheme() {
+        function setDark(dark) { document.body.classList.toggle("dark", !!dark); }
+        try {
+            if (typeof ao_module_getSystemThemeColor === "function") {
+                ao_module_getSystemThemeColor(function (c) { setDark(c !== "whiteTheme"); });
+            }
+        } catch (e) { /* standalone - keep the light theme */ }
+
+        window.desktopThemeChanged = function (theme) { setDark(theme === "dark" || theme === "darkTheme"); };
+        try {
+            if (typeof ao_module_onThemeChanged === "function") {
+                ao_module_onThemeChanged(function (dark) { setDark(dark); });
+            }
+        } catch (e) { /* older ao_module - the global above still works */ }
+    }
+
+    function renderPreviewState(on) {
+        document.getElementById("wb-preview-btn").classList.toggle("active", on);
+    }
+
+    return {
+        init: init,
+        showPanel: showPanel,
+        insertElement: insertElement,
+        deleteNode: deleteNode,
+        duplicateNode: duplicateNode,
+        wrapInContainer: wrapInContainer,
+        unwrapNode: unwrapNode,
+        copyStyles: copyStyles,
+        pasteStyles: pasteStyles,
+        selectNode: selectNode,
+        highlightNode: highlightNode,
+        rerenderCanvas: rerenderCanvas,
+        refreshInspector: refreshInspector,
+        refreshTitles: refreshTitles,
+        switchPage: switchPage,
+        save: save,
+        saveAs: saveAs,
+        getAutosave: getAutosave,
+        setAutosave: setAutosave,
+        renderPreviewState: renderPreviewState,
+        updateSaveState: updateSaveState
+    };
+})();
+
+document.addEventListener("DOMContentLoaded", function () { WBApp.init(); });

+ 82 - 0
src/web/Web Builder/js/embedcompat.js

@@ -0,0 +1,82 @@
+/*
+    embedcompat.js
+
+    Compatibility shim for the case where the Site Builder is embedded as an
+    iframe inside another ArozOS app (the Productivity hub does this).
+
+    In that situation ao_module_virtualDesktop is false, because the host app -
+    not this window - is the one attached to the desktop. Without this patch
+    ao_module_openFileSelector would try to open its own selector window and
+    never get a callback. The shim routes the selector through the real desktop
+    (parent.parent) and registers the callback proxy on the host app window,
+    which is where the desktop resolves callback names.
+*/
+
+(function () {
+    try {
+        if (ao_module_virtualDesktop || !parent.ao_module_virtualDesktop) { return; }
+
+        ao_module_openFileSelector = function (callback, root, type, allowMultiple, options) {
+            root = (root != null) ? root : "user:/";
+            type = type || "file";
+            allowMultiple = allowMultiple || false;
+
+            var callbackname;
+            if (typeof callback === "string") {
+                callbackname = callback;
+            } else if (typeof callback === "function" && callback.name) {
+                callbackname = callback.name;
+            } else {
+                callbackname = "_aoFs_" + Date.now();
+            }
+            if (options && options.fnameOverride) {
+                callbackname = options.fnameOverride;
+            }
+
+            var memKey = (options && options.path_memory_key) || ao_module_pathMemoryDefaultAction;
+
+            /* the desktop resolves callbacks on the host app window */
+            parent[callbackname] = function (files) {
+                try {
+                    if (!(options && options.disable_path_memory === true) &&
+                        Array.isArray(files) && files.length > 0) {
+                        var usedDir = ao_module_pathMemoryDirOf(files[0].filepath, type);
+                        if (usedDir != "") { ao_module_setLastUsedPath(usedDir, memKey); }
+                    }
+                    var fn = (typeof callback === "string") ? window[callback] : callback;
+                    if (fn) { fn(files); }
+                } finally {
+                    delete parent[callbackname];
+                }
+            };
+
+            var initInfo = {
+                root: root,
+                fallbackRoot: root,
+                type: type,
+                allowMultiple: allowMultiple,
+                listenerUUID: "",
+                options: options
+            };
+
+            if (!(options && options.disable_path_memory === true)) {
+                var remembered = (options && options.force_path_overwrite === true)
+                    ? ao_module_getLastUsedPath(false)
+                    : ao_module_getLastUsedPath(memKey);
+                if (remembered != "") { initInfo.root = remembered; }
+            }
+
+            parent.parent.newFloatWindow({
+                url: "SystemAO/file_system/file_selector.html#" + encodeURIComponent(JSON.stringify(initInfo)),
+                width: 960,
+                height: 620,
+                appicon: "SystemAO/file_system/img/selector.png",
+                title: "Open",
+                parent: parent.ao_module_windowID,
+                callback: callbackname
+            });
+        };
+    } catch (e) {
+        /* cross-origin or not nested - leave ao_module_openFileSelector alone */
+    }
+})();