From 50d81434a8fd842722bfbb80180ed9336ab416e6 Mon Sep 17 00:00:00 2001 From: k9ert <117085+k9ert@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:42:52 +0200 Subject: [PATCH] Fix Electron stuck on splash in hwibridge mode (#2600) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/electron-smoketest.yml | 91 ++++++++++++++++++++++++ pyinstaller/electron/main.js | 20 +++++- pyinstaller/electron/src/renderer.js | 21 ++++-- pyinstaller/electron/src/specterd.js | 8 ++- pyinstaller/electron/src/splash.html | 7 +- pyinstaller/electron/src/uiHelpers.js | 28 ++++++-- 6 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/electron-smoketest.yml diff --git a/.github/workflows/electron-smoketest.yml b/.github/workflows/electron-smoketest.yml new file mode 100644 index 000000000..780212749 --- /dev/null +++ b/.github/workflows/electron-smoketest.yml @@ -0,0 +1,91 @@ +name: Electron smoketest + +on: + pull_request: + paths: + - 'pyinstaller/electron/**' + - '.github/workflows/electron-smoketest.yml' + push: + branches: [master] + paths: + - 'pyinstaller/electron/**' + - '.github/workflows/electron-smoketest.yml' + +jobs: + smoketest: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: pyinstaller/electron/package-lock.json + + - name: Install xvfb and electron runtime libs + run: | + sudo apt-get update + sudo apt-get install -y \ + xvfb \ + libgtk-3-0 \ + libnss3 \ + libasound2t64 \ + libgbm1 \ + libxss1 \ + libxtst6 + + - name: Install electron deps + working-directory: pyinstaller/electron + run: npm ci + + - name: Syntax check all electron JS + working-directory: pyinstaller/electron + run: | + for f in main.js src/*.js; do + echo "checking $f" + node --check "$f" + done + + - name: Seed dev data folder + run: | + mkdir -p "$RUNNER_TEMP/specter_dev" + echo '{"version":"","sha256":{"x64":"","arm64":""}}' \ + > "$RUNNER_TEMP/specter_dev/version-data.json" + + - name: Boot electron, assert splash reaches no-specterd branch + working-directory: pyinstaller/electron + env: + NODE_ENV: development + SPECTER_DATA_FOLDER: ${{ runner.temp }}/specter_dev + run: | + set -u + xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' \ + ./node_modules/.bin/electron . --no-sandbox > electron.log 2>&1 & + PID=$! + trap 'kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true' EXIT + + # Expected log line emitted by uiHelpers.js:updatingLoaderMsg when + # main.js reaches the "specterd missing + no version" branch. + # If main.js changes this message, update both sides together. + EXPECTED="Specterd was not found and no version is configured" + + for i in $(seq 1 30); do + if grep -qF "$EXPECTED" electron.log; then + echo "smoketest passed (splash reached no-specterd branch)" + exit 0 + fi + if ! kill -0 $PID 2>/dev/null; then + echo "electron exited before reaching expected state" + echo "--- electron.log ---" + cat electron.log + exit 1 + fi + sleep 1 + done + + echo "expected log line not seen within 30s" + echo "--- electron.log ---" + cat electron.log + exit 1 diff --git a/pyinstaller/electron/main.js b/pyinstaller/electron/main.js index 884d82e38..95cc1d4f6 100644 --- a/pyinstaller/electron/main.js +++ b/pyinstaller/electron/main.js @@ -160,7 +160,9 @@ app.whenReady().then(() => { downloadSpecterd(specterdPath) } else { updatingLoaderMsg( - 'Specterd file could not be validated and no version is configured in the settings
Please go to Preferences and set version to fetch or add an executable manually...' + 'Specterd file could not be validated and no version is configured in the settings
Please go to Preferences and set version to fetch or add an executable manually...', + false, + { isHtml: true } ) updateSpecterdStatus('Failed to locate specterd...') } @@ -169,8 +171,13 @@ app.whenReady().then(() => { if (appSettings.specterdVersion) { downloadSpecterd(specterdPath) } else { + // NB: smoketest workflow (.github/workflows/electron-smoketest.yml) greps + // for the leading "Specterd was not found and no version is configured" + // substring to assert splash reached this branch. Keep both in sync. updatingLoaderMsg( - 'Specterd was not found and no version is configured in the settings
Please go to Preferences and set version to fetch or add an executable manually...' + 'Specterd was not found and no version is configured in the settings
Please go to Preferences and set version to fetch or add an executable manually...', + false, + { isHtml: true } ) updateSpecterdStatus('Failed to locate specterd...') } @@ -200,6 +207,15 @@ app.on('before-quit', (event) => { } }) +ipcMain.on('open-settings', (event) => { + const senderUrl = event.senderFrame?.url || '' + if (!senderUrl.startsWith('file://') || !senderUrl.endsWith('/splash.html')) { + logger.warn(`Rejected open-settings IPC from untrusted sender: ${senderUrl}`) + return + } + openPreferences() +}) + ipcMain.on('request-mainprocess-action', (event, arg) => { switch (arg.message) { case 'save-preferences': diff --git a/pyinstaller/electron/src/renderer.js b/pyinstaller/electron/src/renderer.js index 47d051290..ca37ddb5b 100644 --- a/pyinstaller/electron/src/renderer.js +++ b/pyinstaller/electron/src/renderer.js @@ -8,8 +8,6 @@ // All of the Node.js APIs are available in the preload process. // It has the same sandbox as a Chrome extension. window.addEventListener('DOMContentLoaded', () => { - - const updateSpinner = (show) => { const spinnerElement = document.getElementById('spinner'); if (spinnerElement) { @@ -19,10 +17,23 @@ window.addEventListener('DOMContentLoaded', () => { window.api.receive('update-loader-message', (data) => { const launchTextElement = document.getElementById('launch-text'); if (launchTextElement) { - launchTextElement.textContent = data.msg; + if (data.isHtml) { + launchTextElement.innerHTML = data.msg; + } else { + launchTextElement.textContent = data.msg; + } updateSpinner(data.showSpinner); } + const settingsBtn = document.getElementById('open-settings-btn'); + if (settingsBtn) { + settingsBtn.classList.toggle('hidden', !data.showSettingsButton); + } }); - - }) \ No newline at end of file + const openSettingsBtn = document.getElementById('open-settings-btn'); + if (openSettingsBtn) { + openSettingsBtn.addEventListener('click', () => { + window.api.send('open-settings'); + }); + } +}) \ No newline at end of file diff --git a/pyinstaller/electron/src/specterd.js b/pyinstaller/electron/src/specterd.js index 7c2a4c316..83b799776 100644 --- a/pyinstaller/electron/src/specterd.js +++ b/pyinstaller/electron/src/specterd.js @@ -29,11 +29,13 @@ function checkSpecterd(logs, specterdStarted) { } let specterIsRunning = false +let currentSpecterURL = 'http://localhost:25441' function startSpecterd(specterdPath, automaticWalletImport = false) { if (platformName == 'win64') { specterdPath += '.exe' } let hwiBridgeMode = appSettings.mode == 'hwibridge' + currentSpecterURL = hwiBridgeMode ? appSettings.specterURL : 'http://localhost:25441' updatingLoaderMsg('Launching Specter ...', (showSpinner = 'true')) updateSpecterdStatus('Launching Specter ...') let specterdArgs = ['server'] @@ -85,7 +87,7 @@ function startSpecterd(specterdPath, automaticWalletImport = false) { }, 3000) } else { logger.info('Normal startup of Specter.') - createWindow(appSettings.specterURL) + createWindow(currentSpecterURL) } } else if (serverdStatus === 'timeout') { showError('Specter does not seem to start. Check the logs in the menu for more details.') @@ -113,7 +115,7 @@ function startSpecterd(specterdPath, automaticWalletImport = false) { app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow(appSettings.specterURL) + if (BrowserWindow.getAllWindows().length === 0) createWindow(currentSpecterURL) }) // since these are streams, you can pipe them elsewhere specterdProcess.on('close', (code) => { @@ -167,7 +169,7 @@ app.on('open-url', (_, url) => { // Only proceed with the import if the importFromWalletSoftwareBtn can be found. // If it is not, users are redirected by specterd to the configure connection screen. function importWallet(walletData) { - loadUrl(appSettings.specterURL + '/wallets/new_wallet/') + loadUrl(currentSpecterURL + '/wallets/new_wallet/') let code = ` const importFromWalletSoftwareBtn = document.getElementById('import-from-wallet-software-btn') if (importFromWalletSoftwareBtn) { diff --git a/pyinstaller/electron/src/splash.html b/pyinstaller/electron/src/splash.html index f56b4579b..090f7d466 100644 --- a/pyinstaller/electron/src/splash.html +++ b/pyinstaller/electron/src/splash.html @@ -2,9 +2,12 @@ -
+
+
-

Launching Specter Desktop...

+

Launching Specter Desktop...

+
+
diff --git a/pyinstaller/electron/src/uiHelpers.js b/pyinstaller/electron/src/uiHelpers.js index 7ec674502..e9de8fcdb 100644 --- a/pyinstaller/electron/src/uiHelpers.js +++ b/pyinstaller/electron/src/uiHelpers.js @@ -20,16 +20,27 @@ const executeJavaScript = (code) => { mainWindow.webContents.executeJavaScript(code) } +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + function showError(error) { updatingLoaderMsg('Specter encountered an error:' + error.toString()) } -function updatingLoaderMsg(msg, showSpinner = false) { +function updatingLoaderMsg(msg, showSpinner = false, { isHtml = false, showSettingsButton = false } = {}) { if (mainWindow) { // see preload.js where this is setup mainWindow.webContents.send('update-loader-message', { msg, showSpinner, + isHtml, + showSettingsButton, }) } else { logger.error('mainWindow not initialized in updatingLoaderMsg') @@ -78,11 +89,18 @@ function initMainWindow(dimensions) { return { action: 'deny' } }) - mainWindow.webContents.on('did-fail-load', function () { + mainWindow.webContents.on('did-fail-load', function (event, errorCode, errorDescription, validatedURL) { mainWindow.loadURL(`file://${__dirname}/splash.html`) - updatingLoaderMsg( - `Failed to load: ${appSettings.specterURL}
Please make sure the URL is entered correctly in the settings and try again...` - ) + const failedUrl = escapeHtml(validatedURL || appSettings.specterURL || '') + let msg + if (appSettings.mode === 'hwibridge') { + msg = `Failed to load remote Specter at: ${failedUrl}

` + + `You are running in HWI Bridge mode, which requires a remote Specter server.
` + + `If you want to run Specter locally instead, open Settings (${isMac ? 'Cmd' : 'Ctrl'}+, or via the tray icon) and switch to "Run Specter locally".` + } else { + msg = `Failed to load: ${failedUrl}
Please make sure the URL is entered correctly in the settings and try again...` + } + updatingLoaderMsg(msg, false, { isHtml: true, showSettingsButton: true }) }) return mainWindow }