Files
TimeTracker/desktop/src/renderer/js/connection/connection_manager.js
T
Dries Peeters 4c427c92c0 feat(desktop): centralize connection lifecycle and expand login flow
Introduce a connection manager (state machine, listeners, offline handling,
token/server binding) with timer start/stop reconciliation helpers. Refactor
app.js to use it and extend the first-run wizard (welcome + server + token).

Add node:test coverage for the manager, timer operations, and an /api/v1/info
integration harness. Rebuild renderer bundle for packaging.

Bump desktop toolchain (electron, electron-builder, esbuild) and Python
package version to 5.3.2 in setup.py.
2026-04-24 16:17:56 +02:00

438 lines
12 KiB
JavaScript

const ApiClient = require('../api/client');
const { CONNECTION_STATE } = require('./connection_state');
const STORE_SERVER = 'server_url';
const STORE_TOKEN = 'api_token';
const STORE_TOKEN_SERVER = 'api_token_server_url';
/**
* Single source of truth for server URL, API client lifecycle, and connection state.
* @param {{
* storeGet: (k: string) => Promise<unknown>,
* storeSet: (k: string, v: unknown) => Promise<void>,
* storeDelete: (k: string) => Promise<void>,
* storeClear: () => Promise<void>,
* onCacheClear?: () => void,
* }} deps
*/
function createConnectionManager(deps) {
const storeGet = deps.storeGet;
const storeSet = deps.storeSet;
const storeDelete = deps.storeDelete;
const storeClear = deps.storeClear;
const onCacheClear = deps.onCacheClear || (() => {});
/** @type {import('../api/client') | null} */
let apiClient = null;
/** @type {Set<(s: ReturnType<typeof getSnapshot>) => void>} */
const listeners = new Set();
let offlineListenerBound = false;
/** @type {{ state: string, serverUrl: string|null, lastError: string|null, lastConnectedAt: number|null, serverVersion: string|null }} */
let snapshot = {
state: CONNECTION_STATE.NOT_CONFIGURED,
serverUrl: null,
lastError: null,
lastConnectedAt: null,
serverVersion: null,
};
function getSnapshot() {
return { ...snapshot };
}
function getClient() {
return apiClient;
}
function notify() {
const s = getSnapshot();
for (const fn of listeners) {
try {
fn(s);
} catch (e) {
console.error('ConnectionManager listener error:', e);
}
}
}
function setSnap(partial) {
snapshot = { ...snapshot, ...partial };
notify();
}
function tearDownClient() {
apiClient = null;
}
function attachWindowListeners() {
if (typeof window === 'undefined' || offlineListenerBound) return;
offlineListenerBound = true;
window.addEventListener('online', () => {
if (snapshot.state === CONNECTION_STATE.OFFLINE && apiClient) {
setSnap({ state: CONNECTION_STATE.CONNECTING, lastError: null });
}
});
window.addEventListener('offline', () => {
setSnap({
state: CONNECTION_STATE.OFFLINE,
lastError: 'Network offline.',
});
});
}
/**
* @param {string} baseUrl
* @returns {Promise<import('../api/client').ValidationResult & { app_version?: string|null }>}
*/
async function testServer(baseUrl) {
const normalized = ApiClient.normalizeBaseUrl(String(baseUrl || '').trim());
if (!normalized) {
return { ok: false, code: 'NO_URL', message: 'Please enter a server URL.' };
}
return ApiClient.testPublicServerInfo(normalized);
}
async function bootstrapFromStore() {
attachWindowListeners();
const serverRaw = await storeGet(STORE_SERVER);
const token = await storeGet(STORE_TOKEN);
const serverUrlEarly = serverRaw ? ApiClient.normalizeBaseUrl(String(serverRaw)) : null;
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
tearDownClient();
setSnap({
state: CONNECTION_STATE.OFFLINE,
serverUrl: serverUrlEarly,
lastError: 'Network offline.',
});
return {
ok: false,
reason: 'offline',
hadCredentials: Boolean(serverUrlEarly && token),
};
}
const tokenServer = await storeGet(STORE_TOKEN_SERVER);
let serverUrl = serverRaw ? ApiClient.normalizeBaseUrl(String(serverRaw)) : null;
if (serverUrl && serverRaw && serverUrl !== String(serverRaw).trim()) {
await storeSet(STORE_SERVER, serverUrl);
}
if (!serverUrl) {
tearDownClient();
setSnap({
state: CONNECTION_STATE.NOT_CONFIGURED,
serverUrl: null,
lastError: null,
serverVersion: null,
});
return { ok: false, reason: 'no_server' };
}
if (!token) {
tearDownClient();
setSnap({
state: CONNECTION_STATE.NOT_CONFIGURED,
serverUrl,
lastError: null,
serverVersion: null,
});
return { ok: false, reason: 'no_token' };
}
const tokenNorm = tokenServer ? ApiClient.normalizeBaseUrl(String(tokenServer)) : null;
if (tokenNorm && tokenNorm !== serverUrl) {
await storeDelete(STORE_TOKEN);
await storeDelete(STORE_TOKEN_SERVER);
tearDownClient();
onCacheClear();
setSnap({
state: CONNECTION_STATE.NOT_CONFIGURED,
serverUrl,
lastError: 'This API token was saved for a different server. Please sign in again.',
serverVersion: null,
});
return { ok: false, reason: 'token_server_mismatch' };
}
apiClient = new ApiClient(serverUrl);
await apiClient.setAuthToken(String(token));
setSnap({
state: CONNECTION_STATE.CONNECTING,
serverUrl,
lastError: null,
});
const session = await apiClient.validateSession();
if (session.ok) {
if (!tokenNorm) {
await storeSet(STORE_TOKEN_SERVER, serverUrl);
}
const now = Date.now();
setSnap({
state: CONNECTION_STATE.CONNECTED,
serverUrl,
lastError: null,
lastConnectedAt: now,
serverVersion: null,
});
return { ok: true, session };
}
tearDownClient();
await storeDelete(STORE_TOKEN);
await storeDelete(STORE_TOKEN_SERVER);
onCacheClear();
setSnap({
state: CONNECTION_STATE.ERROR,
serverUrl,
lastError: session.message || 'Session invalid',
serverVersion: null,
});
return { ok: false, reason: 'session', session };
}
/**
* Validate server + token, then persist. No partial token writes on auth failure.
* @param {string} serverUrl
* @param {string} token
*/
async function login(serverUrl, token) {
const normalized = ApiClient.normalizeBaseUrl(String(serverUrl || '').trim());
const pub = await ApiClient.testPublicServerInfo(normalized);
if (!pub.ok) {
setSnap({
state: CONNECTION_STATE.ERROR,
serverUrl: normalized,
lastError: pub.message,
});
return { ok: false, step: 'server', ...pub };
}
const probe = new ApiClient(normalized);
await probe.setAuthToken(token);
const session = await probe.validateSession();
if (!session.ok) {
setSnap({
state: CONNECTION_STATE.ERROR,
serverUrl: normalized,
lastError: session.message || 'Login failed',
serverVersion: null,
});
return { ok: false, step: 'auth', session };
}
await storeSet(STORE_SERVER, normalized);
await storeSet(STORE_TOKEN, token);
await storeSet(STORE_TOKEN_SERVER, normalized);
apiClient = probe;
const now = Date.now();
setSnap({
state: CONNECTION_STATE.CONNECTED,
serverUrl: normalized,
lastError: null,
lastConnectedAt: now,
serverVersion: pub.app_version || null,
});
return { ok: true, session, app_version: pub.app_version || null };
}
async function logoutKeepServer() {
await storeDelete(STORE_TOKEN);
await storeDelete(STORE_TOKEN_SERVER);
tearDownClient();
onCacheClear();
const serverRaw = await storeGet(STORE_SERVER);
const serverUrl = serverRaw ? ApiClient.normalizeBaseUrl(String(serverRaw)) : null;
setSnap({
state: CONNECTION_STATE.NOT_CONFIGURED,
serverUrl,
lastError: null,
serverVersion: null,
});
}
async function fullStoreReset() {
await storeClear();
tearDownClient();
onCacheClear();
snapshot = {
state: CONNECTION_STATE.NOT_CONFIGURED,
serverUrl: null,
lastError: null,
lastConnectedAt: null,
serverVersion: null,
};
notify();
}
/** @returns {Promise<import('../api/client').ValidationResult>} */
async function validateSessionRefresh() {
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
setSnap({
state: CONNECTION_STATE.OFFLINE,
lastError: 'Network offline.',
});
return { ok: false, code: 'OFFLINE', message: 'Network offline.' };
}
if (!apiClient) {
setSnap({
state: CONNECTION_STATE.NOT_CONFIGURED,
lastError: null,
});
return { ok: false, code: 'NO_CLIENT', message: 'Not connected.' };
}
const session = await apiClient.validateSession();
if (session.ok) {
const now = Date.now();
setSnap({
state: CONNECTION_STATE.CONNECTED,
lastError: null,
lastConnectedAt: now,
});
return session;
}
if (session.code === 'UNAUTHORIZED') {
setSnap({
state: CONNECTION_STATE.ERROR,
lastError: session.message || 'Unauthorized',
});
return session;
}
const transportish =
session.code === 'TIMEOUT' ||
session.code === 'REFUSED' ||
session.code === 'UNREACHABLE' ||
session.code === 'DNS' ||
session.code === 'TLS' ||
session.code === 'UNKNOWN';
if (transportish) {
setSnap({
state: CONNECTION_STATE.ERROR,
lastError: session.message || 'Server not reachable',
});
return session;
}
setSnap({
state: CONNECTION_STATE.ERROR,
lastError: session.message || 'Connection error',
});
return session;
}
/**
* Validate server + token, then persist (including optional sync prefs). No partial writes on failure.
* @param {string} serverUrl
* @param {string} token
* @param {{ auto_sync?: boolean, sync_interval?: number }|null} syncExtras
*/
async function saveServerAndToken(serverUrl, token, syncExtras) {
const normalized = ApiClient.normalizeBaseUrl(String(serverUrl || '').trim());
const pub = await ApiClient.testPublicServerInfo(normalized);
if (!pub.ok) {
setSnap({
state: CONNECTION_STATE.ERROR,
lastError: pub.message,
});
return { ok: false, step: 'server', ...pub };
}
const probe = new ApiClient(normalized);
await probe.setAuthToken(token);
const session = await probe.validateSession();
if (!session.ok) {
setSnap({
state: CONNECTION_STATE.ERROR,
lastError: session.message || 'Session check failed. Settings were not saved.',
});
return { ok: false, step: 'auth', session };
}
if (syncExtras) {
if (syncExtras.auto_sync !== undefined) await storeSet('auto_sync', syncExtras.auto_sync);
if (syncExtras.sync_interval !== undefined) await storeSet('sync_interval', syncExtras.sync_interval);
}
await storeSet(STORE_SERVER, normalized);
await storeSet(STORE_TOKEN, token);
await storeSet(STORE_TOKEN_SERVER, normalized);
apiClient = probe;
const now = Date.now();
setSnap({
state: CONNECTION_STATE.CONNECTED,
serverUrl: normalized,
lastError: null,
lastConnectedAt: now,
serverVersion: pub.app_version || null,
});
return { ok: true, session };
}
/**
* Test server + token without persisting.
*/
async function testServerAndSession(serverUrl, token) {
const normalized = ApiClient.normalizeBaseUrl(String(serverUrl || '').trim());
const pub = await ApiClient.testPublicServerInfo(normalized);
if (!pub.ok) return pub;
const probe = new ApiClient(normalized);
await probe.setAuthToken(token);
const session = await probe.validateSession();
if (!session.ok) return session;
return { ok: true, app_version: pub.app_version || null };
}
function subscribe(fn) {
listeners.add(fn);
try {
fn(getSnapshot());
} catch (e) {
console.error('ConnectionManager subscribe initial error:', e);
}
return () => listeners.delete(fn);
}
/** Mark connection error while keeping client (e.g. timer poll failed). */
function signalError(message) {
if (!apiClient) return;
setSnap({
state: CONNECTION_STATE.ERROR,
lastError: message || 'Connection error',
});
}
return {
CONNECTION_STATE,
getSnapshot,
getClient,
subscribe,
testServer,
testServerAndSession,
bootstrapFromStore,
login,
logoutKeepServer,
fullStoreReset,
validateSessionRefresh,
saveServerAndToken,
tearDownClient,
signalError,
/** Expose for tests */
_setSnapForTest: setSnap,
};
}
module.exports = { createConnectionManager, STORE_SERVER, STORE_TOKEN, STORE_TOKEN_SERVER };