Fix Android mic permission, simplify launcher, remove broken biometric

- MainActivity: request RECORD_AUDIO permission at app start via
  ActivityCompat (not WebChromeClient override which broke Capacitor bridge)
- Simplify launcher: remove server reachability check (was failing in
  WebView), just save URL and navigate directly
- Remove biometric auth from launcher (Capacitor plugins need ES module
  bundler, not available in plain HTML). Biometric can be added later
  via the web app with proper Capacitor runtime.
- Add webContentsDebuggingEnabled for development
This commit is contained in:
Daniel 2026-04-11 03:34:47 +02:00
parent 6978ed708c
commit f03ca5cb94
10 changed files with 52 additions and 465 deletions

View file

@ -28,7 +28,9 @@
},
"android": {
"allowMixedContent": true,
"backgroundColor": "#2563eb"
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"appendUserAgent": "PedScribe-Android"
},
"ios": {
"backgroundColor": "#2563eb",

View file

@ -23,22 +23,6 @@
<button id="btn-change-server" class="btn-link">Change Server</button>
</div>
<!-- Biometric lock screen -->
<div id="biometric-screen" style="display:none;">
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
</svg>
</div>
<h1>PedScribe</h1>
<p class="subtitle">Unlock to continue</p>
<button id="btn-unlock" class="btn-primary" style="margin-top:20px;">
Unlock with Biometrics
</button>
<button id="btn-skip-biometric" class="btn-link">Skip</button>
</div>
<!-- Server URL setup screen -->
<div id="setup-screen">

View file

@ -1,36 +1,26 @@
// PedScribe Mobile Launcher
// Handles server URL config, biometric auth, and native features
// Handles configurable server URL and auto-redirect
(function() {
var STORAGE_KEY = 'pedscribe_server_url';
var BIOMETRIC_KEY = 'pedscribe_biometric_enabled';
var DEFAULT_URL = 'https://app.pedshub.com';
var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen');
var biometricScreen = document.getElementById('biometric-screen');
var urlInput = document.getElementById('server-url');
var connectBtn = document.getElementById('btn-connect');
var changeBtn = document.getElementById('btn-change-server');
var unlockBtn = document.getElementById('btn-unlock');
var skipBioBtn = document.getElementById('btn-skip-biometric');
var savedUrl = localStorage.getItem(STORAGE_KEY);
var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
// ── Launch flow ──
if (savedUrl) {
if (biometricEnabled && isCapacitor()) {
showBiometricScreen(savedUrl);
} else {
showConnecting(savedUrl);
}
showConnecting(savedUrl);
} else {
urlInput.value = DEFAULT_URL;
showScreen('setup');
}
// ── Connect button ──
// Connect button
connectBtn.addEventListener('click', function() {
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
if (!url.startsWith('http')) url = 'https://' + url;
@ -39,27 +29,15 @@
connectBtn.textContent = 'Connecting...';
haptic();
testServer(url, function(ok) {
if (ok) {
localStorage.setItem(STORAGE_KEY, url);
// Enable biometric on first connect if available
checkBiometricAvailable(function(available) {
if (available) localStorage.setItem(BIOMETRIC_KEY, 'true');
});
navigateToServer(url);
} else {
connectBtn.disabled = false;
connectBtn.textContent = 'Connect';
showError('Could not reach server. Check the URL and try again.');
}
});
localStorage.setItem(STORAGE_KEY, url);
navigateToServer(url);
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
// ── Change server ──
// Change server
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
urlInput.value = savedUrl || DEFAULT_URL;
@ -67,114 +45,21 @@
urlInput.focus();
});
// ── Biometric unlock ──
if (unlockBtn) {
unlockBtn.addEventListener('click', function() {
doBiometricAuth(function(ok) {
if (ok) {
showConnecting(savedUrl);
} else {
showError('Authentication failed. Try again.');
}
});
});
}
if (skipBioBtn) {
skipBioBtn.addEventListener('click', function() {
showConnecting(savedUrl);
});
}
// ── Screen management ──
// Screen management
function showScreen(which) {
setupScreen.style.display = which === 'setup' ? '' : 'none';
connectingScreen.style.display = which === 'connecting' ? '' : 'none';
biometricScreen.style.display = which === 'biometric' ? '' : 'none';
}
function showBiometricScreen(url) {
showScreen('biometric');
// Auto-trigger biometric prompt
setTimeout(function() {
doBiometricAuth(function(ok) {
if (ok) showConnecting(url);
// If fails, user sees the manual Unlock button
});
}, 300);
}
function showConnecting(url) {
showScreen('connecting');
setTimeout(function() {
testServer(url, function(ok) {
if (ok) {
navigateToServer(url);
} else {
urlInput.value = url;
showScreen('setup');
showError('Server not reachable. Check your connection or change the URL.');
}
});
}, 500);
}
// ── Server check ──
function testServer(url, callback) {
// In native WebView, no-cors fetch and image probes are unreliable.
// Just try a normal fetch to the health endpoint.
fetch(url + '/api/health', { signal: AbortSignal.timeout(8000) })
.then(function(r) { callback(true); })
.catch(function() {
// If CORS blocks it, that still means the server is reachable
// (CORS error = server responded but blocked the origin).
// Just navigate — the WebView will handle it.
callback(true);
});
setTimeout(function() { navigateToServer(url); }, 800);
}
function navigateToServer(url) {
window.location.href = url;
}
// ── Biometric auth ──
function isCapacitor() {
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
}
function checkBiometricAvailable(callback) {
if (!isCapacitor()) { callback(false); return; }
try {
var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
if (!BiometricAuth) { callback(false); return; }
BiometricAuth.checkBiometry().then(function(result) {
callback(result.isAvailable);
}).catch(function() { callback(false); });
} catch(e) { callback(false); }
}
function doBiometricAuth(callback) {
if (!isCapacitor()) { callback(true); return; } // Skip on web
try {
var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
if (!BiometricAuth) { callback(true); return; }
BiometricAuth.authenticate({
reason: 'Unlock PedScribe',
cancelTitle: 'Cancel',
allowDeviceCredential: true, // Allow PIN/password fallback
iosFallbackTitle: 'Use Passcode',
androidTitle: 'PedScribe',
androidSubtitle: 'Verify your identity to continue',
androidConfirmationRequired: false
}).then(function() {
callback(true);
}).catch(function() {
callback(false);
});
} catch(e) { callback(true); } // Fail open if plugin not available
}
// ── Helpers ──
function haptic() {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
@ -182,16 +67,4 @@
}
} catch(e) {}
}
function showError(msg) {
var existing = document.querySelector('.error-msg');
if (existing) existing.remove();
var div = document.createElement('div');
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
})();

View file

@ -1,24 +1,34 @@
package com.pedshub.scribe;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final int MIC_PERMISSION_CODE = 1001;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Auto-grant WebView permissions (microphone, camera)
// The Android runtime permission dialog still shows on first use
this.bridge.getWebView().setWebChromeClient(new WebChromeClient() {
@Override
public void onPermissionRequest(PermissionRequest request) {
request.grant(request.getResources());
}
});
// Request microphone permission at app start (not just when WebView asks)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// Capacitor handles the result via its bridge
}
}

View file

@ -22,7 +22,9 @@
},
"android": {
"allowMixedContent": true,
"backgroundColor": "#2563eb"
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"appendUserAgent": "PedScribe-Android"
},
"ios": {
"backgroundColor": "#2563eb",

View file

@ -28,7 +28,9 @@
},
"android": {
"allowMixedContent": true,
"backgroundColor": "#2563eb"
"backgroundColor": "#2563eb",
"webContentsDebuggingEnabled": true,
"appendUserAgent": "PedScribe-Android"
},
"ios": {
"backgroundColor": "#2563eb",

View file

@ -23,22 +23,6 @@
<button id="btn-change-server" class="btn-link">Change Server</button>
</div>
<!-- Biometric lock screen -->
<div id="biometric-screen" style="display:none;">
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
</svg>
</div>
<h1>PedScribe</h1>
<p class="subtitle">Unlock to continue</p>
<button id="btn-unlock" class="btn-primary" style="margin-top:20px;">
Unlock with Biometrics
</button>
<button id="btn-skip-biometric" class="btn-link">Skip</button>
</div>
<!-- Server URL setup screen -->
<div id="setup-screen">

View file

@ -1,36 +1,26 @@
// PedScribe Mobile Launcher
// Handles server URL config, biometric auth, and native features
// Handles configurable server URL and auto-redirect
(function() {
var STORAGE_KEY = 'pedscribe_server_url';
var BIOMETRIC_KEY = 'pedscribe_biometric_enabled';
var DEFAULT_URL = 'https://app.pedshub.com';
var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen');
var biometricScreen = document.getElementById('biometric-screen');
var urlInput = document.getElementById('server-url');
var connectBtn = document.getElementById('btn-connect');
var changeBtn = document.getElementById('btn-change-server');
var unlockBtn = document.getElementById('btn-unlock');
var skipBioBtn = document.getElementById('btn-skip-biometric');
var savedUrl = localStorage.getItem(STORAGE_KEY);
var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
// ── Launch flow ──
if (savedUrl) {
if (biometricEnabled && isCapacitor()) {
showBiometricScreen(savedUrl);
} else {
showConnecting(savedUrl);
}
showConnecting(savedUrl);
} else {
urlInput.value = DEFAULT_URL;
showScreen('setup');
}
// ── Connect button ──
// Connect button
connectBtn.addEventListener('click', function() {
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
if (!url.startsWith('http')) url = 'https://' + url;
@ -39,27 +29,15 @@
connectBtn.textContent = 'Connecting...';
haptic();
testServer(url, function(ok) {
if (ok) {
localStorage.setItem(STORAGE_KEY, url);
// Enable biometric on first connect if available
checkBiometricAvailable(function(available) {
if (available) localStorage.setItem(BIOMETRIC_KEY, 'true');
});
navigateToServer(url);
} else {
connectBtn.disabled = false;
connectBtn.textContent = 'Connect';
showError('Could not reach server. Check the URL and try again.');
}
});
localStorage.setItem(STORAGE_KEY, url);
navigateToServer(url);
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
// ── Change server ──
// Change server
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
urlInput.value = savedUrl || DEFAULT_URL;
@ -67,114 +45,21 @@
urlInput.focus();
});
// ── Biometric unlock ──
if (unlockBtn) {
unlockBtn.addEventListener('click', function() {
doBiometricAuth(function(ok) {
if (ok) {
showConnecting(savedUrl);
} else {
showError('Authentication failed. Try again.');
}
});
});
}
if (skipBioBtn) {
skipBioBtn.addEventListener('click', function() {
showConnecting(savedUrl);
});
}
// ── Screen management ──
// Screen management
function showScreen(which) {
setupScreen.style.display = which === 'setup' ? '' : 'none';
connectingScreen.style.display = which === 'connecting' ? '' : 'none';
biometricScreen.style.display = which === 'biometric' ? '' : 'none';
}
function showBiometricScreen(url) {
showScreen('biometric');
// Auto-trigger biometric prompt
setTimeout(function() {
doBiometricAuth(function(ok) {
if (ok) showConnecting(url);
// If fails, user sees the manual Unlock button
});
}, 300);
}
function showConnecting(url) {
showScreen('connecting');
setTimeout(function() {
testServer(url, function(ok) {
if (ok) {
navigateToServer(url);
} else {
urlInput.value = url;
showScreen('setup');
showError('Server not reachable. Check your connection or change the URL.');
}
});
}, 500);
}
// ── Server check ──
function testServer(url, callback) {
// In native WebView, no-cors fetch and image probes are unreliable.
// Just try a normal fetch to the health endpoint.
fetch(url + '/api/health', { signal: AbortSignal.timeout(8000) })
.then(function(r) { callback(true); })
.catch(function() {
// If CORS blocks it, that still means the server is reachable
// (CORS error = server responded but blocked the origin).
// Just navigate — the WebView will handle it.
callback(true);
});
setTimeout(function() { navigateToServer(url); }, 800);
}
function navigateToServer(url) {
window.location.href = url;
}
// ── Biometric auth ──
function isCapacitor() {
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
}
function checkBiometricAvailable(callback) {
if (!isCapacitor()) { callback(false); return; }
try {
var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
if (!BiometricAuth) { callback(false); return; }
BiometricAuth.checkBiometry().then(function(result) {
callback(result.isAvailable);
}).catch(function() { callback(false); });
} catch(e) { callback(false); }
}
function doBiometricAuth(callback) {
if (!isCapacitor()) { callback(true); return; } // Skip on web
try {
var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
if (!BiometricAuth) { callback(true); return; }
BiometricAuth.authenticate({
reason: 'Unlock PedScribe',
cancelTitle: 'Cancel',
allowDeviceCredential: true, // Allow PIN/password fallback
iosFallbackTitle: 'Use Passcode',
androidTitle: 'PedScribe',
androidSubtitle: 'Verify your identity to continue',
androidConfirmationRequired: false
}).then(function() {
callback(true);
}).catch(function() {
callback(false);
});
} catch(e) { callback(true); } // Fail open if plugin not available
}
// ── Helpers ──
function haptic() {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
@ -182,16 +67,4 @@
}
} catch(e) {}
}
function showError(msg) {
var existing = document.querySelector('.error-msg');
if (existing) existing.remove();
var div = document.createElement('div');
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
})();

View file

@ -23,22 +23,6 @@
<button id="btn-change-server" class="btn-link">Change Server</button>
</div>
<!-- Biometric lock screen -->
<div id="biometric-screen" style="display:none;">
<div class="logo-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
</svg>
</div>
<h1>PedScribe</h1>
<p class="subtitle">Unlock to continue</p>
<button id="btn-unlock" class="btn-primary" style="margin-top:20px;">
Unlock with Biometrics
</button>
<button id="btn-skip-biometric" class="btn-link">Skip</button>
</div>
<!-- Server URL setup screen -->
<div id="setup-screen">

View file

@ -1,36 +1,26 @@
// PedScribe Mobile Launcher
// Handles server URL config, biometric auth, and native features
// Handles configurable server URL and auto-redirect
(function() {
var STORAGE_KEY = 'pedscribe_server_url';
var BIOMETRIC_KEY = 'pedscribe_biometric_enabled';
var DEFAULT_URL = 'https://app.pedshub.com';
var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen');
var biometricScreen = document.getElementById('biometric-screen');
var urlInput = document.getElementById('server-url');
var connectBtn = document.getElementById('btn-connect');
var changeBtn = document.getElementById('btn-change-server');
var unlockBtn = document.getElementById('btn-unlock');
var skipBioBtn = document.getElementById('btn-skip-biometric');
var savedUrl = localStorage.getItem(STORAGE_KEY);
var biometricEnabled = localStorage.getItem(BIOMETRIC_KEY) === 'true';
// ── Launch flow ──
if (savedUrl) {
if (biometricEnabled && isCapacitor()) {
showBiometricScreen(savedUrl);
} else {
showConnecting(savedUrl);
}
showConnecting(savedUrl);
} else {
urlInput.value = DEFAULT_URL;
showScreen('setup');
}
// ── Connect button ──
// Connect button
connectBtn.addEventListener('click', function() {
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
if (!url.startsWith('http')) url = 'https://' + url;
@ -39,27 +29,15 @@
connectBtn.textContent = 'Connecting...';
haptic();
testServer(url, function(ok) {
if (ok) {
localStorage.setItem(STORAGE_KEY, url);
// Enable biometric on first connect if available
checkBiometricAvailable(function(available) {
if (available) localStorage.setItem(BIOMETRIC_KEY, 'true');
});
navigateToServer(url);
} else {
connectBtn.disabled = false;
connectBtn.textContent = 'Connect';
showError('Could not reach server. Check the URL and try again.');
}
});
localStorage.setItem(STORAGE_KEY, url);
navigateToServer(url);
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') connectBtn.click();
});
// ── Change server ──
// Change server
changeBtn.addEventListener('click', function() {
localStorage.removeItem(STORAGE_KEY);
urlInput.value = savedUrl || DEFAULT_URL;
@ -67,114 +45,21 @@
urlInput.focus();
});
// ── Biometric unlock ──
if (unlockBtn) {
unlockBtn.addEventListener('click', function() {
doBiometricAuth(function(ok) {
if (ok) {
showConnecting(savedUrl);
} else {
showError('Authentication failed. Try again.');
}
});
});
}
if (skipBioBtn) {
skipBioBtn.addEventListener('click', function() {
showConnecting(savedUrl);
});
}
// ── Screen management ──
// Screen management
function showScreen(which) {
setupScreen.style.display = which === 'setup' ? '' : 'none';
connectingScreen.style.display = which === 'connecting' ? '' : 'none';
biometricScreen.style.display = which === 'biometric' ? '' : 'none';
}
function showBiometricScreen(url) {
showScreen('biometric');
// Auto-trigger biometric prompt
setTimeout(function() {
doBiometricAuth(function(ok) {
if (ok) showConnecting(url);
// If fails, user sees the manual Unlock button
});
}, 300);
}
function showConnecting(url) {
showScreen('connecting');
setTimeout(function() {
testServer(url, function(ok) {
if (ok) {
navigateToServer(url);
} else {
urlInput.value = url;
showScreen('setup');
showError('Server not reachable. Check your connection or change the URL.');
}
});
}, 500);
}
// ── Server check ──
function testServer(url, callback) {
// In native WebView, no-cors fetch and image probes are unreliable.
// Just try a normal fetch to the health endpoint.
fetch(url + '/api/health', { signal: AbortSignal.timeout(8000) })
.then(function(r) { callback(true); })
.catch(function() {
// If CORS blocks it, that still means the server is reachable
// (CORS error = server responded but blocked the origin).
// Just navigate — the WebView will handle it.
callback(true);
});
setTimeout(function() { navigateToServer(url); }, 800);
}
function navigateToServer(url) {
window.location.href = url;
}
// ── Biometric auth ──
function isCapacitor() {
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
}
function checkBiometricAvailable(callback) {
if (!isCapacitor()) { callback(false); return; }
try {
var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
if (!BiometricAuth) { callback(false); return; }
BiometricAuth.checkBiometry().then(function(result) {
callback(result.isAvailable);
}).catch(function() { callback(false); });
} catch(e) { callback(false); }
}
function doBiometricAuth(callback) {
if (!isCapacitor()) { callback(true); return; } // Skip on web
try {
var BiometricAuth = window.Capacitor.Plugins.BiometricAuth;
if (!BiometricAuth) { callback(true); return; }
BiometricAuth.authenticate({
reason: 'Unlock PedScribe',
cancelTitle: 'Cancel',
allowDeviceCredential: true, // Allow PIN/password fallback
iosFallbackTitle: 'Use Passcode',
androidTitle: 'PedScribe',
androidSubtitle: 'Verify your identity to continue',
androidConfirmationRequired: false
}).then(function() {
callback(true);
}).catch(function() {
callback(false);
});
} catch(e) { callback(true); } // Fail open if plugin not available
}
// ── Helpers ──
function haptic() {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
@ -182,16 +67,4 @@
}
} catch(e) {}
}
function showError(msg) {
var existing = document.querySelector('.error-msg');
if (existing) existing.remove();
var div = document.createElement('div');
div.className = 'error-msg';
div.style.display = 'block';
div.textContent = msg;
var parent = connectBtn ? connectBtn.parentNode : document.querySelector('.launcher');
if (parent) parent.appendChild(div);
setTimeout(function() { if (div.parentNode) div.remove(); }, 8000);
}
})();