diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml new file mode 100644 index 0000000..a095367 --- /dev/null +++ b/.github/workflows/test-build.yml @@ -0,0 +1,91 @@ +# Manually builds a downloadable Windows test package without creating a tag, +# GitHub Release or updater signature. Intended for verifying feature branches +# and unreleased changes before the real signed release pipeline is used. +name: Test Build + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: test-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Realtime collaboration selftest + run: bun run selftest:live + + - name: Project schema selftest + run: bun run selftest:project + + - name: Publish sidecar + shell: pwsh + run: ./sidecar/publish.ps1 + + - name: Build unsigned test installers + shell: pwsh + run: | + # A test build does not feed the public auto-updater, so updater + # artifacts/signatures are intentionally disabled for this run. + $testConfig = Join-Path $env:RUNNER_TEMP "tauri.test-build.json" + '{"bundle":{"createUpdaterArtifacts":false}}' | + Set-Content -LiteralPath $testConfig -Encoding utf8 + bun run tauri build --ci --no-sign --config $testConfig + + - name: Assemble portable test ZIP + shell: pwsh + run: | + $releaseDir = "src-tauri/target/release" + $portableDir = "dist-test/atelier by feelgood" + New-Item -ItemType Directory -Force $portableDir | Out-Null + $main = @("$releaseDir/atelier by feelgood.exe", "$releaseDir/atelier.exe") | + Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $main) { throw "Main executable not found under $releaseDir" } + Copy-Item $main "$portableDir/atelier by feelgood.exe" + Copy-Item ` + "src-tauri/binaries/fg-atelier-sidecar-x86_64-pc-windows-msvc.exe" ` + "$portableDir/fg-atelier-sidecar.exe" + if (Test-Path "$releaseDir/WebView2Loader.dll") { + Copy-Item "$releaseDir/WebView2Loader.dll" $portableDir + } + @( + "atelier by feelgood – unsigned test build", + "", + "Start 'atelier by feelgood.exe'. Keep fg-atelier-sidecar.exe in the same folder.", + "This package is for testing only and does not use the automatic updater." + ) -join "`r`n" | Set-Content "$portableDir/README.txt" -Encoding utf8 + Compress-Archive -Path $portableDir -DestinationPath "atelier-test-portable.zip" -Force + + - name: Upload test packages + uses: actions/upload-artifact@v4 + with: + name: atelier-windows-test-${{ github.run_number }} + if-no-files-found: error + retention-days: 14 + compression-level: 0 + path: | + src-tauri/target/release/bundle/nsis/*-setup.exe + src-tauri/target/release/bundle/msi/*.msi + atelier-test-portable.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 7baf5c1..3d8861e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to **atelier** are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **Realtime team workspace.** Linked projects now publish stable-id, + field-level operations automatically and apply accepted WebSocket operations + immediately on every connected client. Adds full project coverage (groups, + drawables, tattoos, ordering, deletes and asset changes), crash-safe local + operation queuing, reconnect catch-up and exact content-addressed binary + transfer. `pack.atelier` format v3 stores the live workspace cursor. + +### Fixed + +- Transient workspace contention, rate limits and missing CAS uploads now keep + their durable operation queued instead of discarding a valid local change. +- Startup/reconnect broadcasts can no longer fall into the gap before the + first HTTP snapshot, and edits made while remote assets download are + re-overlaid before the project store is replaced. +- Full authoritative sync verifies local asset size and SHA-256, preventing an + externally changed or partially optimized texture from masquerading as the + cloud version. + ## [1.10.0] — 2026-07-21 ### Added diff --git a/README.md b/README.md index 4bc2653..fd0047c 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,12 @@ optionally collaborate as a team over a cloud. your server and let you browse it on a ped — with the labels and groups you gave your items, instead of bare index numbers. Off by default; without the tick the build output is unchanged and the pack stays invisible to the viewer. -- **Team cloud** _(optional)_ — push/pull against versioned pack revisions, live - presence and advisory locks (via [atelier-api](https://github.com/feelgoodrp-com/atelier-api)). +- **Team cloud** _(optional)_ — Google-Docs-style live project operations: + names, settings, groups, clothing, tattoos, ordering, deletes and optimized + asset hashes appear automatically on every connected client. Binary assets + are uploaded content-addressed before the operation is published; reconnects + recover from the durable server workspace and local operation queue (via + [atelier-api](https://github.com/feelgoodrp-com/atelier-api)). - **Import wizard** — existing packs as well as `.ydd`/`.ytd`/`.yld` via drag & drop, with automatic classification. @@ -91,6 +95,7 @@ Useful scripts: ```powershell bun run build # frontend typecheck + Vite build bun run selftest:project # project-format / sync self-test +bun run selftest:live # realtime operation-diff self-test bun run sidecar:publish # builds the sidecar (src-tauri/binaries/…) bun run tauri:build # release bundle (installer + portable) ``` diff --git a/package.json b/package.json index f22ccbf..49bd421 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "atelier", "private": true, - "version": "1.10.0", + "version": "1.11.0", "type": "module", "scripts": { "dev": "vite", @@ -12,6 +12,7 @@ "tauri:build": "bun run sidecar:publish && tauri build", "sidecar:publish": "powershell -NoProfile -ExecutionPolicy Bypass -File sidecar/publish.ps1", "selftest:project": "bun run src/lib/project/__selftest__.ts", + "selftest:live": "bun run src/lib/sync/__live-selftest__.ts", "selftest:menyoo": "bun run src/lib/preview/__selftest__.ts", "selftest:logs": "bun run src/lib/log-humanize.selftest.ts" }, diff --git a/sidecar/Api/BuildEndpoints.cs b/sidecar/Api/BuildEndpoints.cs index 3658c1c..63c1471 100644 --- a/sidecar/Api/BuildEndpoints.cs +++ b/sidecar/Api/BuildEndpoints.cs @@ -455,9 +455,9 @@ private static void CollectEntries(RpfFile rpf, List entries) return Results.BadRequest(new ErrorResponse($"Projektordner nicht gefunden: {projectDir}")); if (project == null) return Results.BadRequest(new ErrorResponse("Feld 'project' fehlt.")); - if (project.Fgcloth is not (1 or 2)) + if (project.Fgcloth is not (1 or 2 or 3)) return Results.BadRequest(new ErrorResponse( - $"Nicht unterstützte Projektversion (fgcloth={project.Fgcloth}, erwartet 1 oder 2).")); + $"Nicht unterstützte Projektversion (fgcloth={project.Fgcloth}, erwartet 1, 2 oder 3).")); return null; } } diff --git a/sidecar/Engine/Build/ProjectModel.cs b/sidecar/Engine/Build/ProjectModel.cs index 910cb2b..f6615f3 100644 --- a/sidecar/Engine/Build/ProjectModel.cs +++ b/sidecar/Engine/Build/ProjectModel.cs @@ -1,7 +1,7 @@ namespace Feelgood.Atelier.Sidecar.Engine.Build; /// -/// C# mirror of the `pack.atelier` project file (fgcloth v1, see +/// C# mirror of the `pack.atelier` project file (fgcloth v1-v3, see /// atelier/src/lib/project/schema.ts). Only build-relevant fields are mapped; /// unknown JSON properties are ignored by the (case-insensitive) binder. /// diff --git a/sidecar/README.md b/sidecar/README.md index 4b46511..e4ac555 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -151,7 +151,7 @@ Body: ```json { - "projectDir": "C:\\…", "project": { "fgcloth": 1, … }, + "projectDir": "C:\\…", "project": { "fgcloth": 3, … }, "target": "fivem" | "singleplayer" | "ragemp" | "altv", "outDir": "C:\\out", "options": { "dlcName": "mypack", "resourceName": null, "generateShopMeta": true, "splitAt": 128 } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 586cc2d..fe8d2bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "atelier" -version = "1.10.0" +version = "1.11.0" dependencies = [ "keyring", "rand", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ab2f16..ecd5d46 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atelier" -version = "1.10.0" +version = "1.11.0" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a5be01a..0c40299 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "atelier by feelgood", - "version": "1.10.0", + "version": "1.11.0", "identifier": "com.feelgood.atelier", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/App.tsx b/src/App.tsx index 0f6a82d..9daab48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { getLogConsoleEnabled, getOnboardingDone } from "@/lib/settings"; import { openLogWindow, useLogConsoleStore } from "@/lib/stores/log-console-store"; import { usePresenceHeartbeat } from "@/lib/sync/presence"; import { useCollab } from "@/lib/sync/collab"; +import { useLiveWorkspace } from "@/lib/sync/live"; import { startAutosave } from "@/lib/project/autosave"; import { useUpdateStore } from "@/lib/stores/update-store"; import { loadPreferences, usePreferencesStore } from "@/lib/stores/preferences-store"; @@ -52,8 +53,9 @@ function App() { // "Wer ist online?" heartbeat (active only when logged in + approved). usePresenceHeartbeat(); - // Pack-room WebSocket + advisory edit locks (cloud-linked projects only). + // Pack-room WebSocket + enforced edit locks (cloud-linked projects only). useCollab(); + useLiveWorkspace(); // Restore settings + silent login on startup. useEffect(() => { diff --git a/src/components/workbench/cloud-section.tsx b/src/components/workbench/cloud-section.tsx index 4820f94..2841012 100644 --- a/src/components/workbench/cloud-section.tsx +++ b/src/components/workbench/cloud-section.tsx @@ -42,6 +42,7 @@ import { linkProject } from "@/lib/sync/pack-sync"; import type { SyncPhase } from "@/lib/sync/pack-sync"; import { useAuthStore, useCloudEnabled } from "@/lib/stores/auth-store"; import { useCollabStore } from "@/lib/stores/collab-store"; +import { useLiveStore } from "@/lib/stores/live-store"; import { useProjectStore } from "@/lib/stores/project-store"; import { useSyncStore } from "@/lib/stores/sync-store"; @@ -49,18 +50,6 @@ function errorMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } -/** True when local state has edits the cloud has not seen yet (reactive). */ -function useUnsyncedChanges(): boolean { - const dirty = useProjectStore((s) => s.dirty); - const updatedAt = useProjectStore((s) => s.project?.updatedAt ?? null); - const lastSyncedAt = useProjectStore( - (s) => s.project?.sync.lastSyncedAt ?? null, - ); - if (dirty) return true; - if (!lastSyncedAt) return true; - return updatedAt !== null && Date.parse(updatedAt) > Date.parse(lastSyncedAt); -} - // --------------------------------------------------------------------------- // Roster (avatar stack) // --------------------------------------------------------------------------- @@ -150,7 +139,7 @@ function CloudLinkDialog() { const pack = await createPack(trimmed); await linkProject(pack.packId); toast.success(t("cloud.linkedToast", { name: pack.name }), { - description: t("cloud.linkedDescriptionUpload"), + description: t("cloud.linkedDescriptionLive"), }); setOpen(false); } catch (e) { @@ -168,12 +157,7 @@ function CloudLinkDialog() { try { await linkProject(pack.packId); toast.success(t("cloud.linkedToast", { name: pack.name }), { - description: - pack.headRevision > 0 - ? t("cloud.linkedDescriptionPull", { - revision: pack.headRevision, - }) - : t("cloud.linkedDescriptionUpload"), + description: t("cloud.linkedDescriptionLive"), }); setOpen(false); } catch (e) { @@ -283,10 +267,13 @@ const PULL_PHASES: Array<{ id: SyncPhase; labelKey: string }> = [ function SyncProgressDialog() { const { t } = useTranslation("workbench"); const busy = useSyncStore((s) => s.busy); - const progress = useSyncStore((s) => s.progress); - if (!busy) return null; + const syncProgress = useSyncStore((s) => s.progress); + const liveTransfer = useLiveStore((s) => s.transfer); + const operation = liveTransfer?.direction ?? busy; + const progress = liveTransfer?.progress ?? syncProgress; + if (!operation) return null; - const phases = busy === "push" ? PUSH_PHASES : PULL_PHASES; + const phases = operation === "push" ? PUSH_PHASES : PULL_PHASES; const activeIndex = progress ? phases.findIndex((p) => p.id === progress.phase) : 0; @@ -303,7 +290,7 @@ function SyncProgressDialog() { > - {busy === "push" + {operation === "push" ? t("cloud.uploading") : t("cloud.loadingLatest")} @@ -507,98 +494,51 @@ function ServerBuildAction() { function LinkedControls() { const { t } = useTranslation("workbench"); - const baseRevision = useProjectStore( - (s) => s.project?.sync.baseRevision ?? null, - ); - const lastSyncedAt = useProjectStore( - (s) => s.project?.sync.lastSyncedAt ?? null, - ); - const collabStatus = useCollabStore((s) => s.status); - const busy = useSyncStore((s) => s.busy); - const push = useSyncStore((s) => s.push); - const pull = useSyncStore((s) => s.pull); - const unsynced = useUnsyncedChanges(); + const status = useLiveStore((state) => state.status); + const version = useLiveStore((state) => state.version); + const pending = useLiveStore((state) => state.pending); + const error = useLiveStore((state) => state.error); + + const online = status === "online"; + const active = status === "connecting" || status === "syncing"; + const label = + status === "error" + ? t("cloud.liveError") + : pending > 0 + ? t("cloud.liveSaving", { count: pending }) + : online + ? t("cloud.live") + : t("cloud.connecting"); return ( <>
- - - {t("cloud.rev", { revision: baseRevision ?? 0 })} - -
-
- - {collabStatus === "online" - ? t("cloud.connected") - : collabStatus === "connecting" - ? t("cloud.connecting") - : t("cloud.offline")} - {" · "} - {lastSyncedAt - ? t("cloud.lastSynced", { - time: formatRelativeTime(lastSyncedAt), - }) - : t("cloud.neverSynced")} - -
- - - - + - {t("cloud.uploadTooltip")} + {error ?? + (pending > 0 + ? t("cloud.livePendingTooltip", { count: pending }) + : t("cloud.liveTooltip"))} - - - - - {t("cloud.loadLatest")} - - ); diff --git a/src/components/workbench/drawable-list.tsx b/src/components/workbench/drawable-list.tsx index c218018..d35e67e 100644 --- a/src/components/workbench/drawable-list.tsx +++ b/src/components/workbench/drawable-list.tsx @@ -100,7 +100,7 @@ interface RowProps { selected: boolean; /** True when the open 3D preview currently renders this drawable. */ previewed: boolean; - /** Username when someone ELSE holds the advisory edit lock. */ + /** Username when someone ELSE holds the server-enforced edit lock. */ lockedBy: string | null; canReorder: boolean; /** Absolute position (windowed mode) or undefined (normal flow). */ diff --git a/src/components/workbench/inspector.tsx b/src/components/workbench/inspector.tsx index 08f61fd..a70dda6 100644 --- a/src/components/workbench/inspector.tsx +++ b/src/components/workbench/inspector.tsx @@ -270,7 +270,8 @@ function SingleInspector({ drawable }: { drawable: ProjectDrawable }) { // canonical file names shown in the Dateien section. const derivedId = project ? (selectDerivedDrawableIds(project)[drawable.id] ?? 0) : 0; - // Advisory lock hint — editing stays possible, this is information only. + // The server enforces foreign locks; disable this inspector preemptively so + // the user does not see an optimistic edit that will immediately be reverted. const lock = useCollabStore((s) => s.locks[drawable.id]); const selfDiscordId = useAuthStore((s) => s.user?.discordId); const foreignLock = @@ -325,6 +326,7 @@ function SingleInspector({ drawable }: { drawable: ProjectDrawable }) { )} +
{/* Label */}
{t("inspector.label")} @@ -592,6 +594,7 @@ function SingleInspector({ drawable }: { drawable: ProjectDrawable }) { +
).workspaceVersion; const v1settings = v1doc.settings as { dlcName: string }; const lifted = migrateProjectFile(v1doc) as { fgcloth: number; tattoos: unknown[]; tattooCollection: { name: string; label: string }; + sync: { workspaceVersion: number | null }; }; -checkEq("v1→v2 bumps version + adds empty tattoos", [lifted.fgcloth, lifted.tattoos], [2, []]); +checkEq("v1→v3 chains both migrations + adds empty tattoos", [lifted.fgcloth, lifted.tattoos], [3, []]); checkEq( "v1→v2 derives the collection name from dlcName", lifted.tattooCollection.name, v1settings.dlcName, ); check( - "lifted v1 project validates against the v2 schema", + "lifted v1 project validates against the v3 schema", atelierProjectSchema.safeParse(lifted).success, ); +checkEq( + "v1→v3 adds the live workspace cursor", + lifted.sync.workspaceVersion, + null, +); let migrationThrew = false; try { @@ -415,6 +422,7 @@ clearProjectHistory(); const pulledSync = { remoteProjectId: "pack-1", baseRevision: 3, + workspaceVersion: null, lastSyncedAt: new Date().toISOString(), }; useProjectStore.getState().applyPulledState([roundtripped], pulledSync); diff --git a/src/lib/project/migrations.ts b/src/lib/project/migrations.ts index 8a75ccd..63875a4 100644 --- a/src/lib/project/migrations.ts +++ b/src/lib/project/migrations.ts @@ -5,6 +5,7 @@ * Lifts chain (v1 → v2 → …) until {@link PROJECT_FILE_VERSION} is reached; zod * validation runs AFTER migration (in lib/project/io.ts). * v1 → v2: adds the tattoo model (tattooCollection + empty tattoos[]). + * v2 → v3: adds sync.workspaceVersion for the realtime collaboration head. */ import i18n from "@/lib/i18n"; @@ -39,6 +40,18 @@ function migrateV1ToV2(raw: Record): Record { }; } +function migrateV2ToV3(raw: Record): Record { + const sync = + typeof raw.sync === "object" && raw.sync !== null && !Array.isArray(raw.sync) + ? (raw.sync as Record) + : {}; + return { + ...raw, + fgcloth: 3, + sync: { ...sync, workspaceVersion: null }, + }; +} + /** * Takes the raw JSON.parse result of a pack.atelier file and returns an object * shaped like the current {@link PROJECT_FILE_VERSION}. Zod validation happens @@ -57,6 +70,11 @@ export function migrateProjectFile(raw: unknown): unknown { version = doc.fgcloth; } + if (version === 2) { + doc = migrateV2ToV3(doc); + version = doc.fgcloth; + } + if (version === PROJECT_FILE_VERSION) { return doc; } diff --git a/src/lib/project/schema.ts b/src/lib/project/schema.ts index 810c99e..38f0fda 100644 --- a/src/lib/project/schema.ts +++ b/src/lib/project/schema.ts @@ -1,5 +1,5 @@ /** - * Zod schema for the `pack.atelier` project file (fgcloth v1). + * Zod schema for the current `pack.atelier` project file. * * Shape is the shared contract between app, sidecar and atelier-api — keep in * sync with the Phase-1 contract documentation. Notably the in-game drawableId @@ -31,9 +31,9 @@ export type { TattooZoneId, } from "@/lib/gta/tattoos"; -// fgcloth v2 added the tattoo-authoring model (tattooCollection + tattoos[]); -// v1 projects are lifted in migrations.ts (additive: empty tattoos array). -export const PROJECT_FILE_VERSION = 2; +// fgcloth v2 added tattoos; v3 adds the durable live-workspace cursor used by +// realtime cloud collaboration. Both older versions are lifted in migrations. +export const PROJECT_FILE_VERSION = 3; // --------------------------------------------------------------------------- // Building blocks @@ -197,6 +197,7 @@ export type ProjectSettings = z.infer; export const projectSyncSchema = z.object({ remoteProjectId: z.string().nullable(), baseRevision: z.number().int().nullable(), + workspaceVersion: z.number().int().nonnegative().nullable(), lastSyncedAt: z.iso.datetime().nullable(), }); export type ProjectSync = z.infer; @@ -246,7 +247,12 @@ export function createEmptyProject(name: string): AtelierProject { drawables: [], tattooCollection: { name: dlcName, label: "Tattoos" }, tattoos: [], - sync: { remoteProjectId: null, baseRevision: null, lastSyncedAt: null }, + sync: { + remoteProjectId: null, + baseRevision: null, + workspaceVersion: null, + lastSyncedAt: null, + }, }; } diff --git a/src/lib/stores/collab-store.ts b/src/lib/stores/collab-store.ts index 07f8abf..6b3c767 100644 --- a/src/lib/stores/collab-store.ts +++ b/src/lib/stores/collab-store.ts @@ -1,6 +1,6 @@ /** * Live collaboration state of the joined pack room (fed by lib/sync/collab.ts): - * connection status, roster ("wer ist im Pack online?") and the advisory lock + * connection status, roster ("wer ist im Pack online?") and the edit-lock * map (drawableEntryId -> holder). Locks arrive via WebSocket broadcasts and * via the REST acquire responses; there is no initial lock list endpoint, so * the map fills up as events come in. diff --git a/src/lib/stores/live-store.ts b/src/lib/stores/live-store.ts new file mode 100644 index 0000000..7ba7dcf --- /dev/null +++ b/src/lib/stores/live-store.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; +import type { SyncProgress } from "@/lib/sync/pack-sync"; + +export type LiveStatus = "off" | "connecting" | "syncing" | "online" | "error"; + +export interface LiveTransfer { + direction: "push" | "pull"; + progress: SyncProgress; +} + +interface LiveState { + status: LiveStatus; + version: number | null; + pending: number; + error: string | null; + /** Initial workspace bootstrap/clone transfer shown in the cloud dialog. */ + transfer: LiveTransfer | null; + setStatus: (status: LiveStatus, error?: string | null) => void; + setVersion: (version: number | null) => void; + setPending: (pending: number) => void; + setTransfer: (transfer: LiveTransfer | null) => void; + reset: () => void; +} + +export const useLiveStore = create((set) => ({ + status: "off", + version: null, + pending: 0, + error: null, + transfer: null, + setStatus: (status, error = null) => set({ status, error }), + setVersion: (version) => set({ version }), + setPending: (pending) => set({ pending }), + setTransfer: (transfer) => set({ transfer }), + reset: () => + set({ status: "off", version: null, pending: 0, error: null, transfer: null }), +})); diff --git a/src/lib/stores/project-store.ts b/src/lib/stores/project-store.ts index 5e64ca1..561a63a 100644 --- a/src/lib/stores/project-store.ts +++ b/src/lib/stores/project-store.ts @@ -104,6 +104,12 @@ interface ProjectState { * step); clears the selection because old uuids may be gone. */ applyPulledState: (drawables: ProjectDrawable[], sync: ProjectSync) => void; + /** + * Replaces the complete materialized project after a confirmed live-cloud + * operation without recording that replacement as an undo step. The live + * bridge clears stale whole-project history immediately afterward. + */ + applyLiveProject: (project: AtelierProject) => void; /** * Updates the sync block WITHOUT recording an undo step (link/push * bookkeeping must not be undoable). Still marks the project dirty. @@ -437,6 +443,22 @@ export const useProjectStore = create()( : state, ), + applyLiveProject: (project) => { + const temporal = useProjectStore.temporal.getState(); + temporal.pause(); + try { + set((state) => ({ + project, + dirty: true, + selection: state.selection.filter((id) => + project.drawables.some((drawable) => drawable.id === id), + ), + })); + } finally { + temporal.resume(); + } + }, + setSyncState: (sync) => { const temporal = useProjectStore.temporal.getState(); temporal.pause(); diff --git a/src/lib/stores/sync-store.ts b/src/lib/stores/sync-store.ts index 4bbe55b..8c86650 100644 --- a/src/lib/stores/sync-store.ts +++ b/src/lib/stores/sync-store.ts @@ -17,6 +17,8 @@ import { pushProject, type SyncProgress, } from "@/lib/sync/pack-sync"; +import { refreshLiveWorkspace } from "@/lib/sync/live"; +import { useLiveStore } from "@/lib/stores/live-store"; import { useProjectStore } from "@/lib/stores/project-store"; export type SyncBusy = "push" | "pull" | null; @@ -122,12 +124,18 @@ export const useSyncStore = create((set, get) => ({ pull: async (opts) => { if (get().busy) return; - if (!opts?.force && hasUnsyncedLocalChanges()) { + const live = useLiveStore.getState().status !== "off"; + if (!live && !opts?.force && hasUnsyncedLocalChanges()) { set({ pullConfirmOpen: true }); return; } set({ busy: "pull", progress: null, pullConfirmOpen: false }); try { + if (live) { + const version = await refreshLiveWorkspace(); + toast.success(i18n.t("sync:pull.liveSuccess", { version })); + return; + } const result = await pullProject({ onProgress: (progress) => set({ progress }), }); diff --git a/src/lib/sync/__live-selftest__.ts b/src/lib/sync/__live-selftest__.ts new file mode 100644 index 0000000..ce1e79e --- /dev/null +++ b/src/lib/sync/__live-selftest__.ts @@ -0,0 +1,112 @@ +/** Pure operation-diff checks for the realtime client bridge. */ + +import { createDrawable, createEmptyProject } from "@/lib/project/schema"; +import { ApiError } from "./api-client"; +import { + diffWorkspaceProjects, + shouldRetryWorkspaceRequest, + toWorkspaceProject, +} from "./live"; + +let passed = 0; +const check = (name: string, condition: boolean) => { + if (!condition) throw new Error(`live client selftest failed: ${name}`); + passed++; + console.log(` ok ${name}`); +}; + +const before = createEmptyProject("Before"); +const after = structuredClone(before); +after.name = "After"; +after.settings.defaultGender = "female"; +after.drawables.push( + createDrawable({ + label: "Top", + gender: "male", + kind: "component", + type: "jbib", + }), +); + +const operation = diffWorkspaceProjects( + toWorkspaceProject(before), + toWorkspaceProject(after), +); +check("multi-field edit becomes a batch", operation?.kind === "batch"); +if (!operation || operation.kind !== "batch") throw new Error("expected batch"); + +const projectPatch = operation.operations.find((item) => item.kind === "project.patch"); +check( + "project settings use a nested field patch", + projectPatch?.kind === "project.patch" && + projectPatch.patch.name === "After" && + projectPatch.patch.settings?.defaultGender === "female" && + projectPatch.patch.settings.dlcName === undefined, +); +check( + "new drawable uses a stable-id upsert", + operation.operations.some( + (item) => + item.kind === "entity.upsert" && + item.entityType === "drawable" && + item.entity.id === after.drawables[0]!.id, + ), +); + +const reorderedBefore = toWorkspaceProject(after); +const reorderedAfter = structuredClone(reorderedBefore); +reorderedAfter.drawables.push( + toWorkspaceProject({ + ...after, + drawables: [ + createDrawable({ + label: "Second", + gender: "male", + kind: "component", + type: "jbib", + }), + ], + }).drawables[0]!, +); +reorderedAfter.drawables.reverse(); +const reorder = diffWorkspaceProjects(reorderedBefore, reorderedAfter); +const reorderLeaves = reorder?.kind === "batch" ? reorder.operations : reorder ? [reorder] : []; +check( + "drawable order is explicit", + reorderLeaves.some( + (item) => + item.kind === "order.set" && + item.ids.join(",") === reorderedAfter.drawables.map((drawable) => drawable.id).join(","), + ), +); + +const deleted = structuredClone(reorderedAfter); +const deletedId = deleted.drawables[0]!.id; +deleted.drawables.shift(); +const deletion = diffWorkspaceProjects(reorderedAfter, deleted); +const deletionLeaves = deletion?.kind === "batch" ? deletion.operations : deletion ? [deletion] : []; +check( + "deletion is transmitted explicitly", + deletionLeaves.some( + (item) => item.kind === "entity.delete" && item.id === deletedId, + ), +); + +check( + "temporary workspace contention keeps the durable operation queued", + shouldRetryWorkspaceRequest( + new ApiError("workspace_busy", 409, { error: "workspace_busy" }), + ), +); +check( + "a foreign lock is a final rejection for that optimistic operation", + !shouldRetryWorkspaceRequest(new ApiError("locked", 409, { error: "locked" })), +); +check( + "missing CAS assets are uploaded and retried instead of discarded", + shouldRetryWorkspaceRequest( + new ApiError("missing_assets", 400, { error: "missing_assets", missing: [] }), + ), +); + +console.log(`All ${passed} live client checks passed.`); diff --git a/src/lib/sync/api-client.ts b/src/lib/sync/api-client.ts index c4a487b..29762b2 100644 --- a/src/lib/sync/api-client.ts +++ b/src/lib/sync/api-client.ts @@ -48,6 +48,9 @@ import type { DrawableKind, DrawableMode, Gender, + ProjectGroup, + ProjectTattoo, + TattooCollection, } from "@/lib/project/schema"; export type UserStatus = "pending" | "approved" | "locked"; @@ -457,6 +460,59 @@ export interface RevisionDrawable { flags: DrawableFlags; } +export type WorkspaceEntityType = "group" | "drawable" | "tattoo"; + +export interface WorkspaceTattoo extends Omit { + image: RevisionAssetRef | null; +} + +export interface WorkspaceProject { + id: string; + name: string; + createdAt: string; + settings: { dlcName: string; defaultGender: Gender }; + groups: ProjectGroup[]; + drawables: RevisionDrawable[]; + tattooCollection: TattooCollection; + tattoos: WorkspaceTattoo[]; +} + +export type WorkspaceLeafOperation = + | { + kind: "project.patch"; + patch: { + name?: string; + settings?: Partial; + tattooCollection?: Partial; + }; + } + | { + kind: "entity.upsert"; + entityType: WorkspaceEntityType; + entity: ProjectGroup | RevisionDrawable | WorkspaceTattoo; + } + | { + kind: "entity.patch"; + entityType: WorkspaceEntityType; + id: string; + patch: Record; + } + | { kind: "entity.delete"; entityType: WorkspaceEntityType; id: string } + | { kind: "order.set"; entityType: "drawable" | "tattoo"; ids: string[] }; + +export type WorkspaceOperation = + | WorkspaceLeafOperation + | { kind: "batch"; operations: WorkspaceLeafOperation[] }; + +export interface LiveWorkspace { + packId: string; + schemaVersion: 1; + version: number; + project: WorkspaceProject; + updatedAt: string; + updatedByDiscordId: string; +} + export interface RemoteRevision { packId: string; revision: number; @@ -532,6 +588,39 @@ export async function postRevision( } } +// --------------------------------------------------------------------------- +// Realtime live workspace +// --------------------------------------------------------------------------- + +export async function getWorkspace(packId: string): Promise { + const res = await request<{ workspace: LiveWorkspace }>( + `/api/v1/packs/${encodeURIComponent(packId)}/workspace`, + ); + return res.workspace; +} + +export async function initializeWorkspace( + packId: string, + project: WorkspaceProject, + baseRevision: number, +): Promise { + const res = await request<{ workspace: LiveWorkspace }>( + `/api/v1/packs/${encodeURIComponent(packId)}/workspace/initialize`, + { method: "POST", body: JSON.stringify({ project, baseRevision }) }, + ); + return res.workspace; +} + +export async function postWorkspaceOperation( + packId: string, + args: { operationId: string; baseVersion: number; operation: WorkspaceOperation }, +): Promise<{ version: number; duplicate?: boolean; rebased?: boolean }> { + return request<{ version: number; duplicate?: boolean; rebased?: boolean }>( + `/api/v1/packs/${encodeURIComponent(packId)}/workspace/operations`, + { method: "POST", body: JSON.stringify(args) }, + ); +} + // --------------------------------------------------------------------------- // Server-side builds (atelier-api routes/builds.ts — publicBuild shape) // --------------------------------------------------------------------------- @@ -582,7 +671,7 @@ export async function getServerBuild(buildId: string): Promise { // CAS assets + resumable uploads // --------------------------------------------------------------------------- -export type UploadAssetKind = "ydd" | "ytd" | "yld" | "glb"; +export type UploadAssetKind = "ydd" | "ytd" | "yld" | "glb" | "blob"; export interface AssetCheckResult { missing: string[]; @@ -664,7 +753,7 @@ export function completeUpload(uploadId: string): Promise<{ ok: boolean; sha256: } // --------------------------------------------------------------------------- -// Drawable edit locks (advisory, 90s TTL, heartbeat extends) +// Entity edit locks (server-enforced, 90s TTL, heartbeat extends) // --------------------------------------------------------------------------- export interface PackLock { diff --git a/src/lib/sync/clone.ts b/src/lib/sync/clone.ts index 25b2fbd..7ce26aa 100644 --- a/src/lib/sync/clone.ts +++ b/src/lib/sync/clone.ts @@ -3,18 +3,19 @@ * * Flow: pick a collision-free subfolder under the chosen parent -> create an * empty local project there (createAndOpenProject opens it + records recents + - * switches to the workbench) -> link it to the pack -> pull the head revision - * (skipped for empty packs at headRevision 0, which have no manifest yet). + * switches to the workbench) -> link it to the pack -> materialize the + * authoritative live workspace, including every referenced binary asset. * - * A failing pull leaves the (already opened + linked) project in place so the - * user can retry "Neueste Version laden" — the local project is never deleted. + * A failing live bootstrap leaves the (already opened + linked) project in + * place so the user can retry — the local project is never deleted. * All thrown errors carry German user-facing messages. */ import { exists } from "@tauri-apps/plugin-fs"; import { joinPath, sanitizeFolderName } from "@/lib/project/io"; import { createAndOpenProject } from "@/lib/project/session"; -import { linkProject, pullProject, type ProgressFn } from "@/lib/sync/pack-sync"; +import { linkProject, type ProgressFn } from "@/lib/sync/pack-sync"; +import { connectLiveWorkspace } from "@/lib/sync/live"; import type { Pack } from "@/lib/sync/api-client"; /** Picks `/`, appending _1, _2, … until the folder is free. */ @@ -28,13 +29,13 @@ async function resolveCloneDir(parentDir: string, name: string): Promise } /** - * Clones `pack` into a new subfolder of `parentDir`, opens it and pulls the - * head revision. Returns the absolute project directory of the clone. + * Clones `pack` into a new subfolder of `parentDir`, opens it and loads the + * current live workspace. Returns the absolute project directory of the clone. */ export async function clonePackToLocal( pack: Pack, parentDir: string, - onProgress?: ProgressFn, + _onProgress?: ProgressFn, ): Promise { const targetDir = await resolveCloneDir(parentDir, pack.name); @@ -46,11 +47,9 @@ export async function clonePackToLocal( // until the pull below sets it to the head revision). await linkProject(pack.packId); - // headRevision 0 = no revisions yet; the head manifest would 404, so we keep - // the freshly opened (empty) project as-is. - if (pack.headRevision > 0) { - await pullProject({ onProgress }); - } + // Wait for the authoritative workspace (or its one-time revision bootstrap) + // so "clone finished" means every referenced binary is actually local. + await connectLiveWorkspace(pack.packId); return targetDir; } diff --git a/src/lib/sync/collab.ts b/src/lib/sync/collab.ts index eb5a02e..01c41ad 100644 --- a/src/lib/sync/collab.ts +++ b/src/lib/sync/collab.ts @@ -7,9 +7,10 @@ * upgrade (stale JWT) -> refresh tokens before the next attempt. * - feeds collab-store: roster (joined/presence) + lock map (lock events). * - "head-changed" from someone else -> toast with a "Jetzt laden" action. - * - advisory locks follow the selection: selected drawables are locked via + * - edit locks follow the selection: selected drawables/tattoos are locked via * REST (POST), kept alive with a 30s heartbeat and released on deselect / - * project close. Editing is NEVER blocked — the locks are hints only. + * project close. The authoritative operation endpoint rejects foreign edits + * while an active lock exists, preventing silent same-object overwrites. */ import { useEffect } from "react"; @@ -27,7 +28,10 @@ import { import { useAuthStore, useCloudEnabled } from "@/lib/stores/auth-store"; import { useCollabStore, type CollabLock, type CollabUser } from "@/lib/stores/collab-store"; import { useProjectStore } from "@/lib/stores/project-store"; +import { useLiveStore } from "@/lib/stores/live-store"; import { useSyncStore } from "@/lib/stores/sync-store"; +import { handleLiveWorkspaceMessage } from "@/lib/sync/live"; +import { useTattooWorkbenchStore } from "@/lib/stores/tattoo-workbench-store"; const PING_INTERVAL_MS = 25_000; const LOCK_HEARTBEAT_MS = 30_000; @@ -76,7 +80,7 @@ function teardownSocket(): void { export function setCollabTarget(packId: string | null): void { if (packId === desiredPackId) return; - // Leaving a pack: free our advisory locks before switching context. + // Leaving a pack: free our edit locks before switching context. releaseAllHeldLocks(); desiredPackId = packId; reconnectAttempts = 0; @@ -181,6 +185,7 @@ function handleMessage(raw: string): void { /* best effort — broadcasts keep us converging */ }); requestLockSync(); + handleLiveWorkspaceMessage(msg); break; } case "presence": @@ -202,6 +207,10 @@ function handleMessage(raw: string): void { case "head-changed": onHeadChanged(msg); break; + case "workspace-changed": + case "workspace-reset": + handleLiveWorkspaceMessage(msg); + break; case "build-status": // Server-build progress of the pack room — only the build we requested // ourselves is tracked (sync-store ignores foreign buildIds). @@ -226,6 +235,9 @@ function handleMessage(raw: string): void { } function onHeadChanged(msg: Record): void { + // In live mode immutable revisions are checkpoints/build inputs, not the + // collaboration head. Pulling one here would overwrite newer live state. + if (useLiveStore.getState().status !== "off") return; const revision = typeof msg.revision === "number" ? msg.revision : null; if (revision === null) return; @@ -265,14 +277,20 @@ function toCollabLock(lock: PackLock): CollabLock { }; } -/** Selection ids that should be locked right now (existing drawables only). */ +/** Selection ids that should be locked right now (drawables and tattoos). */ function desiredLockIds(): string[] { if (!desiredPackId) return []; if (useCollabStore.getState().lockDenied) return []; // viewer role const { project, selection } = useProjectStore.getState(); if (!project || project.sync.remoteProjectId !== desiredPackId) return []; - const existing = new Set(project.drawables.map((d) => d.id)); - return selection.filter((id) => existing.has(id)).slice(0, MAX_HELD_LOCKS); + const tattooSelection = useTattooWorkbenchStore.getState().selection; + const existing = new Set([ + ...project.drawables.map((drawable) => drawable.id), + ...project.tattoos.map((tattoo) => tattoo.id), + ]); + return [...selection, ...tattooSelection] + .filter((id, index, all) => existing.has(id) && all.indexOf(id) === index) + .slice(0, MAX_HELD_LOCKS); } /** Coalescing trigger — repeated calls while running queue ONE extra pass. */ @@ -365,7 +383,7 @@ function releaseAllHeldLocks(): void { /** * Mount once (App): drives the WebSocket lifecycle from auth + project state - * and mirrors the drawable selection into advisory locks. + * and mirrors drawable/tattoo selection into edit locks. */ export function useCollab(): void { const cloudEnabled = useCloudEnabled(); @@ -375,6 +393,7 @@ export function useCollab(): void { (s) => s.project?.sync.remoteProjectId ?? null, ); const selection = useProjectStore((s) => s.selection); + const tattooSelection = useTattooWorkbenchStore((s) => s.selection); useEffect(() => { const target = @@ -386,5 +405,5 @@ export function useCollab(): void { useEffect(() => { requestLockSync(); - }, [selection, remoteProjectId]); + }, [selection, tattooSelection, remoteProjectId]); } diff --git a/src/lib/sync/live.ts b/src/lib/sync/live.ts new file mode 100644 index 0000000..5eed753 --- /dev/null +++ b/src/lib/sync/live.ts @@ -0,0 +1,1292 @@ +/** + * Realtime live-workspace bridge. + * + * Existing Zustand actions stay the single UI mutation API. This bridge + * observes project snapshots, derives stable-id/field-level operations, + * uploads referenced binary assets before publishing them, and applies the + * authoritative workspace after joins/reconnects. Accepted WebSocket + * operations are applied directly for low latency; authenticated HTTP + * snapshots recover any version gap. + */ + +import { useEffect } from "react"; +import { + exists, + mkdir, + readFile, + readTextFile, + remove, + rename, + writeFile, + writeTextFile, +} from "@tauri-apps/plugin-fs"; +import { toast } from "sonner"; +import { baseName } from "@/lib/format"; +import i18n from "@/lib/i18n"; +import { ASSETS_DIR_NAME, CACHE_DIR_NAME, joinPath, saveProject } from "@/lib/project/io"; +import { + PROJECT_FILE_VERSION, + atelierProjectSchema, + type AssetRef, + type AtelierProject, +} from "@/lib/project/schema"; +import { useAuthStore, useCloudEnabled } from "@/lib/stores/auth-store"; +import { useLiveStore } from "@/lib/stores/live-store"; +import { clearProjectHistory, useProjectStore } from "@/lib/stores/project-store"; +import { useTattooWorkbenchStore } from "@/lib/stores/tattoo-workbench-store"; +import { + ApiError, + checkAssets, + downloadAsset, + getPack, + getWorkspace, + initializeWorkspace, + postWorkspaceOperation, + type LiveWorkspace, + type RevisionAssetRef, + type WorkspaceLeafOperation, + type WorkspaceOperation, + type WorkspaceProject, + type WorkspaceTattoo, +} from "./api-client"; +import { pullProject, uploadLocalAsset, type ProgressFn } from "./pack-sync"; +import { + collectLocalAssets, + fromRevisionDrawable, + sanitizeExportName, + toRevisionDrawable, +} from "./revision-mapping"; + +const CHANGE_DEBOUNCE_MS = 120; +const RESYNC_DEBOUNCE_MS = 40; +const ASSET_CHECK_BATCH = 500; +const ASSET_UPLOAD_CONCURRENCY = 3; +const ASSET_DOWNLOAD_CONCURRENCY = 3; +const OPERATION_BATCH_SIZE = 500; +const LIVE_QUEUE_SCHEMA_VERSION = 1; +const OPERATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +interface PendingOperation { + packId: string; + sessionKey: string; + operationId: string; + operation: WorkspaceOperation; + localProject: AtelierProject; +} + +interface PersistedLiveQueue { + schemaVersion: typeof LIVE_QUEUE_SCHEMA_VERSION; + packId: string; + operations: PendingOperation[]; +} + +let targetPackId: string | null = null; +let targetSessionKey: string | null = null; +let targetProjectDir: string | null = null; +let suppressStoreEvents = false; +let observedProject: AtelierProject | null = null; +let mutationTimer: ReturnType | null = null; +let resyncTimer: ReturnType | null = null; +let projectSaveTimer: ReturnType | null = null; +let pendingBase: WorkspaceProject | null = null; +let pendingLatest: WorkspaceProject | null = null; +let pendingLocalProject: AtelierProject | null = null; +let sendChain: Promise = Promise.resolve(); +let pendingOperations: PendingOperation[] = []; +const knownServerAssets = new Set(); +let unsubscribeProject: (() => void) | null = null; +let bootstrapTask: Promise | null = null; +let queueWriteChain: Promise = Promise.resolve(); +let remoteApplyChain: Promise = Promise.resolve(); + +class LiveQueuePersistenceError extends Error { + constructor(cause: unknown) { + super(`Live-Warteschlange konnte nicht gespeichert werden: ${errorMessage(cause)}`); + this.name = "LiveQueuePersistenceError"; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function liveSessionKey(packId: string): string | null { + const { project, projectDir } = useProjectStore.getState(); + if (!project || !projectDir || project.sync.remoteProjectId !== packId) return null; + return `${projectDir}\u0000${project.id}\u0000${packId}`; +} + +function targetSessionIsCurrent(packId = targetPackId): boolean { + return ( + packId !== null && + packId === targetPackId && + targetSessionKey !== null && + liveSessionKey(packId) === targetSessionKey + ); +} + +function scheduleOpenProjectSave(): void { + if (projectSaveTimer) clearTimeout(projectSaveTimer); + projectSaveTimer = setTimeout(() => { + projectSaveTimer = null; + const { project, projectDir } = useProjectStore.getState(); + if (!project || !projectDir) return; + const snapshot = project; + void saveProject(projectDir, snapshot) + .then(() => { + const state = useProjectStore.getState(); + if (state.project !== snapshot) return; + state.markSaved(); + if (pendingOperations.length > 0 || pendingBase !== null) state.markDirty(); + }) + .catch((error) => { + useLiveStore.getState().setStatus("error", errorMessage(error)); + }); + }, 400); +} + +function equal(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function queueFilePath(projectDir: string, packId: string): string { + const safePackId = packId.replace(/[^a-z0-9-]/giu, "_"); + return joinPath(projectDir, CACHE_DIR_NAME, `live-queue-${safePackId}.json`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function containsUnsafeObjectKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsUnsafeObjectKey); + if (!isRecord(value)) return false; + return Object.entries(value).some( + ([key, nested]) => + key === "__proto__" || + key === "prototype" || + key === "constructor" || + containsUnsafeObjectKey(nested), + ); +} + +function isWorkspaceLeafOperation(value: unknown): value is WorkspaceLeafOperation { + if (!isRecord(value) || typeof value.kind !== "string" || containsUnsafeObjectKey(value)) { + return false; + } + if (value.kind === "project.patch") return isRecord(value.patch); + const entityType = value.entityType; + if (entityType !== "group" && entityType !== "drawable" && entityType !== "tattoo") { + return false; + } + if (value.kind === "entity.upsert") { + return isRecord(value.entity) && typeof value.entity.id === "string"; + } + if (value.kind === "entity.patch") { + return typeof value.id === "string" && isRecord(value.patch); + } + if (value.kind === "entity.delete") return typeof value.id === "string"; + return ( + value.kind === "order.set" && + entityType !== "group" && + Array.isArray(value.ids) && + value.ids.every((id) => typeof id === "string") + ); +} + +function isWorkspaceOperation(value: unknown): value is WorkspaceOperation { + if (isWorkspaceLeafOperation(value)) return true; + return ( + isRecord(value) && + value.kind === "batch" && + Array.isArray(value.operations) && + value.operations.length > 0 && + value.operations.length <= 1_000 && + value.operations.every(isWorkspaceLeafOperation) + ); +} + +async function restorePendingQueue(packId: string): Promise { + const projectDir = targetProjectDir; + const expectedSessionKey = targetSessionKey; + if (!projectDir) return; + let parsed: unknown; + try { + parsed = JSON.parse(await readTextFile(queueFilePath(projectDir, packId))); + } catch { + return; + } + if ( + typeof parsed !== "object" || + parsed === null || + (parsed as { schemaVersion?: unknown }).schemaVersion !== LIVE_QUEUE_SCHEMA_VERSION || + (parsed as { packId?: unknown }).packId !== packId || + !Array.isArray((parsed as { operations?: unknown }).operations) + ) { + return; + } + const restored: PendingOperation[] = []; + for (const item of (parsed as PersistedLiveQueue).operations) { + if ( + typeof item?.operationId !== "string" || + !OPERATION_ID_RE.test(item.operationId) || + item.packId !== packId || + !isWorkspaceOperation(item.operation) + ) { + continue; + } + const localProject = atelierProjectSchema.safeParse(item.localProject); + if (!localProject.success) continue; + restored.push({ + packId, + sessionKey: targetSessionKey ?? "", + operationId: item.operationId, + operation: item.operation, + localProject: localProject.data, + }); + } + if ( + targetPackId !== packId || + targetSessionKey !== expectedSessionKey || + !targetSessionIsCurrent(packId) + ) return; + pendingOperations = restored; + useLiveStore.getState().setPending(restored.length); +} + +function persistPendingQueue(packId = targetPackId): Promise { + const projectDir = targetProjectDir; + if (!projectDir || !packId) return Promise.resolve(); + const operations = structuredClone(pendingOperations); + const path = queueFilePath(projectDir, packId); + const write = queueWriteChain + .catch(() => {}) + .then(async () => { + try { + await mkdir(joinPath(projectDir, CACHE_DIR_NAME), { recursive: true }); + if (operations.length === 0) { + if (await exists(path)) await remove(path); + return; + } + const tmpPath = `${path}.part`; + const payload: PersistedLiveQueue = { + schemaVersion: LIVE_QUEUE_SCHEMA_VERSION, + packId, + operations, + }; + await writeTextFile(tmpPath, JSON.stringify(payload)); + try { + await rename(tmpPath, path); + } finally { + await remove(tmpPath).catch(() => {}); + } + } catch (error) { + throw new LiveQueuePersistenceError(error); + } + }); + // Keep the shared chain handled so fire-and-forget cursor/ack writes never + // create an unhandled rejection. Callers that need durability await `write`. + queueWriteChain = write.catch((error) => { + if (targetProjectDir === projectDir && targetPackId === packId) { + useLiveStore.getState().setStatus("error", errorMessage(error)); + } + }); + return write; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function toRemoteAsset(ref: AssetRef | null): RevisionAssetRef | null { + return ref ? { sha256: ref.hash, size: ref.size, exportName: baseName(ref.path) } : null; +} + +function toWorkspaceTattoo(tattoo: AtelierProject["tattoos"][number]): WorkspaceTattoo { + return { ...tattoo, image: toRemoteAsset(tattoo.image) }; +} + +export function toWorkspaceProject(project: AtelierProject): WorkspaceProject { + return { + id: project.id, + name: project.name, + createdAt: project.createdAt, + settings: { ...project.settings }, + groups: project.groups.map((group) => ({ ...group })), + drawables: project.drawables.map(toRevisionDrawable), + tattooCollection: { ...project.tattooCollection }, + tattoos: project.tattoos.map(toWorkspaceTattoo), + }; +} + +function mergeObject( + current: Record, + patch: Record, +): Record { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + next[key] !== null && + typeof next[key] === "object" && + !Array.isArray(next[key]) + ) { + next[key] = mergeObject( + next[key] as Record, + value as Record, + ); + } else { + next[key] = value; + } + } + return next; +} + +function applyLeaf(project: WorkspaceProject, operation: WorkspaceLeafOperation): WorkspaceProject { + const next = structuredClone(project); + if (operation.kind === "project.patch") { + if (operation.patch.name !== undefined) next.name = operation.patch.name; + if (operation.patch.settings !== undefined) { + next.settings = { ...next.settings, ...operation.patch.settings }; + } + if (operation.patch.tattooCollection !== undefined) { + next.tattooCollection = { + ...next.tattooCollection, + ...operation.patch.tattooCollection, + }; + } + return next; + } + + const key = + operation.entityType === "group" + ? "groups" + : operation.entityType === "drawable" + ? "drawables" + : "tattoos"; + const list = next[key] as Array<{ id: string }>; + if (operation.kind === "entity.delete") { + (next as unknown as Record)[key] = list.filter( + (item) => item.id !== operation.id, + ); + if (operation.entityType === "group") { + next.drawables = next.drawables.map((drawable) => + drawable.groupId === operation.id ? { ...drawable, groupId: null } : drawable, + ); + next.tattoos = next.tattoos.map((tattoo) => + tattoo.groupId === operation.id ? { ...tattoo, groupId: null } : tattoo, + ); + } + return next; + } + if (operation.kind === "entity.upsert") { + const entity = operation.entity as { id: string }; + const index = list.findIndex((item) => item.id === entity.id); + if (index === -1) list.push(entity); + else list[index] = entity; + return next; + } + if (operation.kind === "entity.patch") { + const index = list.findIndex((item) => item.id === operation.id); + if (index !== -1) { + list[index] = mergeObject( + list[index] as unknown as Record, + operation.patch, + ) as unknown as { id: string }; + list[index]!.id = operation.id; + } + return next; + } + + const byId = new Map(list.map((item) => [item.id, item])); + const used = new Set(); + const ordered: Array<{ id: string }> = []; + for (const id of operation.ids) { + const item = byId.get(id); + if (item && !used.has(id)) { + used.add(id); + ordered.push(item); + } + } + for (const item of list) if (!used.has(item.id)) ordered.push(item); + (next as unknown as Record)[key] = ordered; + return next; +} + +function applyOperation(project: WorkspaceProject, operation: WorkspaceOperation): WorkspaceProject { + const operations = operation.kind === "batch" ? operation.operations : [operation]; + return operations.reduce(applyLeaf, project); +} + +function objectPatch(before: Record, after: Record) { + const patch: Record = {}; + for (const [key, value] of Object.entries(after)) { + if (key === "id" || equal(before[key], value)) continue; + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + before[key] !== null && + typeof before[key] === "object" && + !Array.isArray(before[key]) + ) { + const nested = objectPatch( + before[key] as Record, + value as Record, + ); + if (Object.keys(nested).length > 0) patch[key] = nested; + } else { + patch[key] = value; + } + } + return patch; +} + +function diffEntities( + entityType: "group" | "drawable" | "tattoo", + before: Array<{ id: string }>, + after: Array<{ id: string }>, +): WorkspaceLeafOperation[] { + const operations: WorkspaceLeafOperation[] = []; + const beforeById = new Map(before.map((entity) => [entity.id, entity])); + const afterById = new Map(after.map((entity) => [entity.id, entity])); + for (const entity of before) { + if (!afterById.has(entity.id)) { + operations.push({ kind: "entity.delete", entityType, id: entity.id }); + } + } + for (const entity of after) { + const previous = beforeById.get(entity.id); + if (!previous) { + operations.push({ kind: "entity.upsert", entityType, entity } as WorkspaceLeafOperation); + continue; + } + const patch = objectPatch( + previous as unknown as Record, + entity as unknown as Record, + ); + if (Object.keys(patch).length > 0) { + operations.push({ kind: "entity.patch", entityType, id: entity.id, patch }); + } + } + if ( + entityType !== "group" && + !equal(before.map((entity) => entity.id), after.map((entity) => entity.id)) + ) { + operations.push({ + kind: "order.set", + entityType, + ids: after.map((entity) => entity.id), + }); + } + return operations; +} + +export function diffWorkspaceProjects( + before: WorkspaceProject, + after: WorkspaceProject, +): WorkspaceOperation | null { + const operations: WorkspaceLeafOperation[] = []; + const projectPatch: Extract["patch"] = {}; + if (before.name !== after.name) projectPatch.name = after.name; + if (!equal(before.settings, after.settings)) { + projectPatch.settings = objectPatch( + before.settings as unknown as Record, + after.settings as unknown as Record, + ); + } + if (!equal(before.tattooCollection, after.tattooCollection)) { + projectPatch.tattooCollection = objectPatch( + before.tattooCollection as unknown as Record, + after.tattooCollection as unknown as Record, + ); + } + if (Object.keys(projectPatch).length > 0) { + operations.push({ kind: "project.patch", patch: projectPatch }); + } + operations.push(...diffEntities("group", before.groups, after.groups)); + operations.push(...diffEntities("drawable", before.drawables, after.drawables)); + operations.push(...diffEntities("tattoo", before.tattoos, after.tattoos)); + if (operations.length === 0) return null; + return operations.length === 1 ? operations[0]! : { kind: "batch", operations }; +} + +function liveProgress(direction: "push" | "pull"): ProgressFn { + return (progress) => useLiveStore.getState().setTransfer({ direction, progress }); +} + +async function ensureProjectAssetsUploaded( + project: AtelierProject, + onProgress: ProgressFn = () => {}, +): Promise { + const { projectDir } = useProjectStore.getState(); + if (!projectDir) throw new Error("Kein Projektordner geöffnet."); + const assets = collectLocalAssets(project); + const hashes = [...assets.keys()].filter((hash) => !knownServerAssets.has(hash)); + const missing: string[] = []; + const checkTotal = Math.max(1, Math.ceil(hashes.length / ASSET_CHECK_BATCH)); + onProgress({ + phase: "check", + current: hashes.length === 0 ? 1 : 0, + total: checkTotal, + label: + hashes.length === 0 + ? i18n.t("sync:progress.allLocal") + : i18n.t("sync:progress.checking"), + }); + for (let index = 0; index < hashes.length; index += ASSET_CHECK_BATCH) { + const result = await checkAssets(hashes.slice(index, index + ASSET_CHECK_BATCH)); + missing.push(...result.missing); + result.present.forEach((hash) => knownServerAssets.add(hash)); + onProgress({ + phase: "check", + current: Math.floor(index / ASSET_CHECK_BATCH) + 1, + total: checkTotal, + label: i18n.t("sync:progress.checking"), + }); + } + let cursor = 0; + let completed = 0; + const uploadNext = async (): Promise => { + while (cursor < missing.length) { + const hash = missing[cursor++]!; + const asset = assets.get(hash); + if (asset) { + const label = baseName(asset.ref.path); + onProgress({ phase: "upload", current: completed, total: missing.length, label }); + await uploadLocalAsset(projectDir, asset); + knownServerAssets.add(hash); + completed += 1; + onProgress({ phase: "upload", current: completed, total: missing.length, label }); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(ASSET_UPLOAD_CONCURRENCY, missing.length) }, + () => uploadNext(), + ), + ); + if (missing.length === 0) { + onProgress({ + phase: "upload", + current: 1, + total: 1, + label: i18n.t("sync:progress.allLocal"), + }); + } +} + +async function localPathMap( + project: AtelierProject, + projectDir: string, + trustCurrentRefs: boolean, +): Promise> { + const map = new Map(); + const assets = [...collectLocalAssets(project)]; + let cursor = 0; + const verifyNext = async (): Promise => { + while (cursor < assets.length) { + const [sha, asset] = assets[cursor++]!; + if (trustCurrentRefs) { + map.set(sha, asset.ref.path); + continue; + } + const path = joinPath(projectDir, asset.ref.path); + if (!(await exists(path))) continue; + const bytes = await readFile(path); + if (bytes.byteLength === asset.ref.size && (await sha256Hex(bytes)) === sha) { + map.set(sha, asset.ref.path); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(ASSET_DOWNLOAD_CONCURRENCY, assets.length) }, + () => verifyNext(), + ), + ); + return map; +} + +function allRemoteAssets(project: WorkspaceProject): RevisionAssetRef[] { + const bySha = new Map(); + const add = (asset: RevisionAssetRef | null) => { + if (asset && !bySha.has(asset.sha256)) bySha.set(asset.sha256, asset); + }; + for (const drawable of project.drawables) { + add(drawable.ydd); + drawable.textures.forEach(add); + add(drawable.physics); + add(drawable.firstPerson); + } + for (const tattoo of project.tattoos) add(tattoo.image); + return [...bySha.values()]; +} + +async function ensureRemoteAssets( + cloud: WorkspaceProject, + current: AtelierProject, + projectDir: string, + trustCurrentRefs = false, + onProgress: ProgressFn = () => {}, +): Promise> { + const paths = await localPathMap(current, projectDir, trustCurrentRefs); + const missingAssets = allRemoteAssets(cloud).filter((asset) => !paths.has(asset.sha256)); + if (missingAssets.length === 0) { + onProgress({ + phase: "download", + current: 1, + total: 1, + label: i18n.t("sync:progress.allLocal"), + }); + return paths; + } + onProgress({ + phase: "download", + current: 0, + total: missingAssets.length, + label: i18n.t("sync:progress.downloadStarting"), + }); + const cloudDir = joinPath(projectDir, ASSETS_DIR_NAME, ".cloud"); + await mkdir(cloudDir, { recursive: true }); + let completed = 0; + const materialize = async (asset: RevisionAssetRef): Promise => { + const safeName = sanitizeExportName(asset.exportName, asset.sha256); + const relPath = `${ASSETS_DIR_NAME}/.cloud/${asset.sha256.slice(0, 16)}-${safeName}`; + const absPath = joinPath(projectDir, relPath); + if (await exists(absPath)) { + const bytes = await readFile(absPath); + if ((await sha256Hex(bytes)) === asset.sha256) { + paths.set(asset.sha256, relPath); + completed += 1; + onProgress({ + phase: "download", + current: completed, + total: missingAssets.length, + label: safeName, + }); + return; + } + } + onProgress({ + phase: "download", + current: completed, + total: missingAssets.length, + label: safeName, + }); + const bytes = await downloadAsset(asset.sha256); + if ((await sha256Hex(bytes)) !== asset.sha256) { + throw new Error(`Cloud-Datei ${safeName} ist beschädigt.`); + } + const tmpPath = `${absPath}.part-${crypto.randomUUID()}`; + await writeFile(tmpPath, bytes); + try { + await rename(tmpPath, absPath); + } finally { + await remove(tmpPath).catch(() => {}); + } + paths.set(asset.sha256, relPath); + completed += 1; + onProgress({ + phase: "download", + current: completed, + total: missingAssets.length, + label: safeName, + }); + }; + let cursor = 0; + const downloadNext = async (): Promise => { + while (cursor < missingAssets.length) { + await materialize(missingAssets[cursor++]!); + } + }; + await Promise.all( + Array.from( + { length: Math.min(ASSET_DOWNLOAD_CONCURRENCY, missingAssets.length) }, + () => downloadNext(), + ), + ); + return paths; +} + +async function materializeWorkspace( + workspace: LiveWorkspace, + overlayPending = true, + trustCurrentRefs = false, + onProgress: ProgressFn = () => {}, +): Promise { + const initialState = useProjectStore.getState(); + if (!initialState.project || !initialState.projectDir) { + throw new Error("Kein Projekt geöffnet."); + } + const projectDir = initialState.projectDir; + const withPendingOverlay = (): WorkspaceProject => { + let next = structuredClone(workspace.project); + if (!overlayPending) return next; + for (const pending of pendingOperations) next = applyOperation(next, pending.operation); + if (pendingBase && pendingLatest) { + const scheduled = diffWorkspaceProjects(pendingBase, pendingLatest); + if (scheduled) next = applyOperation(next, scheduled); + } + return next; + }; + + let current = initialState.project; + let cloud = withPendingOverlay(); + let paths: Map; + // Asset downloads can take long enough for another local edit to happen. + // Rebuild the optimistic overlay after every await and materialize any newly + // introduced binary before replacing the store, otherwise that edit could + // be overwritten by the older snapshot. + while (true) { + paths = await ensureRemoteAssets(cloud, current, projectDir, trustCurrentRefs, onProgress); + const latestState = useProjectStore.getState(); + if (!latestState.project) throw new Error("Kein Projekt geöffnet."); + current = latestState.project; + const latestCloud = withPendingOverlay(); + if (allRemoteAssets(latestCloud).every((asset) => paths.has(asset.sha256))) { + cloud = latestCloud; + break; + } + cloud = latestCloud; + } + const localGroups = new Set(cloud.groups.map((group) => group.id)); + const localRef = (asset: RevisionAssetRef | null): AssetRef | null => { + if (!asset) return null; + const path = paths.get(asset.sha256); + if (!path) throw new Error(`Cloud-Datei ${asset.exportName} wurde nicht materialisiert.`); + return { path, hash: asset.sha256, size: asset.size }; + }; + const now = new Date().toISOString(); + return { + fgcloth: PROJECT_FILE_VERSION, + id: cloud.id, + name: cloud.name, + createdAt: cloud.createdAt, + updatedAt: now, + settings: { ...cloud.settings }, + groups: cloud.groups.map((group) => ({ ...group })), + drawables: cloud.drawables.map((drawable) => + fromRevisionDrawable(drawable, paths, localGroups), + ), + tattooCollection: { ...cloud.tattooCollection }, + tattoos: cloud.tattoos.map((tattoo) => ({ + ...tattoo, + image: localRef(tattoo.image), + })), + sync: { + remoteProjectId: workspace.packId, + baseRevision: current.sync.baseRevision, + workspaceVersion: workspace.version, + lastSyncedAt: now, + }, + }; +} + +async function applyAuthoritativeWorkspace( + workspace: LiveWorkspace, + trustCurrentRefs = false, + onProgress: ProgressFn = () => {}, +): Promise { + if (workspace.packId !== targetPackId || !targetSessionIsCurrent(workspace.packId)) return; + const beforeVersion = useLiveStore.getState().version; + if (beforeVersion !== null && workspace.version < beforeVersion) return; + const local = await materializeWorkspace(workspace, true, trustCurrentRefs, onProgress); + if (workspace.packId !== targetPackId || !targetSessionIsCurrent(workspace.packId)) return; + const latestVersion = useLiveStore.getState().version; + if (latestVersion !== null && workspace.version < latestVersion) return; + suppressStoreEvents = true; + try { + useProjectStore.getState().applyLiveProject(local); + // zundo snapshots contain the whole project. Keeping a snapshot from + // before a teammate's operation would let a later local Undo overwrite + // that teammate's accepted change, so stale history must not survive an + // authoritative replacement. + clearProjectHistory(); + const tattooIds = new Set(local.tattoos.map((tattoo) => tattoo.id)); + const tattooWorkbench = useTattooWorkbenchStore.getState(); + const validTattooSelection = tattooWorkbench.selection.filter((id) => tattooIds.has(id)); + if (validTattooSelection.length !== tattooWorkbench.selection.length) { + tattooWorkbench.setSelection(validTattooSelection); + } + observedProject = local; + } finally { + suppressStoreEvents = false; + } + scheduleOpenProjectSave(); + allRemoteAssets(workspace.project).forEach((asset) => knownServerAssets.add(asset.sha256)); + useLiveStore.getState().setVersion(workspace.version); +} + +async function fetchAndApplyWorkspace(onProgress: ProgressFn = () => {}): Promise { + const packId = targetPackId; + if (!packId) return null; + const workspace = await getWorkspace(packId); + await applyAuthoritativeWorkspace(workspace, false, onProgress); + if (targetPackId === packId) useLiveStore.getState().setStatus("online"); + return workspace.version; +} + +function scheduleResync(): void { + if (!targetPackId || resyncTimer) return; + resyncTimer = setTimeout(() => { + resyncTimer = null; + const bootstrap = bootstrapTask; + void (async () => { + // Queue restoration is the first bootstrap step. A joined/broadcast + // resync must not overtake it and replace locally persisted optimistic + // edits before their durable operations have been overlaid. + if (bootstrap) await bootstrap.catch(() => {}); + await fetchAndApplyWorkspace(); + })().catch((error) => { + useLiveStore.getState().setStatus("error", errorMessage(error)); + }); + }, RESYNC_DEBOUNCE_MS); +} + +async function applyBroadcastOperation( + expectedPackId: string, + version: number, + operation: WorkspaceOperation, +): Promise { + const packId = targetPackId; + const currentVersion = useLiveStore.getState().version; + if (!packId || packId !== expectedPackId) return; + if (currentVersion === null) { + // A broadcast can race the first HTTP snapshot during startup. There is no + // safe base to apply it to yet, so fetch the now-committed authoritative + // version instead of silently dropping the only notification. + scheduleResync(); + return; + } + if (version <= currentVersion) return; + if (version !== currentVersion + 1) { + scheduleResync(); + return; + } + const current = useProjectStore.getState().project; + if (!current || current.sync.remoteProjectId !== packId) return; + const workspace: LiveWorkspace = { + packId, + schemaVersion: 1, + version, + project: applyOperation(toWorkspaceProject(current), operation), + updatedAt: new Date().toISOString(), + updatedByDiscordId: "", + }; + // Existing local refs are already hash-verified by the importer/optimizer. + // This lets metadata-only broadcasts update the UI immediately; only a new + // remote binary needs an authenticated CAS download first. + await applyAuthoritativeWorkspace(workspace, true); +} + +function queueBroadcastOperation( + packId: string, + version: number, + operation: WorkspaceOperation, +): void { + remoteApplyChain = remoteApplyChain + .catch(() => {}) + .then(() => applyBroadcastOperation(packId, version, operation)) + .catch((error) => { + if (targetPackId !== packId) return; + useLiveStore.getState().setStatus("error", errorMessage(error)); + scheduleResync(); + }); +} + +async function persistSyncCursor(version: number): Promise { + suppressStoreEvents = true; + try { + const state = useProjectStore.getState(); + if (!state.project) return; + state.setSyncState({ + ...state.project.sync, + workspaceVersion: version, + lastSyncedAt: new Date().toISOString(), + }); + observedProject = useProjectStore.getState().project; + } finally { + suppressStoreEvents = false; + } + scheduleOpenProjectSave(); +} + +function removePending(operationId: string): void { + pendingOperations = pendingOperations.filter((item) => item.operationId !== operationId); + useLiveStore.getState().setPending(pendingOperations.length + (pendingBase ? 1 : 0)); + persistPendingQueue(); +} + +function workspaceErrorCode(error: ApiError): string { + return typeof error.details?.error === "string" ? error.details.error : error.message; +} + +/** 4xx does not always mean the operation is invalid. Busy/rate-limit/auth and + * missing-CAS responses are recoverable and must retain the durable outbox. */ +export function shouldRetryWorkspaceRequest(error: unknown): boolean { + if (!(error instanceof ApiError)) return true; + const code = workspaceErrorCode(error); + return ( + error.status === 401 || + error.status === 408 || + error.status === 425 || + error.status === 429 || + error.status >= 500 || + code === "workspace_busy" || + code === "missing_assets" + ); +} + +async function sendPending(item: PendingOperation): Promise { + let retryMs = 750; + let queuePersisted = false; + while (targetSessionIsCurrent(item.packId) && targetSessionKey === item.sessionKey) { + const packId = item.packId; + try { + // Never begin an ambiguous network write until the operation id and + // payload are recoverable after a crash. + if (!queuePersisted) { + await persistPendingQueue(packId); + queuePersisted = true; + } + await ensureProjectAssetsUploaded(item.localProject); + const baseVersion = useLiveStore.getState().version ?? 0; + const result = await postWorkspaceOperation(packId, { + operationId: item.operationId, + baseVersion, + operation: item.operation, + }); + if (packId !== targetPackId || targetSessionKey !== item.sessionKey) return; + removePending(item.operationId); + const knownVersion = useLiveStore.getState().version ?? 0; + const acceptedVersion = Math.max(knownVersion, result.version); + useLiveStore.getState().setVersion(acceptedVersion); + useLiveStore.getState().setStatus("online"); + await persistSyncCursor(acceptedVersion); + if (result.rebased || result.duplicate || result.version < acceptedVersion) scheduleResync(); + return; + } catch (error) { + if (packId !== targetPackId || targetSessionKey !== item.sessionKey) return; + if (error instanceof ApiError && workspaceErrorCode(error) === "missing_assets") { + const missing = error.details?.missing; + if (Array.isArray(missing)) { + for (const sha of missing) if (typeof sha === "string") knownServerAssets.delete(sha); + } + } + if (error instanceof ApiError && !shouldRetryWorkspaceRequest(error)) { + removePending(item.operationId); + useLiveStore.getState().setStatus("error", error.message); + toast.error("Live-Änderung konnte nicht übernommen werden", { + description: workspaceErrorCode(error) === "locked" + ? "Das Objekt wird gerade von einem anderen Teammitglied bearbeitet." + : error.message, + }); + // Revert only the rejected optimistic operation, while overlaying any + // later durable operations before their serial send turn starts. + try { + await fetchAndApplyWorkspace(); + } catch (resyncError) { + useLiveStore.getState().setStatus("error", errorMessage(resyncError)); + scheduleResync(); + } + return; + } + useLiveStore.getState().setStatus( + error instanceof LiveQueuePersistenceError ? "error" : "connecting", + errorMessage(error), + ); + await new Promise((resolve) => setTimeout(resolve, retryMs)); + retryMs = Math.min(10_000, retryMs * 2); + } + } +} + +function enqueueOperation(operation: WorkspaceOperation, localProject: AtelierProject): void { + if (!targetPackId) return; + const item: PendingOperation = { + packId: targetPackId, + sessionKey: targetSessionKey ?? "", + operationId: crypto.randomUUID(), + operation, + localProject, + }; + pendingOperations.push(item); + useLiveStore.getState().setPending(pendingOperations.length); + persistPendingQueue(); + sendChain = sendChain.then(() => sendPending(item)).catch(() => {}); +} + +function enqueueOperationChunks( + operation: WorkspaceOperation, + localProject: AtelierProject, +): void { + const leaves = operation.kind === "batch" ? operation.operations : [operation]; + for (let index = 0; index < leaves.length; index += OPERATION_BATCH_SIZE) { + const chunk = leaves.slice(index, index + OPERATION_BATCH_SIZE); + enqueueOperation( + chunk.length === 1 ? chunk[0]! : { kind: "batch", operations: chunk }, + localProject, + ); + } +} + +function flushLocalChanges(): void { + mutationTimer = null; + const before = pendingBase; + const after = pendingLatest; + const localProject = pendingLocalProject; + pendingBase = null; + pendingLatest = null; + pendingLocalProject = null; + if (!before || !after || !localProject || !targetPackId) return; + const operation = diffWorkspaceProjects(before, after); + if (operation) enqueueOperationChunks(operation, localProject); +} + +function observeProjectChanges(): void { + if (unsubscribeProject) return; + observedProject = useProjectStore.getState().project; + unsubscribeProject = useProjectStore.subscribe((state) => { + const current = state.project; + const previous = observedProject; + observedProject = current; + if ( + suppressStoreEvents || + !targetPackId || + !targetSessionIsCurrent() || + !current || + !previous + ) return; + if ( + current.sync.remoteProjectId !== targetPackId || + previous.sync.remoteProjectId !== targetPackId + ) { + return; + } + const before = toWorkspaceProject(previous); + const after = toWorkspaceProject(current); + if (equal(before, after)) return; // sync cursor / updatedAt only + if (!pendingBase) pendingBase = before; + pendingLatest = after; + pendingLocalProject = current; + if (mutationTimer) clearTimeout(mutationTimer); + mutationTimer = setTimeout(flushLocalChanges, CHANGE_DEBOUNCE_MS); + useLiveStore.getState().setPending(pendingOperations.length + 1); + }); +} + +async function bootstrapWorkspace(packId: string): Promise { + const expectedSessionKey = targetSessionKey; + const stillCurrent = () => + expectedSessionKey !== null && + targetSessionKey === expectedSessionKey && + targetSessionIsCurrent(packId); + useLiveStore.getState().setStatus("syncing"); + const reportPull = liveProgress("pull"); + const reportPush = liveProgress("push"); + reportPull({ + phase: "download", + current: 0, + total: 1, + label: i18n.t("sync:progress.workspaceLoading"), + }); + try { + let project = useProjectStore.getState().project; + if (!project || project.sync.remoteProjectId !== packId) return; + await restorePendingQueue(packId); + if (!stillCurrent()) return; + try { + const existing = await getWorkspace(packId); + if (!stillCurrent()) return; + await applyAuthoritativeWorkspace(existing, false, reportPull); + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 404) throw error; + if (!stillCurrent()) return; + const pack = await getPack(packId); + if (!stillCurrent()) return; + if (pack.headRevision > 0 && project.sync.baseRevision !== pack.headRevision) { + await pullProject({ onProgress: reportPull }); + if (!stillCurrent()) return; + project = useProjectStore.getState().project; + if (!project) return; + } + await ensureProjectAssetsUploaded(project, reportPush); + if (!stillCurrent()) return; + reportPush({ + phase: "commit", + current: 0, + total: 1, + label: i18n.t("sync:progress.workspaceInitializing"), + }); + const initialized = await initializeWorkspace( + packId, + toWorkspaceProject(project), + pack.headRevision, + ); + if (!stillCurrent()) return; + reportPush({ + phase: "commit", + current: 1, + total: 1, + label: i18n.t("sync:progress.done"), + }); + await applyAuthoritativeWorkspace(initialized); + } + if (stillCurrent()) { + useLiveStore.getState().setStatus("online"); + for (const item of pendingOperations) { + sendChain = sendChain.then(() => sendPending(item)).catch(() => {}); + } + } + } finally { + if (stillCurrent()) useLiveStore.getState().setTransfer(null); + } +} + +export function setLiveWorkspaceTarget(packId: string | null): void { + const nextSessionKey = packId ? liveSessionKey(packId) : null; + if (targetPackId === packId && targetSessionKey === nextSessionKey) return; + if (targetPackId && pendingBase && pendingLatest && pendingLocalProject) { + flushLocalChanges(); + } + targetPackId = packId; + targetSessionKey = nextSessionKey; + targetProjectDir = nextSessionKey ? useProjectStore.getState().projectDir : null; + if (mutationTimer) clearTimeout(mutationTimer); + if (resyncTimer) clearTimeout(resyncTimer); + mutationTimer = null; + resyncTimer = null; + pendingBase = null; + pendingLatest = null; + pendingLocalProject = null; + pendingOperations = []; + sendChain = Promise.resolve(); + remoteApplyChain = Promise.resolve(); + knownServerAssets.clear(); + useLiveStore.getState().reset(); + observedProject = useProjectStore.getState().project; + if (packId && nextSessionKey) { + useLiveStore.getState().setStatus("connecting"); + bootstrapTask = bootstrapWorkspace(packId); + const startedSessionKey = nextSessionKey; + void bootstrapTask.catch((error) => { + if (targetPackId !== packId || targetSessionKey !== startedSessionKey) return; + useLiveStore.getState().setStatus("error", errorMessage(error)); + toast.error("Live-Projekt konnte nicht verbunden werden", { + description: errorMessage(error), + }); + }); + } else { + bootstrapTask = null; + } +} + +/** Used by the clone flow so it only reports success once the first + * authoritative live snapshot and its binaries have been materialized. */ +export async function connectLiveWorkspace(packId: string): Promise { + setLiveWorkspaceTarget(packId); + const task = bootstrapTask; + if (task) await task; +} + +/** Explicitly reloads the authoritative live snapshot. This replaces the old + * revision-only download action for realtime projects and also exposes its + * binary download progress in the existing cloud progress dialog. */ +export async function refreshLiveWorkspace(): Promise { + const packId = targetPackId; + if (!packId || !targetSessionIsCurrent(packId)) { + throw new Error(i18n.t("sync:errors.notLinked")); + } + const runningBootstrap = bootstrapTask; + if (runningBootstrap) await runningBootstrap; + if (!targetSessionIsCurrent(packId)) { + throw new Error(i18n.t("sync:errors.projectChanged")); + } + const reportPull = liveProgress("pull"); + useLiveStore.getState().setStatus("syncing"); + reportPull({ + phase: "download", + current: 0, + total: 1, + label: i18n.t("sync:progress.workspaceLoading"), + }); + try { + const version = await fetchAndApplyWorkspace(reportPull); + if (version === null) throw new Error(i18n.t("sync:errors.notLinked")); + return version; + } catch (error) { + if (targetSessionIsCurrent(packId)) { + useLiveStore.getState().setStatus("error", errorMessage(error)); + } + throw error; + } finally { + if (targetSessionIsCurrent(packId)) useLiveStore.getState().setTransfer(null); + } +} + +/** Called by the collaboration WebSocket dispatcher. */ +export function handleLiveWorkspaceMessage(message: Record): void { + if (!targetSessionIsCurrent()) return; + if (message.type === "joined") { + const serverVersion = + typeof message.workspaceVersion === "number" ? message.workspaceVersion : null; + const localVersion = useLiveStore.getState().version; + if (serverVersion !== null && serverVersion !== localVersion) { + scheduleResync(); + } + return; + } + if (message.type === "workspace-reset") { + scheduleResync(); + return; + } + if (message.type !== "workspace-changed" || typeof message.version !== "number") return; + const operationId = typeof message.operationId === "string" ? message.operationId : ""; + const ownPending = pendingOperations.some((item) => item.operationId === operationId); + const currentVersion = useLiveStore.getState().version ?? -1; + if (ownPending) { + if (message.version === currentVersion + 1) { + useLiveStore.getState().setVersion(message.version); + } else if (message.version > currentVersion) { + // Our operation was accepted after a version we did not receive. Do not + // jump the cursor over that gap; the HTTP response/retry is idempotent + // and the authoritative resync will overlay the still-pending operation. + scheduleResync(); + } + return; + } + if (isWorkspaceOperation(message.operation)) { + const packId = targetPackId; + if (packId) queueBroadcastOperation(packId, message.version, message.operation); + } else { + scheduleResync(); + } +} + +/** Mount once from App. */ +export function useLiveWorkspace(): void { + const cloudEnabled = useCloudEnabled(); + const authStatus = useAuthStore((state) => state.status); + const approved = useAuthStore((state) => state.user?.status === "approved"); + const packId = useProjectStore( + (state) => state.project?.sync.remoteProjectId ?? null, + ); + const projectId = useProjectStore((state) => state.project?.id ?? null); + const projectDir = useProjectStore((state) => state.projectDir); + + useEffect(() => { + observeProjectChanges(); + const target = cloudEnabled && authStatus === "loggedIn" && approved ? packId : null; + setLiveWorkspaceTarget(target); + }, [cloudEnabled, authStatus, approved, packId, projectId, projectDir]); +} diff --git a/src/lib/sync/pack-sync.ts b/src/lib/sync/pack-sync.ts index 05d5f77..94f0c79 100644 --- a/src/lib/sync/pack-sync.ts +++ b/src/lib/sync/pack-sync.ts @@ -111,7 +111,12 @@ async function saveOpenProject(): Promise { export async function linkProject(packId: string): Promise { const { project, projectDir, setSyncState } = useProjectStore.getState(); if (!project || !projectDir) throw new Error(i18n.t("sync:errors.noProjectOpen")); - setSyncState({ remoteProjectId: packId, baseRevision: null, lastSyncedAt: null }); + setSyncState({ + remoteProjectId: packId, + baseRevision: null, + workspaceVersion: null, + lastSyncedAt: null, + }); await saveOpenProject(); } @@ -120,7 +125,7 @@ export async function linkProject(packId: string): Promise { // --------------------------------------------------------------------------- /** Uploads one local file via the resumable chunk protocol. */ -async function uploadLocalAsset(projectDir: string, asset: LocalAsset): Promise { +export async function uploadLocalAsset(projectDir: string, asset: LocalAsset): Promise { const name = baseName(asset.ref.path); const absPath = joinPath(projectDir, asset.ref.path); @@ -134,7 +139,21 @@ async function uploadLocalAsset(projectDir: string, asset: LocalAsset): Promise< // The revision will reference ref.hash — a drifted file would brick the // upload at /complete anyway, so fail early with a readable message. if ((await sha256Hex(bytes)) !== asset.ref.hash) { - throw new Error(i18n.t("sync:errors.fileDrifted", { name })); + // In-place texture optimization keeps every prior generation by hash. + // A durable live operation may still need one of those bytes after the + // same path has already been optimized again while offline. + const backupPath = joinPath( + projectDir, + ".atelier-cache/texture-backups", + `${asset.ref.hash}-${name}`, + ); + if (asset.kind === "ytd" && (await exists(backupPath).catch(() => false))) { + const backup = await readFile(backupPath); + if ((await sha256Hex(backup)) === asset.ref.hash) bytes = backup; + else throw new Error(i18n.t("sync:errors.fileDrifted", { name })); + } else { + throw new Error(i18n.t("sync:errors.fileDrifted", { name })); + } } let session: UploadSession; @@ -239,6 +258,7 @@ export async function pushProject(options: PushOptions = {}): Promise