diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..3f09395
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,6 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ { "name": "agui-editor", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5173, "autoPort": true }
+ ]
+}
diff --git a/src/components/Header.vue b/src/components/Header.vue
index afc0804..9811c36 100644
--- a/src/components/Header.vue
+++ b/src/components/Header.vue
@@ -41,6 +41,7 @@
get_app
{{ t("header.download", "Download") }}
+
{{ t("header.lang", "Language") }}
translate
@@ -115,6 +116,7 @@
import { defineComponent } from "vue";
import Modal from "@/components/Modal.vue";
+import LiveSync from "@/components/LiveSync.vue";
import { settings } from "../utils/manager/SettingsManager";
import {
availableLanguages,
@@ -139,7 +141,7 @@ import { info } from "../utils/manager/WorkspaceManager";
import { normalizeProjectName } from "@/utils/ProjectName";
export default defineComponent({
- components: { Modal },
+ components: { Modal, LiveSync },
data() {
return {
diff --git a/src/components/LiveSync.vue b/src/components/LiveSync.vue
new file mode 100644
index 0000000..c6e4e87
--- /dev/null
+++ b/src/components/LiveSync.vue
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
{{ blockReason }}
+
{{ error }}
+
+ Live-sync не передаёт картинки/гифки/шрифты — после их изменения
+ перезалей полный layout-файл на сервер.
+
+
+
+ {{ command }}
+
+
+
+ 1. Запусти эту команду на сервере (layout должен быть уже загружен).
+ 2. Нажми Connect — правки полетят в плагин вживую.
+
+
+
+
+
+
+ отправлено: {{ pushes }}{{ lastAck ? " · ack ✓" : "" }}
+
+
+
+
+
+
+
+
+
diff --git a/src/utils/manager/SyncManager.ts b/src/utils/manager/SyncManager.ts
new file mode 100644
index 0000000..19399b4
--- /dev/null
+++ b/src/utils/manager/SyncManager.ts
@@ -0,0 +1,225 @@
+/**
+ * Live-Sync client.
+ *
+ * Re-implements the editor side of AdvancedGUI's live-sync without any
+ * dependency on the advancedgui.app cloud. The plugin runs a plain WebSocket
+ * server on its configured `SyncSocketPort` (default 27757) and, for every text
+ * message it receives, parses it with `LayoutManager.syncFromJson(json)` and
+ * echoes the session key back as an ack.
+ *
+ * The wire payload the plugin expects is:
+ * { "name":
,
+ * "componentTree": , // double-encoded
+ * "invisible": [, ...] }
+ *
+ * The finalized component tree is exactly what {@link convertToFinalized}
+ * already produces (the same format the downloadable layout file carries under
+ * `exportedTree.finalized`), so this client reuses the verified serialization
+ * pipeline and adds only transport + a debounced change signal.
+ *
+ * This client is push-only: it never applies inbound layouts, so there is no
+ * echo loop to guard against. Resources (images/gifs/fonts) cannot travel over
+ * live-sync — see {@link syncUsesUnsyncableResources}.
+ */
+import { ref, watch, type WatchStopHandle } from "vue";
+import { convertToFinalized } from "../LocalConverter";
+import {
+ BK,
+ getCurrentTransferData,
+} from "../handler/ProjectSerializationHandler";
+import { settings } from "./SettingsManager";
+import { normalizeProjectName } from "../ProjectName";
+import { history, initializeHistoryAutoTracking } from "./HistoryManager";
+import { JsonObject } from "./ComponentManager";
+import { DEFAULT_FONTS, regFonts } from "./FontManager";
+import { DEFAULT_IMAGES, images } from "./ImageManager";
+
+export interface SyncPayload {
+ name: string;
+ /** Stringified finalized GroupComponent — the plugin re-parses this string. */
+ componentTree: string;
+ invisible: string[];
+}
+
+export type SyncStatus = "off" | "connecting" | "live" | "error";
+
+export const DEFAULT_SYNC_URL = "ws://localhost:27757";
+const PUSH_DEBOUNCE = 300;
+
+// ---- public reactive state -------------------------------------------------
+
+export const syncStatus = ref("off");
+export const syncError = ref(null);
+export const syncUrl = ref(DEFAULT_SYNC_URL);
+/** Timestamp (ms) of the last ack echoed back by the plugin. */
+export const lastAckAt = ref(null);
+/** Number of payloads sent in the current session. */
+export const pushCount = ref(0);
+
+// ---- payload building ------------------------------------------------------
+
+/** The layout name used both in the payload and the `/ag sync` command. */
+export function syncLayoutName(): string {
+ return normalizeProjectName(settings.projectName, "Unnamed");
+}
+
+/**
+ * Build the exact wire payload `LayoutManager.syncFromJson` expects. The
+ * `componentTree` is finalized and then stringified; the plugin reads it via
+ * `.asText()` and re-parses it (double-encoding).
+ */
+export function buildSyncPayload(): SyncPayload {
+ const transfer = getCurrentTransferData();
+ const finalizedTree = convertToFinalized(
+ transfer.componentTree as unknown as JsonObject,
+ );
+ return {
+ name: syncLayoutName(),
+ componentTree: JSON.stringify(finalizedTree),
+ invisible: [...transfer.invisible],
+ };
+}
+
+/** The in-game command the admin must run, with the editor's persistent key. */
+export function buildSyncCommand(): string {
+ return `/ag sync ${BK} ${syncLayoutName()}`;
+}
+
+/**
+ * True when the project references images/gifs/fonts that are not built-in.
+ * Live-sync cannot transmit resources, so the UI should warn that a full layout
+ * file re-upload is required after changing them.
+ */
+export function syncUsesUnsyncableResources(): boolean {
+ const usesCustomImage = Object.values(images).some(
+ (img) => !DEFAULT_IMAGES.includes(img.name),
+ );
+ const usesCustomFont = regFonts.some((font) => !DEFAULT_FONTS.includes(font));
+ return usesCustomImage || usesCustomFont;
+}
+
+// ---- transport -------------------------------------------------------------
+
+let ws: WebSocket | null = null;
+let stopWatch: WatchStopHandle | null = null;
+let pushTimer: number | null = null;
+
+function clearPushTimer(): void {
+ if (pushTimer !== null) {
+ clearTimeout(pushTimer);
+ pushTimer = null;
+ }
+}
+
+function schedulePush(): void {
+ clearPushTimer();
+ pushTimer = window.setTimeout(() => {
+ pushTimer = null;
+ pushSyncNow();
+ }, PUSH_DEBOUNCE);
+}
+
+/** Send the current layout immediately (no debounce). */
+export function pushSyncNow(): void {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ try {
+ ws.send(JSON.stringify(buildSyncPayload()));
+ pushCount.value += 1;
+ } catch (exc) {
+ syncError.value = String((exc as Error)?.message ?? exc);
+ }
+}
+
+/**
+ * Mixed-content guard: a page served over https can only open a `wss://`
+ * connection, or a `ws://` connection to a loopback host (treated as
+ * potentially-trustworthy by browsers). Returns an error string when the target
+ * would be blocked, else null.
+ */
+export function syncUrlBlockReason(url: string): string | null {
+ if (typeof location === "undefined" || location.protocol !== "https:") {
+ return null;
+ }
+ if (url.startsWith("wss://")) return null;
+ const isLoopback = /^ws:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(url);
+ if (isLoopback) return null;
+ return (
+ "Страница загружена по https — браузер заблокирует ws:// к не-loopback " +
+ "адресу. Используй ws://localhost (плагин на этом же ПК или SSH-туннель) " +
+ "либо wss:// через свой reverse-proxy."
+ );
+}
+
+function teardownWatch(): void {
+ if (stopWatch) {
+ stopWatch();
+ stopWatch = null;
+ }
+ clearPushTimer();
+}
+
+/** Open the live-sync connection and start pushing on every change. */
+export function connectSync(url: string = syncUrl.value): void {
+ disconnectSync();
+ syncUrl.value = url;
+ syncError.value = null;
+
+ const blocked = syncUrlBlockReason(url);
+ if (blocked) {
+ syncStatus.value = "error";
+ syncError.value = blocked;
+ return;
+ }
+
+ syncStatus.value = "connecting";
+ try {
+ ws = new WebSocket(url);
+ } catch (exc) {
+ syncStatus.value = "error";
+ syncError.value = String((exc as Error)?.message ?? exc);
+ ws = null;
+ return;
+ }
+
+ ws.onopen = () => {
+ syncStatus.value = "live";
+ pushCount.value = 0;
+ // Make sure the change signal we subscribe to is actually being produced.
+ initializeHistoryAutoTracking();
+ // Initial full sync, then push on every committed (debounced, pause-aware)
+ // history entry — captures edits, undo and redo of the live state.
+ pushSyncNow();
+ stopWatch = watch(
+ () => history.snapshots[history.historyIndex],
+ () => schedulePush(),
+ );
+ };
+ // The plugin echoes the session key back on every applied message — treat it
+ // purely as an ack heartbeat.
+ ws.onmessage = () => {
+ lastAckAt.value = Date.now();
+ };
+ ws.onerror = () => {
+ syncStatus.value = "error";
+ syncError.value = "Ошибка WebSocket-соединения";
+ };
+ ws.onclose = () => {
+ teardownWatch();
+ if (syncStatus.value !== "error") syncStatus.value = "off";
+ ws = null;
+ };
+}
+
+/** Close the live-sync connection and stop pushing. */
+export function disconnectSync(): void {
+ teardownWatch();
+ if (ws) {
+ try {
+ ws.close();
+ } catch {
+ /* ignore */
+ }
+ ws = null;
+ }
+ if (syncStatus.value !== "error") syncStatus.value = "off";
+}
diff --git a/tests/sync.payload.test.ts b/tests/sync.payload.test.ts
new file mode 100644
index 0000000..69cc56d
--- /dev/null
+++ b/tests/sync.payload.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { resetRegistry } from "./helpers";
+import {
+ deserializeColor,
+ PluginParseError,
+} from "./contract/pluginDeserializers";
+import { componentTree } from "@/utils/manager/WorkspaceManager";
+import { setInvisibleIDs } from "@/utils/manager/ComponentManager";
+import { settings } from "@/utils/manager/SettingsManager";
+import { Rect } from "@/utils/components/Rect";
+import { GroupComponent } from "@/utils/components/GroupComponent";
+import {
+ buildSyncPayload,
+ buildSyncCommand,
+ syncLayoutName,
+ syncUrlBlockReason,
+} from "@/utils/manager/SyncManager";
+import { BK } from "@/utils/handler/ProjectSerializationHandler";
+
+/**
+ * Guards the live-sync wire contract: buildSyncPayload() must produce exactly
+ * what the plugin's LayoutManager.syncFromJson expects —
+ * { name, componentTree: , invisible[] }
+ * with the finalized tree being parseable by the plugin's deserializers.
+ */
+describe("live-sync payload contract", () => {
+ beforeEach(() => {
+ resetRegistry();
+ settings.projectName = "My GUI";
+ });
+
+ function seedLayout() {
+ const child = new Rect(
+ "box1",
+ "Box",
+ [],
+ 10,
+ 20,
+ 80,
+ 40,
+ "rgba(255,136,0,1)",
+ 4,
+ );
+ const group = new GroupComponent("grp1", "Group", [], [new Rect(
+ "box2",
+ "Box2",
+ [],
+ 0,
+ 0,
+ 10,
+ 10,
+ "rgba(1,2,3,1)",
+ 0,
+ )], true);
+ componentTree.value = [child, group];
+ }
+
+ it("uses the normalized project name as the layout name", () => {
+ settings.projectName = " Spaced Name ";
+ expect(syncLayoutName()).toBe(buildSyncPayload().name);
+ expect(buildSyncPayload().name.length).toBeGreaterThan(0);
+ });
+
+ it("emits componentTree as a stringified finalized GroupComponent", () => {
+ seedLayout();
+ const payload = buildSyncPayload();
+
+ // Double-encoding: componentTree is a STRING (plugin does .asText() then re-parses).
+ expect(typeof payload.componentTree).toBe("string");
+
+ const tree = JSON.parse(payload.componentTree);
+ expect(tree.type).toBe("Group");
+ expect(Array.isArray(tree.components)).toBe(true);
+ // The synthetic root carries both top-level components, nested group preserved.
+ expect(tree.components).toHaveLength(2);
+ const nested = tree.components.find(
+ (c: { type: string }) => c.type === "Group",
+ );
+ expect(nested.components).toHaveLength(1);
+ });
+
+ it("produces colors the plugin ColorDeserializer accepts", () => {
+ seedLayout();
+ const tree = JSON.parse(buildSyncPayload().componentTree);
+ const rect = tree.components.find(
+ (c: { type: string }) => c.type === "Rect",
+ );
+ expect(() => deserializeColor(rect.color)).not.toThrow(PluginParseError);
+ expect(deserializeColor(rect.color)).toEqual({ r: 255, g: 136, b: 0, a: 255 });
+ });
+
+ it("passes invisible ids through as an independent copy", () => {
+ seedLayout();
+ setInvisibleIDs(["box1", "box2"]);
+ const payload = buildSyncPayload();
+ expect(payload.invisible).toEqual(["box1", "box2"]);
+ // Mutating the payload must not touch the live invisible list.
+ payload.invisible.push("mutated");
+ expect(buildSyncPayload().invisible).toEqual(["box1", "box2"]);
+ });
+
+ it("whole wire frame double-encodes componentTree once serialized", () => {
+ seedLayout();
+ const frame = JSON.stringify(buildSyncPayload());
+ const parsed = JSON.parse(frame);
+ expect(typeof parsed.componentTree).toBe("string"); // still a string after one parse
+ expect(() => JSON.parse(parsed.componentTree)).not.toThrow(); // second parse = the tree
+ });
+
+ it("builds the /ag sync command with the persistent browser key", () => {
+ settings.projectName = "Arena";
+ expect(buildSyncCommand()).toBe(`/ag sync ${BK} Arena`);
+ });
+});
+
+describe("live-sync mixed-content guard", () => {
+ const realProto = Object.getOwnPropertyDescriptor(location, "protocol");
+
+ afterEach(() => {
+ if (realProto) Object.defineProperty(location, "protocol", realProto);
+ vi.unstubAllGlobals();
+ });
+
+ function setProtocol(proto: string) {
+ Object.defineProperty(location, "protocol", {
+ value: proto,
+ configurable: true,
+ });
+ }
+
+ it("allows any target when the page is http", () => {
+ setProtocol("http:");
+ expect(syncUrlBlockReason("ws://192.168.1.50:27757")).toBeNull();
+ expect(syncUrlBlockReason("ws://localhost:27757")).toBeNull();
+ });
+
+ it("from https: blocks ws:// to non-loopback, allows loopback + wss", () => {
+ setProtocol("https:");
+ expect(syncUrlBlockReason("ws://192.168.1.50:27757")).not.toBeNull();
+ expect(syncUrlBlockReason("ws://example.com:27757")).not.toBeNull();
+ expect(syncUrlBlockReason("ws://localhost:27757")).toBeNull();
+ expect(syncUrlBlockReason("ws://127.0.0.1:27757")).toBeNull();
+ expect(syncUrlBlockReason("wss://sync.example.com")).toBeNull();
+ });
+});