├── src ├── renderer │ ├── styles │ │ ├── src.css │ │ └── renderer.css │ ├── index.js │ ├── app.html │ ├── components │ │ └── ErrorQueue.jsx │ └── HyperclayLocalApp.jsx ├── main │ ├── utils │ │ ├── utils.js │ │ └── backup.js │ ├── preload.js │ └── server.js └── sync-engine │ ├── constants.js │ ├── utils.js │ ├── file-operations.js │ ├── error-handler.js │ ├── sync-queue.js │ ├── logger.js │ ├── api-client.js │ ├── validation.js │ └── index.js ├── .babelrc ├── .gitattributes ├── assets ├── icons │ ├── icon.png │ ├── tray-icon.png │ ├── tray-icon@2x.png │ └── icon.svg └── fonts │ ├── fixedsys-webfont.woff2 │ └── BerkeleyMonoVariable-Regular.woff2 ├── config ├── tailwind.config.js └── webpack.config.js ├── entitlements.plist ├── .env.example ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── build-and-sign-windows.yml ├── scripts ├── update-external-docs.js └── release.js ├── BUILD.md ├── package.json └── README.md /src/renderer/styles/src.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react"] 3 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panphora/hyperclay-local/HEAD/assets/icons/icon.png -------------------------------------------------------------------------------- /assets/icons/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panphora/hyperclay-local/HEAD/assets/icons/tray-icon.png -------------------------------------------------------------------------------- /assets/icons/tray-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panphora/hyperclay-local/HEAD/assets/icons/tray-icon@2x.png -------------------------------------------------------------------------------- /assets/fonts/fixedsys-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panphora/hyperclay-local/HEAD/assets/fonts/fixedsys-webfont.woff2 -------------------------------------------------------------------------------- /assets/fonts/BerkeleyMonoVariable-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panphora/hyperclay-local/HEAD/assets/fonts/BerkeleyMonoVariable-Regular.woff2 -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { Config } from 'tailwindcss' 2 | 3 | export default { 4 | content: [ 5 | './new-app-ui.html', 6 | './preload.js', 7 | './main.js' 8 | ] 9 | } satisfies Config -------------------------------------------------------------------------------- /src/renderer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import HyperclayLocalApp from './HyperclayLocalApp.jsx'; 4 | 5 | const container = document.getElementById('root'); 6 | const root = createRoot(container); 7 | root.render(); -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.cs.allow-unsigned-executable-memory 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Apple Code Signing & Notarization (macOS builds) 2 | APPLE_ID=your-apple-id@example.com 3 | APPLE_TEAM_ID=YOUR_TEAM_ID 4 | APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx 5 | 6 | # Azure Trusted Signing (Windows builds) 7 | AZURE_TENANT_ID=your-azure-tenant-id 8 | AZURE_CLIENT_ID=your-azure-client-id 9 | AZURE_CLIENT_SECRET=your-azure-client-secret 10 | 11 | # Cloudflare R2 (for upload-to-r2 script) 12 | R2_ACCOUNT_ID=your-r2-account-id 13 | R2_ACCESS_KEY_ID=your-r2-access-key 14 | R2_SECRET_ACCESS_KEY=your-r2-secret-key 15 | R2_BUCKET_NAME=your-bucket-name 16 | -------------------------------------------------------------------------------- /src/main/utils/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared utility functions for Hyperclay Local 3 | */ 4 | 5 | /** 6 | * Determine the base server URL for sync operations 7 | * @param {string} serverUrl - Optional custom server URL 8 | * @returns {string} The base server URL 9 | */ 10 | function getServerBaseUrl(serverUrl) { 11 | if (serverUrl) { 12 | return serverUrl; 13 | } 14 | 15 | const isDev = process.env.NODE_ENV === 'development' || process.argv.includes('--dev'); 16 | return isDev ? 'https://localhyperclay.com' : 'https://hyperclay.com'; 17 | } 18 | 19 | module.exports = { 20 | getServerBaseUrl 21 | }; -------------------------------------------------------------------------------- /config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'development', 5 | entry: './src/renderer/index.js', 6 | output: { 7 | path: path.resolve(__dirname, '../dist'), 8 | filename: 'bundle.js', 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.(js|jsx)$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: 'babel-loader', 17 | options: { 18 | presets: ['@babel/preset-react'] 19 | } 20 | } 21 | } 22 | ] 23 | }, 24 | resolve: { 25 | extensions: ['.js', '.jsx'] 26 | }, 27 | target: 'electron-renderer', 28 | devtool: 'source-map' 29 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Build outputs 8 | dist/ 9 | build/ 10 | executables/ 11 | 12 | # OS generated files 13 | .DS_Store 14 | .DS_Store? 15 | ._* 16 | .Spotlight-V100 17 | .Trashes 18 | ehthumbs.db 19 | Thumbs.db 20 | 21 | # Editor files 22 | .vscode/ 23 | .idea/ 24 | *.swp 25 | *.swo 26 | *~ 27 | 28 | # Logs 29 | logs 30 | *.log 31 | 32 | # Environment variables 33 | .env 34 | .env.local 35 | .env.development.local 36 | .env.test.local 37 | .env.production.local 38 | 39 | # Electron specific 40 | *.tgz 41 | *.tar.gz 42 | 43 | # Auto-generated files 44 | package-lock.json (optional - remove this line if you want to commit it) 45 | .notarization-submissions-*.json 46 | UPLOAD_REPORT.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 David Miranda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/sync-engine/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants and configuration for the sync engine 3 | */ 4 | 5 | // Error priority levels 6 | const ERROR_PRIORITY = { 7 | CRITICAL: 1, // Show immediately, don't auto-dismiss 8 | HIGH: 2, // Show immediately, auto-dismiss after 10s 9 | MEDIUM: 3, // Show in queue, auto-dismiss after 5s 10 | LOW: 4 // Log only, don't show UI 11 | }; 12 | 13 | // Error type mappings 14 | const ERROR_TYPES = { 15 | NAME_CONFLICT: 'name_conflict', 16 | AUTH_FAILURE: 'auth_failure', 17 | NETWORK_ERROR: 'network_error', 18 | FILE_ACCESS: 'file_access', 19 | SYNC_CONFLICT: 'sync_conflict', 20 | UNKNOWN: 'unknown' 21 | }; 22 | 23 | // Sync configuration 24 | const SYNC_CONFIG = { 25 | POLL_INTERVAL: 30000, // Poll every 30 seconds 26 | TIME_BUFFER: 10000, // 10 seconds buffer for "same time" 27 | MAX_RETRIES: 3, 28 | RETRY_DELAYS: [5000, 15000, 60000], // 5s, 15s, 60s exponential backoff 29 | FILE_STABILIZATION: { 30 | stabilityThreshold: 1000, 31 | pollInterval: 100 32 | } 33 | }; 34 | 35 | module.exports = { 36 | ERROR_PRIORITY, 37 | ERROR_TYPES, 38 | SYNC_CONFIG 39 | }; -------------------------------------------------------------------------------- /src/renderer/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hyperclay Local 7 | 8 | 43 | 44 | 45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /src/main/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | // Expose protected methods that allow the renderer process to use 4 | // the ipcRenderer without exposing the entire object 5 | contextBridge.exposeInMainWorld('electronAPI', { 6 | selectFolder: () => ipcRenderer.invoke('select-folder'), 7 | startServer: () => ipcRenderer.invoke('start-server'), 8 | stopServer: () => ipcRenderer.invoke('stop-server'), 9 | getState: () => ipcRenderer.invoke('get-state'), 10 | openFolder: () => ipcRenderer.invoke('open-folder'), 11 | openLogs: () => ipcRenderer.invoke('open-logs'), 12 | openBrowser: (url) => ipcRenderer.invoke('open-browser', url), 13 | 14 | // Sync methods 15 | syncStart: (apiKey, username, syncFolder, serverUrl) => ipcRenderer.invoke('sync-start', { apiKey, username, syncFolder, serverUrl }), 16 | syncStop: () => ipcRenderer.invoke('sync-stop'), 17 | syncResume: (selectedFolder, username) => ipcRenderer.invoke('sync-resume', selectedFolder, username), 18 | syncStatus: () => ipcRenderer.invoke('sync-status'), 19 | 20 | // API key and settings methods 21 | setApiKey: (key, serverUrl) => ipcRenderer.invoke('set-api-key', key, serverUrl), 22 | getApiKeyInfo: () => ipcRenderer.invoke('get-api-key-info'), 23 | removeApiKey: () => ipcRenderer.invoke('remove-api-key'), 24 | toggleSync: (enabled) => ipcRenderer.invoke('toggle-sync', enabled), 25 | getSyncStats: () => ipcRenderer.invoke('get-sync-stats'), 26 | 27 | // Window management 28 | resizeWindow: (height) => ipcRenderer.invoke('resize-window', height), 29 | 30 | // Listen for state updates 31 | onStateUpdate: (callback) => { 32 | ipcRenderer.on('update-state', (_event, state) => callback(state)); 33 | }, 34 | 35 | // Sync event listeners 36 | onSyncUpdate: (callback) => { 37 | ipcRenderer.on('sync-update', (_event, data) => callback(data)); 38 | }, 39 | 40 | onFileSynced: (callback) => { 41 | ipcRenderer.on('file-synced', (_event, data) => callback(data)); 42 | }, 43 | 44 | onSyncStats: (callback) => { 45 | ipcRenderer.on('sync-stats', (_event, data) => callback(data)); 46 | }, 47 | 48 | // Backup event listener 49 | onBackupCreated: (callback) => { 50 | ipcRenderer.on('backup-created', (_event, data) => callback(data)); 51 | }, 52 | 53 | // Retry and failure event listeners 54 | onSyncRetry: (callback) => { 55 | ipcRenderer.on('sync-retry', (_event, data) => callback(data)); 56 | }, 57 | 58 | onSyncFailed: (callback) => { 59 | ipcRenderer.on('sync-failed', (_event, data) => callback(data)); 60 | }, 61 | 62 | // Update available listener 63 | onUpdateAvailable: (callback) => { 64 | ipcRenderer.on('update-available', (_event, data) => callback(data)); 65 | }, 66 | 67 | // Remove listeners 68 | removeAllListeners: (channel) => { 69 | ipcRenderer.removeAllListeners(channel); 70 | } 71 | }); -------------------------------------------------------------------------------- /src/sync-engine/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for the sync engine 3 | */ 4 | 5 | const crypto = require('crypto'); 6 | const { SYNC_CONFIG } = require('./constants'); 7 | 8 | /** 9 | * Calculate file checksum 10 | */ 11 | async function calculateChecksum(content) { 12 | return crypto.createHash('sha256') 13 | .update(content) 14 | .digest('hex') 15 | .substring(0, 16); 16 | } 17 | 18 | /** 19 | * Generate timestamp in same format as hyperclay local server 20 | * Format: YYYY-MM-DD-HH-MM-SS-MMM 21 | */ 22 | function generateTimestamp() { 23 | const now = new Date(); 24 | const year = now.getFullYear(); 25 | const month = String(now.getMonth() + 1).padStart(2, '0'); 26 | const day = String(now.getDate()).padStart(2, '0'); 27 | const hours = String(now.getHours()).padStart(2, '0'); 28 | const minutes = String(now.getMinutes()).padStart(2, '0'); 29 | const seconds = String(now.getSeconds()).padStart(2, '0'); 30 | const milliseconds = String(now.getMilliseconds()).padStart(3, '0'); 31 | 32 | return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}-${milliseconds}`; 33 | } 34 | 35 | /** 36 | * Check if local file is newer than server file 37 | */ 38 | function isLocalNewer(localMtime, serverTime, clockOffset) { 39 | const adjustedLocalTime = localMtime.getTime() + clockOffset; 40 | const serverMtime = new Date(serverTime).getTime(); 41 | 42 | // If times are within buffer, they're considered "same time" 43 | const diff = Math.abs(adjustedLocalTime - serverMtime); 44 | if (diff <= SYNC_CONFIG.TIME_BUFFER) { 45 | return false; // Within buffer, use server version 46 | } 47 | 48 | return adjustedLocalTime > serverMtime; 49 | } 50 | 51 | /** 52 | * Check if file is in the future (likely intentional) 53 | */ 54 | function isFutureFile(mtime, clockOffset) { 55 | const adjustedTime = mtime.getTime() + clockOffset; 56 | const now = Date.now(); 57 | return adjustedTime > now + 60000; // More than 1 minute in future 58 | } 59 | 60 | /** 61 | * Calibrate local clock with server 62 | */ 63 | async function calibrateClock(serverUrl, apiKey) { 64 | try { 65 | const response = await fetch(`${serverUrl}/sync/status`, { 66 | headers: { 67 | 'X-API-Key': apiKey 68 | } 69 | }); 70 | 71 | if (!response.ok) { 72 | throw new Error(`Server returned ${response.status}`); 73 | } 74 | 75 | const data = await response.json(); 76 | const serverTime = new Date(data.serverTime).getTime(); 77 | const localTime = Date.now(); 78 | 79 | const clockOffset = serverTime - localTime; 80 | 81 | console.log(`[SYNC] Clock offset: ${clockOffset}ms`); 82 | return clockOffset; 83 | } catch (error) { 84 | console.error('[SYNC] Failed to calibrate clock:', error); 85 | return 0; // Assume no offset if calibration fails 86 | } 87 | } 88 | 89 | module.exports = { 90 | calculateChecksum, 91 | generateTimestamp, 92 | isLocalNewer, 93 | isFutureFile, 94 | calibrateClock 95 | }; -------------------------------------------------------------------------------- /src/sync-engine/file-operations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Local file operations for the sync engine 3 | */ 4 | 5 | const fs = require('fs').promises; 6 | const fsSync = require('fs'); 7 | const path = require('upath'); // Use upath for cross-platform compatibility 8 | 9 | /** 10 | * Get all local HTML files recursively with relative paths 11 | */ 12 | async function getLocalFiles(syncFolder) { 13 | const files = new Map(); 14 | 15 | async function scanDirectory(dirPath, relativePath = '') { 16 | try { 17 | const entries = await fs.readdir(dirPath, { withFileTypes: true }); 18 | 19 | for (const entry of entries) { 20 | const fullPath = path.join(dirPath, entry.name); 21 | const relPath = relativePath 22 | ? path.join(relativePath, entry.name) 23 | : entry.name; 24 | 25 | if (entry.isDirectory()) { 26 | // Skip system directories 27 | if (!entry.name.startsWith('.') && 28 | entry.name !== 'node_modules' && 29 | entry.name !== 'sites-versions') { 30 | // Recursively scan subdirectories 31 | await scanDirectory(fullPath, relPath); 32 | } 33 | } else if (entry.isFile() && entry.name.endsWith('.html')) { 34 | const stats = await fs.stat(fullPath); 35 | 36 | // relPath is already normalized by upath.join() to forward slashes 37 | files.set(relPath, { 38 | path: fullPath, 39 | relativePath: relPath, 40 | mtime: stats.mtime, 41 | size: stats.size 42 | }); 43 | } 44 | } 45 | } catch (error) { 46 | console.error(`Error scanning directory ${dirPath}:`, error); 47 | } 48 | } 49 | 50 | await scanDirectory(syncFolder); 51 | return files; 52 | } 53 | 54 | /** 55 | * Read file content 56 | */ 57 | async function readFile(filePath) { 58 | return fs.readFile(filePath, 'utf8'); 59 | } 60 | 61 | /** 62 | * Write file ensuring parent directories exist 63 | */ 64 | async function writeFile(filePath, content, modifiedTime) { 65 | // Ensure parent directory exists 66 | const dir = path.dirname(filePath); 67 | await fs.mkdir(dir, { recursive: true }); 68 | 69 | // Write the file 70 | await fs.writeFile(filePath, content, 'utf8'); 71 | 72 | // Set modification time if provided 73 | if (modifiedTime) { 74 | const mtime = new Date(modifiedTime); 75 | await fs.utimes(filePath, mtime, mtime); 76 | } 77 | } 78 | 79 | /** 80 | * Check if file exists 81 | */ 82 | function fileExists(filePath) { 83 | return fsSync.existsSync(filePath); 84 | } 85 | 86 | /** 87 | * Get file stats 88 | */ 89 | async function getFileStats(filePath) { 90 | return fs.stat(filePath); 91 | } 92 | 93 | /** 94 | * Ensure directory exists 95 | */ 96 | async function ensureDirectory(dirPath) { 97 | await fs.mkdir(dirPath, { recursive: true }); 98 | } 99 | 100 | /** 101 | * Copy file from source to destination 102 | */ 103 | async function copyFile(source, destination) { 104 | await fs.copyFile(source, destination); 105 | } 106 | 107 | /** 108 | * Delete file 109 | */ 110 | async function deleteFile(filePath) { 111 | await fs.unlink(filePath); 112 | } 113 | 114 | /** 115 | * Read directory contents 116 | */ 117 | async function readDirectory(dirPath) { 118 | try { 119 | return await fs.readdir(dirPath); 120 | } catch (error) { 121 | // Directory doesn't exist 122 | return []; 123 | } 124 | } 125 | 126 | module.exports = { 127 | getLocalFiles, 128 | readFile, 129 | writeFile, 130 | fileExists, 131 | getFileStats, 132 | ensureDirectory, 133 | copyFile, 134 | deleteFile, 135 | readDirectory 136 | }; -------------------------------------------------------------------------------- /src/main/utils/backup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Unified backup utility for Hyperclay Local 3 | * Handles backups for both server saves and sync operations 4 | */ 5 | 6 | const fs = require('fs').promises; 7 | const path = require('upath'); 8 | 9 | /** 10 | * Generate timestamp in same format as hyperclay hosted platform 11 | */ 12 | function generateTimestamp() { 13 | const now = new Date(); 14 | const year = now.getFullYear(); 15 | const month = String(now.getMonth() + 1).padStart(2, '0'); 16 | const day = String(now.getDate()).padStart(2, '0'); 17 | const hours = String(now.getHours()).padStart(2, '0'); 18 | const minutes = String(now.getMinutes()).padStart(2, '0'); 19 | const seconds = String(now.getSeconds()).padStart(2, '0'); 20 | const milliseconds = String(now.getMilliseconds()).padStart(3, '0'); 21 | 22 | return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}-${milliseconds}`; 23 | } 24 | 25 | /** 26 | * Create a backup of a file 27 | * @param {string} baseDir - Base directory (sync folder or server folder) 28 | * @param {string} siteName - Site name (e.g., "mysite" or "folder1/folder2/mysite") 29 | * @param {string} content - Content to backup 30 | * @param {function} emit - Optional event emitter function 31 | * @param {object} logger - Optional logger instance 32 | */ 33 | async function createBackup(baseDir, siteName, content, emit, logger = null) { 34 | try { 35 | const versionsDir = path.join(baseDir, 'sites-versions'); 36 | const siteVersionsDir = path.join(versionsDir, siteName); 37 | 38 | // Create sites-versions directory if it doesn't exist 39 | await fs.mkdir(versionsDir, { recursive: true }); 40 | 41 | // Create site-specific directory if it doesn't exist 42 | await fs.mkdir(siteVersionsDir, { recursive: true }); 43 | 44 | // Generate timestamp filename 45 | const timestamp = generateTimestamp(); 46 | const backupFilename = `${timestamp}.html`; 47 | const backupPath = path.join(siteVersionsDir, backupFilename); 48 | 49 | // Write backup file 50 | await fs.writeFile(backupPath, content, 'utf8'); 51 | console.log(`[BACKUP] Created: sites-versions/${siteName}/${backupFilename}`); 52 | 53 | // Log backup creation 54 | if (logger) { 55 | logger.info('BACKUP', 'Backup created', { 56 | site: siteName, 57 | backupFile: backupFilename 58 | }); 59 | } 60 | 61 | // Emit event if emitter provided 62 | if (emit) { 63 | emit('backup-created', { 64 | original: siteName, 65 | backup: backupPath 66 | }); 67 | } 68 | 69 | return backupPath; 70 | } catch (error) { 71 | console.error(`[BACKUP] Failed to create backup for ${siteName}:`, error.message); 72 | 73 | // Log backup error 74 | if (logger) { 75 | logger.error('BACKUP', 'Backup creation failed', { 76 | site: siteName, 77 | error 78 | }); 79 | } 80 | 81 | // Don't throw error - backup failure shouldn't prevent save/sync 82 | return null; 83 | } 84 | } 85 | 86 | /** 87 | * Create backup if file exists 88 | * Reads the file content and creates a backup 89 | * @param {string} filePath - Absolute path to file 90 | * @param {string} siteName - Site name for backup directory 91 | * @param {string} baseDir - Base directory 92 | * @param {function} emit - Optional event emitter function 93 | * @param {object} logger - Optional logger instance 94 | */ 95 | async function createBackupIfExists(filePath, siteName, baseDir, emit, logger = null) { 96 | try { 97 | await fs.access(filePath); 98 | // File exists, read and backup 99 | const content = await fs.readFile(filePath, 'utf8'); 100 | return await createBackup(baseDir, siteName, content, emit, logger); 101 | } catch { 102 | // File doesn't exist, no backup needed 103 | return null; 104 | } 105 | } 106 | 107 | module.exports = { 108 | generateTimestamp, 109 | createBackup, 110 | createBackupIfExists 111 | }; 112 | -------------------------------------------------------------------------------- /src/renderer/components/ErrorQueue.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | const ERROR_PRIORITY = { 4 | CRITICAL: 1, 5 | HIGH: 2, 6 | MEDIUM: 3, 7 | LOW: 4 8 | }; 9 | 10 | const ERROR_COLORS = { 11 | 1: 'bg-red-600 text-white border-red-700', 12 | 2: 'bg-red-500 text-white border-red-600', 13 | 3: 'bg-red-400 text-white border-red-500', 14 | 4: 'bg-red-300 text-white border-red-400' 15 | }; 16 | 17 | export default function ErrorQueue({ errors, onDismiss, maxVisible = 3 }) { 18 | const [expandedErrors, setExpandedErrors] = useState(new Set()); 19 | const [autoDismissTimers, setAutoDismissTimers] = useState({}); 20 | 21 | // Sort errors by priority and timestamp 22 | const sortedErrors = [...errors].sort((a, b) => { 23 | if (a.priority !== b.priority) { 24 | return a.priority - b.priority; // Lower number = higher priority 25 | } 26 | return b.timestamp - a.timestamp; // Newer first 27 | }); 28 | 29 | const visibleErrors = sortedErrors.slice(0, maxVisible); 30 | const hiddenCount = sortedErrors.length - maxVisible; 31 | 32 | // Auto-dismiss logic 33 | useEffect(() => { 34 | errors.forEach(error => { 35 | if (error.dismissable && !autoDismissTimers[error.id]) { 36 | const dismissTime = 37 | error.priority === ERROR_PRIORITY.HIGH ? 10000 : 38 | error.priority === ERROR_PRIORITY.MEDIUM ? 5000 : 39 | null; 40 | 41 | if (dismissTime) { 42 | const timer = setTimeout(() => { 43 | onDismiss(error.id); 44 | }, dismissTime); 45 | 46 | setAutoDismissTimers(prev => ({ 47 | ...prev, 48 | [error.id]: timer 49 | })); 50 | } 51 | } 52 | }); 53 | 54 | return () => { 55 | // Clear all timers on unmount 56 | Object.values(autoDismissTimers).forEach(clearTimeout); 57 | }; 58 | }, [errors]); 59 | 60 | const toggleExpanded = (errorId) => { 61 | setExpandedErrors(prev => { 62 | const next = new Set(prev); 63 | if (next.has(errorId)) { 64 | next.delete(errorId); 65 | } else { 66 | next.add(errorId); 67 | } 68 | return next; 69 | }); 70 | }; 71 | 72 | if (errors.length === 0) return null; 73 | 74 | return ( 75 |
76 | {visibleErrors.map(error => ( 77 |
85 |
86 |
87 |
88 | {expandedErrors.has(error.id) || error.error.length < 100 89 | ? error.error 90 | : error.error.substring(0, 100) + '...'} 91 |
92 | 93 | {error.error.length > 100 && ( 94 | 100 | )} 101 |
102 | 103 | 110 |
111 |
112 | ))} 113 | 114 | {hiddenCount > 0 && ( 115 |
116 | +{hiddenCount} more {hiddenCount === 1 ? 'error' : 'errors'} 117 |
118 | )} 119 |
120 | ); 121 | } -------------------------------------------------------------------------------- /src/sync-engine/error-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Error handling utilities for the sync engine 3 | */ 4 | 5 | const { ERROR_PRIORITY, ERROR_TYPES } = require('./constants'); 6 | 7 | /** 8 | * Classify error and determine priority and type 9 | */ 10 | function classifyError(error, context = {}) { 11 | const { filename, action } = context; 12 | 13 | let priority = ERROR_PRIORITY.HIGH; 14 | let errorType = ERROR_TYPES.UNKNOWN; 15 | let userMessage = error.message; 16 | 17 | const errorMsg = error.message.toLowerCase(); 18 | 19 | // Authentication errors 20 | if (errorMsg.includes('401') || errorMsg.includes('403') || errorMsg.includes('unauthorized')) { 21 | priority = ERROR_PRIORITY.CRITICAL; 22 | errorType = ERROR_TYPES.AUTH_FAILURE; 23 | userMessage = 'Authentication failed. Please reconnect with a valid API key.'; 24 | } 25 | // Name conflict errors 26 | else if (errorMsg.includes('already taken') || errorMsg.includes('name conflict')) { 27 | priority = ERROR_PRIORITY.CRITICAL; 28 | errorType = ERROR_TYPES.NAME_CONFLICT; 29 | if (filename) { 30 | const siteName = filename.replace('.html', ''); 31 | userMessage = `The name "${siteName}" is already taken by another user. Please rename your local file.`; 32 | } else { 33 | userMessage = 'This name is already taken by another user. Please rename your local file.'; 34 | } 35 | } 36 | // Network errors 37 | else if (errorMsg.includes('fetch failed') || errorMsg.includes('enotfound') || 38 | errorMsg.includes('network') || errorMsg.includes('etimedout')) { 39 | priority = ERROR_PRIORITY.MEDIUM; 40 | errorType = ERROR_TYPES.NETWORK_ERROR; 41 | userMessage = 'Network connection issue. Will retry automatically.'; 42 | } 43 | // File access errors 44 | else if (errorMsg.includes('eacces') || errorMsg.includes('eperm') || 45 | errorMsg.includes('permission') || errorMsg.includes('access denied')) { 46 | priority = ERROR_PRIORITY.HIGH; 47 | errorType = ERROR_TYPES.FILE_ACCESS; 48 | userMessage = filename 49 | ? `Cannot write to file: ${filename}. Check file permissions.` 50 | : 'File access error. Check file permissions.'; 51 | } 52 | // Sync conflict errors 53 | else if (errorMsg.includes('conflict') || errorMsg.includes('mismatch')) { 54 | priority = ERROR_PRIORITY.HIGH; 55 | errorType = ERROR_TYPES.SYNC_CONFLICT; 56 | userMessage = 'Sync conflict detected. Manual resolution may be required.'; 57 | } 58 | // Reserved name errors (from server validation) 59 | else if (errorMsg.includes(' is reserved')) { 60 | priority = ERROR_PRIORITY.HIGH; 61 | errorType = ERROR_TYPES.NAME_CONFLICT; 62 | const siteName = filename ? filename.replace(/\.html$/i, '') : 'This name'; 63 | userMessage = `"${siteName}" is a reserved name. Rename to sync.`; 64 | } 65 | 66 | return { 67 | priority, 68 | errorType, 69 | userMessage, 70 | originalError: error.message, 71 | dismissable: priority > ERROR_PRIORITY.CRITICAL, 72 | action, 73 | filename, 74 | timestamp: Date.now() 75 | }; 76 | } 77 | 78 | /** 79 | * Format error for logging 80 | */ 81 | function formatErrorForLog(error, context = {}) { 82 | const classified = classifyError(error, context); 83 | const timestamp = new Date().toISOString(); 84 | 85 | return { 86 | time: timestamp, 87 | file: context.filename, 88 | action: context.action, 89 | error: error.message, 90 | type: classified.errorType, 91 | priority: classified.priority 92 | }; 93 | } 94 | 95 | /** 96 | * Determine if error is retryable 97 | */ 98 | function isRetryableError(error) { 99 | const errorMsg = error.message.toLowerCase(); 100 | 101 | // Non-retryable errors 102 | if (errorMsg.includes('already taken') || 103 | errorMsg.includes('401') || 104 | errorMsg.includes('403') || 105 | errorMsg.includes('unauthorized') || 106 | errorMsg.includes('permission')) { 107 | return false; 108 | } 109 | 110 | // Retryable errors (network issues, temporary failures) 111 | if (errorMsg.includes('fetch failed') || 112 | errorMsg.includes('enotfound') || 113 | errorMsg.includes('network') || 114 | errorMsg.includes('etimedout') || 115 | errorMsg.includes('500') || 116 | errorMsg.includes('502') || 117 | errorMsg.includes('503') || 118 | errorMsg.includes('504')) { 119 | return true; 120 | } 121 | 122 | // Default to not retrying unknown errors 123 | return false; 124 | } 125 | 126 | module.exports = { 127 | classifyError, 128 | formatErrorForLog, 129 | isRetryableError 130 | }; -------------------------------------------------------------------------------- /.github/workflows/build-and-sign-windows.yml: -------------------------------------------------------------------------------- 1 | name: Build and Sign Windows Installer 2 | 3 | on: 4 | workflow_dispatch: # Manual trigger only 5 | 6 | jobs: 7 | build-and-sign: 8 | runs-on: windows-latest # Native x64 Windows 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Build CSS 24 | run: npm run build-css 25 | 26 | - name: Build React (production) 27 | run: npm run build-react-prod 28 | 29 | - name: Build unsigned installer with electron-builder 30 | run: npm run clean-windows && npx electron-builder --win 31 | env: 32 | # Don't pass Azure vars - we want unsigned first 33 | NODE_ENV: production 34 | # Provide GH_TOKEN to avoid publish errors (we're not publishing) 35 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Install TrustedSigning PowerShell module 38 | run: Install-Module -Name TrustedSigning -Force -Scope CurrentUser -AcceptLicense 39 | shell: pwsh 40 | 41 | - name: Sign installer with Azure Trusted Signing 42 | env: 43 | AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} 44 | AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} 45 | AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} 46 | run: | 47 | $installerPath = Resolve-Path "dist\HyperclayLocal-Setup-*.exe" 48 | Write-Host "Signing: $installerPath" 49 | 50 | Invoke-TrustedSigning ` 51 | -Endpoint "https://eus.codesigning.azure.net" ` 52 | -CodeSigningAccountName "Hyperclay" ` 53 | -CertificateProfileName "HyperclayLocalPublicCertProfile" ` 54 | -Files "$installerPath" ` 55 | -FileDigest SHA256 ` 56 | -Verbose 57 | 58 | Write-Host "Signing completed successfully!" 59 | shell: pwsh 60 | 61 | - name: Verify signature 62 | run: | 63 | $installer = Resolve-Path "dist\HyperclayLocal-Setup-*.exe" 64 | $sig = Get-AuthenticodeSignature $installer 65 | Write-Host "Signature status: $($sig.Status)" 66 | Write-Host "Signer: $($sig.SignerCertificate.Subject)" 67 | 68 | if ($sig.Status -ne "Valid") { 69 | Write-Error "Signature verification failed!" 70 | exit 1 71 | } 72 | shell: pwsh 73 | 74 | - name: Upload signed installer 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: hyperclay-local-windows-signed 78 | path: dist/HyperclayLocal-Setup-*.exe 79 | if-no-files-found: error 80 | 81 | - name: Get installer info 82 | run: | 83 | $installer = Get-Item (Resolve-Path "dist\HyperclayLocal-Setup-*.exe") 84 | Write-Host "Signed installer:" 85 | Write-Host " Name: $($installer.Name)" 86 | Write-Host " Size: $([math]::Round($installer.Length / 1MB, 2)) MB" 87 | Write-Host " Path: $($installer.FullName)" 88 | shell: pwsh 89 | 90 | - name: Upload to R2 91 | env: 92 | R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} 93 | R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }} 94 | R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} 95 | R2_BUCKET: ${{ secrets.R2_BUCKET }} 96 | R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} 97 | run: | 98 | # Install AWS CLI 99 | $ProgressPreference = 'SilentlyContinue' 100 | Invoke-WebRequest -Uri "https://awscli.amazonaws.com/AWSCLIV2.msi" -OutFile "AWSCLIV2.msi" 101 | Start-Process msiexec.exe -Wait -ArgumentList '/i AWSCLIV2.msi /quiet' 102 | $env:PATH += ";C:\Program Files\Amazon\AWSCLI2" 103 | 104 | # Configure for R2 105 | aws configure set aws_access_key_id $env:R2_ACCESS_KEY 106 | aws configure set aws_secret_access_key $env:R2_SECRET_KEY 107 | aws configure set region auto 108 | 109 | # Upload 110 | $installer = Get-Item (Resolve-Path "dist\HyperclayLocal-Setup-*.exe") 111 | $filename = $installer.Name -replace ' ', '-' 112 | 113 | Write-Host "Uploading $filename to R2..." 114 | aws s3 cp $installer.FullName "s3://$env:R2_BUCKET/$filename" ` 115 | --endpoint-url "https://$env:R2_ACCOUNT_ID.r2.cloudflarestorage.com" ` 116 | --content-type "application/x-msdos-program" 117 | 118 | Write-Host "Uploaded: $env:R2_PUBLIC_URL/$filename" 119 | shell: pwsh 120 | -------------------------------------------------------------------------------- /src/sync-engine/sync-queue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sync queue management with retry logic 3 | */ 4 | 5 | const { SYNC_CONFIG } = require('./constants'); 6 | const { isRetryableError } = require('./error-handler'); 7 | 8 | class SyncQueue { 9 | constructor() { 10 | this.queue = []; 11 | this.retryQueue = new Map(); 12 | this.retryTimers = new Set(); 13 | this.queueTimer = null; 14 | this.isProcessing = false; 15 | } 16 | 17 | /** 18 | * Add item to sync queue 19 | */ 20 | add(type, filename) { 21 | // Only sync .html files 22 | if (!filename.endsWith('.html')) return false; 23 | 24 | // Check if already in queue 25 | const existing = this.queue.find(item => item.filename === filename); 26 | if (existing) { 27 | return false; 28 | } 29 | 30 | this.queue.push({ 31 | type, 32 | filename, 33 | queuedAt: Date.now() 34 | }); 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * Get next item from queue 41 | */ 42 | next() { 43 | return this.queue.shift(); 44 | } 45 | 46 | /** 47 | * Check if queue is empty 48 | */ 49 | isEmpty() { 50 | return this.queue.length === 0; 51 | } 52 | 53 | /** 54 | * Get queue length 55 | */ 56 | length() { 57 | return this.queue.length; 58 | } 59 | 60 | /** 61 | * Check if currently processing 62 | */ 63 | isProcessingQueue() { 64 | return this.isProcessing; 65 | } 66 | 67 | /** 68 | * Set processing state 69 | */ 70 | setProcessing(state) { 71 | this.isProcessing = state; 72 | } 73 | 74 | /** 75 | * Handle retry for failed item 76 | */ 77 | scheduleRetry(item, error, onRetry) { 78 | // Check if error is retryable 79 | if (!isRetryableError(error)) { 80 | return { 81 | shouldRetry: false, 82 | reason: 'Non-retryable error' 83 | }; 84 | } 85 | 86 | const retryInfo = this.retryQueue.get(item.filename) || { attempts: 0 }; 87 | retryInfo.attempts++; 88 | retryInfo.lastError = error.message; 89 | 90 | if (retryInfo.attempts >= SYNC_CONFIG.MAX_RETRIES) { 91 | // Max retries exceeded 92 | this.retryQueue.delete(item.filename); 93 | return { 94 | shouldRetry: false, 95 | reason: 'Max retries exceeded', 96 | attempts: retryInfo.attempts 97 | }; 98 | } 99 | 100 | // Schedule retry with exponential backoff 101 | this.retryQueue.set(item.filename, retryInfo); 102 | const delay = SYNC_CONFIG.RETRY_DELAYS[retryInfo.attempts - 1]; 103 | 104 | console.log(`[SYNC] Scheduling retry ${retryInfo.attempts}/${SYNC_CONFIG.MAX_RETRIES} for ${item.filename} in ${delay/1000}s`); 105 | 106 | const timer = setTimeout(() => { 107 | // Remove timer from tracking set 108 | this.retryTimers.delete(timer); 109 | 110 | // Call retry callback 111 | if (onRetry) { 112 | onRetry(item); 113 | } 114 | }, delay); 115 | 116 | // Track the timer 117 | this.retryTimers.add(timer); 118 | 119 | return { 120 | shouldRetry: true, 121 | attempt: retryInfo.attempts, 122 | maxAttempts: SYNC_CONFIG.MAX_RETRIES, 123 | nextRetryIn: delay / 1000 124 | }; 125 | } 126 | 127 | /** 128 | * Clear retry info for successful item 129 | */ 130 | clearRetry(filename) { 131 | this.retryQueue.delete(filename); 132 | } 133 | 134 | /** 135 | * Check if file has failed permanently 136 | */ 137 | hasFailedPermanently(filename) { 138 | const retryInfo = this.retryQueue.get(filename); 139 | return !!(retryInfo && retryInfo.attempts >= SYNC_CONFIG.MAX_RETRIES); 140 | } 141 | 142 | /** 143 | * Clear all pending operations 144 | */ 145 | clear() { 146 | // Clear main queue 147 | this.queue = []; 148 | 149 | // Clear retry queue 150 | this.retryQueue.clear(); 151 | 152 | // Clear all retry timers 153 | for (const timer of this.retryTimers) { 154 | clearTimeout(timer); 155 | } 156 | this.retryTimers.clear(); 157 | 158 | // Clear queue timer 159 | clearTimeout(this.queueTimer); 160 | this.queueTimer = null; 161 | 162 | // Reset processing state 163 | this.isProcessing = false; 164 | } 165 | 166 | /** 167 | * Set queue processing timer 168 | */ 169 | setQueueTimer(callback, delay = 500) { 170 | clearTimeout(this.queueTimer); 171 | this.queueTimer = setTimeout(callback, delay); 172 | } 173 | 174 | /** 175 | * Clear queue processing timer 176 | */ 177 | clearQueueTimer() { 178 | clearTimeout(this.queueTimer); 179 | this.queueTimer = null; 180 | } 181 | 182 | /** 183 | * Get retry info for a file 184 | */ 185 | getRetryInfo(filename) { 186 | return this.retryQueue.get(filename); 187 | } 188 | 189 | /** 190 | * Get all items currently in queue 191 | */ 192 | getQueuedItems() { 193 | return [...this.queue]; 194 | } 195 | 196 | /** 197 | * Get all items with retry info 198 | */ 199 | getRetryItems() { 200 | return Array.from(this.retryQueue.entries()).map(([filename, info]) => ({ 201 | filename, 202 | ...info 203 | })); 204 | } 205 | } 206 | 207 | module.exports = SyncQueue; -------------------------------------------------------------------------------- /scripts/update-external-docs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Update version numbers in external documentation files. 5 | * 6 | * Usage: 7 | * node scripts/update-external-docs.js # Uses version from package.json 8 | * node scripts/update-external-docs.js 1.2.0 # Uses specified version 9 | */ 10 | 11 | const fs = require('fs'); 12 | const path = require('path'); 13 | 14 | // ============================================ 15 | // CONFIGURATION 16 | // ============================================ 17 | 18 | const ROOT_DIR = path.join(__dirname, '..'); 19 | const PARENT_DIR = path.join(ROOT_DIR, '..'); 20 | 21 | const EXTERNAL_FILES = [ 22 | { 23 | path: path.join(PARENT_DIR, 'hyperclay/server-pages/hyperclay-local.edge'), 24 | name: 'hyperclay-local.edge' 25 | }, 26 | { 27 | path: path.join(PARENT_DIR, 'hyperclay-website/vault/DOCS/12 Hyperclay Local - Desktop App Documentation.md'), 28 | name: 'Hyperclay Local Documentation.md' 29 | } 30 | ]; 31 | 32 | // ============================================ 33 | // COLORS 34 | // ============================================ 35 | 36 | const colors = { 37 | reset: '\x1b[0m', 38 | red: '\x1b[31m', 39 | green: '\x1b[32m', 40 | yellow: '\x1b[33m', 41 | blue: '\x1b[34m', 42 | cyan: '\x1b[36m' 43 | }; 44 | 45 | function logSuccess(msg) { console.log(`${colors.green}✓${colors.reset} ${msg}`); } 46 | function logWarn(msg) { console.log(`${colors.yellow}⚠${colors.reset} ${msg}`); } 47 | function logError(msg) { console.log(`${colors.red}✗${colors.reset} ${msg}`); } 48 | function logInfo(msg) { console.log(`${colors.blue}→${colors.reset} ${msg}`); } 49 | 50 | // ============================================ 51 | // VERSION DETECTION 52 | // ============================================ 53 | 54 | function getCurrentVersion() { 55 | const pkgPath = path.join(ROOT_DIR, 'package.json'); 56 | const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); 57 | return pkg.version; 58 | } 59 | 60 | function detectOldVersion(content) { 61 | // Look for version pattern in download URLs 62 | const match = content.match(/HyperclayLocal-(\d+\.\d+\.\d+)/); 63 | return match ? match[1] : null; 64 | } 65 | 66 | // ============================================ 67 | // UPDATE LOGIC 68 | // ============================================ 69 | 70 | function updateVersionInContent(content, oldVersion, newVersion) { 71 | // Replace all version occurrences in download URLs and filenames 72 | // Patterns: HyperclayLocal-X.X.X and HyperclayLocal-Setup-X.X.X 73 | const oldEscaped = oldVersion.replace(/\./g, '\\.'); 74 | 75 | let updated = content; 76 | 77 | // HyperclayLocal-X.X.X (dmg, AppImage) 78 | updated = updated.replace( 79 | new RegExp(`HyperclayLocal-${oldEscaped}`, 'g'), 80 | `HyperclayLocal-${newVersion}` 81 | ); 82 | 83 | // HyperclayLocal-Setup-X.X.X (exe) 84 | updated = updated.replace( 85 | new RegExp(`HyperclayLocal-Setup-${oldEscaped}`, 'g'), 86 | `HyperclayLocal-Setup-${newVersion}` 87 | ); 88 | 89 | return updated; 90 | } 91 | 92 | // ============================================ 93 | // MAIN 94 | // ============================================ 95 | 96 | function main() { 97 | // Get target version 98 | const targetVersion = process.argv[2] || getCurrentVersion(); 99 | 100 | console.log(''); 101 | console.log(`${colors.cyan}Updating external docs to version ${targetVersion}${colors.reset}`); 102 | console.log(''); 103 | 104 | let updatedCount = 0; 105 | let skippedCount = 0; 106 | const updatedFiles = []; 107 | 108 | for (const file of EXTERNAL_FILES) { 109 | // Check if file exists 110 | if (!fs.existsSync(file.path)) { 111 | logWarn(`Skipped ${file.name} (file not found)`); 112 | logInfo(` Expected: ${file.path}`); 113 | skippedCount++; 114 | continue; 115 | } 116 | 117 | // Read content 118 | const content = fs.readFileSync(file.path, 'utf8'); 119 | 120 | // Detect old version 121 | const oldVersion = detectOldVersion(content); 122 | if (!oldVersion) { 123 | logWarn(`Skipped ${file.name} (no version found in file)`); 124 | skippedCount++; 125 | continue; 126 | } 127 | 128 | // Check if already up to date 129 | if (oldVersion === targetVersion) { 130 | logSuccess(`${file.name} already at ${targetVersion}`); 131 | continue; 132 | } 133 | 134 | // Update content 135 | const updatedContent = updateVersionInContent(content, oldVersion, targetVersion); 136 | 137 | // Write back 138 | fs.writeFileSync(file.path, updatedContent); 139 | logSuccess(`Updated ${file.name} (${oldVersion} → ${targetVersion})`); 140 | updatedFiles.push(file); 141 | updatedCount++; 142 | } 143 | 144 | // Summary 145 | console.log(''); 146 | if (updatedCount > 0) { 147 | console.log(`${colors.green}Updated ${updatedCount} file(s)${colors.reset}`); 148 | console.log(''); 149 | console.log(`${colors.yellow}Remember to commit these changes in their respective repos:${colors.reset}`); 150 | updatedFiles.forEach(file => { 151 | console.log(` ${file.path}`); 152 | }); 153 | } else if (skippedCount === EXTERNAL_FILES.length) { 154 | logError('No files were updated (all skipped or not found)'); 155 | process.exit(1); 156 | } else { 157 | console.log('All files already up to date.'); 158 | } 159 | console.log(''); 160 | } 161 | 162 | main(); 163 | -------------------------------------------------------------------------------- /src/sync-engine/logger.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const path = require('upath'); 3 | const { app } = require('electron'); 4 | 5 | // Helper functions for formatting timestamps 6 | function formatDate(date) { 7 | return date.toISOString().split('T')[0]; // YYYY-MM-DD 8 | } 9 | 10 | function formatTimestamp(date) { 11 | return date.toISOString().replace('T', ' ').replace('Z', ''); // YYYY-MM-DD HH:MM:SS.mmm 12 | } 13 | 14 | // Safe serializer for metadata - handles circular refs and Error objects 15 | function safeStringify(obj) { 16 | try { 17 | // Handle Error objects specially - include stack trace 18 | if (obj instanceof Error) { 19 | return JSON.stringify({ 20 | name: obj.name, 21 | message: obj.message, 22 | stack: obj.stack 23 | }); 24 | } 25 | 26 | const seen = new WeakSet(); 27 | return JSON.stringify(obj, (key, value) => { 28 | // Filter out circular references 29 | if (typeof value === 'object' && value !== null) { 30 | if (seen.has(value)) return '[Circular]'; 31 | seen.add(value); 32 | } 33 | return value; 34 | }); 35 | } catch (error) { 36 | return '[Unserializable]'; 37 | } 38 | } 39 | 40 | // Sanitize paths to remove base directory (privacy protection) 41 | function sanitizePath(fullPath, baseDir) { 42 | if (!fullPath || !baseDir) return fullPath; 43 | if (fullPath.startsWith(baseDir)) { 44 | return fullPath.slice(baseDir.length + 1); // Remove base + separator 45 | } 46 | return fullPath; 47 | } 48 | 49 | class SyncLogger { 50 | constructor() { 51 | this.logDir = null; 52 | this.currentLogFile = null; 53 | this.currentDate = null; 54 | this.baseDir = null; // Store base directory for path sanitization 55 | } 56 | 57 | // Initialize logger with log directory 58 | async init(baseDir = null) { 59 | try { 60 | const logsPath = app.getPath('logs'); 61 | this.logDir = path.join(logsPath, 'sync'); 62 | this.baseDir = baseDir; // Store for sanitizing paths 63 | await fs.mkdir(this.logDir, { recursive: true }); 64 | 65 | // Clean up old logs (older than 30 days) 66 | await this.cleanupOldLogs(); 67 | } catch (error) { 68 | console.error('[SyncLogger] Failed to initialize:', error); 69 | // Don't throw - allow sync to continue without logging 70 | } 71 | } 72 | 73 | // Delete log files older than 30 days 74 | async cleanupOldLogs() { 75 | try { 76 | const files = await fs.readdir(this.logDir); 77 | const now = Date.now(); 78 | const thirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000); 79 | 80 | for (const file of files) { 81 | if (!file.endsWith('.log')) continue; 82 | 83 | const filePath = path.join(this.logDir, file); 84 | const stats = await fs.stat(filePath); 85 | 86 | if (stats.mtimeMs < thirtyDaysAgo) { 87 | await fs.unlink(filePath); 88 | console.log(`[SyncLogger] Deleted old log file: ${file}`); 89 | } 90 | } 91 | } catch (error) { 92 | console.error('[SyncLogger] Failed to cleanup old logs:', error); 93 | // Don't throw - not critical 94 | } 95 | } 96 | 97 | // Get current log file path 98 | getCurrentLogFile() { 99 | const now = new Date(); 100 | const dateStr = formatDate(now); // YYYY-MM-DD 101 | 102 | // Check if we need a new file (day changed) 103 | if (dateStr !== this.currentDate) { 104 | this.currentDate = dateStr; 105 | this.currentLogFile = path.join(this.logDir, `${dateStr}.log`); 106 | } 107 | 108 | return this.currentLogFile; 109 | } 110 | 111 | // Append log entry - fire-and-forget (non-blocking) 112 | log(level, context, message, metadata = {}) { 113 | // Fire and forget - don't await 114 | this._writeLog(level, context, message, metadata).catch(error => { 115 | // Graceful fallback to console logging if filesystem fails 116 | console.log(`[SYNC-LOG-FALLBACK] [${level}] [${context}] ${message}`, metadata); 117 | }); 118 | } 119 | 120 | // Internal async write method 121 | async _writeLog(level, context, message, metadata) { 122 | // Lazy initialization - ensure logger is ready before first use 123 | if (!this.logDir) { 124 | await this.init(); 125 | } 126 | 127 | const timestamp = formatTimestamp(new Date()); 128 | const logFile = this.getCurrentLogFile(); 129 | 130 | let logEntry = `[${timestamp}] [${level}] [${context}] ${message}`; 131 | 132 | // Add metadata if present (using safe serializer) 133 | if (Object.keys(metadata).length > 0) { 134 | logEntry += ` | ${safeStringify(metadata)}`; 135 | } 136 | 137 | logEntry += '\n'; 138 | 139 | await fs.appendFile(logFile, logEntry, 'utf8'); 140 | } 141 | 142 | // Convenience methods 143 | info(context, message, metadata = {}) { 144 | this.log('INFO', context, message, metadata); 145 | } 146 | 147 | success(context, message, metadata = {}) { 148 | this.log('SUCCESS', context, message, metadata); 149 | } 150 | 151 | skip(context, message, metadata = {}) { 152 | this.log('SKIP', context, message, metadata); 153 | } 154 | 155 | warn(context, message, metadata = {}) { 156 | this.log('WARN', context, message, metadata); 157 | } 158 | 159 | error(context, message, metadata = {}) { 160 | this.log('ERROR', context, message, metadata); 161 | } 162 | 163 | // Helper to sanitize file paths before logging 164 | sanitizePath(fullPath) { 165 | return sanitizePath(fullPath, this.baseDir); 166 | } 167 | } 168 | 169 | // Export singleton instance 170 | module.exports = new SyncLogger(); 171 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Building & Releasing HyperclayLocal 2 | 3 | ## Quick Reference 4 | 5 | | Platform | Build Command | Signing Method | 6 | |----------|---------------|----------------| 7 | | macOS | `npm run mac-build:run` | Local (Developer ID + Notarization) | 8 | | Windows | `npm run win-build:run` | GitHub Actions (Azure Trusted Signing) | 9 | | Linux | `npm run linux-build:run` | None required | 10 | 11 | --- 12 | 13 | ## Prerequisites 14 | 15 | ### All Platforms 16 | - Node.js 18+ 17 | - npm 18 | 19 | ### macOS Signing 20 | Requires a Mac with: 21 | - Apple Developer Program membership ($99/year) 22 | - "Developer ID Application" certificate in Keychain 23 | - App-specific password from appleid.apple.com 24 | 25 | ### Windows Signing 26 | Requires: 27 | - GitHub repository with Actions enabled 28 | - Azure Trusted Signing account 29 | - GitHub Secrets configured (see below) 30 | 31 | --- 32 | 33 | ## macOS Build 34 | 35 | macOS apps are built and signed locally on a Mac. 36 | 37 | ### Setup (One-time) 38 | 39 | 1. **Install certificate**: In Xcode → Settings → Accounts → Manage Certificates → Create "Developer ID Application" 40 | 41 | 2. **Create app-specific password**: Go to appleid.apple.com → Security → Generate app-specific password 42 | 43 | 3. **Set environment variables** (add to `.env` or export): 44 | ```bash 45 | export APPLE_ID="your-apple-id@example.com" 46 | export APPLE_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" 47 | export APPLE_TEAM_ID="YOUR10CHAR" 48 | ``` 49 | 50 | ### Build 51 | 52 | ```bash 53 | npm run mac-build:run 54 | ``` 55 | 56 | This will: 57 | 1. Build the React app and CSS 58 | 2. Package with electron-builder 59 | 3. Sign with your Developer ID certificate 60 | 4. Submit for Apple notarization 61 | 5. Output DMG files for Intel and Apple Silicon in `dist/` 62 | 63 | ### Check Notarization Status 64 | 65 | ```bash 66 | npm run mac-build:finalize 67 | ``` 68 | 69 | --- 70 | 71 | ## Windows Build 72 | 73 | Windows builds use GitHub Actions because Azure Trusted Signing tools don't work reliably on ARM64 Windows. 74 | 75 | ### Setup (One-time) 76 | 77 | Add these secrets to your GitHub repository at: 78 | `https://github.com/YOUR_USERNAME/hyperclay-local/settings/secrets/actions` 79 | 80 | | Secret | Description | 81 | |--------|-------------| 82 | | `AZURE_TENANT_ID` | Your Azure tenant ID | 83 | | `AZURE_CLIENT_ID` | Service principal client ID | 84 | | `AZURE_CLIENT_SECRET` | Service principal secret | 85 | 86 | ### Build 87 | 88 | ```bash 89 | # Trigger the GitHub Actions workflow 90 | npm run win-build:run 91 | 92 | # Check build status 93 | npm run win-build:status 94 | 95 | # Download signed installer when complete 96 | npm run win-build:download 97 | ``` 98 | 99 | The signed installer will be downloaded to `executables/`. 100 | 101 | ### Manual Trigger 102 | 103 | You can also trigger the workflow from GitHub: 104 | 1. Go to Actions tab in your repository 105 | 2. Click "Build and Sign Windows Installer" 106 | 3. Click "Run workflow" 107 | 108 | --- 109 | 110 | ## Linux Build 111 | 112 | Linux builds don't require code signing. 113 | 114 | ```bash 115 | npm run linux-build:run 116 | ``` 117 | 118 | Output: AppImage in `dist/` 119 | 120 | --- 121 | 122 | ## Release Process 123 | 124 | ### 1. Update Version 125 | 126 | Update version in these files: 127 | - `package.json` 128 | - `README.md` (download links) 129 | - `src/main/main.js` (lines 21, 417) 130 | 131 | ### 2. Build All Platforms 132 | 133 | ```bash 134 | # macOS (run on Mac) 135 | npm run mac-build:run 136 | 137 | # Linux (run on Mac or Linux) 138 | npm run linux-build:run 139 | 140 | # Windows (triggers GitHub Actions) 141 | npm run win-build:run 142 | ``` 143 | 144 | ### 3. Download Windows Installer 145 | 146 | ```bash 147 | npm run win-build:status # Wait for completion 148 | npm run win-build:download 149 | ``` 150 | 151 | ### 4. Upload to CDN 152 | 153 | ```bash 154 | npm run upload-to-r2 155 | ``` 156 | 157 | ### 5. Verify 158 | 159 | - Check uploads at `https://local.hyperclay.com/` 160 | - Update download page at `../hyperclay/server-pages/hyperclay-local.edge` 161 | 162 | --- 163 | 164 | ## Build Scripts Reference 165 | 166 | ### Main Build Commands 167 | - `npm run mac-build:run` - Build signed macOS DMG 168 | - `npm run mac-build:local` - Build unsigned macOS DMG (for testing) 169 | - `npm run win-build:run` - Trigger Windows build on GitHub Actions 170 | - `npm run win-build:download` - Download signed Windows installer 171 | - `npm run linux-build:run` - Build Linux AppImage 172 | - `npm run build-all` - Build macOS and Linux (not Windows) 173 | 174 | ### CDN Management 175 | - `npm run upload-to-r2` - Upload executables to R2 CDN 176 | 177 | ### Utility 178 | - `npm run clean` - Clean all dist files 179 | - `npm run clean-mac` - Clean macOS builds only 180 | - `npm run clean-windows` - Clean Windows builds only 181 | - `npm run clean-linux` - Clean Linux builds only 182 | 183 | --- 184 | 185 | ## Troubleshooting 186 | 187 | ### macOS: "App is damaged" error 188 | ```bash 189 | xattr -cr "/Applications/HyperclayLocal.app" 190 | ``` 191 | 192 | ### macOS: Notarization fails 193 | - Verify Apple ID credentials are correct 194 | - Check that your Developer ID certificate is valid 195 | - Ensure hardened runtime is enabled in `package.json` 196 | 197 | ### Windows: Workflow fails at signing step 198 | - Check GitHub secrets are set correctly (no extra spaces) 199 | - Verify Azure credentials are still valid 200 | - Check Azure Trusted Signing account is active 201 | 202 | ### Windows: Can't download artifacts 203 | - Artifacts expire after 90 days 204 | - Re-run workflow to generate new ones 205 | 206 | ### Linux: Permission denied 207 | ```bash 208 | chmod +x HyperclayLocal-*.AppImage 209 | ``` 210 | 211 | --- 212 | 213 | ## Output Sizes 214 | 215 | - macOS DMG: ~100-115 MB 216 | - Windows EXE: ~86 MB 217 | - Linux AppImage: ~114 MB 218 | -------------------------------------------------------------------------------- /src/sync-engine/api-client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API client for server communication 3 | */ 4 | 5 | /** 6 | * Fetch list of files from server 7 | */ 8 | async function fetchServerFiles(serverUrl, apiKey) { 9 | const url = `${serverUrl}/sync/files`; 10 | console.log(`[API] Fetching files from: ${url}`); 11 | console.log(`[API] Using API key: ${apiKey.substring(0, 12)}...`); 12 | 13 | try { 14 | const response = await fetch(url, { 15 | headers: { 16 | 'X-API-Key': apiKey 17 | } 18 | }); 19 | 20 | console.log(`[API] Response status: ${response.status}`); 21 | 22 | if (!response.ok) { 23 | const errorText = await response.text().catch(() => 'Unable to read error'); 24 | console.error(`[API] Error response: ${errorText}`); 25 | throw new Error(`Server returned ${response.status}: ${errorText}`); 26 | } 27 | 28 | const data = await response.json(); 29 | console.log(`[API] Fetched ${data.files?.length || 0} files from server`); 30 | 31 | // Log each file for debugging 32 | if (data.files && data.files.length > 0) { 33 | console.log(`[API] Server files:`); 34 | data.files.forEach(file => { 35 | console.log(`[API] - ${file.filename} (path: ${file.path}, checksum: ${file.checksum})`); 36 | }); 37 | } 38 | 39 | return data.files || []; 40 | } catch (error) { 41 | console.error(`[API] Fetch failed:`, error); 42 | console.error(`[API] Error type: ${error.name}`); 43 | console.error(`[API] Error message: ${error.message}`); 44 | console.error(`[API] Full error:`, error); 45 | throw error; 46 | } 47 | } 48 | 49 | /** 50 | * Download file content from server 51 | * @param {string} serverUrl - Server base URL 52 | * @param {string} apiKey - API key for authentication 53 | * @param {string} filename - Full path WITHOUT .html extension (may include folders) 54 | */ 55 | async function downloadFromServer(serverUrl, apiKey, filename) { 56 | // NO encoding - send raw path with slashes 57 | const downloadUrl = `${serverUrl}/sync/download/${filename}`; 58 | console.log(`[API] Downloading from: ${downloadUrl}`); 59 | 60 | const response = await fetch(downloadUrl, { 61 | headers: { 62 | 'X-API-Key': apiKey 63 | } 64 | }); 65 | 66 | if (!response.ok) { 67 | const errorText = await response.text().catch(() => response.statusText); 68 | console.error(`[API] Download failed (${response.status}): ${errorText}`); 69 | throw new Error(`Failed to download ${filename}: ${errorText}`); 70 | } 71 | 72 | const data = await response.json(); 73 | 74 | return { 75 | content: data.content, 76 | modifiedAt: data.modifiedAt, 77 | checksum: data.checksum 78 | }; 79 | } 80 | 81 | /** 82 | * Upload file content to server 83 | * @param {string} serverUrl - Server base URL 84 | * @param {string} apiKey - API key for authentication 85 | * @param {string} filename - Full path WITHOUT .html extension (may include folders) 86 | * @param {string} content - File content 87 | * @param {Date} modifiedAt - Modification time 88 | */ 89 | async function uploadToServer(serverUrl, apiKey, filename, content, modifiedAt) { 90 | const response = await fetch(`${serverUrl}/sync/upload`, { 91 | method: 'POST', 92 | headers: { 93 | 'Content-Type': 'application/json', 94 | 'X-API-Key': apiKey 95 | }, 96 | body: JSON.stringify({ 97 | filename: filename, // Full path WITHOUT .html 98 | content, 99 | modifiedAt: modifiedAt.toISOString() 100 | }) 101 | }); 102 | 103 | if (!response.ok) { 104 | let errorMessage = `Server returned ${response.status}`; 105 | let errorDetails = null; 106 | 107 | try { 108 | // Clone response so we can try multiple parsing strategies 109 | const errorData = await response.clone().json(); 110 | errorMessage = errorData.message || errorData.error || errorMessage; 111 | errorDetails = errorData.details; 112 | 113 | // Log the parsed error for debugging 114 | console.error(`[API] Upload error (${response.status}):`, errorMessage); 115 | if (errorDetails) { 116 | console.error(`[API] Error details:`, errorDetails); 117 | } 118 | } catch (parseError) { 119 | // If JSON parsing fails, try to get text 120 | try { 121 | const errorText = await response.text(); 122 | if (errorText) { 123 | errorMessage = errorText; 124 | console.error(`[API] Upload error (${response.status}):`, errorText); 125 | } 126 | } catch (textError) { 127 | // Use default error message 128 | console.error(`[API] Upload error (${response.status}): Unable to parse response`); 129 | } 130 | } 131 | 132 | const error = new Error(errorMessage); 133 | error.statusCode = response.status; 134 | if (errorDetails) { 135 | error.details = errorDetails; 136 | } 137 | throw error; 138 | } 139 | 140 | return response.json(); 141 | } 142 | 143 | /** 144 | * Get server status and time (for clock calibration) 145 | */ 146 | async function getServerStatus(serverUrl, apiKey) { 147 | const url = `${serverUrl}/sync/status`; 148 | console.log(`[API] Getting server status from: ${url}`); 149 | console.log(`[API] Using API key: ${apiKey.substring(0, 12)}...`); 150 | 151 | try { 152 | const response = await fetch(url, { 153 | headers: { 154 | 'X-API-Key': apiKey 155 | } 156 | }); 157 | 158 | console.log(`[API] Response status: ${response.status}`); 159 | 160 | if (!response.ok) { 161 | const errorText = await response.text().catch(() => 'Unable to read error'); 162 | console.error(`[API] Error response: ${errorText}`); 163 | throw new Error(`Server returned ${response.status}: ${errorText}`); 164 | } 165 | 166 | const data = await response.json(); 167 | console.log(`[API] Server time: ${data.serverTime}`); 168 | return data; 169 | } catch (error) { 170 | console.error(`[API] Fetch failed:`, error); 171 | console.error(`[API] Error type: ${error.name}`); 172 | console.error(`[API] Error message: ${error.message}`); 173 | console.error(`[API] Full error:`, error); 174 | throw error; 175 | } 176 | } 177 | 178 | module.exports = { 179 | fetchServerFiles, 180 | downloadFromServer, 181 | uploadToServer, 182 | getServerStatus 183 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperclay-local-electron", 3 | "productName": "HyperclayLocal", 4 | "version": "1.2.5", 5 | "description": "Hyperclay Local Server - Desktop App", 6 | "main": "src/main/main.js", 7 | "homepage": "https://hyperclay.com", 8 | "author": "Hyperclay", 9 | "license": "MIT", 10 | "scripts": { 11 | "start": "npm run build-react && cross-env NODE_ENV=production electron .", 12 | "dev": "concurrently \"npm run dev-css\" \"npm run dev-react\" \"npm run electron-dev\"", 13 | "electron-dev": "wait-on dist/bundle.js && cross-env NODE_ENV=development electron . --dev", 14 | "build": "npm run build-css && npm run build-react && electron-builder", 15 | "build-css": "npx @tailwindcss/cli -i ./src/renderer/styles/src.css -o ./src/renderer/styles/renderer.css", 16 | "dev-css": "npx @tailwindcss/cli -i ./src/renderer/styles/src.css -o ./src/renderer/styles/renderer.css --watch", 17 | "build-react": "webpack --config config/webpack.config.js --mode=development", 18 | "dev-react": "webpack --config config/webpack.config.js --mode=development --watch", 19 | "build-react-prod": "webpack --config config/webpack.config.js --mode=production", 20 | "build-icons": "node build-scripts/generate-icons.js", 21 | "clean": "node build-scripts/clean-dist.js", 22 | "clean-mac": "node build-scripts/clean-dist.js mac", 23 | "clean-windows": "node build-scripts/clean-dist.js windows", 24 | "clean-linux": "node build-scripts/clean-dist.js linux", 25 | "build-all": "npm run build-icons && npm run clean && npm run build-css && npm run build-react-prod && electron-builder --mac --linux && node build-scripts/move-executables.js linux", 26 | "linux-build:run": "npm run build-icons && npm run clean-linux && npm run build-css && npm run build-react-prod && electron-builder --linux && node build-scripts/move-executables.js linux", 27 | "mac-build:run": "npm run build-icons && npm run clean-mac && npm run build-css && npm run build-react-prod && electron-builder --mac", 28 | "mac-build:local": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false CSC_NAME= npm run mac-build:run -- --config.mac.identity=null --config.afterSign=build-scripts/no-op.js", 29 | "mac-build:finalize": "node build-scripts/check-notarization.js", 30 | "win-build:run": "gh workflow run build-and-sign-windows.yml", 31 | "win-build:status": "gh run list --workflow=build-and-sign-windows.yml --limit 5", 32 | "win-build:download": "npm run clean-windows && gh run download --name hyperclay-local-windows-signed --dir executables", 33 | "upload-to-r2": "node build-scripts/post-build.js", 34 | "release": "node scripts/release.js", 35 | "update-external-docs": "node scripts/update-external-docs.js" 36 | }, 37 | "build": { 38 | "appId": "com.hyperclay.local-server", 39 | "productName": "HyperclayLocal", 40 | "npmRebuild": false, 41 | "directories": { 42 | "output": "dist", 43 | "buildResources": "build" 44 | }, 45 | "files": [ 46 | "src/main/**/*", 47 | "src/sync-engine/**/*", 48 | "src/renderer/app.html", 49 | "src/renderer/styles/renderer.css", 50 | "dist/bundle.js", 51 | "assets/**/*" 52 | ], 53 | "extraFiles": [], 54 | "asarUnpack": [ 55 | "node_modules/tailwind-hyperclay/**", 56 | "node_modules/tailwindcss/**", 57 | "node_modules/@tailwindcss/**", 58 | "node_modules/lightningcss*/**", 59 | "node_modules/postcss-selector-parser/**", 60 | "node_modules/cssesc/**", 61 | "node_modules/util-deprecate/**", 62 | "node_modules/mini-svg-data-uri/**" 63 | ], 64 | "mac": { 65 | "icon": "build/icon.icns", 66 | "category": "public.app-category.developer-tools", 67 | "hardenedRuntime": true, 68 | "gatekeeperAssess": false, 69 | "identity": "Hyperspace Systems LLC (JC7YGGXYKH)", 70 | "type": "distribution", 71 | "entitlements": "entitlements.plist", 72 | "entitlementsInherit": "entitlements.plist", 73 | "electronLanguages": [ 74 | "en", 75 | "en_US" 76 | ], 77 | "target": [ 78 | { 79 | "target": "dmg", 80 | "arch": [ 81 | "x64", 82 | "arm64" 83 | ] 84 | } 85 | ] 86 | }, 87 | "dmg": { 88 | "background": "build/dmg-background.png", 89 | "window": { 90 | "width": 540, 91 | "height": 380, 92 | "x": 400, 93 | "y": 100 94 | }, 95 | "contents": [ 96 | { 97 | "x": 130, 98 | "y": 100 99 | }, 100 | { 101 | "x": 410, 102 | "y": 100, 103 | "type": "link", 104 | "path": "/Applications" 105 | } 106 | ], 107 | "iconSize": 128 108 | }, 109 | "win": { 110 | "icon": "build/icon.ico", 111 | "target": [ 112 | { 113 | "target": "nsis", 114 | "arch": [ 115 | "x64" 116 | ] 117 | } 118 | ] 119 | }, 120 | "linux": { 121 | "icon": "build/icon.png", 122 | "target": [ 123 | { 124 | "target": "AppImage", 125 | "arch": [ 126 | "x64" 127 | ] 128 | } 129 | ] 130 | }, 131 | "nsis": { 132 | "oneClick": false, 133 | "allowToChangeInstallationDirectory": true, 134 | "artifactName": "HyperclayLocal-Setup-${version}.${ext}" 135 | }, 136 | "afterSign": "build-scripts/notarize-submit.js" 137 | }, 138 | "devDependencies": { 139 | "@babel/core": "^7.28.4", 140 | "@babel/preset-react": "^7.27.1", 141 | "@electron/notarize": "^3.1.1", 142 | "@tailwindcss/cli": "^4.1.14", 143 | "babel-loader": "^10.0.0", 144 | "concurrently": "^9.2.1", 145 | "cross-env": "^10.1.0", 146 | "dotenv": "^17.2.3", 147 | "electron": "^38.3.0", 148 | "electron-builder": "^25.1.8", 149 | "electron-reload": "^2.0.0-alpha.1", 150 | "rimraf": "^6.1.0", 151 | "tslib": "^2.8.1", 152 | "wait-on": "^9.0.1", 153 | "webpack": "^5.102.1", 154 | "webpack-cli": "^6.0.1" 155 | }, 156 | "dependencies": { 157 | "@aws-sdk/client-s3": "^3.913.0", 158 | "chokidar": "^3.6.0", 159 | "express": "^4.18.2", 160 | "lightningcss": "^1.30.1", 161 | "livesync-hyperclay": "^0.3.0", 162 | "react": "^19.2.0", 163 | "react-dom": "^19.2.0", 164 | "tailwind-hyperclay": "^0.1.11", 165 | "tailwindcss": "^4.1.14", 166 | "upath": "^2.0.1" 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/sync-engine/validation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validation rules for local file operations 3 | * Platform-specific restrictions (reserved names) are validated server-side during sync 4 | */ 5 | 6 | /** 7 | * Validate folder name 8 | * Must be lowercase letters, numbers, underscore, hyphen only 9 | */ 10 | function validateFolderName(name) { 11 | // Check if empty 12 | if (!name || name.trim() === '') { 13 | return { 14 | valid: false, 15 | error: 'Folder name cannot be empty' 16 | }; 17 | } 18 | 19 | // Check for invalid characters 20 | if (!name.match(/^[a-z0-9_-]+$/)) { 21 | return { 22 | valid: false, 23 | error: 'Folder names can only contain lowercase letters, numbers, hyphens and underscores' 24 | }; 25 | } 26 | 27 | // Check length 28 | if (name.length > 255) { 29 | return { 30 | valid: false, 31 | error: 'Folder name is too long (max 255 characters)' 32 | }; 33 | } 34 | 35 | return { valid: true }; 36 | } 37 | 38 | /** 39 | * Validate site name 40 | * Must follow hyperclay rules for site names 41 | */ 42 | function validateSiteName(name) { 43 | // Check if empty 44 | if (!name || name.trim() === '') { 45 | return { 46 | valid: false, 47 | error: 'Site name cannot be empty' 48 | }; 49 | } 50 | 51 | // Strip .html extension if present (we add it back later) 52 | const baseName = name.replace(/\.html$/i, ''); 53 | 54 | // Check length 55 | if (baseName.length < 1) { 56 | return { 57 | valid: false, 58 | error: 'Site name is too short' 59 | }; 60 | } 61 | if (baseName.length > 63) { 62 | return { 63 | valid: false, 64 | error: 'Site name is too long (max 63 characters)' 65 | }; 66 | } 67 | 68 | // Check format - only letters, numbers, and hyphens 69 | if (!baseName.match(/^[a-zA-Z0-9-]+$/)) { 70 | return { 71 | valid: false, 72 | error: 'Site name can only contain letters (A-Z), numbers (0-9), and hyphens (-)' 73 | }; 74 | } 75 | 76 | // Cannot start or end with hyphen 77 | if (baseName.startsWith('-') || baseName.endsWith('-')) { 78 | return { 79 | valid: false, 80 | error: 'Site name cannot start or end with a hyphen' 81 | }; 82 | } 83 | 84 | // Check for consecutive hyphens 85 | if (baseName.includes('--')) { 86 | return { 87 | valid: false, 88 | error: 'Site name cannot contain consecutive hyphens' 89 | }; 90 | } 91 | 92 | // Check for Windows reserved filenames (critical for cross-platform compatibility) 93 | const lowerName = baseName.toLowerCase(); 94 | const windowsReservedNames = [ 95 | 'con', 'prn', 'aux', 'nul', 96 | 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 97 | 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' 98 | ]; 99 | 100 | if (windowsReservedNames.includes(lowerName)) { 101 | return { 102 | valid: false, 103 | error: `"${baseName}" is a reserved system name and cannot be used on Windows` 104 | }; 105 | } 106 | 107 | return { valid: true }; 108 | } 109 | 110 | /** 111 | * Validate upload/file name 112 | * More permissive than sites/folders but still has restrictions 113 | * NOTE: This is a placeholder for future upload sync support 114 | */ 115 | function validateUploadName(name) { 116 | // For now, we don't sync uploads, so this is just a placeholder 117 | // that shows the structure for when we do implement it 118 | 119 | // Check if empty 120 | if (!name || name.trim() === '') { 121 | return { 122 | valid: false, 123 | error: 'File name cannot be empty' 124 | }; 125 | } 126 | 127 | // Check length (byte length for UTF-8) 128 | const byteLength = Buffer.from(name).length; 129 | if (byteLength > 255) { 130 | return { 131 | valid: false, 132 | error: 'File name is too long' 133 | }; 134 | } 135 | 136 | // Prevent leading/trailing dots 137 | if (name.startsWith('.') || name.endsWith('.')) { 138 | return { 139 | valid: false, 140 | error: 'File name cannot start or end with a dot' 141 | }; 142 | } 143 | 144 | // Prevent control characters and problematic chars 145 | // Blocks: Control chars, /, \, <, >, :, ", |, ?, *, null byte 146 | if (/[\x00-\x1F\x7F\/\\<>:"|?*\u0000]/u.test(name)) { 147 | return { 148 | valid: false, 149 | error: 'File name contains invalid characters' 150 | }; 151 | } 152 | 153 | // Check for Windows reserved names 154 | const lowerName = name.toLowerCase(); 155 | const baseName = lowerName.split('.')[0]; 156 | const problematicNames = [ 157 | 'con', 'prn', 'aux', 'nul', 158 | 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 159 | 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9' 160 | ]; 161 | 162 | if (problematicNames.includes(baseName)) { 163 | return { 164 | valid: false, 165 | error: 'This file name is reserved by the system' 166 | }; 167 | } 168 | 169 | // For now, since we don't sync uploads, always return valid 170 | // This structure is here for future implementation 171 | return { valid: true }; 172 | } 173 | 174 | /** 175 | * Determine file type from filename/path 176 | */ 177 | function getFileType(filename, isDirectory = false) { 178 | if (isDirectory) { 179 | return 'folder'; 180 | } 181 | 182 | // Sites have .html extension 183 | if (filename.endsWith('.html')) { 184 | return 'site'; 185 | } 186 | 187 | // Everything else would be an upload (when we support them) 188 | return 'upload'; 189 | } 190 | 191 | /** 192 | * Main validation function 193 | * Returns { valid: boolean, error?: string, type: string } 194 | */ 195 | function validateFileName(filename, isDirectory = false) { 196 | const type = getFileType(filename, isDirectory); 197 | 198 | // Remove .html extension for site validation 199 | const nameToValidate = type === 'site' 200 | ? filename.replace(/\.html$/i, '') 201 | : filename; 202 | 203 | let result; 204 | switch (type) { 205 | case 'folder': 206 | result = validateFolderName(nameToValidate); 207 | break; 208 | case 'site': 209 | result = validateSiteName(nameToValidate); 210 | break; 211 | case 'upload': 212 | result = validateUploadName(nameToValidate); 213 | break; 214 | default: 215 | result = { valid: false, error: 'Unknown file type' }; 216 | } 217 | 218 | return { ...result, type }; 219 | } 220 | 221 | /** 222 | * Validate a full path (folder/folder/file.html) 223 | */ 224 | function validateFullPath(fullPath) { 225 | const parts = fullPath.split('/').filter(Boolean); 226 | 227 | if (parts.length === 0) { 228 | return { valid: false, error: 'Empty path' }; 229 | } 230 | 231 | // Check folder depth (parts.length - 1 because last part is filename) 232 | const folderDepth = parts.length - 1; 233 | if (folderDepth > 5) { 234 | return { 235 | valid: false, 236 | error: 'Folder depth cannot exceed 5 levels. Please reorganize your files into a shallower structure.' 237 | }; 238 | } 239 | 240 | // Validate each folder in the path 241 | for (let i = 0; i < parts.length - 1; i++) { 242 | const folderResult = validateFolderName(parts[i]); 243 | if (!folderResult.valid) { 244 | return { 245 | valid: false, 246 | error: `Invalid folder "${parts[i]}": ${folderResult.error}` 247 | }; 248 | } 249 | } 250 | 251 | // Validate the filename (last part) 252 | const filename = parts[parts.length - 1]; 253 | const isDirectory = !filename.includes('.'); 254 | const fileResult = validateFileName(filename, isDirectory); 255 | 256 | if (!fileResult.valid) { 257 | return { 258 | valid: false, 259 | error: `Invalid ${fileResult.type} name "${filename}": ${fileResult.error}` 260 | }; 261 | } 262 | 263 | return { valid: true, type: fileResult.type }; 264 | } 265 | 266 | module.exports = { 267 | validateFileName, 268 | validateFolderName, 269 | validateSiteName, 270 | validateUploadName, 271 | validateFullPath, 272 | getFileType 273 | }; -------------------------------------------------------------------------------- /assets/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hyperclay Local Server (Electron App) 2 | 3 | A beautiful, cross-platform desktop application for running your Hyperclay HTML apps locally with zero configuration. 4 | 5 | ## ✨ Features 6 | 7 | - 🖥️ **Native desktop app** - Familiar GUI interface 8 | - 📁 **Visual folder selection** - Point and click to choose your apps folder 9 | - 🚀 **One-click server start** - Start/stop server with buttons 10 | - 🌐 **Auto-browser opening** - Automatically opens your default browser 11 | - 📊 **Real-time status** - Visual indicators for server state 12 | - 🔔 **System tray integration** - Runs in background, accessible from tray 13 | - 🎨 **Beautiful UI** - Modern, responsive interface 14 | - 🔗 **Quick links** - Easy access to Hyperclay.com and docs 15 | - ⚡ **Cross-platform** - Works on macOS, Windows, and Linux 16 | 17 | ## What is Hyperclay? 18 | 19 | Hyperclay lets you create **malleable HTML applications** - powerful, self-contained web apps that you fully own and control. Think of it as combining the simplicity of Google Docs with the power of custom web applications. 20 | 21 | ### The Big Idea 22 | - **Own Your Stack**: No vendor lock-in. Download your apps and run them anywhere. 23 | - **Malleable**: Your HTML apps can modify themselves and save changes instantly. 24 | - **Shareable**: Send a link, and others can view or clone your app with one click. 25 | - **Future-Proof**: Built on standard HTML/CSS/JavaScript - no proprietary frameworks. 26 | 27 | ### How Hyperclay Apps Work 28 | 29 | #### Self-Modifying HTML 30 | Your HTML applications can edit themselves in real-time. Change text, add features, modify layouts - everything saves automatically and becomes part of the app. Each app is a complete HTML document that includes: 31 | - Your content and data 32 | - Styling (CSS) 33 | - Behavior (JavaScript) 34 | - File references 35 | - Version metadata 36 | 37 | #### Edit Mode 38 | Toggle edit mode by adding `?editmode=true` to any app URL or clicking the edit button. In edit mode: 39 | - Click any text to edit it inline 40 | - Add new elements and components 41 | - Upload files and images 42 | - Customize styling and behavior 43 | - Save changes instantly with Ctrl+S 44 | 45 | #### Examples You Can Build 46 | - **📝 Writer**: Personal writing app with auto-save, word count, and export options 47 | - **📋 Kanban Board**: Visual project management with drag-and-drop cards and columns 48 | - **🛠️ Development Log**: Track coding projects, bugs, features, and progress over time 49 | - **🏠 Landing Pages**: Beautiful pages for projects, products, or personal sites 50 | - **🎯 Custom Apps**: Calculators, games, portfolios, databases, dashboards - anything you can imagine 51 | 52 | ### Why Use This Local Server? 53 | 54 | While [hyperclay.com](https://hyperclay.com) provides the full hosted experience with user accounts, version history, and collaboration features, this local server lets you: 55 | 56 | - ✅ **Work offline** - Edit your apps without internet connection 57 | - ✅ **Own your data** - Complete independence from any platform 58 | - ✅ **No subscription needed** - Run unlimited apps locally for free 59 | - ✅ **Privacy first** - Your apps and data never leave your computer 60 | - ✅ **Future-proof** - Apps work forever, regardless of service status 61 | 62 | This local server provides the core functionality needed to run and edit your Hyperclay apps, ensuring you're never locked into any platform while still benefiting from the powerful malleable HTML concept. 63 | 64 | ## 🚀 Quick Start 65 | 66 | ### Download Pre-built App 67 | 68 | 1. **Download** the app for your platform: 69 | - **macOS (Apple Silicon)**: [HyperclayLocal-1.2.5-arm64.dmg](https://local.hyperclay.com/HyperclayLocal-1.2.5-arm64.dmg) (110.5MB) 70 | - **macOS (Intel)**: [HyperclayLocal-1.2.5.dmg](https://local.hyperclay.com/HyperclayLocal-1.2.5.dmg) (118.1MB) 71 | - **Windows**: [HyperclayLocal-Setup-1.2.5.exe](https://local.hyperclay.com/HyperclayLocal-Setup-1.2.5.exe) (~88.6MB) 72 | - **Linux**: [HyperclayLocal-1.2.5.AppImage](https://local.hyperclay.com/HyperclayLocal-1.2.5.AppImage) (117.3MB) 73 | 74 | 2. **Install** and run the app 75 | 76 | 3. **Select your folder** containing HTML apps 77 | 78 | 4. **Click "Start Server"** 79 | 80 | 5. **Browser opens** automatically to your apps! 81 | 82 | ### Development 83 | 84 | ```bash 85 | npm install 86 | npm run dev 87 | ``` 88 | 89 | For building and releasing, see [BUILD.md](./BUILD.md). 90 | 91 | ## 🎯 User Interface 92 | 93 | ### Main Window 94 | - **Header**: Shows app name and server status indicator 95 | - **Folder Selection**: Visual folder picker with current selection display 96 | - **Server Controls**: Start/stop buttons and browser launcher 97 | - **Server Info**: Shows URL and folder path when running 98 | - **Instructions**: Step-by-step usage guide 99 | - **Quick Links**: Links to Hyperclay.com and documentation 100 | 101 | ### System Tray 102 | - **Status indicator**: Green (running) / Red (stopped) 103 | - **Quick actions**: Start/stop server, show/hide window 104 | - **Background operation**: App continues running when window closed 105 | 106 | ### Keyboard Shortcuts 107 | - `Cmd/Ctrl + O`: Select folder 108 | - `Cmd/Ctrl + R`: Start server 109 | - `Cmd/Ctrl + S`: Stop server 110 | - `Cmd/Ctrl + W`: Close window (app stays in tray) 111 | - `Cmd/Ctrl + Q`: Quit app (macOS only) 112 | 113 | ## 🔧 How It Works 114 | 115 | ### Server Integration 116 | The app runs an embedded Express.js server (same as the Node.js version) with: 117 | - Static file serving with extensionless HTML support 118 | - POST `/save/:name` endpoint for app self-saving 119 | - Beautiful directory listings 120 | - Security protections (path traversal, filename validation) 121 | 122 | ### File Management 123 | - **Folder Selection**: Native OS folder picker dialog 124 | - **Path Security**: Ensures all files served are within selected folder 125 | - **File Types**: Serves all file types, special handling for HTML 126 | - **Hidden Files**: Automatically hides dotfiles and system files 127 | 128 | ### Browser Integration 129 | - **Auto-launch**: Opens default browser when server starts 130 | - **URL copying**: One-click copy of server URL 131 | - **External links**: Opens external links in default browser 132 | 133 | ## 🛡️ Security Features 134 | 135 | - **Sandboxed renderer**: Web content runs in isolated context 136 | - **IPC security**: Secure communication between main and renderer processes 137 | - **Path validation**: Prevents access to files outside selected folder 138 | - **Filename sanitization**: Only allows safe characters in saved files 139 | - **Content validation**: Validates file content before saving 140 | 141 | ## 📁 Project Structure 142 | 143 | ``` 144 | electron/ 145 | ├── main.js # Main Electron process 146 | ├── server.js # Express server implementation 147 | ├── preload.js # Secure IPC bridge 148 | ├── renderer.html # Main UI 149 | ├── renderer.css # UI styling 150 | ├── package.json # Dependencies and build config 151 | ├── assets/ # App icons and images 152 | └── dist/ # Built applications (after build) 153 | ``` 154 | 155 | ## 🔧 Development 156 | 157 | ```bash 158 | npm install 159 | npm run dev 160 | ``` 161 | 162 | Development mode features: 163 | - **Hot reload**: Automatically restarts on file changes 164 | - **Developer tools**: Press F12 to open DevTools 165 | 166 | For building signed installers, see [BUILD.md](./BUILD.md). 167 | 168 | ### Adding npm Modules 169 | 170 | When adding new npm dependencies, consider whether they need `asarUnpack` in `package.json`. Electron bundles node_modules into a `.asar` archive, which can break: 171 | 172 | - **Native bindings** (e.g., `lightningcss` in `tailwind-hyperclay`) 173 | - **Dynamic require.resolve()** with package.json exports 174 | - **File system operations** with hardcoded paths 175 | 176 | If a module fails in production builds but works in development, add it to `asarUnpack`: 177 | 178 | ```json 179 | "asarUnpack": [ 180 | "node_modules/your-module/**" 181 | ] 182 | ``` 183 | 184 | Pure JavaScript modules (like `livesync-hyperclay`) typically work without unpacking. 185 | 186 | ## 🚨 Troubleshooting 187 | 188 | ### Installation Issues 189 | 190 | **macOS "App is damaged" error**: 191 | ```bash 192 | xattr -cr "/Applications/HyperclayLocal.app" 193 | ``` 194 | 195 | **Windows SmartScreen warning**: 196 | - Click "More info" → "Run anyway" 197 | - This happens because the app isn't code-signed 198 | 199 | **Linux permission denied**: 200 | ```bash 201 | chmod +x Hyperclay-Local-1.1.0.AppImage 202 | ``` 203 | 204 | ### Runtime Issues 205 | 206 | **Port 4321 already in use**: 207 | - The app will show an error dialog 208 | - Kill any existing process using the port 209 | - Or wait for the existing process to terminate 210 | 211 | **Folder selection not working**: 212 | - Ensure you have read permissions for the folder 213 | - Try selecting a different folder 214 | - Restart the app if the dialog doesn't appear 215 | 216 | **Server won't start**: 217 | - Check the folder contains some files 218 | - Ensure folder path doesn't contain special characters 219 | - Try selecting the folder again 220 | 221 | **Apps won't save**: 222 | - Check browser console for error messages 223 | - Ensure the app is making requests to `localhost:4321` 224 | - Verify the save endpoint is working by testing manually 225 | 226 | ### Performance Issues 227 | 228 | **App feels slow**: 229 | - This is normal for Electron apps 230 | - Close other resource-intensive applications 231 | - Consider using the Go binary for better performance 232 | 233 | **High memory usage**: 234 | - Electron apps use more memory than native apps 235 | - ~100-200MB usage is normal 236 | - Restart the app if memory usage grows excessively 237 | 238 | ## 🔮 Future Enhancements 239 | 240 | Planned features for future versions: 241 | 242 | - **Auto-updater**: Automatic app updates 243 | - **Multiple servers**: Run multiple folders simultaneously 244 | - **Custom ports**: Configure server port in settings 245 | - **HTTPS support**: Local SSL certificates 246 | - **File watcher**: Auto-refresh browser on file changes 247 | - **Themes**: Dark mode and custom themes 248 | - **Plugin system**: Extend functionality with plugins 249 | 250 | ## 🤝 Contributing 251 | 252 | Contributions welcome! Areas that need help: 253 | 254 | - **UI/UX improvements**: Better design and user experience 255 | - **Performance optimization**: Reduce app size and memory usage 256 | - **Cross-platform testing**: Ensure consistent behavior 257 | - **Documentation**: Improve guides and troubleshooting 258 | - **Feature requests**: Suggest and implement new features 259 | 260 | --- 261 | 262 | **Made with ❤️ for Hyperclay** - The platform for malleable HTML applications 263 | Get the full experience at [hyperclay.com](https://hyperclay.com) -------------------------------------------------------------------------------- /src/main/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const fs = require('fs').promises; 3 | const path = require('upath'); 4 | const { validateFileName } = require('../sync-engine/validation'); 5 | const { createBackup } = require('./utils/backup'); 6 | const { compileTailwind, getTailwindCssName } = require('tailwind-hyperclay'); 7 | const { setupLiveSync } = require('livesync-hyperclay'); 8 | 9 | let server = null; 10 | let app = null; 11 | const PORT = 4321; 12 | let connections = new Set(); 13 | 14 | function startServer(baseDir) { 15 | return new Promise((resolve, reject) => { 16 | if (server) { 17 | return reject(new Error('Server is already running')); 18 | } 19 | 20 | app = express(); 21 | 22 | // Cookie options for all local development cookies 23 | const cookieOptions = { 24 | httpOnly: false, // Allow JavaScript access 25 | secure: false, // Allow over HTTP for local development 26 | sameSite: 'lax' 27 | }; 28 | 29 | // Set admin and login cookies for all requests since local user owns all files 30 | app.use((req, res, next) => { 31 | res.cookie('isAdminOfCurrentResource', 'true', cookieOptions); 32 | res.cookie('isLoggedIn', 'true', cookieOptions); 33 | next(); 34 | }); 35 | 36 | // Middleware to parse JSON body for live-sync endpoint 37 | app.use('/live-sync', express.json({ limit: '10mb' })); 38 | 39 | // Enable live sync for AI agent hot reload and collaborative editing 40 | setupLiveSync(app, { baseDir }); 41 | 42 | // Middleware to parse plain text body for the /save route 43 | app.use('/save/:name', express.text({ type: 'text/plain', limit: '10mb' })); 44 | 45 | // POST route to save/overwrite HTML files 46 | app.post('/save/:name', async (req, res) => { 47 | const { name } = req.params; 48 | const content = req.body; 49 | 50 | // Validate filename: only allow alphanumeric, underscore, hyphen 51 | const safeNameRegex = /^[a-zA-Z0-9_-]+$/; 52 | if (!safeNameRegex.test(name)) { 53 | return res.status(400).json({ 54 | msg: 'Invalid characters in filename. Only alphanumeric, underscores, and hyphens are allowed.', 55 | msgType: 'error' 56 | }); 57 | } 58 | 59 | const filename = `${name}.html`; 60 | 61 | // Validate against Windows reserved filenames and other restrictions 62 | const validationResult = validateFileName(filename); 63 | if (!validationResult.valid) { 64 | return res.status(400).json({ 65 | msg: validationResult.error, 66 | msgType: 'error' 67 | }); 68 | } 69 | const filePath = path.join(baseDir, filename); 70 | 71 | // Security check: Ensure the final path resolves within the base directory 72 | const resolvedPath = path.resolve(filePath); 73 | const resolvedBaseDir = path.resolve(baseDir); 74 | 75 | if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) || path.dirname(resolvedPath) !== resolvedBaseDir) { 76 | console.error(`Security Alert: Attempt to save outside base directory blocked for "${name}"`); 77 | return res.status(400).json({ 78 | msg: 'Invalid file path. Saving is only allowed in the base directory.', 79 | msgType: 'error' 80 | }); 81 | } 82 | 83 | // Ensure body content is a string 84 | if (typeof content !== 'string') { 85 | return res.status(400).json({ 86 | msg: 'Invalid request body. Plain text HTML content expected.', 87 | msgType: 'error' 88 | }); 89 | } 90 | 91 | try { 92 | // Check if this is the first save (no versions exist yet) 93 | const siteVersionsDir = path.join(baseDir, 'sites-versions', name); 94 | let isFirstSave = false; 95 | try { 96 | const versionFiles = await fs.readdir(siteVersionsDir); 97 | isFirstSave = versionFiles.length === 0; 98 | } catch (error) { 99 | // Directory doesn't exist yet, so this is the first save 100 | isFirstSave = true; 101 | } 102 | 103 | // If first save, backup the existing site content first 104 | if (isFirstSave) { 105 | try { 106 | const existingContent = await fs.readFile(filePath, 'utf8'); 107 | await createBackup(baseDir, name, existingContent); 108 | console.log(`Created initial backup of existing ${name}.html`); 109 | } catch (error) { 110 | // File doesn't exist yet, that's OK 111 | } 112 | } 113 | 114 | // Create backup of the new content 115 | await createBackup(baseDir, name, content); 116 | 117 | // Write file (creates if not exists, overwrites if exists) 118 | await fs.writeFile(filePath, content, 'utf8'); 119 | 120 | // Generate Tailwind CSS if site uses it 121 | const tailwindName = getTailwindCssName(content); 122 | if (tailwindName) { 123 | const css = await compileTailwind(content); 124 | const cssDir = path.join(baseDir, 'tailwindcss'); 125 | await fs.mkdir(cssDir, { recursive: true }); 126 | await fs.writeFile(path.join(cssDir, `${tailwindName}.css`), css, 'utf8'); 127 | console.log(`Generated Tailwind CSS: tailwindcss/${tailwindName}.css`); 128 | } 129 | 130 | res.status(200).json({ 131 | msg: `File ${filename} saved successfully.`, 132 | msgType: 'success' 133 | }); 134 | console.log(`Saved: ${filename}`); 135 | } catch (error) { 136 | console.error(`Error saving file ${filename}:`, error); 137 | res.status(500).json({ 138 | msg: `Server error saving file: ${error.message}`, 139 | msgType: 'error' 140 | }); 141 | } 142 | }); 143 | 144 | // Set currentResource cookie based on requested HTML file 145 | app.use((req, res, next) => { 146 | const urlPath = req.path; 147 | 148 | // Extract app name from URL path (just the filename, not the full path) 149 | let appName = null; 150 | if (urlPath === '/') { 151 | appName = 'index'; 152 | } else { 153 | const cleanPath = urlPath.substring(1); // Remove leading slash 154 | if (cleanPath.endsWith('.html')) { 155 | // Get just the filename without the path, then remove .html extension 156 | const filename = path.basename(cleanPath); 157 | appName = filename.slice(0, -5); // Remove .html extension 158 | } else if (!cleanPath.includes('.')) { 159 | // Extensionless HTML file - get just the basename 160 | appName = path.basename(cleanPath); 161 | } 162 | } 163 | 164 | // Set currentResource cookie if this is an HTML app request 165 | if (appName) { 166 | res.cookie('currentResource', appName, cookieOptions); 167 | } 168 | 169 | next(); 170 | }); 171 | 172 | // Static file serving with extensionless HTML support 173 | app.use((req, res, next) => { 174 | const urlPath = req.path; 175 | 176 | // Clean the path and remove leading slash 177 | const requestedPath = urlPath === '/' ? 'index.html' : urlPath.substring(1); 178 | const filePath = path.join(baseDir, requestedPath); 179 | 180 | // Security check 181 | const resolvedPath = path.resolve(filePath); 182 | const resolvedBaseDir = path.resolve(baseDir); 183 | 184 | if (!resolvedPath.startsWith(resolvedBaseDir)) { 185 | return res.status(403).send('Access denied'); 186 | } 187 | 188 | // Check if file exists 189 | fs.stat(resolvedPath) 190 | .then(stats => { 191 | if (stats.isDirectory()) { 192 | // Try index.html in directory 193 | const indexPath = path.join(resolvedPath, 'index.html'); 194 | return fs.stat(indexPath) 195 | .then(() => res.sendFile(indexPath)) 196 | .catch(() => serveDirListing(res, resolvedPath, baseDir)); 197 | } else { 198 | res.sendFile(resolvedPath); 199 | } 200 | }) 201 | .catch(() => { 202 | // If file doesn't exist, try with .html extension 203 | if (!requestedPath.endsWith('.html') && requestedPath !== 'index.html') { 204 | const htmlPath = path.join(baseDir, requestedPath + '.html'); 205 | return fs.stat(htmlPath) 206 | .then(() => res.sendFile(htmlPath)) 207 | .catch(() => { 208 | if (requestedPath === 'index.html') { 209 | serveDirListing(res, baseDir, baseDir); 210 | } else { 211 | res.status(404).send('File not found'); 212 | } 213 | }); 214 | } else if (requestedPath === 'index.html') { 215 | serveDirListing(res, baseDir, baseDir); 216 | } else { 217 | res.status(404).send('File not found'); 218 | } 219 | }); 220 | }); 221 | 222 | // Start the server 223 | server = app.listen(PORT, 'localhost', (err) => { 224 | if (err) { 225 | server = null; 226 | return reject(err); 227 | } 228 | console.log(`Hyperclay Local Server running on http://localhost:${PORT}`); 229 | console.log(`Serving files from: ${baseDir}`); 230 | resolve(); 231 | }); 232 | 233 | // Track connections for proper cleanup 234 | server.on('connection', (connection) => { 235 | connections.add(connection); 236 | connection.on('close', () => { 237 | connections.delete(connection); 238 | }); 239 | }); 240 | 241 | server.on('error', (err) => { 242 | server = null; 243 | connections.clear(); 244 | reject(err); 245 | }); 246 | }); 247 | } 248 | 249 | function stopServer() { 250 | return new Promise((resolve) => { 251 | if (server) { 252 | console.log('Stopping server...'); 253 | 254 | // Force close all active connections 255 | for (const connection of connections) { 256 | connection.destroy(); 257 | } 258 | connections.clear(); 259 | 260 | server.close(() => { 261 | server = null; 262 | app = null; 263 | console.log('Server stopped'); 264 | resolve(); 265 | }); 266 | 267 | // Fallback: Use built-in closeAllConnections if available (Node.js 18.2+) 268 | if (server.closeAllConnections) { 269 | server.closeAllConnections(); 270 | } 271 | } else { 272 | resolve(); 273 | } 274 | }); 275 | } 276 | 277 | function getServerPort() { 278 | return PORT; 279 | } 280 | 281 | function isServerRunning() { 282 | return server !== null; 283 | } 284 | 285 | async function serveDirListing(res, dirPath, baseDir) { 286 | try { 287 | const entries = await fs.readdir(dirPath, { withFileTypes: true }); 288 | 289 | // Get relative path for display 290 | const relPath = path.relative(baseDir, dirPath); 291 | const displayPath = relPath === '' ? '' : relPath; 292 | 293 | res.setHeader('Content-Type', 'text/html'); 294 | 295 | let html = ` 296 | 297 | 298 | 📁 Directory: /${displayPath} 299 | 356 | 357 | 358 |
359 |

📁 Directory: /${displayPath}

`; 360 | 361 | // Add back link if not in root 362 | if (displayPath !== '') { 363 | const parentPath = path.dirname('/' + displayPath); 364 | const backPath = parentPath === '/.' ? '/' : parentPath; 365 | html += `⬆️ Back to parent directory`; 366 | } 367 | 368 | html += ' 398 |
399 | 400 | `; 401 | 402 | res.send(html); 403 | } catch (error) { 404 | res.status(500).send('Error reading directory'); 405 | } 406 | } 407 | 408 | module.exports = { 409 | startServer, 410 | stopServer, 411 | getServerPort, 412 | isServerRunning 413 | }; -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync, spawn } = require('child_process'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const readline = require('readline'); 7 | 8 | // ============================================ 9 | // CONFIGURATION 10 | // ============================================ 11 | 12 | const ROOT_DIR = path.join(__dirname, '..'); 13 | 14 | // Load .env file for Apple credentials 15 | require('dotenv').config({ path: path.join(ROOT_DIR, '.env') }); 16 | const LOG_FILE = path.join(ROOT_DIR, 'release.log'); 17 | const NOTARIZATION_FILE = path.join(ROOT_DIR, '.notarization-submissions-mac.json'); 18 | 19 | const FILES_TO_UPDATE = [ 20 | { path: 'package.json', type: 'json' }, 21 | { path: 'README.md', type: 'readme' }, 22 | { path: 'src/main/main.js', type: 'main-js' } 23 | ]; 24 | 25 | const NOTARIZATION_POLL_INTERVAL = 30000; // 30 seconds 26 | 27 | // ============================================ 28 | // COLORS 29 | // ============================================ 30 | 31 | const colors = { 32 | reset: '\x1b[0m', 33 | red: '\x1b[31m', 34 | green: '\x1b[32m', 35 | yellow: '\x1b[33m', 36 | blue: '\x1b[34m', 37 | cyan: '\x1b[36m', 38 | dim: '\x1b[2m' 39 | }; 40 | 41 | // ============================================ 42 | // LOGGING 43 | // ============================================ 44 | 45 | let startTime; 46 | 47 | function initLog() { 48 | startTime = Date.now(); 49 | fs.writeFileSync(LOG_FILE, `# Release Log - ${new Date().toISOString()}\n\n`); 50 | } 51 | 52 | function log(message, color = null) { 53 | const timestamp = new Date().toISOString(); 54 | const logLine = `[${timestamp}] ${message}\n`; 55 | fs.appendFileSync(LOG_FILE, logLine); 56 | 57 | if (color) { 58 | console.log(`${color}${message}${colors.reset}`); 59 | } else { 60 | console.log(message); 61 | } 62 | } 63 | 64 | function logSection(title) { 65 | const line = '═'.repeat(50); 66 | log(''); 67 | log(line, colors.cyan); 68 | log(` ${title}`, colors.cyan); 69 | log(line, colors.cyan); 70 | log(''); 71 | } 72 | 73 | function logSuccess(message) { 74 | log(`✓ ${message}`, colors.green); 75 | } 76 | 77 | function logError(message) { 78 | log(`✗ ${message}`, colors.red); 79 | } 80 | 81 | function logInfo(message) { 82 | log(`→ ${message}`, colors.blue); 83 | } 84 | 85 | function logWarn(message) { 86 | log(`⚠ ${message}`, colors.yellow); 87 | } 88 | 89 | // ============================================ 90 | // UTILITIES 91 | // ============================================ 92 | 93 | function sleep(ms) { 94 | return new Promise(resolve => setTimeout(resolve, ms)); 95 | } 96 | 97 | function prompt(question) { 98 | const rl = readline.createInterface({ 99 | input: process.stdin, 100 | output: process.stdout 101 | }); 102 | 103 | return new Promise(resolve => { 104 | rl.question(question, answer => { 105 | rl.close(); 106 | resolve(answer.trim()); 107 | }); 108 | }); 109 | } 110 | 111 | function execSafe(command, options = {}) { 112 | try { 113 | return execSync(command, { encoding: 'utf8', cwd: ROOT_DIR, ...options }); 114 | } catch (error) { 115 | throw new Error(`Command failed: ${command}\n${error.message}`); 116 | } 117 | } 118 | 119 | // ============================================ 120 | // VERSION MANAGEMENT 121 | // ============================================ 122 | 123 | function getCurrentVersion() { 124 | const pkgPath = path.join(ROOT_DIR, 'package.json'); 125 | const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); 126 | return pkg.version; 127 | } 128 | 129 | function bumpVersion(current, type) { 130 | const [major, minor, patch] = current.split('.').map(Number); 131 | switch (type) { 132 | case 'major': return `${major + 1}.0.0`; 133 | case 'minor': return `${major}.${minor + 1}.0`; 134 | case 'patch': return `${major}.${minor}.${patch + 1}`; 135 | default: throw new Error(`Invalid bump type: ${type}`); 136 | } 137 | } 138 | 139 | function updateVersionInFile(filePath, oldVersion, newVersion) { 140 | const fullPath = path.join(ROOT_DIR, filePath); 141 | let content = fs.readFileSync(fullPath, 'utf8'); 142 | 143 | if (filePath === 'package.json') { 144 | // Update version field in JSON 145 | const pkg = JSON.parse(content); 146 | pkg.version = newVersion; 147 | content = JSON.stringify(pkg, null, 2) + '\n'; 148 | } else if (filePath === 'README.md') { 149 | // Update download URLs: HyperclayLocal-X.X.X patterns 150 | content = content.replace( 151 | new RegExp(`HyperclayLocal-${oldVersion.replace(/\./g, '\\.')}`, 'g'), 152 | `HyperclayLocal-${newVersion}` 153 | ); 154 | // Update Setup URLs: HyperclayLocal-Setup-X.X.X patterns 155 | content = content.replace( 156 | new RegExp(`HyperclayLocal-Setup-${oldVersion.replace(/\./g, '\\.')}`, 'g'), 157 | `HyperclayLocal-Setup-${newVersion}` 158 | ); 159 | } else if (filePath === 'src/main/main.js') { 160 | // Update applicationVersion and version strings 161 | content = content.replace( 162 | new RegExp(`applicationVersion: '${oldVersion.replace(/\./g, '\\.')}'`, 'g'), 163 | `applicationVersion: '${newVersion}'` 164 | ); 165 | content = content.replace( 166 | new RegExp(`version: '${oldVersion.replace(/\./g, '\\.')}'`, 'g'), 167 | `version: '${newVersion}'` 168 | ); 169 | } 170 | 171 | fs.writeFileSync(fullPath, content); 172 | } 173 | 174 | function formatFileSize(bytes) { 175 | const mb = bytes / (1024 * 1024); 176 | return `${mb.toFixed(1)}MB`; 177 | } 178 | 179 | function updateReadmeSizes(version) { 180 | const readmePath = path.join(ROOT_DIR, 'README.md'); 181 | const executablesDir = path.join(ROOT_DIR, 'executables'); 182 | let content = fs.readFileSync(readmePath, 'utf8'); 183 | 184 | const files = fs.readdirSync(executablesDir); 185 | 186 | for (const file of files) { 187 | if (!file.endsWith('.dmg') && !file.endsWith('.AppImage')) continue; 188 | 189 | const size = formatFileSize(fs.statSync(path.join(executablesDir, file)).size); 190 | const escaped = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 191 | content = content.replace(new RegExp(`(${escaped}\\)) \\([^)]+\\)`, 'g'), `$1 (${size})`); 192 | 193 | // Estimate Windows as ~75% of macOS Intel 194 | if (file.endsWith('.dmg') && !file.includes('arm64')) { 195 | const winSize = formatFileSize(fs.statSync(path.join(executablesDir, file)).size * 0.75); 196 | const winFile = file.replace('.dmg', '.exe').replace('HyperclayLocal-', 'HyperclayLocal-Setup-'); 197 | const winEscaped = winFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 198 | content = content.replace(new RegExp(`(${winEscaped}\\)) \\([^)]+\\)`, 'g'), `$1 (~${winSize})`); 199 | } 200 | } 201 | 202 | fs.writeFileSync(readmePath, content); 203 | } 204 | 205 | // ============================================ 206 | // BUILD FUNCTIONS 207 | // ============================================ 208 | 209 | function runBuild(name, command) { 210 | return new Promise((resolve, reject) => { 211 | const proc = spawn('npm', ['run', command], { 212 | cwd: ROOT_DIR, 213 | shell: true, 214 | stdio: ['ignore', 'pipe', 'pipe'] 215 | }); 216 | 217 | let output = ''; 218 | 219 | proc.stdout.on('data', data => { 220 | output += data.toString(); 221 | fs.appendFileSync(LOG_FILE, data.toString()); 222 | }); 223 | 224 | proc.stderr.on('data', data => { 225 | output += data.toString(); 226 | fs.appendFileSync(LOG_FILE, data.toString()); 227 | }); 228 | 229 | proc.on('close', code => { 230 | if (code === 0) { 231 | resolve(output); 232 | } else { 233 | reject(new Error(`${name} build failed with code ${code}`)); 234 | } 235 | }); 236 | 237 | proc.on('error', reject); 238 | }); 239 | } 240 | 241 | function triggerWindowsBuild() { 242 | logInfo('Triggering Windows build on GitHub Actions...'); 243 | 244 | try { 245 | execSafe('gh workflow run build-and-sign-windows.yml'); 246 | } catch (error) { 247 | throw new Error(`Failed to trigger Windows workflow: ${error.message}`); 248 | } 249 | 250 | // Wait for the run to be created 251 | logInfo('Waiting for workflow to initialize...'); 252 | execSync('sleep 5'); 253 | 254 | // Get the run ID 255 | const output = execSafe('gh run list --workflow=build-and-sign-windows.yml --limit 1 --json databaseId -q ".[0].databaseId"'); 256 | const runId = output.trim(); 257 | 258 | if (!runId) { 259 | throw new Error('Could not get Windows workflow run ID'); 260 | } 261 | 262 | logSuccess(`Windows build triggered (run ID: ${runId})`); 263 | return runId; 264 | } 265 | 266 | 267 | // ============================================ 268 | // NOTARIZATION 269 | // ============================================ 270 | 271 | function getNotarizationSubmissions() { 272 | if (!fs.existsSync(NOTARIZATION_FILE)) { 273 | return []; 274 | } 275 | try { 276 | return JSON.parse(fs.readFileSync(NOTARIZATION_FILE, 'utf8')); 277 | } catch { 278 | return []; 279 | } 280 | } 281 | 282 | function checkNotarizationStatus(submissionId) { 283 | const appleId = process.env.APPLE_ID; 284 | const teamId = process.env.APPLE_TEAM_ID; 285 | const password = process.env.APPLE_APP_SPECIFIC_PASSWORD; 286 | 287 | if (!appleId || !teamId || !password) { 288 | return { status: 'Error', message: 'Missing Apple credentials in environment' }; 289 | } 290 | 291 | try { 292 | const cmd = `xcrun notarytool info "${submissionId}" \ 293 | --apple-id "${appleId}" \ 294 | --team-id "${teamId}" \ 295 | --password "${password}" \ 296 | --output-format json`; 297 | 298 | const output = execSafe(cmd, { stdio: 'pipe' }); 299 | return JSON.parse(output); 300 | } catch (error) { 301 | return { status: 'Error', message: error.message }; 302 | } 303 | } 304 | 305 | async function pollNotarizationUntilComplete() { 306 | logInfo('Waiting for notarization to complete...'); 307 | 308 | while (true) { 309 | const submissions = getNotarizationSubmissions(); 310 | const pending = submissions.filter(s => s.status === 'submitted'); 311 | 312 | if (pending.length === 0) { 313 | logSuccess('All notarization submissions processed'); 314 | return; 315 | } 316 | 317 | let allAccepted = true; 318 | 319 | for (const submission of pending) { 320 | const info = checkNotarizationStatus(submission.id); 321 | 322 | if (info.status === 'Accepted') { 323 | log(` Notarization ${submission.arch}: Accepted`, colors.green); 324 | submission.status = 'accepted'; 325 | } else if (info.status === 'Invalid') { 326 | logError(`Notarization ${submission.arch}: Invalid - ${info.statusSummary || 'Unknown error'}`); 327 | submission.status = 'invalid'; 328 | throw new Error('Notarization was rejected by Apple'); 329 | } else if (info.status === 'In Progress') { 330 | log(` Notarization ${submission.arch}: In Progress...`, colors.dim); 331 | allAccepted = false; 332 | } else { 333 | log(` Notarization ${submission.arch}: ${info.status}`, colors.yellow); 334 | allAccepted = false; 335 | } 336 | } 337 | 338 | // Save updated statuses 339 | fs.writeFileSync(NOTARIZATION_FILE, JSON.stringify(submissions, null, 2)); 340 | 341 | if (allAccepted) { 342 | return; 343 | } 344 | 345 | await sleep(NOTARIZATION_POLL_INTERVAL); 346 | } 347 | } 348 | 349 | function stapleAndMoveExecutables() { 350 | logInfo('Stapling notarization tickets and moving executables...'); 351 | 352 | try { 353 | execSafe('node build-scripts/check-notarization.js', { stdio: 'inherit' }); 354 | logSuccess('macOS executables stapled and moved'); 355 | } catch (error) { 356 | throw new Error(`Failed to staple/move executables: ${error.message}`); 357 | } 358 | } 359 | 360 | // ============================================ 361 | // UPLOAD 362 | // ============================================ 363 | 364 | function uploadToR2() { 365 | logInfo('Uploading macOS and Linux to R2...'); 366 | 367 | try { 368 | execSafe('node build-scripts/post-build.js', { stdio: 'inherit' }); 369 | logSuccess('Upload complete'); 370 | } catch (error) { 371 | throw new Error(`Failed to upload to R2: ${error.message}`); 372 | } 373 | } 374 | 375 | // ============================================ 376 | // EXTERNAL DOCS 377 | // ============================================ 378 | 379 | function updateExternalDocs(version) { 380 | logInfo('Updating external documentation...'); 381 | 382 | try { 383 | execSafe(`node scripts/update-external-docs.js ${version}`, { stdio: 'inherit' }); 384 | } catch (error) { 385 | // Don't fail the release if external docs can't be updated 386 | logWarn(`Could not update external docs: ${error.message}`); 387 | } 388 | } 389 | 390 | // ============================================ 391 | // MAIN 392 | // ============================================ 393 | 394 | async function main() { 395 | process.chdir(ROOT_DIR); 396 | initLog(); 397 | 398 | console.log(''); 399 | console.log(`${colors.cyan}╔════════════════════════════════════════════════════╗${colors.reset}`); 400 | console.log(`${colors.cyan}║ HyperclayLocal Release ║${colors.reset}`); 401 | console.log(`${colors.cyan}╚════════════════════════════════════════════════════╝${colors.reset}`); 402 | console.log(''); 403 | 404 | // ========================================== 405 | // STEP 1: Version bump selection 406 | // ========================================== 407 | 408 | logSection('Step 1: Pre-flight Checks'); 409 | 410 | // Check for uncommitted changes (other than the files we'll modify) 411 | const status = execSafe('git status --porcelain').trim(); 412 | if (status) { 413 | const lines = status.split('\n'); 414 | const allowedFiles = FILES_TO_UPDATE.map(f => f.path); 415 | const unexpectedChanges = lines.filter(line => { 416 | const file = line.slice(3); // Remove status prefix like " M " or "?? " 417 | return !allowedFiles.some(allowed => file.endsWith(allowed)); 418 | }); 419 | 420 | if (unexpectedChanges.length > 0) { 421 | logError('Uncommitted changes detected:'); 422 | unexpectedChanges.forEach(line => log(` ${line}`)); 423 | log(''); 424 | log('Please commit or stash these changes before releasing.'); 425 | process.exit(1); 426 | } 427 | } 428 | 429 | logSuccess('Working directory clean'); 430 | 431 | // Clear executables folder 432 | const executablesDir = path.join(ROOT_DIR, 'executables'); 433 | if (fs.existsSync(executablesDir)) { 434 | fs.rmSync(executablesDir, { recursive: true }); 435 | } 436 | fs.mkdirSync(executablesDir, { recursive: true }); 437 | logSuccess('Cleared executables folder'); 438 | 439 | // ========================================== 440 | // STEP 2: Version bump selection 441 | // ========================================== 442 | 443 | logSection('Step 2: Version'); 444 | 445 | const currentVersion = getCurrentVersion(); 446 | log(`Current version: ${currentVersion}`); 447 | log(''); 448 | log('Select version bump:'); 449 | log(' 1) patch (bug fixes)'); 450 | log(' 2) minor (new features)'); 451 | log(' 3) major (breaking changes)'); 452 | log(''); 453 | 454 | const choice = await prompt('Enter choice [1-3]: '); 455 | 456 | let bumpType; 457 | switch (choice) { 458 | case '1': bumpType = 'patch'; break; 459 | case '2': bumpType = 'minor'; break; 460 | case '3': bumpType = 'major'; break; 461 | default: 462 | logError('Invalid choice'); 463 | process.exit(1); 464 | } 465 | 466 | const newVersion = bumpVersion(currentVersion, bumpType); 467 | log(''); 468 | logSuccess(`Version: ${currentVersion} → ${newVersion}`); 469 | 470 | // ========================================== 471 | // STEP 3: Update version in files 472 | // ========================================== 473 | 474 | logSection('Step 3: Update Files'); 475 | 476 | for (const file of FILES_TO_UPDATE) { 477 | updateVersionInFile(file.path, currentVersion, newVersion); 478 | logSuccess(`Updated ${file.path}`); 479 | } 480 | 481 | // ========================================== 482 | // STEP 4: Commit and push version bump 483 | // ========================================== 484 | 485 | logSection('Step 4: Commit & Push'); 486 | 487 | // Stage only the files we modified 488 | for (const file of FILES_TO_UPDATE) { 489 | execSafe(`git add "${file.path}"`); 490 | } 491 | execSafe(`git commit -m "chore: release v${newVersion}"`); 492 | logSuccess('Committed version bump'); 493 | 494 | // Push to remote so GitHub Actions builds the new version 495 | logInfo('Pushing to remote...'); 496 | execSafe('git push origin HEAD'); 497 | logSuccess('Pushed to remote'); 498 | 499 | // ========================================== 500 | // STEP 5: Build all platforms 501 | // ========================================== 502 | 503 | logSection('Step 5: Build'); 504 | 505 | // Clear old notarization submissions 506 | if (fs.existsSync(NOTARIZATION_FILE)) { 507 | fs.unlinkSync(NOTARIZATION_FILE); 508 | } 509 | 510 | // Trigger Windows build (runs independently on GitHub Actions) 511 | triggerWindowsBuild(); 512 | 513 | // Start macOS and Linux builds in parallel 514 | logInfo('Starting macOS and Linux builds in parallel...'); 515 | 516 | const buildPromises = [ 517 | runBuild('macOS', 'mac-build:run').then(() => logSuccess('macOS build complete')), 518 | runBuild('Linux', 'linux-build:run').then(() => logSuccess('Linux build complete')) 519 | ]; 520 | 521 | await Promise.all(buildPromises); 522 | 523 | // ========================================== 524 | // STEP 6: Wait for notarization 525 | // ========================================== 526 | 527 | logSection('Step 6: Wait for Notarization'); 528 | 529 | logInfo('Waiting for macOS notarization...'); 530 | logInfo('(Windows build runs independently on GitHub Actions)'); 531 | 532 | await pollNotarizationUntilComplete(); 533 | 534 | // ========================================== 535 | // STEP 7: Finalize macOS 536 | // ========================================== 537 | 538 | logSection('Step 7: Finalize'); 539 | 540 | stapleAndMoveExecutables(); 541 | 542 | // Move Linux executable too 543 | logInfo('Moving Linux executable...'); 544 | execSafe('node build-scripts/move-executables.js linux'); 545 | 546 | // Update README with actual file sizes 547 | logInfo('Updating README with file sizes...'); 548 | updateReadmeSizes(newVersion); 549 | logSuccess('README sizes updated'); 550 | 551 | // ========================================== 552 | // STEP 8: Upload to R2 553 | // ========================================== 554 | 555 | logSection('Step 8: Upload'); 556 | 557 | uploadToR2(); 558 | 559 | // ========================================== 560 | // STEP 9: Update external docs 561 | // ========================================== 562 | 563 | logSection('Step 9: Update External Docs'); 564 | 565 | updateExternalDocs(newVersion); 566 | 567 | // ========================================== 568 | // DONE 569 | // ========================================== 570 | 571 | const duration = Math.round((Date.now() - startTime) / 1000); 572 | const minutes = Math.floor(duration / 60); 573 | const seconds = duration % 60; 574 | 575 | logSection('Release Complete'); 576 | 577 | log(`Version: ${newVersion}`); 578 | log(`Duration: ${minutes}m ${seconds}s`); 579 | log(''); 580 | log('Download URLs:'); 581 | log(` macOS (ARM): https://local.hyperclay.com/HyperclayLocal-${newVersion}-arm64.dmg`); 582 | log(` macOS (Intel): https://local.hyperclay.com/HyperclayLocal-${newVersion}.dmg`); 583 | log(` Windows: https://local.hyperclay.com/HyperclayLocal-Setup-${newVersion}.exe`); 584 | log(` Linux: https://local.hyperclay.com/HyperclayLocal-${newVersion}.AppImage`); 585 | log(''); 586 | logSuccess('All platforms built, signed, and uploaded!'); 587 | log(''); 588 | logWarn('Remember to commit changes in hyperclay and hyperclay-website repos!'); 589 | log(''); 590 | log(`Full log: ${LOG_FILE}`); 591 | } 592 | 593 | main().catch(error => { 594 | logError(error.message); 595 | log(''); 596 | log(`Full log: ${LOG_FILE}`); 597 | process.exit(1); 598 | }); 599 | -------------------------------------------------------------------------------- /src/renderer/styles/renderer.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v4.1.14 | MIT License | https://tailwindcss.com */ 2 | @layer properties; 3 | @layer theme, base, components, utilities; 4 | @layer theme { 5 | :root, :host { 6 | --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", 7 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 8 | --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 9 | "Courier New", monospace; 10 | --color-red-300: oklch(80.8% 0.114 19.571); 11 | --color-red-400: oklch(70.4% 0.191 22.216); 12 | --color-red-500: oklch(63.7% 0.237 25.331); 13 | --color-red-600: oklch(57.7% 0.245 27.325); 14 | --color-red-700: oklch(50.5% 0.213 27.518); 15 | --color-gray-200: oklch(92.8% 0.006 264.531); 16 | --color-gray-700: oklch(37.3% 0.034 259.733); 17 | --color-white: #fff; 18 | --spacing: 0.25rem; 19 | --container-md: 28rem; 20 | --text-xs: 0.75rem; 21 | --text-xs--line-height: calc(1 / 0.75); 22 | --text-sm: 0.875rem; 23 | --text-sm--line-height: calc(1.25 / 0.875); 24 | --text-2xl: 1.5rem; 25 | --text-2xl--line-height: calc(2 / 1.5); 26 | --font-weight-bold: 700; 27 | --radius-lg: 0.5rem; 28 | --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 29 | --default-font-family: var(--font-sans); 30 | --default-mono-font-family: var(--font-mono); 31 | } 32 | } 33 | @layer base { 34 | *, ::after, ::before, ::backdrop, ::file-selector-button { 35 | box-sizing: border-box; 36 | margin: 0; 37 | padding: 0; 38 | border: 0 solid; 39 | } 40 | html, :host { 41 | line-height: 1.5; 42 | -webkit-text-size-adjust: 100%; 43 | tab-size: 4; 44 | font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); 45 | font-feature-settings: var(--default-font-feature-settings, normal); 46 | font-variation-settings: var(--default-font-variation-settings, normal); 47 | -webkit-tap-highlight-color: transparent; 48 | } 49 | hr { 50 | height: 0; 51 | color: inherit; 52 | border-top-width: 1px; 53 | } 54 | abbr:where([title]) { 55 | -webkit-text-decoration: underline dotted; 56 | text-decoration: underline dotted; 57 | } 58 | h1, h2, h3, h4, h5, h6 { 59 | font-size: inherit; 60 | font-weight: inherit; 61 | } 62 | a { 63 | color: inherit; 64 | -webkit-text-decoration: inherit; 65 | text-decoration: inherit; 66 | } 67 | b, strong { 68 | font-weight: bolder; 69 | } 70 | code, kbd, samp, pre { 71 | font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); 72 | font-feature-settings: var(--default-mono-font-feature-settings, normal); 73 | font-variation-settings: var(--default-mono-font-variation-settings, normal); 74 | font-size: 1em; 75 | } 76 | small { 77 | font-size: 80%; 78 | } 79 | sub, sup { 80 | font-size: 75%; 81 | line-height: 0; 82 | position: relative; 83 | vertical-align: baseline; 84 | } 85 | sub { 86 | bottom: -0.25em; 87 | } 88 | sup { 89 | top: -0.5em; 90 | } 91 | table { 92 | text-indent: 0; 93 | border-color: inherit; 94 | border-collapse: collapse; 95 | } 96 | :-moz-focusring { 97 | outline: auto; 98 | } 99 | progress { 100 | vertical-align: baseline; 101 | } 102 | summary { 103 | display: list-item; 104 | } 105 | ol, ul, menu { 106 | list-style: none; 107 | } 108 | img, svg, video, canvas, audio, iframe, embed, object { 109 | display: block; 110 | vertical-align: middle; 111 | } 112 | img, video { 113 | max-width: 100%; 114 | height: auto; 115 | } 116 | button, input, select, optgroup, textarea, ::file-selector-button { 117 | font: inherit; 118 | font-feature-settings: inherit; 119 | font-variation-settings: inherit; 120 | letter-spacing: inherit; 121 | color: inherit; 122 | border-radius: 0; 123 | background-color: transparent; 124 | opacity: 1; 125 | } 126 | :where(select:is([multiple], [size])) optgroup { 127 | font-weight: bolder; 128 | } 129 | :where(select:is([multiple], [size])) optgroup option { 130 | padding-inline-start: 20px; 131 | } 132 | ::file-selector-button { 133 | margin-inline-end: 4px; 134 | } 135 | ::placeholder { 136 | opacity: 1; 137 | } 138 | @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { 139 | ::placeholder { 140 | color: currentcolor; 141 | @supports (color: color-mix(in lab, red, red)) { 142 | color: color-mix(in oklab, currentcolor 50%, transparent); 143 | } 144 | } 145 | } 146 | textarea { 147 | resize: vertical; 148 | } 149 | ::-webkit-search-decoration { 150 | -webkit-appearance: none; 151 | } 152 | ::-webkit-date-and-time-value { 153 | min-height: 1lh; 154 | text-align: inherit; 155 | } 156 | ::-webkit-datetime-edit { 157 | display: inline-flex; 158 | } 159 | ::-webkit-datetime-edit-fields-wrapper { 160 | padding: 0; 161 | } 162 | ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { 163 | padding-block: 0; 164 | } 165 | ::-webkit-calendar-picker-indicator { 166 | line-height: 1; 167 | } 168 | :-moz-ui-invalid { 169 | box-shadow: none; 170 | } 171 | button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { 172 | appearance: button; 173 | } 174 | ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { 175 | height: auto; 176 | } 177 | [hidden]:where(:not([hidden="until-found"])) { 178 | display: none !important; 179 | } 180 | } 181 | @layer utilities { 182 | .pointer-events-none { 183 | pointer-events: none; 184 | } 185 | .absolute { 186 | position: absolute; 187 | } 188 | .fixed { 189 | position: fixed; 190 | } 191 | .relative { 192 | position: relative; 193 | } 194 | .top-20 { 195 | top: calc(var(--spacing) * 20); 196 | } 197 | .right-4 { 198 | right: calc(var(--spacing) * 4); 199 | } 200 | .bottom-\[-42px\] { 201 | bottom: -42px; 202 | } 203 | .left-1\/2 { 204 | left: calc(1/2 * 100%); 205 | } 206 | .z-10 { 207 | z-index: 10; 208 | } 209 | .z-50 { 210 | z-index: 50; 211 | } 212 | .container { 213 | width: 100%; 214 | @media (width >= 40rem) { 215 | max-width: 40rem; 216 | } 217 | @media (width >= 48rem) { 218 | max-width: 48rem; 219 | } 220 | @media (width >= 64rem) { 221 | max-width: 64rem; 222 | } 223 | @media (width >= 80rem) { 224 | max-width: 80rem; 225 | } 226 | @media (width >= 96rem) { 227 | max-width: 96rem; 228 | } 229 | } 230 | .my-auto { 231 | margin-block: auto; 232 | } 233 | .-mt-1 { 234 | margin-top: calc(var(--spacing) * -1); 235 | } 236 | .-mt-\[2px\] { 237 | margin-top: calc(2px * -1); 238 | } 239 | .mt-2 { 240 | margin-top: calc(var(--spacing) * 2); 241 | } 242 | .mt-3 { 243 | margin-top: calc(var(--spacing) * 3); 244 | } 245 | .-mb-\[2px\] { 246 | margin-bottom: calc(2px * -1); 247 | } 248 | .mb-0 { 249 | margin-bottom: calc(var(--spacing) * 0); 250 | } 251 | .mb-1 { 252 | margin-bottom: calc(var(--spacing) * 1); 253 | } 254 | .mb-2\.5 { 255 | margin-bottom: calc(var(--spacing) * 2.5); 256 | } 257 | .mb-3 { 258 | margin-bottom: calc(var(--spacing) * 3); 259 | } 260 | .mb-4 { 261 | margin-bottom: calc(var(--spacing) * 4); 262 | } 263 | .mb-5 { 264 | margin-bottom: calc(var(--spacing) * 5); 265 | } 266 | .mb-6 { 267 | margin-bottom: calc(var(--spacing) * 6); 268 | } 269 | .mb-\[17px\] { 270 | margin-bottom: 17px; 271 | } 272 | .ml-auto { 273 | margin-left: auto; 274 | } 275 | .block { 276 | display: block; 277 | } 278 | .contents { 279 | display: contents; 280 | } 281 | .flex { 282 | display: flex; 283 | } 284 | .grid { 285 | display: grid; 286 | } 287 | .hidden { 288 | display: none; 289 | } 290 | .inline { 291 | display: inline; 292 | } 293 | .inline-block { 294 | display: inline-block; 295 | } 296 | .aspect-square { 297 | aspect-ratio: 1 / 1; 298 | } 299 | .h-\[15px\] { 300 | height: 15px; 301 | } 302 | .h-\[42px\] { 303 | height: 42px; 304 | } 305 | .h-full { 306 | height: 100%; 307 | } 308 | .min-h-screen { 309 | min-height: 100vh; 310 | } 311 | .w-5 { 312 | width: calc(var(--spacing) * 5); 313 | } 314 | .w-\[15px\] { 315 | width: 15px; 316 | } 317 | .w-\[42px\] { 318 | width: 42px; 319 | } 320 | .w-full { 321 | width: 100%; 322 | } 323 | .max-w-md { 324 | max-width: var(--container-md); 325 | } 326 | .min-w-0 { 327 | min-width: calc(var(--spacing) * 0); 328 | } 329 | .flex-1 { 330 | flex: 1; 331 | } 332 | .flex-shrink-0 { 333 | flex-shrink: 0; 334 | } 335 | .grow { 336 | flex-grow: 1; 337 | } 338 | .-translate-x-1\/2 { 339 | --tw-translate-x: calc(calc(1/2 * 100%) * -1); 340 | translate: var(--tw-translate-x) var(--tw-translate-y); 341 | } 342 | .transform { 343 | transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); 344 | } 345 | .animate-pulse { 346 | animation: var(--animate-pulse); 347 | } 348 | .cursor-pointer { 349 | cursor: pointer; 350 | } 351 | .resize { 352 | resize: both; 353 | } 354 | .grid-cols-2 { 355 | grid-template-columns: repeat(2, minmax(0, 1fr)); 356 | } 357 | .flex-col { 358 | flex-direction: column; 359 | } 360 | .items-center { 361 | align-items: center; 362 | } 363 | .items-start { 364 | align-items: flex-start; 365 | } 366 | .items-stretch { 367 | align-items: stretch; 368 | } 369 | .justify-between { 370 | justify-content: space-between; 371 | } 372 | .justify-center { 373 | justify-content: center; 374 | } 375 | .justify-end { 376 | justify-content: flex-end; 377 | } 378 | .gap-1 { 379 | gap: calc(var(--spacing) * 1); 380 | } 381 | .gap-2 { 382 | gap: calc(var(--spacing) * 2); 383 | } 384 | .gap-3 { 385 | gap: calc(var(--spacing) * 3); 386 | } 387 | .gap-4 { 388 | gap: calc(var(--spacing) * 4); 389 | } 390 | .gap-\[17px\] { 391 | gap: 17px; 392 | } 393 | .space-y-1 { 394 | :where(& > :not(:last-child)) { 395 | --tw-space-y-reverse: 0; 396 | margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); 397 | margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); 398 | } 399 | } 400 | .space-y-2 { 401 | :where(& > :not(:last-child)) { 402 | --tw-space-y-reverse: 0; 403 | margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); 404 | margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); 405 | } 406 | } 407 | .truncate { 408 | overflow: hidden; 409 | text-overflow: ellipsis; 410 | white-space: nowrap; 411 | } 412 | .rounded-full { 413 | border-radius: calc(infinity * 1px); 414 | } 415 | .rounded-lg { 416 | border-radius: var(--radius-lg); 417 | } 418 | .border-2 { 419 | border-style: var(--tw-border-style); 420 | border-width: 2px; 421 | } 422 | .border-\[1px\] { 423 | border-style: var(--tw-border-style); 424 | border-width: 1px; 425 | } 426 | .border-\[2px\] { 427 | border-style: var(--tw-border-style); 428 | border-width: 2px; 429 | } 430 | .border-\[3px\] { 431 | border-style: var(--tw-border-style); 432 | border-width: 3px; 433 | } 434 | .border-t-2 { 435 | border-top-style: var(--tw-border-style); 436 | border-top-width: 2px; 437 | } 438 | .border-r-2 { 439 | border-right-style: var(--tw-border-style); 440 | border-right-width: 2px; 441 | } 442 | .border-b-0 { 443 | border-bottom-style: var(--tw-border-style); 444 | border-bottom-width: 0px; 445 | } 446 | .border-b-2 { 447 | border-bottom-style: var(--tw-border-style); 448 | border-bottom-width: 2px; 449 | } 450 | .border-l-2 { 451 | border-left-style: var(--tw-border-style); 452 | border-left-width: 2px; 453 | } 454 | .border-\[\#0B0C11\] { 455 | border-color: #0B0C11; 456 | } 457 | .border-\[\#4F5A97\] { 458 | border-color: #4F5A97; 459 | } 460 | .border-\[\#28C83E\] { 461 | border-color: #28C83E; 462 | } 463 | .border-\[\#292F52\] { 464 | border-color: #292F52; 465 | } 466 | .border-red-400 { 467 | border-color: var(--color-red-400); 468 | } 469 | .border-red-500 { 470 | border-color: var(--color-red-500); 471 | } 472 | .border-red-600 { 473 | border-color: var(--color-red-600); 474 | } 475 | .border-red-700 { 476 | border-color: var(--color-red-700); 477 | } 478 | .border-t-\[\#4F7CC4\] { 479 | border-top-color: #4F7CC4; 480 | } 481 | .border-t-\[\#56B96C\] { 482 | border-top-color: #56B96C; 483 | } 484 | .border-t-\[\#474C65\] { 485 | border-top-color: #474C65; 486 | } 487 | .border-t-\[\#B45454\] { 488 | border-top-color: #B45454; 489 | } 490 | .border-r-\[\#0F2447\] { 491 | border-right-color: #0F2447; 492 | } 493 | .border-r-\[\#15311C\] { 494 | border-right-color: #15311C; 495 | } 496 | .border-r-\[\#131725\] { 497 | border-right-color: #131725; 498 | } 499 | .border-r-\[\#371111\] { 500 | border-right-color: #371111; 501 | } 502 | .border-b-\[\#0F2447\] { 503 | border-bottom-color: #0F2447; 504 | } 505 | .border-b-\[\#15311C\] { 506 | border-bottom-color: #15311C; 507 | } 508 | .border-b-\[\#131725\] { 509 | border-bottom-color: #131725; 510 | } 511 | .border-b-\[\#371111\] { 512 | border-bottom-color: #371111; 513 | } 514 | .border-l-\[\#4F7CC4\] { 515 | border-left-color: #4F7CC4; 516 | } 517 | .border-l-\[\#56B96C\] { 518 | border-left-color: #56B96C; 519 | } 520 | .border-l-\[\#474C65\] { 521 | border-left-color: #474C65; 522 | } 523 | .border-l-\[\#B45454\] { 524 | border-left-color: #B45454; 525 | } 526 | .bg-\[\#0B0C12\] { 527 | background-color: #0B0C12; 528 | } 529 | .bg-\[\#1D1F2F\] { 530 | background-color: #1D1F2F; 531 | } 532 | .bg-\[\#1D498E\] { 533 | background-color: #1D498E; 534 | } 535 | .bg-\[\#1E8136\] { 536 | background-color: #1E8136; 537 | } 538 | .bg-\[\#7B2525\] { 539 | background-color: #7B2525; 540 | } 541 | .bg-\[\#181F28\] { 542 | background-color: #181F28; 543 | } 544 | .bg-\[\#292F52\] { 545 | background-color: #292F52; 546 | } 547 | .bg-\[\#111220\] { 548 | background-color: #111220; 549 | } 550 | .bg-\[\#281818\] { 551 | background-color: #281818; 552 | } 553 | .bg-gray-200 { 554 | background-color: var(--color-gray-200); 555 | } 556 | .bg-red-300 { 557 | background-color: var(--color-red-300); 558 | } 559 | .bg-red-400 { 560 | background-color: var(--color-red-400); 561 | } 562 | .bg-red-500 { 563 | background-color: var(--color-red-500); 564 | } 565 | .bg-red-600 { 566 | background-color: var(--color-red-600); 567 | } 568 | .p-2 { 569 | padding: calc(var(--spacing) * 2); 570 | } 571 | .p-3 { 572 | padding: calc(var(--spacing) * 3); 573 | } 574 | .p-4 { 575 | padding: calc(var(--spacing) * 4); 576 | } 577 | .p-6 { 578 | padding: calc(var(--spacing) * 6); 579 | } 580 | .p-\[2px_20px_4px\] { 581 | padding: 2px 20px 4px; 582 | } 583 | .p-\[4px_17px_7px\] { 584 | padding: 4px 17px 7px; 585 | } 586 | .p-\[6px_17px_9px\] { 587 | padding: 6px 17px 9px; 588 | } 589 | .p-\[7px_16px_9px\] { 590 | padding: 7px 16px 9px; 591 | } 592 | .p-\[16px_24px_15px_24px\] { 593 | padding: 16px 24px 15px 24px; 594 | } 595 | .p-\[16px_24px_30px_24px\] { 596 | padding: 16px 24px 30px 24px; 597 | } 598 | .px-2 { 599 | padding-inline: calc(var(--spacing) * 2); 600 | } 601 | .px-3 { 602 | padding-inline: calc(var(--spacing) * 3); 603 | } 604 | .py-1 { 605 | padding-block: calc(var(--spacing) * 1); 606 | } 607 | .text-center { 608 | text-align: center; 609 | } 610 | .text-2xl { 611 | font-size: var(--text-2xl); 612 | line-height: var(--tw-leading, var(--text-2xl--line-height)); 613 | } 614 | .text-sm { 615 | font-size: var(--text-sm); 616 | line-height: var(--tw-leading, var(--text-sm--line-height)); 617 | } 618 | .text-xs { 619 | font-size: var(--text-xs); 620 | line-height: var(--tw-leading, var(--text-xs--line-height)); 621 | } 622 | .text-\[14px\] { 623 | font-size: 14px; 624 | } 625 | .text-\[16px\] { 626 | font-size: 16px; 627 | } 628 | .text-\[18px\] { 629 | font-size: 18px; 630 | } 631 | .text-\[20px\] { 632 | font-size: 20px; 633 | } 634 | .text-\[23px\] { 635 | font-size: 23px; 636 | } 637 | .text-\[24px\] { 638 | font-size: 24px; 639 | } 640 | .text-\[28px\] { 641 | font-size: 28px; 642 | } 643 | .text-\[36px\] { 644 | font-size: 36px; 645 | } 646 | .leading-\[42px\] { 647 | --tw-leading: 42px; 648 | line-height: 42px; 649 | } 650 | .leading-none { 651 | --tw-leading: 1; 652 | line-height: 1; 653 | } 654 | .font-bold { 655 | --tw-font-weight: var(--font-weight-bold); 656 | font-weight: var(--font-weight-bold); 657 | } 658 | .whitespace-nowrap { 659 | white-space: nowrap; 660 | } 661 | .text-\[\#8A92BB\] { 662 | color: #8A92BB; 663 | } 664 | .text-\[\#28C83E\] { 665 | color: #28C83E; 666 | } 667 | .text-\[\#69AEFE\] { 668 | color: #69AEFE; 669 | } 670 | .text-\[\#292F52\] { 671 | color: #292F52; 672 | } 673 | .text-\[\#B8BFE5\] { 674 | color: #B8BFE5; 675 | } 676 | .text-\[\#F73D48\] { 677 | color: #F73D48; 678 | } 679 | .text-\[\#FE5F58\] { 680 | color: #FE5F58; 681 | } 682 | .text-gray-700 { 683 | color: var(--color-gray-700); 684 | } 685 | .text-white { 686 | color: var(--color-white); 687 | } 688 | .lowercase { 689 | text-transform: lowercase; 690 | } 691 | .underline { 692 | text-decoration-line: underline; 693 | } 694 | .opacity-75 { 695 | opacity: 75%; 696 | } 697 | .shadow-lg { 698 | --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); 699 | box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 700 | } 701 | .select-none { 702 | -webkit-user-select: none; 703 | user-select: none; 704 | } 705 | .group-hover\:block { 706 | &:is(:where(.group):hover *) { 707 | @media (hover: hover) { 708 | display: block; 709 | } 710 | } 711 | } 712 | .group-hover\:no-underline { 713 | &:is(:where(.group):hover *) { 714 | @media (hover: hover) { 715 | text-decoration-line: none; 716 | } 717 | } 718 | } 719 | .group-active\:translate-x-\[1\.5px\] { 720 | &:is(:where(.group):active *) { 721 | --tw-translate-x: 1.5px; 722 | translate: var(--tw-translate-x) var(--tw-translate-y); 723 | } 724 | } 725 | .group-active\:translate-y-\[1\.5px\] { 726 | &:is(:where(.group):active *) { 727 | --tw-translate-y: 1.5px; 728 | translate: var(--tw-translate-x) var(--tw-translate-y); 729 | } 730 | } 731 | .hover\:bg-\[\#9F3030\] { 732 | &:hover { 733 | @media (hover: hover) { 734 | background-color: #9F3030; 735 | } 736 | } 737 | } 738 | .hover\:bg-\[\#2156A8\] { 739 | &:hover { 740 | @media (hover: hover) { 741 | background-color: #2156A8; 742 | } 743 | } 744 | } 745 | .hover\:bg-\[\#23973F\] { 746 | &:hover { 747 | @media (hover: hover) { 748 | background-color: #23973F; 749 | } 750 | } 751 | } 752 | .hover\:bg-\[\#232639\] { 753 | &:hover { 754 | @media (hover: hover) { 755 | background-color: #232639; 756 | } 757 | } 758 | } 759 | .hover\:no-underline { 760 | &:hover { 761 | @media (hover: hover) { 762 | text-decoration-line: none; 763 | } 764 | } 765 | } 766 | .hover\:opacity-70 { 767 | &:hover { 768 | @media (hover: hover) { 769 | opacity: 70%; 770 | } 771 | } 772 | } 773 | .hover\:opacity-100 { 774 | &:hover { 775 | @media (hover: hover) { 776 | opacity: 100%; 777 | } 778 | } 779 | } 780 | .focus\:border-\[\#69AEFE\] { 781 | &:focus { 782 | border-color: #69AEFE; 783 | } 784 | } 785 | .focus\:outline-none { 786 | &:focus { 787 | --tw-outline-style: none; 788 | outline-style: none; 789 | } 790 | } 791 | .active\:border-t-\[\#0F2447\] { 792 | &:active { 793 | border-top-color: #0F2447; 794 | } 795 | } 796 | .active\:border-t-\[\#15311C\] { 797 | &:active { 798 | border-top-color: #15311C; 799 | } 800 | } 801 | .active\:border-t-\[\#131725\] { 802 | &:active { 803 | border-top-color: #131725; 804 | } 805 | } 806 | .active\:border-t-\[\#371111\] { 807 | &:active { 808 | border-top-color: #371111; 809 | } 810 | } 811 | .active\:border-r-\[\#4F7CC4\] { 812 | &:active { 813 | border-right-color: #4F7CC4; 814 | } 815 | } 816 | .active\:border-r-\[\#56B96C\] { 817 | &:active { 818 | border-right-color: #56B96C; 819 | } 820 | } 821 | .active\:border-r-\[\#474C65\] { 822 | &:active { 823 | border-right-color: #474C65; 824 | } 825 | } 826 | .active\:border-r-\[\#B45454\] { 827 | &:active { 828 | border-right-color: #B45454; 829 | } 830 | } 831 | .active\:border-b-\[\#4F7CC4\] { 832 | &:active { 833 | border-bottom-color: #4F7CC4; 834 | } 835 | } 836 | .active\:border-b-\[\#56B96C\] { 837 | &:active { 838 | border-bottom-color: #56B96C; 839 | } 840 | } 841 | .active\:border-b-\[\#474C65\] { 842 | &:active { 843 | border-bottom-color: #474C65; 844 | } 845 | } 846 | .active\:border-b-\[\#B45454\] { 847 | &:active { 848 | border-bottom-color: #B45454; 849 | } 850 | } 851 | .active\:border-l-\[\#0F2447\] { 852 | &:active { 853 | border-left-color: #0F2447; 854 | } 855 | } 856 | .active\:border-l-\[\#15311C\] { 857 | &:active { 858 | border-left-color: #15311C; 859 | } 860 | } 861 | .active\:border-l-\[\#131725\] { 862 | &:active { 863 | border-left-color: #131725; 864 | } 865 | } 866 | .active\:border-l-\[\#371111\] { 867 | &:active { 868 | border-left-color: #371111; 869 | } 870 | } 871 | .disabled\:cursor-not-allowed { 872 | &:disabled { 873 | cursor: not-allowed; 874 | } 875 | } 876 | .disabled\:opacity-50 { 877 | &:disabled { 878 | opacity: 50%; 879 | } 880 | } 881 | .sm\:p-\[4px_19px_7px\] { 882 | @media (width >= 40rem) { 883 | padding: 4px 19px 7px; 884 | } 885 | } 886 | .sm\:p-\[6px_19px_9px\] { 887 | @media (width >= 40rem) { 888 | padding: 6px 19px 9px; 889 | } 890 | } 891 | .sm\:text-\[21px\] { 892 | @media (width >= 40rem) { 893 | font-size: 21px; 894 | } 895 | } 896 | .sm\:text-\[24px\] { 897 | @media (width >= 40rem) { 898 | font-size: 24px; 899 | } 900 | } 901 | } 902 | @property --tw-translate-x { 903 | syntax: "*"; 904 | inherits: false; 905 | initial-value: 0; 906 | } 907 | @property --tw-translate-y { 908 | syntax: "*"; 909 | inherits: false; 910 | initial-value: 0; 911 | } 912 | @property --tw-translate-z { 913 | syntax: "*"; 914 | inherits: false; 915 | initial-value: 0; 916 | } 917 | @property --tw-rotate-x { 918 | syntax: "*"; 919 | inherits: false; 920 | } 921 | @property --tw-rotate-y { 922 | syntax: "*"; 923 | inherits: false; 924 | } 925 | @property --tw-rotate-z { 926 | syntax: "*"; 927 | inherits: false; 928 | } 929 | @property --tw-skew-x { 930 | syntax: "*"; 931 | inherits: false; 932 | } 933 | @property --tw-skew-y { 934 | syntax: "*"; 935 | inherits: false; 936 | } 937 | @property --tw-space-y-reverse { 938 | syntax: "*"; 939 | inherits: false; 940 | initial-value: 0; 941 | } 942 | @property --tw-border-style { 943 | syntax: "*"; 944 | inherits: false; 945 | initial-value: solid; 946 | } 947 | @property --tw-leading { 948 | syntax: "*"; 949 | inherits: false; 950 | } 951 | @property --tw-font-weight { 952 | syntax: "*"; 953 | inherits: false; 954 | } 955 | @property --tw-shadow { 956 | syntax: "*"; 957 | inherits: false; 958 | initial-value: 0 0 #0000; 959 | } 960 | @property --tw-shadow-color { 961 | syntax: "*"; 962 | inherits: false; 963 | } 964 | @property --tw-shadow-alpha { 965 | syntax: ""; 966 | inherits: false; 967 | initial-value: 100%; 968 | } 969 | @property --tw-inset-shadow { 970 | syntax: "*"; 971 | inherits: false; 972 | initial-value: 0 0 #0000; 973 | } 974 | @property --tw-inset-shadow-color { 975 | syntax: "*"; 976 | inherits: false; 977 | } 978 | @property --tw-inset-shadow-alpha { 979 | syntax: ""; 980 | inherits: false; 981 | initial-value: 100%; 982 | } 983 | @property --tw-ring-color { 984 | syntax: "*"; 985 | inherits: false; 986 | } 987 | @property --tw-ring-shadow { 988 | syntax: "*"; 989 | inherits: false; 990 | initial-value: 0 0 #0000; 991 | } 992 | @property --tw-inset-ring-color { 993 | syntax: "*"; 994 | inherits: false; 995 | } 996 | @property --tw-inset-ring-shadow { 997 | syntax: "*"; 998 | inherits: false; 999 | initial-value: 0 0 #0000; 1000 | } 1001 | @property --tw-ring-inset { 1002 | syntax: "*"; 1003 | inherits: false; 1004 | } 1005 | @property --tw-ring-offset-width { 1006 | syntax: ""; 1007 | inherits: false; 1008 | initial-value: 0px; 1009 | } 1010 | @property --tw-ring-offset-color { 1011 | syntax: "*"; 1012 | inherits: false; 1013 | initial-value: #fff; 1014 | } 1015 | @property --tw-ring-offset-shadow { 1016 | syntax: "*"; 1017 | inherits: false; 1018 | initial-value: 0 0 #0000; 1019 | } 1020 | @keyframes pulse { 1021 | 50% { 1022 | opacity: 0.5; 1023 | } 1024 | } 1025 | @layer properties { 1026 | @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { 1027 | *, ::before, ::after, ::backdrop { 1028 | --tw-translate-x: 0; 1029 | --tw-translate-y: 0; 1030 | --tw-translate-z: 0; 1031 | --tw-rotate-x: initial; 1032 | --tw-rotate-y: initial; 1033 | --tw-rotate-z: initial; 1034 | --tw-skew-x: initial; 1035 | --tw-skew-y: initial; 1036 | --tw-space-y-reverse: 0; 1037 | --tw-border-style: solid; 1038 | --tw-leading: initial; 1039 | --tw-font-weight: initial; 1040 | --tw-shadow: 0 0 #0000; 1041 | --tw-shadow-color: initial; 1042 | --tw-shadow-alpha: 100%; 1043 | --tw-inset-shadow: 0 0 #0000; 1044 | --tw-inset-shadow-color: initial; 1045 | --tw-inset-shadow-alpha: 100%; 1046 | --tw-ring-color: initial; 1047 | --tw-ring-shadow: 0 0 #0000; 1048 | --tw-inset-ring-color: initial; 1049 | --tw-inset-ring-shadow: 0 0 #0000; 1050 | --tw-ring-inset: initial; 1051 | --tw-ring-offset-width: 0px; 1052 | --tw-ring-offset-color: #fff; 1053 | --tw-ring-offset-shadow: 0 0 #0000; 1054 | } 1055 | } 1056 | } 1057 | -------------------------------------------------------------------------------- /src/renderer/HyperclayLocalApp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import ErrorQueue from './components/ErrorQueue'; 3 | 4 | const HyperclayLocalApp = () => { 5 | const [currentState, setCurrentState] = useState({ 6 | selectedFolder: null, 7 | serverRunning: false, 8 | serverPort: 4321, 9 | syncStatus: { 10 | isRunning: false, 11 | syncFolder: null, 12 | username: null, 13 | stats: { 14 | filesProtected: 0, 15 | filesDownloaded: 0, 16 | filesUploaded: 0, 17 | filesDownloadedSkipped: 0, 18 | filesUploadedSkipped: 0, // Placeholder for future upload skip feature 19 | lastSync: null, 20 | recentErrors: [] 21 | } 22 | } 23 | }); 24 | 25 | const [startButtonText, setStartButtonText] = useState('start server'); 26 | const [startButtonDisabled, setStartButtonDisabled] = useState(true); 27 | const [showError, setShowError] = useState(false); 28 | 29 | // Sync state 30 | const [syncApiKey, setSyncApiKey] = useState(''); 31 | const [syncUsername, setSyncUsername] = useState(''); 32 | const [syncEnabled, setSyncEnabled] = useState(false); 33 | const [hasStoredApiKey, setHasStoredApiKey] = useState(false); 34 | const [syncButtonText, setSyncButtonText] = useState('enable sync'); 35 | const [syncButtonDisabled, setSyncButtonDisabled] = useState(false); 36 | const [showSyncError, setShowSyncError] = useState(false); 37 | const [syncErrorMessage, setSyncErrorMessage] = useState(''); 38 | 39 | // Error queue state 40 | const [errorQueue, setErrorQueue] = useState([]); 41 | const errorIdCounter = useRef(0); 42 | 43 | // Navigation state 44 | const [currentView, setCurrentView] = useState('main'); // 'main' | 'sync' 45 | 46 | // Update notification state 47 | const [updateAvailable, setUpdateAvailable] = useState(false); 48 | const [updateVersion, setUpdateVersion] = useState(null); 49 | 50 | // Ref for content container to measure height 51 | const contentRef = useRef(null); 52 | 53 | // Error management functions 54 | const addError = (errorData) => { 55 | const errorId = errorIdCounter.current++; 56 | 57 | const error = { 58 | id: errorId, 59 | ...errorData, 60 | timestamp: errorData.timestamp || Date.now() 61 | }; 62 | 63 | setErrorQueue(prev => { 64 | const filtered = prev.filter(e => { 65 | // Don't duplicate identical recent errors 66 | if (e.error === error.error && e.type === error.type) { 67 | const timeDiff = error.timestamp - e.timestamp; 68 | return timeDiff > 5000; // Keep if older than 5 seconds 69 | } 70 | return true; 71 | }); 72 | 73 | return [...filtered, error].slice(-20); // Keep max 20 errors 74 | }); 75 | }; 76 | 77 | const dismissError = (errorId) => { 78 | setErrorQueue(prev => prev.filter(e => e.id !== errorId)); 79 | }; 80 | 81 | // Initialize app state 82 | useEffect(() => { 83 | const initializeApp = async () => { 84 | if (window.electronAPI) { 85 | const state = await window.electronAPI.getState(); 86 | setCurrentState(prevState => ({ ...prevState, ...state })); 87 | 88 | // Check if API key is already configured 89 | const apiKeyInfo = await window.electronAPI.getApiKeyInfo(); 90 | if (apiKeyInfo && apiKeyInfo.hasApiKey) { 91 | setSyncUsername(apiKeyInfo.username || ''); 92 | setSyncApiKey('••••••••••••••••••••••••••••••••'); 93 | setHasStoredApiKey(true); 94 | } 95 | } 96 | }; 97 | 98 | // Listen for state updates 99 | if (window.electronAPI) { 100 | window.electronAPI.onStateUpdate((state) => { 101 | // Update all state including sync status 102 | if (state.syncStatus) { 103 | setCurrentState(prevState => ({ 104 | ...prevState, 105 | syncStatus: state.syncStatus, 106 | selectedFolder: state.selectedFolder, 107 | serverRunning: state.serverRunning, 108 | serverPort: state.serverPort 109 | })); 110 | } else { 111 | setCurrentState(prevState => ({ ...prevState, ...state })); 112 | } 113 | }); 114 | 115 | // Listen for sync errors 116 | window.electronAPI.onSyncUpdate((data) => { 117 | if (data.error) { 118 | // Handle validation errors specially 119 | if (data.type === 'validation') { 120 | addError({ 121 | ...data, 122 | priority: data.priority || 2, // HIGH priority 123 | dismissable: true, 124 | error: `❌ Validation failed: ${data.error}`, 125 | file: data.file 126 | }); 127 | } else { 128 | addError(data); 129 | } 130 | } 131 | }); 132 | 133 | // Listen for sync stats updates 134 | window.electronAPI.onSyncStats((stats) => { 135 | setCurrentState(prevState => ({ 136 | ...prevState, 137 | syncStatus: { 138 | ...prevState.syncStatus, 139 | stats: stats 140 | } 141 | })); 142 | }); 143 | 144 | // Listen for retry events 145 | window.electronAPI.onSyncRetry((data) => { 146 | addError({ 147 | ...data, 148 | priority: 3, // MEDIUM priority 149 | type: 'sync_retry', 150 | dismissable: true, 151 | error: `Retrying ${data.file} (attempt ${data.attempt}/${data.maxAttempts})` 152 | }); 153 | }); 154 | 155 | // Listen for permanent failure events 156 | window.electronAPI.onSyncFailed((data) => { 157 | addError({ 158 | ...data, 159 | error: `Failed to sync ${data.file} after ${data.attempts} attempts: ${data.error}` 160 | }); 161 | }); 162 | 163 | // Listen for update available 164 | window.electronAPI.onUpdateAvailable((data) => { 165 | setUpdateAvailable(true); 166 | setUpdateVersion(data.latestVersion); 167 | }); 168 | } 169 | 170 | initializeApp(); 171 | }, []); 172 | 173 | // Update button states based on current state 174 | useEffect(() => { 175 | if (currentState.selectedFolder) { 176 | setShowError(false); // Hide error when folder is selected 177 | } 178 | 179 | if (!currentState.serverRunning) { 180 | setStartButtonText('start server'); 181 | setStartButtonDisabled(false); // Always enable start button so users can click it 182 | } else { 183 | setStartButtonDisabled(true); // Disable when server is running 184 | } 185 | 186 | // Update sync state 187 | if (currentState.syncStatus) { 188 | setSyncEnabled(currentState.syncStatus.isRunning); 189 | 190 | // Update button text to match running state 191 | if (currentState.syncStatus.isRunning) { 192 | setSyncButtonText('disable sync'); 193 | } else { 194 | setSyncButtonText('enable sync'); 195 | } 196 | 197 | if (currentState.syncStatus.username) { 198 | setSyncUsername(currentState.syncStatus.username); 199 | } 200 | } 201 | }, [currentState]); 202 | 203 | const handleSelectFolder = async () => { 204 | if (window.electronAPI) { 205 | await window.electronAPI.selectFolder(); 206 | } 207 | }; 208 | 209 | const handleStartServer = async () => { 210 | // Check if folder is selected, show error if not 211 | if (!currentState.selectedFolder) { 212 | setShowError(true); 213 | return; 214 | } 215 | 216 | setStartButtonDisabled(true); 217 | setStartButtonText('starting...'); 218 | setShowError(false); 219 | try { 220 | if (window.electronAPI) { 221 | await window.electronAPI.startServer(); 222 | } 223 | } catch (error) { 224 | console.error('Failed to start server:', error); 225 | setStartButtonDisabled(false); 226 | setStartButtonText('start server'); 227 | } 228 | }; 229 | 230 | const handleStopServer = async () => { 231 | if (window.electronAPI) { 232 | await window.electronAPI.stopServer(); 233 | } 234 | }; 235 | 236 | const handleOpenBrowser = async (e) => { 237 | e.preventDefault(); 238 | if (window.electronAPI) { 239 | await window.electronAPI.openBrowser(); 240 | } 241 | }; 242 | 243 | const handleOpenFolder = async () => { 244 | if (window.electronAPI) { 245 | await window.electronAPI.openFolder(); 246 | } 247 | }; 248 | 249 | const handleOpenLogs = async () => { 250 | if (window.electronAPI) { 251 | await window.electronAPI.openLogs(); 252 | } 253 | }; 254 | 255 | const handleEnableSync = async () => { 256 | // Check if user is using stored credentials (placeholder dots) 257 | const isUsingStoredKey = syncApiKey.startsWith('••••'); 258 | 259 | if (isUsingStoredKey) { 260 | // Validate username even when reusing stored key 261 | if (!syncUsername.trim()) { 262 | setShowSyncError(true); 263 | setSyncErrorMessage('Username is required'); 264 | return; 265 | } 266 | 267 | // Resume with stored credentials 268 | setSyncButtonDisabled(true); 269 | setSyncButtonText('enabling...'); 270 | setShowSyncError(false); 271 | 272 | try { 273 | if (window.electronAPI) { 274 | // Pass selectedFolder and username to sync-resume 275 | const result = await window.electronAPI.syncResume( 276 | currentState.selectedFolder, 277 | syncUsername.trim() 278 | ); 279 | 280 | if (result.success) { 281 | setSyncEnabled(true); 282 | setSyncButtonText('disable sync'); 283 | setSyncButtonDisabled(false); 284 | } else { 285 | setShowSyncError(true); 286 | setSyncErrorMessage(result.error || 'Failed to enable sync'); 287 | setSyncButtonText('enable sync'); 288 | setSyncButtonDisabled(false); 289 | } 290 | } 291 | } catch (error) { 292 | console.error('Sync resume error:', error); 293 | setShowSyncError(true); 294 | setSyncErrorMessage('Failed to enable sync'); 295 | setSyncButtonText('enable sync'); 296 | setSyncButtonDisabled(false); 297 | } 298 | return; 299 | } 300 | 301 | // Validate inputs for new API key 302 | if (!syncApiKey.trim()) { 303 | setShowSyncError(true); 304 | setSyncErrorMessage('API key is required'); 305 | return; 306 | } 307 | 308 | if (!syncUsername.trim()) { 309 | setShowSyncError(true); 310 | setSyncErrorMessage('Username is required'); 311 | return; 312 | } 313 | 314 | if (!currentState.selectedFolder) { 315 | setShowSyncError(true); 316 | setSyncErrorMessage('Select a folder first'); 317 | return; 318 | } 319 | 320 | setSyncButtonDisabled(true); 321 | setSyncButtonText('enabling...'); 322 | setShowSyncError(false); 323 | 324 | try { 325 | if (window.electronAPI) { 326 | const result = await window.electronAPI.syncStart( 327 | syncApiKey.trim(), 328 | syncUsername.trim(), 329 | currentState.selectedFolder 330 | ); 331 | 332 | if (result.success) { 333 | setSyncEnabled(true); 334 | setSyncButtonText('disable sync'); 335 | setHasStoredApiKey(true); 336 | // Replace API key with placeholder dots 337 | setSyncApiKey('••••••••••••••••••••••••••••••••'); 338 | setSyncButtonDisabled(false); 339 | } else { 340 | setShowSyncError(true); 341 | setSyncErrorMessage(result.error || 'Failed to enable sync'); 342 | setSyncButtonDisabled(false); 343 | setSyncButtonText('enable sync'); 344 | } 345 | } 346 | } catch (error) { 347 | console.error('Failed to enable sync:', error); 348 | setShowSyncError(true); 349 | setSyncErrorMessage('Failed to enable sync'); 350 | setSyncButtonDisabled(false); 351 | setSyncButtonText('enable sync'); 352 | } 353 | }; 354 | 355 | const handleDisableSync = async () => { 356 | setSyncButtonDisabled(true); 357 | setSyncButtonText('disabling...'); 358 | 359 | try { 360 | if (window.electronAPI) { 361 | await window.electronAPI.syncStop(); 362 | setSyncEnabled(false); 363 | setSyncButtonText('enable sync'); 364 | setSyncButtonDisabled(false); 365 | } 366 | } catch (error) { 367 | console.error('Failed to disable sync:', error); 368 | setSyncButtonDisabled(false); 369 | setSyncButtonText('disable sync'); 370 | } 371 | }; 372 | 373 | const getServerStatusClass = () => { 374 | return currentState.serverRunning 375 | ? 'whitespace-nowrap p-[2px_20px_4px] font-bold text-[#28C83E] bg-[#181F28] rounded-full' 376 | : 'whitespace-nowrap p-[2px_20px_4px] font-bold text-[#F73D48] bg-[#281818] rounded-full'; 377 | }; 378 | 379 | const getSyncStatusClass = () => { 380 | return 'whitespace-nowrap p-[2px_20px_4px] font-bold text-[#28C83E] bg-[#181F28] rounded-full'; 381 | }; 382 | 383 | const handleTabClick = (view) => { 384 | setCurrentView(view); 385 | }; 386 | 387 | // Auto-resize window based on content height 388 | useEffect(() => { 389 | if (contentRef.current && window.electronAPI) { 390 | // Use ResizeObserver to watch for content size changes 391 | const resizeObserver = new ResizeObserver(() => { 392 | if (contentRef.current) { 393 | // Get the full content height including padding and borders 394 | const contentHeight = contentRef.current.scrollHeight; 395 | 396 | // Add extra space for top bar, padding, and breathing room 397 | // Top bar (~65px) + content + bottom padding 398 | const targetHeight = contentHeight + 100; 399 | 400 | // Request resize from main process 401 | window.electronAPI.resizeWindow(targetHeight); 402 | } 403 | }); 404 | 405 | resizeObserver.observe(contentRef.current); 406 | 407 | // Cleanup 408 | return () => { 409 | resizeObserver.disconnect(); 410 | }; 411 | } 412 | }, [currentView]); // Re-run when view changes 413 | 414 | const shouldShowErrorMessage = () => { 415 | return showError; 416 | }; 417 | 418 | return ( 419 |
420 | {/* top bar */} 421 |
422 |
423 | {updateAvailable && ( 424 | 435 | )} 436 |
437 | {currentState.serverRunning ? 'server on' : 'server off'} 438 |
439 | {syncEnabled && ( 440 |
441 | sync active 442 |
443 | )} 444 |
445 |
446 | 447 |
448 | 449 | {/* main area */} 450 |
451 | {/* heading */} 452 |
453 |

Hyperclay Local

454 |
455 | · 456 | 462 | · 463 | 468 | browser 469 | 470 | 471 | 472 | 473 |
474 |
475 | 476 | {/* Folder selector - shared across tabs */} 477 |
478 |
Folder:
479 |
480 | 481 | {currentState.selectedFolder || 'No folder selected'} 482 | 483 |
484 | {/* button: select folder */} 485 | 493 |
494 | 495 | {/* Tabs */} 496 |
497 |
498 | 508 | 518 |
519 | 520 |
521 | {/* Main view content */} 522 | {currentView === 'main' && ( 523 | <> 524 | {/* button: start server */} 525 | 534 | 535 | {/* button: stop server */} 536 | 544 | 545 | {/* conditional error message */} 546 |
547 | Select a folder before starting server 548 |
549 | 550 |
551 |
552 |
1
553 |
Select folder that contains HTML app files
554 |
555 |
556 |
2
557 |
Start server and visit http://localhost:4321
558 |
559 |
560 |
3
561 |
Locally edit HTML apps using their own UI
562 |
563 |
564 | 565 | )} 566 | 567 | {/* Sync view content */} 568 | {currentView === 'sync' && ( 569 | <> 570 |
571 |

Sync to Hyperclay Platform

572 |
573 | 579 |
580 |
581 | 582 | {!syncEnabled ? ( 583 | <> 584 | {/* Sync setup form - always show */} 585 |
586 | 587 | setSyncApiKey(e.target.value)} 593 | /> 594 |
595 | 596 |
597 | 598 | setSyncUsername(e.target.value)} 604 | /> 605 |
606 | 607 | {/* Enable sync button */} 608 | 617 | 618 | {/* Sync error message */} 619 | {showSyncError && ( 620 |
621 | {syncErrorMessage} 622 |
623 | )} 624 | 625 |
626 | Generate your sync key at{' '} 627 | 633 |
634 | 635 | ) : ( 636 | <> 637 | {/* Sync stats */} 638 |
639 |
✓ Sync Active
640 |
641 |
Username: {currentState.syncStatus.username}
642 |
Folder: {currentState.syncStatus.syncFolder || currentState.selectedFolder}
643 | {currentState.syncStatus.stats.lastSync && ( 644 |
Last sync: {new Date(currentState.syncStatus.stats.lastSync).toLocaleString()}
645 | )} 646 |
647 | 648 | {/* Stats grid - merged format */} 649 |
650 |
651 |
Downloaded / Skipped
652 |
653 | 654 | {currentState.syncStatus.stats.filesDownloaded} 655 | 656 | / 657 | 658 | {currentState.syncStatus.stats.filesProtected + currentState.syncStatus.stats.filesDownloadedSkipped} 659 | 660 |
661 |
662 |
663 |
Uploaded / Skipped
664 |
665 | 666 | {currentState.syncStatus.stats.filesUploaded} 667 | 668 | / 669 | 670 | {currentState.syncStatus.stats.filesUploadedSkipped || 0} 671 | 672 |
673 |
674 |
675 |
676 | 677 | {/* Disable sync button */} 678 | 687 | 688 | )} 689 | 690 | )} 691 |
692 |
693 |
694 | 695 | {/* Error Queue Display */} 696 | 701 |
702 | ); 703 | }; 704 | 705 | export default HyperclayLocalApp; -------------------------------------------------------------------------------- /src/sync-engine/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sync Engine for Hyperclay Local 3 | * Main module that orchestrates bidirectional sync 4 | */ 5 | 6 | const EventEmitter = require('events').EventEmitter; 7 | const path = require('upath'); // Use upath for cross-platform compatibility 8 | const chokidar = require('chokidar'); 9 | const { safeStorage } = require('electron'); 10 | const { getServerBaseUrl } = require('../main/utils/utils'); 11 | 12 | // Import sync engine modules 13 | const { SYNC_CONFIG, ERROR_PRIORITY } = require('./constants'); 14 | const { calculateChecksum, generateTimestamp, isLocalNewer, isFutureFile, calibrateClock } = require('./utils'); 15 | const { createBackupIfExists } = require('../main/utils/backup'); 16 | const { classifyError, formatErrorForLog } = require('./error-handler'); 17 | const { 18 | getLocalFiles, 19 | readFile, 20 | writeFile, 21 | fileExists, 22 | getFileStats, 23 | ensureDirectory 24 | } = require('./file-operations'); 25 | const { 26 | fetchServerFiles, 27 | downloadFromServer, 28 | uploadToServer, 29 | getServerStatus 30 | } = require('./api-client'); 31 | const SyncQueue = require('./sync-queue'); 32 | const { validateFileName, validateFullPath } = require('./validation'); 33 | 34 | class SyncEngine extends EventEmitter { 35 | constructor() { 36 | super(); 37 | this.apiKey = null; 38 | this.apiKeyEncrypted = null; 39 | this.username = null; 40 | this.serverUrl = null; 41 | this.syncFolder = null; 42 | this.watcher = null; 43 | this.isRunning = false; 44 | this.clockOffset = 0; 45 | this.pollTimer = null; 46 | this.syncQueue = new SyncQueue(); 47 | this.serverFilesCache = null; // Cache for server files list 48 | this.serverFilesCacheTime = null; // When cache was last updated 49 | this.logger = null; // Logger instance 50 | this.stats = { 51 | filesProtected: 0, 52 | filesDownloaded: 0, 53 | filesUploaded: 0, 54 | filesDownloadedSkipped: 0, 55 | filesUploadedSkipped: 0, 56 | lastSync: null, 57 | errors: [] 58 | }; 59 | } 60 | 61 | /** 62 | * Set the logger instance 63 | */ 64 | setLogger(logger) { 65 | this.logger = logger; 66 | } 67 | 68 | /** 69 | * Initialize sync with API key and folder 70 | */ 71 | async init(apiKey, username, syncFolder, serverUrl) { 72 | console.log(`[SYNC] Init called with:`, { 73 | username, 74 | syncFolder, 75 | serverUrl, 76 | apiKeyLength: apiKey?.length, 77 | apiKeyPrefix: apiKey?.substring(0, 12) 78 | }); 79 | 80 | if (this.isRunning) { 81 | throw new Error('Sync is already running'); 82 | } 83 | 84 | // Reset stats for fresh session 85 | this.stats = { 86 | filesProtected: 0, 87 | filesDownloaded: 0, 88 | filesUploaded: 0, 89 | filesDownloadedSkipped: 0, 90 | filesUploadedSkipped: 0, 91 | lastSync: null, 92 | errors: [] 93 | }; 94 | 95 | // Clear any pending operations 96 | this.syncQueue.clear(); 97 | 98 | this.apiKey = apiKey; 99 | this.username = username; 100 | this.syncFolder = syncFolder; 101 | 102 | // Set server URL with fallback to environment-based default 103 | this.serverUrl = getServerBaseUrl(serverUrl); 104 | 105 | console.log(`[SYNC] Initializing for ${username} at ${syncFolder}`); 106 | console.log(`[SYNC] Server: ${this.serverUrl}`); 107 | 108 | // Log sync initialization 109 | if (this.logger) { 110 | this.logger.info('SYNC', 'Sync initialized', { 111 | username, 112 | syncFolder: this.logger.sanitizePath(syncFolder), 113 | serverUrl: this.serverUrl 114 | }); 115 | } 116 | 117 | // Encrypt and store API key 118 | if (safeStorage.isEncryptionAvailable()) { 119 | this.apiKeyEncrypted = safeStorage.encryptString(apiKey); 120 | } 121 | 122 | try { 123 | // Ensure sync folder exists 124 | console.log(`[SYNC] Ensuring sync folder exists: ${syncFolder}`); 125 | await ensureDirectory(syncFolder); 126 | 127 | // Calibrate clock with server 128 | console.log(`[SYNC] Calibrating clock with server...`); 129 | this.clockOffset = await calibrateClock(this.serverUrl, this.apiKey); 130 | console.log(`[SYNC] Clock offset: ${this.clockOffset}ms`); 131 | 132 | // Perform initial sync 133 | console.log(`[SYNC] Starting initial sync...`); 134 | await this.performInitialSync(); 135 | console.log(`[SYNC] Initial sync completed`); 136 | 137 | // Start file watcher 138 | console.log(`[SYNC] Starting file watcher...`); 139 | this.startFileWatcher(); 140 | 141 | // Start polling for remote changes 142 | console.log(`[SYNC] Starting polling...`); 143 | this.startPolling(); 144 | 145 | this.isRunning = true; 146 | 147 | console.log(`[SYNC] Initialization complete!`); 148 | return { 149 | success: true, 150 | stats: this.stats 151 | }; 152 | } catch (error) { 153 | console.error(`[SYNC] Initialization failed:`, error); 154 | console.error(`[SYNC] Error type: ${error.name}`); 155 | console.error(`[SYNC] Error message: ${error.message}`); 156 | console.error(`[SYNC] Stack trace:`, error.stack); 157 | 158 | // Log initialization error 159 | if (this.logger) { 160 | this.logger.error('SYNC', 'Sync initialization failed', { error }); 161 | } 162 | 163 | throw error; 164 | } 165 | } 166 | 167 | /** 168 | * Fetch server files and cache them 169 | * @param {boolean} forceRefresh - Force refresh even if cache is valid 170 | */ 171 | async fetchAndCacheServerFiles(forceRefresh = false) { 172 | // Use cache if it's fresh (less than 30 seconds old) and not forcing refresh 173 | if (!forceRefresh && this.serverFilesCache && this.serverFilesCacheTime) { 174 | const cacheAge = Date.now() - this.serverFilesCacheTime; 175 | if (cacheAge < 30000) { 176 | console.log(`[SYNC] Using cached server files (age: ${cacheAge}ms)`); 177 | return this.serverFilesCache; 178 | } 179 | } 180 | 181 | // Fetch fresh data 182 | console.log(`[SYNC] Fetching fresh server files list...`); 183 | this.serverFilesCache = await fetchServerFiles(this.serverUrl, this.apiKey); 184 | this.serverFilesCacheTime = Date.now(); 185 | return this.serverFilesCache; 186 | } 187 | 188 | /** 189 | * Invalidate the server files cache 190 | */ 191 | invalidateServerFilesCache() { 192 | this.serverFilesCache = null; 193 | this.serverFilesCacheTime = null; 194 | } 195 | 196 | /** 197 | * Perform initial sync - download files from server but preserve newer local files 198 | */ 199 | async performInitialSync() { 200 | console.log('[SYNC] Starting initial sync...'); 201 | this.emit('sync-start', { type: 'initial' }); 202 | 203 | try { 204 | // Get list of files from server (and cache them) 205 | const serverFiles = await this.fetchAndCacheServerFiles(true); 206 | 207 | // Get list of local files 208 | const localFiles = await getLocalFiles(this.syncFolder); 209 | 210 | // Process each server file 211 | for (const serverFile of serverFiles) { 212 | // Server returns path WITH .html (e.g., "folder1/folder2/site.html" or "site.html") 213 | const relativePath = serverFile.path || `${serverFile.filename}.html`; 214 | const localPath = path.join(this.syncFolder, relativePath); 215 | const localExists = localFiles.has(relativePath); 216 | 217 | if (!localExists) { 218 | // File doesn't exist locally, download it 219 | try { 220 | await this.downloadFile(serverFile.filename, relativePath); 221 | this.stats.filesDownloaded++; 222 | } catch (error) { 223 | // Log the error but don't fail initial sync 224 | console.error(`[SYNC] Failed to download ${relativePath} during initial sync:`, error.message); 225 | // Error already logged and emitted in downloadFile 226 | } 227 | } else { 228 | // File exists locally, check if we should update 229 | try { 230 | const localStat = await getFileStats(localPath); 231 | 232 | // Check if file is intentionally future-dated 233 | if (isFutureFile(localStat.mtime, this.clockOffset)) { 234 | console.log(`[SYNC] PRESERVE ${relativePath} - future-dated file`); 235 | this.stats.filesProtected++; 236 | continue; 237 | } 238 | 239 | // Check if local is newer 240 | if (isLocalNewer(localStat.mtime, serverFile.modifiedAt, this.clockOffset)) { 241 | console.log(`[SYNC] PRESERVE ${relativePath} - local is newer`); 242 | this.stats.filesProtected++; 243 | continue; 244 | } 245 | 246 | // Check checksums 247 | const localContent = await readFile(localPath); 248 | const localChecksum = await calculateChecksum(localContent); 249 | 250 | if (localChecksum === serverFile.checksum) { 251 | console.log(`[SYNC] SKIP ${relativePath} - checksums match`); 252 | this.stats.filesDownloadedSkipped++; 253 | continue; 254 | } 255 | 256 | // Server file is different and not older, download it 257 | await this.downloadFile(serverFile.filename, relativePath); 258 | this.stats.filesDownloaded++; 259 | } catch (error) { 260 | // Log the error but don't fail initial sync 261 | console.error(`[SYNC] Failed to process ${relativePath} during initial sync:`, error.message); 262 | // Error already logged and emitted in downloadFile if it was a download error 263 | if (!error.message.includes('Failed to download')) { 264 | this.stats.errors.push(formatErrorForLog(error, { filename: relativePath, action: 'initial-sync-check' })); 265 | const errorInfo = classifyError(error, { filename: relativePath, action: 'check' }); 266 | this.emit('sync-error', errorInfo); 267 | 268 | // Log file processing error 269 | if (this.logger) { 270 | this.logger.error('SYNC', 'Initial sync file processing failed', { 271 | file: relativePath, 272 | error 273 | }); 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | // Upload local files not on server 281 | for (const [relativePath, localInfo] of localFiles) { 282 | const serverFile = serverFiles.find(f => 283 | (f.path === relativePath) || (`${f.filename}.html` === relativePath) 284 | ); 285 | 286 | if (!serverFile) { 287 | console.log(`[SYNC] LOCAL ONLY: ${relativePath} - uploading`); 288 | try { 289 | await this.uploadFile(relativePath); 290 | this.stats.filesUploaded++; 291 | } catch (error) { 292 | // Log the error but don't fail initial sync 293 | console.error(`[SYNC] Failed to upload ${relativePath} during initial sync:`, error.message); 294 | this.stats.errors.push(formatErrorForLog(error, { filename: relativePath, action: 'initial-upload' })); 295 | 296 | // Emit error event for UI 297 | const errorInfo = classifyError(error, { filename: relativePath, action: 'upload' }); 298 | this.emit('sync-error', errorInfo); 299 | } 300 | } 301 | } 302 | 303 | this.stats.lastSync = new Date().toISOString(); 304 | console.log('[SYNC] Initial sync complete'); 305 | console.log(`[SYNC] Stats: ${JSON.stringify(this.stats)}`); 306 | 307 | // Log initial sync completion 308 | if (this.logger) { 309 | this.logger.success('SYNC', 'Initial sync completed', { 310 | filesDownloaded: this.stats.filesDownloaded, 311 | filesUploaded: this.stats.filesUploaded, 312 | filesProtected: this.stats.filesProtected, 313 | filesDownloadedSkipped: this.stats.filesDownloadedSkipped, 314 | filesUploadedSkipped: this.stats.filesUploadedSkipped 315 | }); 316 | } 317 | 318 | // Emit completion event 319 | this.emit('sync-complete', { 320 | type: 'initial', 321 | stats: { ...this.stats } 322 | }); 323 | 324 | // Emit stats update 325 | this.emit('sync-stats', this.stats); 326 | 327 | } catch (error) { 328 | console.error('[SYNC] Initial sync failed:', error); 329 | this.stats.errors.push(formatErrorForLog(error, { action: 'initial-sync' })); 330 | 331 | // Log initial sync error 332 | if (this.logger) { 333 | this.logger.error('SYNC', 'Initial sync failed', { error }); 334 | } 335 | 336 | // Emit error event 337 | this.emit('sync-error', { 338 | type: 'initial', 339 | error: error.message, 340 | priority: ERROR_PRIORITY.CRITICAL 341 | }); 342 | 343 | throw error; 344 | } 345 | } 346 | 347 | /** 348 | * Download a file from server 349 | * @param {string} filename - Filename WITHOUT .html (may include folders) 350 | * @param {string} relativePath - Full path WITH .html for local storage 351 | */ 352 | async downloadFile(filename, relativePath) { 353 | try { 354 | // Server expects filename WITHOUT .html 355 | const { content, modifiedAt } = await downloadFromServer( 356 | this.serverUrl, 357 | this.apiKey, 358 | filename 359 | ); 360 | 361 | // Use provided relativePath or construct it 362 | const localFilename = relativePath || (filename.endsWith('.html') ? filename : `${filename}.html`); 363 | const localPath = path.join(this.syncFolder, localFilename); 364 | 365 | // Create backup if file exists locally 366 | // Remove .html extension for siteName (matches server.js behavior) 367 | const siteName = localFilename.replace(/\.html$/i, ''); 368 | await createBackupIfExists(localPath, siteName, this.syncFolder, this.emit.bind(this), this.logger); 369 | 370 | // Write file with server modification time (ensures directories exist) 371 | await writeFile(localPath, content, modifiedAt); 372 | 373 | console.log(`[SYNC] Downloaded ${localFilename}`); 374 | 375 | // Log download success 376 | if (this.logger) { 377 | this.logger.success('DOWNLOAD', 'File downloaded', { 378 | file: this.logger.sanitizePath(localPath), 379 | modifiedAt 380 | }); 381 | } 382 | 383 | // Emit success event 384 | this.emit('file-synced', { 385 | file: localFilename, 386 | action: 'download' 387 | }); 388 | 389 | } catch (error) { 390 | console.error(`[SYNC] Failed to download ${filename}:`, error); 391 | 392 | // Log download error 393 | if (this.logger) { 394 | this.logger.error('DOWNLOAD', 'Download failed', { 395 | file: filename, 396 | error 397 | }); 398 | } 399 | 400 | const errorInfo = classifyError(error, { filename, action: 'download' }); 401 | this.stats.errors.push(formatErrorForLog(error, { filename, action: 'download' })); 402 | 403 | // Emit structured error 404 | this.emit('sync-error', errorInfo); 405 | } 406 | } 407 | 408 | /** 409 | * Upload a file to server 410 | * @param {string} filename - Relative path WITH .html (may include folders) 411 | */ 412 | async uploadFile(filename) { 413 | try { 414 | // Validate filename before uploading 415 | const validationResult = filename.includes('/') 416 | ? validateFullPath(filename) 417 | : validateFileName(filename, false); 418 | 419 | if (!validationResult.valid) { 420 | const validationError = new Error(validationResult.error); 421 | validationError.isValidationError = true; 422 | 423 | console.error(`[SYNC] Validation failed for ${filename}: ${validationResult.error}`); 424 | 425 | // Log validation error 426 | if (this.logger) { 427 | this.logger.error('VALIDATION', 'Filename validation failed', { 428 | file: filename, 429 | reason: validationResult.error 430 | }); 431 | } 432 | 433 | // Emit validation error 434 | this.emit('sync-error', { 435 | file: filename, 436 | error: validationResult.error, 437 | type: 'validation', 438 | priority: ERROR_PRIORITY.HIGH, 439 | action: 'upload', 440 | canRetry: false 441 | }); 442 | 443 | // Don't throw - just skip this file 444 | return; 445 | } 446 | 447 | const localPath = path.join(this.syncFolder, filename); 448 | const content = await readFile(localPath); 449 | const stat = await getFileStats(localPath); 450 | 451 | // Calculate checksum for skip optimization 452 | const localChecksum = await calculateChecksum(content); 453 | 454 | // Check if server already has this exact content using cached data 455 | try { 456 | const serverFiles = await this.fetchAndCacheServerFiles(false); 457 | const filenameWithoutHtml = filename.replace(/\.html$/i, ''); 458 | const serverFile = serverFiles.find(f => f.filename === filenameWithoutHtml); 459 | 460 | if (serverFile && serverFile.checksum === localChecksum) { 461 | console.log(`[SYNC] SKIP upload ${filename} - server has same checksum`); 462 | this.stats.filesUploadedSkipped++; 463 | 464 | // Log upload skip 465 | if (this.logger) { 466 | this.logger.skip('UPLOAD', 'Upload skipped - checksums match', { 467 | file: this.logger.sanitizePath(localPath) 468 | }); 469 | } 470 | 471 | return; 472 | } 473 | } catch (error) { 474 | // If checksum check fails, continue with upload 475 | console.log(`[SYNC] Could not verify server checksum, proceeding with upload: ${error.message}`); 476 | } 477 | 478 | // Upload to server (filename WITHOUT .html) 479 | const filenameWithoutHtml = filename.replace(/\.html$/i, ''); 480 | await uploadToServer( 481 | this.serverUrl, 482 | this.apiKey, 483 | filenameWithoutHtml, 484 | content, 485 | stat.mtime 486 | ); 487 | 488 | console.log(`[SYNC] Uploaded ${filename}`); 489 | this.stats.filesUploaded++; 490 | 491 | // Log upload success 492 | if (this.logger) { 493 | this.logger.success('UPLOAD', 'File uploaded', { 494 | file: this.logger.sanitizePath(localPath), 495 | modifiedAt: stat.mtime 496 | }); 497 | } 498 | 499 | // Invalidate cache since server state changed 500 | this.invalidateServerFilesCache(); 501 | 502 | // Emit success event 503 | this.emit('file-synced', { 504 | file: filename, 505 | action: 'upload' 506 | }); 507 | 508 | } catch (error) { 509 | console.error(`[SYNC] Failed to upload ${filename}:`, error); 510 | 511 | // Log upload error 512 | if (this.logger) { 513 | this.logger.error('UPLOAD', 'Upload failed', { 514 | file: filename, 515 | error 516 | }); 517 | } 518 | 519 | // Check for detailed error structure (name conflicts) 520 | if (error.details) { 521 | this.emit('sync-conflict', { 522 | file: filename, 523 | conflict: 'name_taken', 524 | suggestions: error.details.suggestions, 525 | message: error.details.message 526 | }); 527 | } 528 | 529 | const errorInfo = classifyError(error, { filename, action: 'upload' }); 530 | this.stats.errors.push(formatErrorForLog(error, { filename, action: 'upload' })); 531 | 532 | // Emit structured error 533 | this.emit('sync-error', errorInfo); 534 | 535 | // Re-throw for retry logic 536 | throw error; 537 | } 538 | } 539 | 540 | /** 541 | * Queue a file for sync 542 | */ 543 | queueSync(type, filename) { 544 | // Don't queue if sync is not running 545 | if (!this.isRunning) return; 546 | 547 | // Validate filename before queueing (for add/change operations) 548 | if (type === 'add' || type === 'change') { 549 | const validationResult = filename.includes('/') 550 | ? validateFullPath(filename) 551 | : validateFileName(filename, false); 552 | 553 | if (!validationResult.valid) { 554 | console.error(`[SYNC] Cannot queue ${filename}: ${validationResult.error}`); 555 | 556 | // Log validation error 557 | if (this.logger) { 558 | this.logger.error('VALIDATION', 'Cannot queue file - validation failed', { 559 | file: filename, 560 | reason: validationResult.error 561 | }); 562 | } 563 | 564 | // Emit validation error immediately 565 | this.emit('sync-error', { 566 | file: filename, 567 | error: validationResult.error, 568 | type: 'validation', 569 | priority: ERROR_PRIORITY.HIGH, 570 | action: 'queue', 571 | canRetry: false 572 | }); 573 | 574 | return; 575 | } 576 | } 577 | 578 | // Add to queue 579 | if (!this.syncQueue.add(type, filename)) { 580 | return; // Already in queue or invalid file 581 | } 582 | 583 | // Process queue after a short delay (debounce) 584 | this.syncQueue.setQueueTimer(() => { 585 | if (this.isRunning) { 586 | this.processQueue(); 587 | } 588 | }); 589 | } 590 | 591 | /** 592 | * Process sync queue with retry logic 593 | */ 594 | async processQueue() { 595 | // Don't process if stopped or already processing 596 | if (!this.isRunning || this.syncQueue.isProcessingQueue() || this.syncQueue.isEmpty()) { 597 | return; 598 | } 599 | 600 | this.syncQueue.setProcessing(true); 601 | 602 | while (!this.syncQueue.isEmpty()) { 603 | const item = this.syncQueue.next(); 604 | 605 | try { 606 | if (item.type === 'add' || item.type === 'change') { 607 | await this.uploadFile(item.filename); 608 | } 609 | 610 | // Success - clear retry tracking 611 | this.syncQueue.clearRetry(item.filename); 612 | 613 | // Log successful queue item processing 614 | if (this.logger) { 615 | this.logger.success('QUEUE', 'Queue item processed', { 616 | file: item.filename, 617 | type: item.type 618 | }); 619 | } 620 | 621 | } catch (error) { 622 | // Log queue processing error 623 | if (this.logger) { 624 | this.logger.error('QUEUE', 'Queue processing failed', { 625 | file: item.filename, 626 | type: item.type, 627 | error 628 | }); 629 | } 630 | 631 | // Handle retry 632 | const retryResult = this.syncQueue.scheduleRetry( 633 | item, 634 | error, 635 | (retryItem) => { 636 | // Only retry if sync is still running and file exists 637 | if (this.isRunning) { 638 | const filePath = path.join(this.syncFolder, retryItem.filename); 639 | if (fileExists(filePath)) { 640 | this.queueSync(retryItem.type, retryItem.filename); 641 | } else { 642 | this.syncQueue.clearRetry(retryItem.filename); 643 | } 644 | } 645 | } 646 | ); 647 | 648 | if (!retryResult.shouldRetry) { 649 | // Permanent failure 650 | console.error(`[SYNC] Permanent failure for ${item.filename}: ${retryResult.reason}`); 651 | 652 | this.emit('sync-failed', { 653 | file: item.filename, 654 | error: error.message, 655 | priority: ERROR_PRIORITY.CRITICAL, 656 | finalFailure: true, 657 | attempts: retryResult.attempts 658 | }); 659 | } else { 660 | // Log retry scheduling 661 | if (this.logger) { 662 | this.logger.warn('QUEUE', 'Retry scheduled', { 663 | file: item.filename, 664 | attempt: retryResult.attempt, 665 | maxAttempts: retryResult.maxAttempts, 666 | nextRetryIn: retryResult.nextRetryIn 667 | }); 668 | } 669 | 670 | // Scheduled for retry 671 | this.emit('sync-retry', { 672 | file: item.filename, 673 | attempt: retryResult.attempt, 674 | maxAttempts: retryResult.maxAttempts, 675 | nextRetryIn: retryResult.nextRetryIn, 676 | error: error.message 677 | }); 678 | } 679 | } 680 | } 681 | 682 | this.stats.lastSync = new Date().toISOString(); 683 | 684 | // Emit stats update to UI 685 | this.emit('sync-stats', this.stats); 686 | 687 | this.syncQueue.setProcessing(false); 688 | } 689 | 690 | /** 691 | * Start watching local files 692 | */ 693 | startFileWatcher() { 694 | // Watch recursively for all HTML files 695 | this.watcher = chokidar.watch('**/*.html', { 696 | cwd: this.syncFolder, 697 | persistent: true, 698 | ignoreInitial: true, 699 | ignored: [ 700 | '**/node_modules/**', 701 | '**/sites-versions/**', 702 | '**/.*' // Ignore hidden files/folders 703 | ], 704 | awaitWriteFinish: SYNC_CONFIG.FILE_STABILIZATION 705 | }); 706 | 707 | this.watcher 708 | .on('add', filename => { 709 | // Normalize path to forward slashes (fixes Windows backslash issue) 710 | const normalizedPath = path.normalize(filename); 711 | console.log(`[SYNC] File added: ${normalizedPath}`); 712 | this.queueSync('add', normalizedPath); 713 | }) 714 | .on('change', filename => { 715 | // Normalize path to forward slashes (fixes Windows backslash issue) 716 | const normalizedPath = path.normalize(filename); 717 | console.log(`[SYNC] File changed: ${normalizedPath}`); 718 | this.queueSync('change', normalizedPath); 719 | }) 720 | .on('unlink', filename => { 721 | // Normalize path to forward slashes (fixes Windows backslash issue) 722 | const normalizedPath = path.normalize(filename); 723 | // Intentionally ignore deletes (per design spec) 724 | console.log(`[SYNC] File deleted locally (not syncing to server): ${normalizedPath}`); 725 | }) 726 | .on('error', error => { 727 | console.error('[SYNC] Watcher error:', error); 728 | this.stats.errors.push(formatErrorForLog(error, { action: 'watcher' })); 729 | 730 | // Log watcher error 731 | if (this.logger) { 732 | this.logger.error('WATCHER', 'File watcher error', { error }); 733 | } 734 | }); 735 | 736 | console.log('[SYNC] File watcher started (watching recursively)'); 737 | 738 | // Log watcher start 739 | if (this.logger) { 740 | this.logger.info('WATCHER', 'File watcher started', { 741 | syncFolder: this.logger.sanitizePath(this.syncFolder) 742 | }); 743 | } 744 | } 745 | 746 | /** 747 | * Start polling for remote changes 748 | */ 749 | startPolling() { 750 | this.pollTimer = setInterval(async () => { 751 | await this.checkForRemoteChanges(); 752 | }, SYNC_CONFIG.POLL_INTERVAL); 753 | 754 | console.log('[SYNC] Polling started'); 755 | 756 | // Log polling start 757 | if (this.logger) { 758 | this.logger.info('POLL', 'Polling started', { 759 | interval: SYNC_CONFIG.POLL_INTERVAL 760 | }); 761 | } 762 | } 763 | 764 | /** 765 | * Check for changes on the server 766 | */ 767 | async checkForRemoteChanges() { 768 | // Don't poll if sync is not running 769 | if (!this.isRunning) { 770 | return; 771 | } 772 | 773 | if (this.syncQueue.isProcessingQueue()) { 774 | // Log when poll is skipped due to queue processing 775 | if (this.logger) { 776 | this.logger.info('POLL', 'Poll check skipped - queue is processing'); 777 | } 778 | return; 779 | } 780 | 781 | try { 782 | // Log poll check start 783 | if (this.logger) { 784 | this.logger.info('POLL', 'Checking for remote changes'); 785 | } 786 | 787 | const serverFiles = await this.fetchAndCacheServerFiles(true); 788 | 789 | // Check if sync was stopped during the fetch 790 | if (!this.isRunning) { 791 | return; 792 | } 793 | 794 | const localFiles = await getLocalFiles(this.syncFolder); 795 | let changesFound = false; 796 | 797 | for (const serverFile of serverFiles) { 798 | // Check if sync was stopped during iteration 799 | if (!this.isRunning) { 800 | return; 801 | } 802 | // Server returns path WITH .html (e.g., "folder1/folder2/site.html" or "site.html") 803 | const relativePath = serverFile.path || `${serverFile.filename}.html`; 804 | const localPath = path.join(this.syncFolder, relativePath); 805 | const localExists = localFiles.has(relativePath); 806 | 807 | if (!localExists) { 808 | // New file on server 809 | await this.downloadFile(serverFile.filename, relativePath); 810 | this.stats.filesDownloaded++; 811 | changesFound = true; 812 | } else { 813 | const localInfo = localFiles.get(relativePath); 814 | const localContent = await readFile(localPath); 815 | const localChecksum = await calculateChecksum(localContent); 816 | 817 | // Check if content is different 818 | if (localChecksum !== serverFile.checksum) { 819 | // Check if local is newer 820 | if (isLocalNewer(localInfo.mtime, serverFile.modifiedAt, this.clockOffset)) { 821 | console.log(`[SYNC] PRESERVE ${relativePath} - local is newer`); 822 | this.stats.filesProtected++; 823 | } else { 824 | // Download newer version from server 825 | await this.downloadFile(serverFile.filename, relativePath); 826 | this.stats.filesDownloaded++; 827 | changesFound = true; 828 | } 829 | } 830 | } 831 | } 832 | 833 | if (changesFound) { 834 | this.emit('sync-stats', this.stats); 835 | 836 | // Log poll check completion with changes 837 | if (this.logger) { 838 | this.logger.success('POLL', 'Remote changes detected and downloaded', { 839 | filesDownloaded: this.stats.filesDownloaded 840 | }); 841 | } 842 | } else { 843 | // Log poll check completion with no changes 844 | if (this.logger) { 845 | this.logger.info('POLL', 'Poll check completed - no changes'); 846 | } 847 | } 848 | 849 | this.stats.lastSync = new Date().toISOString(); 850 | } catch (error) { 851 | console.error('[SYNC] Failed to check for remote changes:', error); 852 | this.stats.errors.push(formatErrorForLog(error, { action: 'poll' })); 853 | 854 | // Log polling error 855 | if (this.logger) { 856 | this.logger.error('POLL', 'Polling check failed', { error }); 857 | } 858 | } 859 | } 860 | 861 | /** 862 | * Stop sync 863 | */ 864 | async stop() { 865 | if (!this.isRunning) return; 866 | 867 | console.log('[SYNC] Stopping sync engine...'); 868 | 869 | // Mark as not running immediately (this will abort any ongoing polls) 870 | this.isRunning = false; 871 | 872 | // Stop polling FIRST (before watcher, to prevent new polls from starting) 873 | if (this.pollTimer) { 874 | clearInterval(this.pollTimer); 875 | this.pollTimer = null; 876 | console.log('[SYNC] Polling timer cleared'); 877 | } 878 | 879 | // Stop file watcher 880 | if (this.watcher) { 881 | await this.watcher.close(); 882 | this.watcher = null; 883 | console.log('[SYNC] File watcher closed'); 884 | } 885 | 886 | // Clear all pending operations 887 | this.syncQueue.clear(); 888 | 889 | // Clear server files cache 890 | this.invalidateServerFilesCache(); 891 | 892 | console.log('[SYNC] Sync stopped'); 893 | 894 | // Log sync stop 895 | if (this.logger) { 896 | this.logger.info('SYNC', 'Sync stopped', { 897 | finalStats: { 898 | filesDownloaded: this.stats.filesDownloaded, 899 | filesUploaded: this.stats.filesUploaded, 900 | filesProtected: this.stats.filesProtected, 901 | errors: this.stats.errors.length 902 | } 903 | }); 904 | } 905 | 906 | return { 907 | success: true, 908 | stats: this.stats 909 | }; 910 | } 911 | 912 | /** 913 | * Get sync status 914 | */ 915 | getStatus() { 916 | return { 917 | isRunning: this.isRunning, 918 | syncFolder: this.syncFolder, 919 | username: this.username, 920 | stats: { 921 | ...this.stats, 922 | recentErrors: this.stats.errors.slice(-5) // Last 5 errors 923 | }, 924 | queueStatus: { 925 | queueLength: this.syncQueue.length(), 926 | isProcessing: this.syncQueue.isProcessingQueue(), 927 | retryItems: this.syncQueue.getRetryItems() 928 | } 929 | }; 930 | } 931 | 932 | /** 933 | * Clear API key from memory 934 | */ 935 | clearApiKey() { 936 | this.apiKey = null; 937 | this.apiKeyEncrypted = null; 938 | this.username = null; 939 | } 940 | 941 | /** 942 | * Check if file has permanent failure 943 | */ 944 | hasFailedPermanently(filename) { 945 | return this.syncQueue.hasFailedPermanently(filename); 946 | } 947 | } 948 | 949 | // Export singleton instance 950 | const syncEngine = new SyncEngine(); 951 | module.exports = syncEngine; --------------------------------------------------------------------------------