Greasy Fork

Greasy Fork is available in English.

Websim Local Sync - WebSocket Bridge

Zero-click local sync for Websim projects via WebSocket

当前为 2026-01-26 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Websim Local Sync - WebSocket Bridge
// @namespace    http://tampermonkey.net/
// @version      1.5.2
// @description  Zero-click local sync for Websim projects via WebSocket
// @author       Trey6383
// @match        https://websim.com/*
// @grant        none
// @license MIT 
// ==/UserScript==

(function () {
    'use strict';

    const SYNC_VERSION = "1.5.2";
    console.log(`%c[Websim Bridge] Userscript ${SYNC_VERSION} active.`, "color: #3b82f6; font-weight: bold");

    async function getProjectState() {
        const pathParts = window.location.pathname.split('/').filter(p => p);
        if (pathParts.length < 1) return null;

        let slugOrId = pathParts[pathParts.length - 1];
        let version = null;

        if (!isNaN(slugOrId) && pathParts.length >= 2) {
            version = slugOrId;
            slugOrId = pathParts[pathParts.length - 2];
        }

        try {
            const projRes = await fetch(`/api/v1/projects/${slugOrId}`);
            if (!projRes.ok) return null;
            const projData = await projRes.json();
            const project = projData.project;
            const finalVersion = version || project.current_version || project.last_posted_version || 1;

            const assetRes = await fetch(`/api/v1/projects/${project.id}/revisions/${finalVersion}/assets`);
            const assetData = assetRes.ok ? await assetRes.json() : { assets: [] };

            return {
                projectId: project.id,
                version: parseInt(finalVersion),
                assets: assetData.assets || [],
                title: project.title
            };
        } catch (e) { return null; }
    }

    function showStatus(text, color = '#3b82f6') {
        let el = document.getElementById('websim-sync-status');
        if (!el) {
            el = document.createElement('div');
            el.id = 'websim-sync-status';
            Object.assign(el.style, {
                position: 'fixed', bottom: '20px', right: '20px', zIndex: '999999',
                padding: '10px 20px', borderRadius: '8px', color: 'white',
                fontWeight: 'bold', fontSize: '14px', pointerEvents: 'none',
                transition: 'opacity 0.3s', opacity: '0', boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
            });
            document.body.appendChild(el);
        }
        el.textContent = text;
        el.style.backgroundColor = color;
        el.style.opacity = '1';
        clearTimeout(el.timeout);
        el.timeout = setTimeout(() => { el.style.opacity = '0'; }, 5000);
    }

    let socket;
    let isActiveTab = true;

    window.addEventListener('focus', () => { isActiveTab = true; });
    window.addEventListener('blur', () => { isActiveTab = false; });

    function connect() {
        socket = new WebSocket('ws://localhost:38383');

        socket.onopen = async () => {
            console.log("%c[Websim Bridge] Socket opened.", "color: #10b981");
            const state = await getProjectState();
            socket.send(JSON.stringify({
                type: 'hello',
                syncVersion: SYNC_VERSION,
                focused: isActiveTab,
                url: window.location.href,
                projectState: state
            }));
        };

        socket.onmessage = async (event) => {
            try {
                const data = JSON.parse(event.data);
                if (data.type === 'push') {
                    await executePush(data.payload);
                } else if (data.type === 'create-init') {
                    await executeCreateInit();
                } else if (data.type === 'create-meta') {
                    await executeCreateMeta(data.payload);
                } else if (data.type === 'hello') {
                    const state = await getProjectState();
                    socket.send(JSON.stringify({
                        type: 'hello',
                        syncVersion: SYNC_VERSION,
                        focused: isActiveTab,
                        url: window.location.href,
                        projectState: state
                    }));
                } else if (data.type === 'pull-assets-list') {
                    const { projectId, version } = data.payload;
                    try {
                        const res = await fetch(`/api/v1/projects/${projectId}/revisions/${version}/assets`);
                        if (!res.ok) throw new Error(`Assets list fetch failed: ${res.status}`);
                        const assetsData = await res.json();
                        socket.send(JSON.stringify({
                            type: 'assets-list',
                            projectId,
                            version,
                            assets: assetsData.assets || []
                        }));
                    } catch (e) {
                        socket.send(JSON.stringify({ type: 'status', message: 'error', error: `List pull failed: ${e.message}` }));
                    }
                } else if (data.type === 'pull-asset') {
                    const { projectId, path } = data.payload;
                    try {
                        const url = `https://${projectId}.c.websim.com/${path}`;
                        const res = await fetch(url);
                        if (!res.ok) throw new Error(`Asset fetch failed: ${res.status} at ${url}`);
                        const blob = await res.blob();
                        const reader = new FileReader();
                        reader.onloadend = () => {
                            socket.send(JSON.stringify({
                                type: 'asset-data',
                                path: path,
                                content: reader.result.split(',')[1] // base64
                            }));
                        };
                        reader.readAsDataURL(blob);
                    } catch (e) {
                        socket.send(JSON.stringify({ type: 'status', message: 'error', error: `Pull failed: ${e.message}` }));
                    }
                }
            } catch (e) {
                console.error("[Websim Bridge] Error processing message:", e);
                socket.send(JSON.stringify({ type: 'status', message: 'error', error: e.message }));
            }
        };

        socket.onclose = () => {
            setTimeout(connect, 2000);
        };

        socket.onerror = (err) => {
            // Silently retry
        };
    }

    function b64ToUint8(b64) {
        const bin = atob(b64);
        const bytes = new Uint8Array(bin.length);
        for (let i = 0; i < bin.length; i++) {
            bytes[i] = bin.charCodeAt(i);
        }
        return bytes;
    }

    let isSyncing = false;

    async function executePush(payload, isInternal = false) {
        if (isSyncing && !isInternal) {
            console.warn("[Websim Bridge] Push already in progress, skipping redundant request.");
            return;
        }
        if (!isInternal) isSyncing = true;

        let { projectId, parentVersion, files, title, slug, revisionId } = payload;
        const opName = (title || slug) ? "Sync" : "Push";

        console.log(`%c[Websim Bridge] Starting ${opName} for ${projectId}`, "color: #3b82f6; font-weight: bold");

        try {
            // 0. Metadata Update
            if (title || slug) {
                const patchBody = {};
                if (title) patchBody.title = title;
                if (slug) patchBody.slug = slug;
                await fetch(`/api/v1/projects/${projectId}`, {
                    method: 'PATCH',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(patchBody)
                });
            }

            // 1. Revision Resolution
            let nextVersion = parentVersion;
            let nextRevId = revisionId;

            if (!nextRevId) {
                socket.send(JSON.stringify({ type: 'progress', step: 1, label: 'Revision' }));
                const revRes = await fetch(`/api/v1/projects/${projectId}/revisions`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ parent_version: parentVersion })
                });

                if (revRes.status === 409) {
                    const listRes = await fetch(`/api/v1/projects/${projectId}/revisions`);
                    const listData = await listRes.json();
                    let revisions = [];
                    if (Array.isArray(listData)) revisions = listData;
                    else if (listData && Array.isArray(listData.revisions)) revisions = listData.revisions;
                    else if (listData && Array.isArray(listData.project_revisions)) revisions = listData.project_revisions;

                    const draft = revisions.find(r => r.draft);
                    if (!draft) throw new Error("Conflict: No draft found.");
                    nextVersion = draft.version;
                    nextRevId = draft.id;
                } else if (!revRes.ok) {
                    throw new Error(`Revision Err: ${revRes.status}`);
                } else {
                    const revData = await revRes.json();
                    nextVersion = revData.project_revision.version;
                    nextRevId = revData.project_revision.id;
                }
            }
            console.log(`[Websim Bridge] Target Version: v${nextVersion}, Rev: ${nextRevId}`);

            // 2. Asset Cleanup & Upload (The Nuclear Sweep)
            socket.send(JSON.stringify({ type: 'progress', step: 2, label: 'Assets' }));

            if (files && files.length > 0) {
                const existingRes = await fetch(`/api/v1/projects/${projectId}/revisions/${nextVersion}/assets`);
                const existingData = existingRes.ok ? await existingRes.json() : { assets: [] };
                const existingAssets = existingData.assets || [];

                const duplicatesToDelete = [];
                const assetMapJSON = [];
                const processedAssetIds = new Set();

                files.forEach(f => {
                    const fileName = f.path.split('/').pop();
                    const lastDotIndex = fileName.lastIndexOf('.');
                    const baseName = lastDotIndex !== -1 ? fileName.substring(0, lastDotIndex) : fileName;
                    const ext = lastDotIndex !== -1 ? fileName.substring(lastDotIndex + 1) : '';

                    // Escaped regex for multi-dot filenames
                    const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
                    const duplicateRegex = new RegExp(`^${escapedBase}(\\s*\\(\\d+\\))?\\.${ext}$`, 'i');

                    const matches = existingAssets.filter(a => {
                        const aPath = a.path.split('/').pop();
                        return duplicateRegex.test(aPath);
                    });

                    const exactMatch = matches.find(m => m.path === f.path);
                    const duplicates = matches.filter(m => m.path !== f.path);

                    if (exactMatch) {
                        // Use existingAssetPath for reliable overwriting
                        assetMapJSON.push({ existingAssetPath: exactMatch.path, size: f.size });
                        processedAssetIds.add(exactMatch.id);
                    } else {
                        assetMapJSON.push({ path: f.path, size: f.size });
                    }

                    duplicates.forEach(d => {
                        if (!processedAssetIds.has(d.id)) {
                            duplicatesToDelete.push(d);
                            processedAssetIds.add(d.id);
                        }
                    });
                });

                // 2.1 Sequential DELETE (Clean Sweep)
                if (duplicatesToDelete.length > 0) {
                    console.log(`[Websim Bridge] Purging ${duplicatesToDelete.length} duplicates...`);
                    for (const asset of duplicatesToDelete) {
                        try {
                            const delRes = await fetch(`/api/v1/projects/${projectId}/revisions/${nextVersion}/assets/${encodeURIComponent(asset.path)}`, {
                                method: 'DELETE'
                            });
                            if (delRes.ok) console.log(`[Websim Bridge] Deleted: ${asset.path}`);
                        } catch (e) { console.error(`Failed to delete ${asset.path}`, e); }
                    }
                }

                // 2.2 Upload/Overwrite
                const formData = new FormData();
                formData.append('contents', JSON.stringify(assetMapJSON));

                assetMapJSON.forEach((entry, i) => {
                    // entry.existingAssetPath or entry.path
                    const targetPath = entry.existingAssetPath || entry.path;
                    const localFile = files.find(f => f.path === targetPath) || files[0];
                    formData.append(i.toString(), new Blob([b64ToUint8(localFile.content)]), targetPath);
                });

                console.log(`[Websim Bridge] Syncing ${assetMapJSON.length} primary assets...`);
                const assetRes = await fetch(`/api/v1/projects/${projectId}/revisions/${nextVersion}/assets`, {
                    method: 'POST',
                    body: formData
                });
                if (!assetRes.ok) {
                    const errText = await assetRes.text().catch(() => "No Body");
                    throw new Error(`Asset Sync Error (${assetRes.status}): ${errText.substring(0, 100)}`);
                }
            }

            // 3. Site Update (Publishing the Preview)
            socket.send(JSON.stringify({ type: 'progress', step: 3, label: 'Site' }));
            const indexFile = (files || []).find(f => f.path === 'index.html');
            if (indexFile) {
                const siteRes = await fetch('/api/v1/sites', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        content: new TextDecoder().decode(b64ToUint8(indexFile.content)),
                        project_id: projectId,
                        project_version: nextVersion,
                        project_revision_id: nextRevId,
                        prompt_data_override: { type: 'manual-edit', text: "Clean Push v1.5.2", data: null }
                    })
                });
                if (!siteRes.ok) console.warn("Site update failed", siteRes.status);
            }

            // 4. Finalize
            socket.send(JSON.stringify({ type: 'progress', step: 4, label: 'Finalizing' }));
            await fetch(`/api/v1/projects/${projectId}/revisions/${nextVersion}`, {
                method: 'PATCH',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ draft: false })
            });

            if (!isInternal) {
                showStatus(`Clean Sweep Success!`, '#10b981');
                let username = 'Trey6383';
                let slug = '';
                try {
                    const [userRes, projRes] = await Promise.all([
                        fetch('/api/v1/session'),
                        fetch(`/api/v1/projects/${projectId}`)
                    ]);
                    if (userRes.ok) username = (await userRes.json()).user?.username || username;
                    if (projRes.ok) slug = (await projRes.json()).project?.slug || '';
                } catch (e) { }

                socket.send(JSON.stringify({
                    type: 'status',
                    message: 'success',
                    version: nextVersion,
                    projectId,
                    username,
                    slug
                }));
            }
            return { version: nextVersion, revisionId: nextRevId };

        } catch (err) {
            console.error("[Websim Bridge] Sync Failure:", err);
            if (!isInternal) {
                showStatus(`Sync Error: ${err.message}`, '#ef4444');
                socket.send(JSON.stringify({ type: 'status', message: 'error', error: err.message }));
            }
            throw err;
        } finally {
            if (!isInternal) isSyncing = false;
        }
    }

    async function executeCreateInit() {
        try {
            const projRes = await fetch('/api/v1/projects', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ visibility: 'public' })
            });
            const projData = await projRes.json();
            socket.send(JSON.stringify({
                type: 'assignment',
                projectId: projData.project.id,
                version: projData.project_revision.version || 1,
                revisionId: projData.project_revision.id
            }));
        } catch (err) {
            socket.send(JSON.stringify({ type: 'status', message: 'error', error: err.message }));
        }
    }

    async function executeCreateMeta(payload) {
        let { projectId, revisionId, version, files, title, slug } = payload;
        const v1 = version || 1;
        try {
            if (title || slug) {
                await fetch(`/api/v1/projects/${projectId}`, {
                    method: 'PATCH',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ title, slug })
                });
            }
            const indexFile = (files || []).find(f => f.path === 'index.html');
            if (indexFile) {
                await fetch('/api/v1/sites', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        content: new TextDecoder().decode(b64ToUint8(indexFile.content)),
                        project_id: projectId,
                        project_version: v1,
                        project_revision_id: revisionId,
                        prompt_data_override: { type: 'manual-edit', text: 'Initialize', data: null }
                    })
                });
            }
            await fetch(`/api/v1/projects/${projectId}/revisions/${v1}`, {
                method: 'PATCH',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ draft: false })
            });
            const pushResult = await executePush({ projectId, parentVersion: v1, files }, true);
            let username = 'Trey6383';
            try {
                const userRes = await fetch('/api/v1/session');
                username = (await userRes.json()).user?.username || username;
            } catch (e) { }
            socket.send(JSON.stringify({
                type: 'status', message: 'created', projectId, version: pushResult.version, title, slug, username, files
            }));
            showStatus(`Created v${pushResult.version}`, '#10b981');
        } catch (err) {
            console.error("[Websim Bridge] Create Failure:", err);
            socket.send(JSON.stringify({ type: 'status', message: 'error', error: err.message }));
        }
    }

    connect();
})();