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 @@ + + + + + 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(); + }); +});