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