Greasy Fork is available in English.
Zero-click local sync for Websim projects via WebSocket
当前为
// ==UserScript==
// @name Websim Local Sync - WebSocket Bridge
// @namespace http://tampermonkey.net/
// @version 1.4.3
// @description Zero-click local sync for Websim projects via WebSocket
// @author Antigravity
// @match https://websim.com/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const SYNC_VERSION = "1.4.3";
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 Upload (Robust overwriting)
socket.send(JSON.stringify({ type: 'progress', step: 2, label: 'Assets' }));
if (files && files.length > 0) {
const formData = new FormData();
const assetMap = files.map(f => ({ path: f.path, size: f.size }));
formData.append('contents', JSON.stringify(assetMap));
files.forEach((f, i) => {
formData.append(i.toString(), new Blob([b64ToUint8(f.content)]), f.path);
});
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: "", 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(`${opName} 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;
}
}
let currentCreation = null;
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();
currentCreation = {
projectId: projData.project.id,
revisionId: projData.project_revision.id,
version: projData.project_revision.version || 1
};
socket.send(JSON.stringify({
type: 'assignment',
projectId: currentCreation.projectId,
version: currentCreation.version,
revisionId: currentCreation.revisionId
}));
} 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 {
// 1. Metadata setup on v1
if (title || slug) {
await fetch(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug })
});
}
// 2. Site update on v1 (Stable method)
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 }
})
});
}
// 3. Finalize v1
await fetch(`/api/v1/projects/${projectId}/revisions/${v1}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draft: false })
});
// 4. Staged Push (Creates v2)
const pushResult = await executePush({
projectId,
parentVersion: v1,
files
}, true);
const finalVersion = pushResult.version;
// 5. Response
let username = 'Trey6383';
try {
const userRes = await fetch('/api/v1/session');
const userData = await userRes.json();
username = userData.user?.username || username;
} catch (e) { }
socket.send(JSON.stringify({
type: 'status',
message: 'created',
projectId,
version: finalVersion,
title,
slug,
username,
files
}));
showStatus(`Created v${finalVersion}`, '#10b981');
} catch (err) {
console.error("[Websim Bridge] Create Failure:", err);
showStatus(`Error: ${err.message}`, '#ef4444');
socket.send(JSON.stringify({ type: 'status', message: 'error', error: err.message }));
}
}
connect();
})();