diff --git a/package-lock.json b/package-lock.json index b6b20793..db7caffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/phone-island": "^0.18.2", + "@nethesis/phone-island": "^0.18.4", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", "@types/node": "^18.19.9", @@ -5641,9 +5641,9 @@ } }, "node_modules/@nethesis/phone-island": { - "version": "0.18.2", - "resolved": "https://registry.npmjs.org/@nethesis/phone-island/-/phone-island-0.18.2.tgz", - "integrity": "sha512-tvRQfc3LE48lVJu2McTw8ifDH6unTHc3/GqUjlgwGX05gAEPjCFicKoLh3b5YnfjOUxcKmltIE1yLqZCAmTUOw==", + "version": "0.18.4", + "resolved": "https://registry.npmjs.org/@nethesis/phone-island/-/phone-island-0.18.4.tgz", + "integrity": "sha512-CzJAj8A/Rl7FRN1wKfJdjaLwceH99NPbdJYwEIb2itN60GRVQGSSmbSAifzYm4g70Uojhi4p0oCUadbcN/HrrQ==", "dev": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 8adfb084..b07dc9f3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/phone-island": "^0.18.2", + "@nethesis/phone-island": "^0.18.4", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", "@types/node": "^18.19.9", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5ed27deb..03cacd38 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -71,6 +71,7 @@ "This field must be at least": "This field must be at least {{number}} characters", "This is not a phone number": "This is not a phone number", "Quit": "Quit", + "Retry": "Retry", "No internet connection title": "No internet connection", "No internet connection description": "Please check your connection and try again", "Refresh": "Refresh", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index e2f0bff9..6a803402 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -71,6 +71,7 @@ "This field must be at least": "Questo campo deve avere almeno {{number}} caratteri", "This is not a phone number": "Questo non é un numero telefonico", "Quit": "Chiudi", + "Retry": "Riprova", "No internet connection title": "Nessuna connessione internet", "No internet connection description": "Si prega di controllare la connessione e riprovare", "Refresh": "Aggiorna", diff --git a/src/main/classes/controllers/NetworkController.ts b/src/main/classes/controllers/NetworkController.ts index f9750c45..690994bf 100644 --- a/src/main/classes/controllers/NetworkController.ts +++ b/src/main/classes/controllers/NetworkController.ts @@ -34,4 +34,17 @@ export class NetworkController { } } + async head(path: string, timeoutMs: number = 5000): Promise { + try { + await axios.head(path, { + timeout: timeoutMs + }) + return true + } catch (e: any) { + const err: AxiosError = e + Log.debug('during fetch HEAD', err.name, err.code, err.message, path) + return false + } + } + } diff --git a/src/main/main.ts b/src/main/main.ts index d478694c..e9ee4363 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, nativeTheme, powerMonitor, protocol, systemPreferences, dialog, shell, globalShortcut } from 'electron' +import { app, ipcMain, nativeTheme, powerMonitor, protocol, systemPreferences, dialog, shell, globalShortcut, net } from 'electron' import { registerIpcEvents, isCallActive, disableCommandBarShortcuts } from '@/lib/ipcEvents' import { AccountController } from './classes/controllers' import { PhoneIslandController } from './classes/controllers/PhoneIslandController' @@ -289,6 +289,9 @@ function attachOnReadyProcess() { } }) + // Track if we're waiting for connection (dialog is shown) + let waitingForConnection = false + async function startApp(attempt = 0) { let data = store.store || store.getFromDisk() if (!checkData(data)) { @@ -311,13 +314,19 @@ function attachOnReadyProcess() { const isOnline = await checkConnection() Log.info('START - START APP, retry:', attempt) if (!isOnline) { - Log.info('START - NO CONNECTION', attempt, store.store) + Log.info('START - NO CONNECTION', attempt) if (attempt >= 3) { + // Stop retrying and show the no connection dialog + Log.info('START - showing no connection dialog, stopping automatic retries') + waitingForConnection = true + startConnectionPolling() try { SplashScreenController.instance.window.emit(IPC_EVENTS.SHOW_NO_CONNECTION) } catch (e) { Log.error(e) } + // Don't continue retrying - wait for user to click Retry or network to come back + return } retryAppStart = setTimeout(() => { @@ -366,6 +375,48 @@ function attachOnReadyProcess() { } } + // Polling interval for checking connection when waiting + let connectionCheckInterval: NodeJS.Timeout | null = null + + // Start polling for connection when dialog is shown + function startConnectionPolling() { + if (connectionCheckInterval) return + connectionCheckInterval = setInterval(async () => { + if (!waitingForConnection) { + stopConnectionPolling() + return + } + // Reuse checkConnection which has fallback logic + const connected = await checkConnection() + if (connected) { + Log.info('START - network came back online, retrying automatically') + waitingForConnection = false + stopConnectionPolling() + try { + SplashScreenController.instance.window.emit(IPC_EVENTS.HIDE_NO_CONNECTION) + } catch (e) { + // Splash screen might be closed + } + startApp(0) + } + }, 5000) // Check every 5 seconds + } + + function stopConnectionPolling() { + if (connectionCheckInterval) { + clearInterval(connectionCheckInterval) + connectionCheckInterval = null + } + } + + // Listen for retry connection event from the splash screen + ipcMain.on(IPC_EVENTS.RETRY_CONNECTION, () => { + Log.info('START - user requested connection retry') + waitingForConnection = false + stopConnectionPolling() + startApp(0) + }) + app.on('window-all-closed', () => { app.dock?.hide() }) @@ -807,14 +858,33 @@ function checkData(data: any): boolean { } +const CONNECTIVITY_CHECK_ENDPOINTS = [ + 'https://connectivitycheck.gstatic.com/generate_204', // Google's connectivity check + 'https://1.1.1.1/cdn-cgi/trace', // Cloudflare + 'https://cloudflare.com/cdn-cgi/trace' // Cloudflare alternative +] + async function checkConnection() { - const connected = await new Promise((resolve) => { - NetworkController.instance.get('https://google.com', {} as any).then(() => { - resolve(true) - }).catch(() => { - resolve(false) - }) - }) + // Quick check using Electron's built-in net.isOnline() + if (!net.isOnline()) { + Log.debug("checkConnection: net.isOnline() returned false") + if (store.store.connection !== false) { + ipcMain.emit(IPC_EVENTS.UPDATE_CONNECTION_STATE, undefined, false); + } + return false + } + + // Try connectivity check endpoints with fallbacks + let connected = false + for (const endpoint of CONNECTIVITY_CHECK_ENDPOINTS) { + connected = await NetworkController.instance.head(endpoint, 3000) + if (connected) { + Log.debug("checkConnection: succeeded with", endpoint) + break + } + Log.debug("checkConnection: failed with", endpoint, "trying next...") + } + Log.debug("checkConnection:", { connected, connection: store.store.connection }) if (connected !== store.store.connection) { ipcMain.emit(IPC_EVENTS.UPDATE_CONNECTION_STATE, undefined, connected); diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index 949bdbb1..82137cb1 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -71,6 +71,7 @@ "This field must be at least": "This field must be at least {{number}} characters", "This is not a phone number": "This is not a phone number", "Quit": "Quit", + "Retry": "Retry", "No internet connection title": "No internet connection", "No internet connection description": "Please check your connection and try again", "Refresh": "Refresh", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index 215dc966..e398b953 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -71,6 +71,7 @@ "This field must be at least": "Questo campo deve avere almeno {{number}} caratteri", "This is not a phone number": "Questo non é un numero telefonico", "Quit": "Chiudi", + "Retry": "Riprova", "No internet connection title": "Nessuna connessione internet", "No internet connection description": "Si prega di controllare la connessione e riprovare", "Refresh": "Aggiorna", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 99d7e008..cd9abbfa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,16 +22,35 @@ const RequestStateComponent = () => { const [account,] = useSharedState('account') const [connection,] = useSharedState('connection') const [hasWindowConfig, setHasWindowConfig] = useState(false) - const { GET } = useNetwork() + const { HEAD } = useNetwork() + + const CONNECTIVITY_CHECK_ENDPOINTS = [ + 'https://connectivitycheck.gstatic.com/generate_204', // Google's connectivity check + 'https://1.1.1.1/cdn-cgi/trace', // Cloudflare + 'https://cloudflare.com/cdn-cgi/trace' // Cloudflare alternative + ] async function checkConnection() { - const connected = await new Promise((resolve) => { - GET('https://google.com', {} as any).then(() => { - resolve(true) - }).catch(() => { - resolve(false) - }) - }) + // Quick check using browser's navigator.onLine + if (!navigator.onLine) { + Log.debug('check connection: navigator.onLine returned false') + if (connection !== false) { + window.electron.send(IPC_EVENTS.UPDATE_CONNECTION_STATE, false); + } + return + } + + // Try connectivity check endpoints with fallbacks + let connected = false + for (const endpoint of CONNECTIVITY_CHECK_ENDPOINTS) { + connected = await HEAD(endpoint, 3000) + if (connected) { + Log.debug('check connection: succeeded with', endpoint) + break + } + Log.debug('check connection: failed with', endpoint, 'trying next...') + } + Log.debug('check connection', { connected, connection: connection }) if (connected !== connection) { window.electron.send(IPC_EVENTS.UPDATE_CONNECTION_STATE, connected); diff --git a/src/renderer/src/pages/SplashScreenPage.tsx b/src/renderer/src/pages/SplashScreenPage.tsx index 627be23d..0af1ef42 100644 --- a/src/renderer/src/pages/SplashScreenPage.tsx +++ b/src/renderer/src/pages/SplashScreenPage.tsx @@ -7,7 +7,6 @@ import darkLogo from '../assets/nethvoiceDarkIcon.svg' import lightLogo from '../assets/nethvoiceLightIcon.svg' import { t } from 'i18next' import { useState } from 'react' -import { useSharedState } from '@renderer/store' import { IPC_EVENTS } from '@shared/constants' import { ConnectionErrorDialog } from '@renderer/components' @@ -16,16 +15,19 @@ export interface SplashScreenPageProps { } export function SplashScreenPage({ themeMode }: SplashScreenPageProps) { - const [connection] = useSharedState('connection') const [isNoConnectionDialogOpen, setIsnoConnectionDialogOpen] = useState(false) useInitialize(() => { window.electron.receive(IPC_EVENTS.SHOW_NO_CONNECTION, () => { setIsnoConnectionDialogOpen(true) }) + window.electron.receive(IPC_EVENTS.HIDE_NO_CONNECTION, () => { + setIsnoConnectionDialogOpen(false) + }) }) - function exitApp() { - window.api.exitNethLink() + function retryConnection() { + setIsnoConnectionDialogOpen(false) + window.electron.send(IPC_EVENTS.RETRY_CONNECTION) } return ( @@ -36,11 +38,11 @@ export function SplashScreenPage({ themeMode }: SplashScreenPageProps) { className="absolute w-screen h-screen top-0 left-0 object-cover" />
- {isNoConnectionDialogOpen && !connection && ( + {isNoConnectionDialogOpen && ( ) } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 78c0e621..8474b79f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -69,6 +69,8 @@ export enum IPC_EVENTS { RECONNECT_SOCKET = "RECONNECT_SOCKET", LOGOUT_COMPLETED = "LOGOUT_COMPLETED", SHOW_NO_CONNECTION = "SHOW_NO_CONNECTION", + HIDE_NO_CONNECTION = "HIDE_NO_CONNECTION", + RETRY_CONNECTION = "RETRY_CONNECTION", UPDATE_CONNECTION_STATE = "UPDATE_CONNECTION_STATE", DEV_TOOL_TOGGLE_CONNECTION = "DEV_TOOL_TOGGLE_CONNECTION", START_DRAG = "START_DRAG", diff --git a/src/shared/useNetwork.ts b/src/shared/useNetwork.ts index b5e32488..79f20142 100644 --- a/src/shared/useNetwork.ts +++ b/src/shared/useNetwork.ts @@ -27,8 +27,22 @@ export const useNetwork = () => { } } + async function HEAD(path: string, timeoutMs: number = 5000): Promise { + try { + await axios.head(path, { + timeout: timeoutMs + }) + return true + } catch (e: any) { + const err: AxiosError = e + Log.debug('during fetch HEAD', err.name, err.code, err.message, path) + return false + } + } + return { GET, - POST + POST, + HEAD } }