├── 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 |
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 += '
';
369 |
370 | // Sort entries: directories first, then files
371 | const dirs = entries.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'));
372 | const files = entries.filter(entry => entry.isFile() && !entry.name.startsWith('.'));
373 |
374 | // List directories
375 | for (const entry of dirs) {
376 | const entryPath = displayPath ? `${displayPath}/${entry.name}` : entry.name;
377 | html += `-
378 |
379 | 📁${entry.name}/
380 |
381 |
`;
382 | }
383 |
384 | // List files
385 | for (const entry of files) {
386 | const entryPath = displayPath ? `${displayPath}/${entry.name}` : entry.name;
387 | const icon = entry.name.endsWith('.html') ? '🌐' : '📄';
388 | const className = entry.name.endsWith('.html') ? 'html-file' : '';
389 |
390 | html += `-
391 |
392 | ${icon}${entry.name}
393 |
394 |
`;
395 | }
396 |
397 | 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 |
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;
--------------------------------------------------------------------------------