Skip to content

Commit b110b67

Browse files
Replace remote host+port with single URL for Tailscale support
- Change remoteVibora schema from { host, port } to { url } - Add schema migration (v2 → v3) to convert existing settings - Update desktop onboarding to accept full URL input - Add Remote Server section to Settings UI - Enable changing remote connection from Settings without manual edit - Desktop app reconnects when remote URL is changed via postMessage
1 parent 7c3635c commit b110b67

6 files changed

Lines changed: 289 additions & 125 deletions

File tree

desktop/resources/js/main.js

Lines changed: 126 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// Configuration
1212
const SERVER_EXTENSION_ID = 'io.vibora.server';
1313
const DEFAULT_PORT = 7777;
14-
const CURRENT_SCHEMA_VERSION = 2;
14+
const CURRENT_SCHEMA_VERSION = 3;
1515
const HEALTH_CHECK_TIMEOUT = 3000; // 3 seconds per check
1616
const MAX_HEALTH_RETRIES = 10;
1717
const DEV_PORT = 5173;
@@ -63,58 +63,85 @@ function isFirstLaunch() {
6363
return !desktopSettings || desktopSettings._schemaVersion === undefined;
6464
}
6565

66+
/**
67+
* Helper: Construct URL from host and port
68+
*/
69+
function constructRemoteUrl(host, port) {
70+
if (!host) return '';
71+
const effectivePort = port || DEFAULT_PORT;
72+
// Omit port for standard HTTP/HTTPS ports
73+
const portSuffix = effectivePort === 80 || effectivePort === 443 ? '' : `:${effectivePort}`;
74+
return `http://${host}${portSuffix}`;
75+
}
76+
6677
/**
6778
* Migrate settings from flat to nested format
6879
*/
6980
function migrateSettings(settings) {
70-
if (settings._schemaVersion >= CURRENT_SCHEMA_VERSION) {
81+
const version = settings._schemaVersion || 1;
82+
if (version >= CURRENT_SCHEMA_VERSION) {
7183
return settings; // Already migrated
7284
}
7385

74-
console.log('[Vibora] Migrating settings to nested format...');
75-
76-
// Migration map from flat keys to nested paths
77-
const migrationMap = {
78-
port: ['server', 'port'],
79-
defaultGitReposDir: ['paths', 'defaultGitReposDir'],
80-
basicAuthUsername: ['authentication', 'username'],
81-
basicAuthPassword: ['authentication', 'password'],
82-
remoteHost: ['remoteVibora', 'host'],
83-
hostname: ['remoteVibora', 'host'], // Legacy key
84-
remotePort: ['remoteVibora', 'port'],
85-
sshPort: ['editor', 'sshPort'],
86-
linearApiKey: ['integrations', 'linearApiKey'],
87-
githubPat: ['integrations', 'githubPat'],
88-
language: ['appearance', 'language'],
89-
};
86+
console.log('[Vibora] Migrating settings from version', version, 'to', CURRENT_SCHEMA_VERSION);
9087

9188
const migrated = {
9289
_schemaVersion: CURRENT_SCHEMA_VERSION,
9390
server: { port: DEFAULT_PORT },
94-
remoteVibora: { host: '', port: DEFAULT_PORT },
91+
remoteVibora: { url: '' },
9592
editor: { app: 'vscode', host: '', sshPort: 22 },
9693
};
9794

98-
// Copy existing nested groups if present
99-
for (const key of ['server', 'paths', 'authentication', 'remoteVibora', 'editor', 'integrations', 'appearance', 'notifications', 'zai']) {
95+
// Copy existing nested groups if present (except remoteVibora which needs special handling)
96+
for (const key of ['server', 'paths', 'authentication', 'editor', 'integrations', 'appearance', 'notifications', 'zai']) {
10097
if (settings[key] && typeof settings[key] === 'object') {
10198
migrated[key] = { ...migrated[key], ...settings[key] };
10299
}
103100
}
104101

105-
// Migrate flat keys
106-
for (const [flatKey, [group, prop]] of Object.entries(migrationMap)) {
107-
if (settings[flatKey] !== undefined && settings[flatKey] !== null) {
108-
// Don't migrate old default port (3333) - let user get new default
109-
if (flatKey === 'port' && settings[flatKey] === 3333) {
110-
continue;
111-
}
112-
// Only migrate if not already set in nested format
113-
if (!migrated[group]) migrated[group] = {};
114-
if (migrated[group][prop] === undefined || migrated[group][prop] === null || migrated[group][prop] === '') {
115-
migrated[group][prop] = settings[flatKey];
102+
// Schema 1 → 2: Migrate flat keys to nested structure
103+
if (version < 2) {
104+
const migrationMap = {
105+
port: ['server', 'port'],
106+
defaultGitReposDir: ['paths', 'defaultGitReposDir'],
107+
basicAuthUsername: ['authentication', 'username'],
108+
basicAuthPassword: ['authentication', 'password'],
109+
sshPort: ['editor', 'sshPort'],
110+
linearApiKey: ['integrations', 'linearApiKey'],
111+
githubPat: ['integrations', 'githubPat'],
112+
language: ['appearance', 'language'],
113+
};
114+
115+
for (const [flatKey, [group, prop]] of Object.entries(migrationMap)) {
116+
if (settings[flatKey] !== undefined && settings[flatKey] !== null) {
117+
// Don't migrate old default port (3333) - let user get new default
118+
if (flatKey === 'port' && settings[flatKey] === 3333) {
119+
continue;
120+
}
121+
if (!migrated[group]) migrated[group] = {};
122+
if (migrated[group][prop] === undefined || migrated[group][prop] === null || migrated[group][prop] === '') {
123+
migrated[group][prop] = settings[flatKey];
124+
}
116125
}
117126
}
127+
128+
// Handle flat remoteHost/hostname → remoteVibora.url
129+
const flatHost = settings.remoteHost || settings.hostname || '';
130+
if (flatHost) {
131+
migrated.remoteVibora = { url: constructRemoteUrl(flatHost, DEFAULT_PORT) };
132+
}
133+
}
134+
135+
// Schema 2 → 3: Migrate remoteVibora.host + remoteVibora.port → remoteVibora.url
136+
if (version < 3 && settings.remoteVibora) {
137+
if ('host' in settings.remoteVibora) {
138+
const host = settings.remoteVibora.host || '';
139+
const port = settings.remoteVibora.port || DEFAULT_PORT;
140+
migrated.remoteVibora = { url: constructRemoteUrl(host, port) };
141+
} else if ('url' in settings.remoteVibora) {
142+
// Already has url format
143+
migrated.remoteVibora = { url: settings.remoteVibora.url || '' };
144+
}
118145
}
119146

120147
// Preserve non-migrated keys (like lastUpdateCheck, lastConnectedHost)
@@ -247,7 +274,7 @@ function promptOnboardingChoice() {
247274

248275
/**
249276
* Prompt user to configure remote server connection
250-
* @returns {Promise<{host: string, port: number} | null>} null if cancelled
277+
* @returns {Promise<{url: string} | null>} null if cancelled
251278
*/
252279
function promptRemoteConfig() {
253280
return new Promise((resolve) => {
@@ -257,17 +284,13 @@ function promptRemoteConfig() {
257284
<div class="prompt-container">
258285
<div class="prompt-title">Connect to Remote Server</div>
259286
<div class="prompt-description">
260-
Enter the hostname and port of your remote Vibora server.
287+
Enter the URL of your remote Vibora server.
261288
</div>
262289
<div id="remote-error" class="prompt-error" style="display: none;"></div>
263290
<form class="prompt-form" id="remote-form">
264291
<div class="input-group">
265-
<label for="remote-host">Hostname</label>
266-
<input type="text" id="remote-host" placeholder="example.com or 192.168.1.100" required autocomplete="off" />
267-
</div>
268-
<div class="input-group">
269-
<label for="remote-port">Port</label>
270-
<input type="number" id="remote-port" placeholder="${DEFAULT_PORT}" value="${DEFAULT_PORT}" min="1" max="65535" />
292+
<label for="remote-url">Server URL</label>
293+
<input type="url" id="remote-url" placeholder="http://example.com:7777 or https://vibora.tailnet.ts.net" required autocomplete="off" />
271294
</div>
272295
<div class="button-group">
273296
<button type="button" class="secondary-btn" id="back-btn">Back</button>
@@ -280,37 +303,46 @@ function promptRemoteConfig() {
280303
document.getElementById('back-btn').onclick = () => resolve(null);
281304
document.getElementById('remote-form').onsubmit = (e) => {
282305
e.preventDefault();
283-
const host = document.getElementById('remote-host').value.trim();
284-
const port = parseInt(document.getElementById('remote-port').value, 10) || DEFAULT_PORT;
306+
const urlInput = document.getElementById('remote-url').value.trim();
307+
const errorEl = document.getElementById('remote-error');
285308

286-
if (!host) {
287-
const errorEl = document.getElementById('remote-error');
288-
errorEl.textContent = 'Please enter a hostname';
309+
if (!urlInput) {
310+
errorEl.textContent = 'Please enter a URL';
289311
errorEl.style.display = 'block';
290312
return;
291313
}
292314

293-
resolve({ host, port });
315+
// Validate URL
316+
try {
317+
const url = new URL(urlInput);
318+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
319+
throw new Error('URL must be http:// or https://');
320+
}
321+
// Normalize to origin (removes trailing slash, path)
322+
resolve({ url: url.origin });
323+
} catch (err) {
324+
errorEl.textContent = 'Please enter a valid URL (e.g., http://example.com:7777)';
325+
errorEl.style.display = 'block';
326+
}
294327
};
295328
});
296329
}
297330

298331
/**
299332
* Prompt user to choose between local and remote server
300-
* Only shown when remoteHost is configured in settings
333+
* Only shown when remote URL is configured in settings
301334
* @returns {Promise<boolean>} true if user wants to connect to remote
302335
*/
303-
function promptServerChoice(remoteHost, remotePort) {
336+
function promptServerChoice(remoteUrl) {
304337
return new Promise((resolve) => {
305338
const app = document.getElementById('app');
306-
const displayHost = remotePort !== DEFAULT_PORT ? `${remoteHost}:${remotePort}` : remoteHost;
307339
app.innerHTML = `
308340
<img src="/icons/icon.png" alt="Vibora" class="logo" style="animation: none;">
309341
<div class="prompt-container">
310342
<div class="prompt-title">Choose Server</div>
311343
<div class="prompt-description">
312344
You have a remote server configured at:<br>
313-
<strong>${displayHost}</strong>
345+
<strong>${remoteUrl}</strong>
314346
</div>
315347
<div class="button-group" style="margin-top: 1.5rem;">
316348
<button class="primary-btn" id="use-local-btn">Use Local Server</button>
@@ -517,24 +549,21 @@ async function startLocalServer() {
517549
* Get remote server config from settings (nested format)
518550
*/
519551
function getRemoteConfig() {
520-
const host = desktopSettings?.remoteVibora?.host?.trim() || '';
521-
const port = desktopSettings?.remoteVibora?.port || DEFAULT_PORT;
522-
return { host, port };
552+
const url = desktopSettings?.remoteVibora?.url?.trim() || '';
553+
return { url };
523554
}
524555

525556
/**
526557
* Connect to remote server
527558
*/
528-
async function connectToRemote(remoteHost, remotePort) {
529-
const remoteUrl = `http://${remoteHost}:${remotePort}`;
530-
531-
setStatus('Connecting to remote server...', `${remoteHost}:${remotePort}`);
532-
console.log('[Vibora] Connecting to remote:', remoteHost);
559+
async function connectToRemote(remoteUrl) {
560+
setStatus('Connecting to remote server...', remoteUrl);
561+
console.log('[Vibora] Connecting to remote:', remoteUrl);
533562

534563
if (await waitForServerReady(remoteUrl)) {
535564
await saveSettings({
536565
...desktopSettings,
537-
lastConnectedHost: remoteHost
566+
lastConnectedHost: remoteUrl
538567
});
539568
loadViboraApp(remoteUrl);
540569
return true;
@@ -593,7 +622,7 @@ async function tryConnect() {
593622
await loadSettings();
594623

595624
const remote = getRemoteConfig();
596-
const hasRemoteConfig = remote.host !== '';
625+
const hasRemoteConfig = remote.url !== '';
597626

598627
// First launch - show onboarding
599628
if (isFirstLaunch()) {
@@ -611,17 +640,17 @@ async function tryConnect() {
611640
...desktopSettings,
612641
_schemaVersion: CURRENT_SCHEMA_VERSION,
613642
server: { port: DEFAULT_PORT },
614-
remoteVibora: { host: config.host, port: config.port },
643+
remoteVibora: { url: config.url },
615644
editor: { app: 'vscode', host: '', sshPort: 22 },
616645
});
617646

618647
// Try to connect to remote
619-
if (await connectToRemote(config.host, config.port)) {
648+
if (await connectToRemote(config.url)) {
620649
return;
621650
}
622651

623652
// Remote failed - ask if they want to try local instead
624-
showError('Connection Failed', `Could not connect to ${config.host}:${config.port}. Try running locally or check the server.`);
653+
showError('Connection Failed', `Could not connect to ${config.url}. Try running locally or check the server.`);
625654
return;
626655
}
627656

@@ -635,18 +664,18 @@ async function tryConnect() {
635664
...desktopSettings,
636665
_schemaVersion: CURRENT_SCHEMA_VERSION,
637666
server: { port: DEFAULT_PORT },
638-
remoteVibora: { host: '', port: DEFAULT_PORT },
667+
remoteVibora: { url: '' },
639668
editor: { app: 'vscode', host: '', sshPort: 22 },
640669
});
641670
}
642671

643-
// Check if remoteHost is configured (returning user with remote setup)
672+
// Check if remote URL is configured (returning user with remote setup)
644673
if (hasRemoteConfig) {
645674
// Ask user which server to use
646-
const useRemote = await promptServerChoice(remote.host, remote.port);
675+
const useRemote = await promptServerChoice(remote.url);
647676

648677
if (useRemote) {
649-
if (await connectToRemote(remote.host, remote.port)) {
678+
if (await connectToRemote(remote.url)) {
650679
return;
651680
}
652681
// Remote failed - fall through to local
@@ -658,6 +687,32 @@ async function tryConnect() {
658687
await connectToLocal();
659688
}
660689

690+
/**
691+
* Handle reconnection request from the React app (via postMessage)
692+
* Called when user changes the remote URL in Settings
693+
*/
694+
async function handleReconnect(newUrl) {
695+
console.log('[Vibora] Reconnect requested:', newUrl || 'local');
696+
697+
// Save new URL to settings
698+
await saveSettings({
699+
...desktopSettings,
700+
remoteVibora: { url: newUrl || '' }
701+
});
702+
703+
if (newUrl) {
704+
// Connect to remote
705+
if (await connectToRemote(newUrl)) {
706+
return;
707+
}
708+
// Failed - show error but don't fall back automatically
709+
showError('Connection Failed', `Could not connect to ${newUrl}`);
710+
} else {
711+
// Switch to local
712+
await connectToLocal();
713+
}
714+
}
715+
661716
/**
662717
* Handle extension ready event (when running with local server extension)
663718
*/
@@ -987,6 +1042,10 @@ async function init() {
9871042
} else if (event.data?.type === 'vibora:playSound') {
9881043
// Play notification sound locally
9891044
playNotificationSound();
1045+
} else if (event.data?.type === 'vibora:reconnect') {
1046+
// Handle reconnection request from Settings UI
1047+
const newUrl = event.data.url;
1048+
handleReconnect(newUrl);
9901049
}
9911050
});
9921051

0 commit comments

Comments
 (0)