polish(desktop): non-destructive Change Instance + recovery enter/exit logs
UX bug found during smoke testing: clicking Change Instance immediately deleted the saved instance URL and showed an empty picker, with no way back if the user changed their mind. Fix: - Don't clearInstanceUrl() in recovery action 'change-instance' — picker is now non-destructive - Picker pre-fills the input with the current saved URL when present - Cancel button (shown only when a saved URL exists) returns to current instance via idempotent setInstanceUrl re-save - Header copy switches to 'Switch instance' / 'Cancel to stay' framing when a saved URL is present - URL only overwrites on explicit Connect to a different instance Also: add console.log enter/exit lines in enterRecoveryMode and the clear-recovery-state action handlers, so smoke-test scripts can grep stderr for recovery activity without UI introspection. Spec + docs/systems/desktop.md updated.
This commit is contained in:
+26
-2
@@ -105,7 +105,17 @@ When no instance URL is configured, the app loads `resources/instance-picker.htm
|
|||||||
|
|
||||||
After navigation (both to an instance URL and back to the picker), the main process forces Electron to re-evaluate drag regions by momentarily resizing the window (+1px then back).
|
After navigation (both to an instance URL and back to the picker), the main process forces Electron to re-evaluate drag regions by momentarily resizing the window (+1px then back).
|
||||||
|
|
||||||
The tray menu and macOS app menu both include a "Change Instance" option that clears the saved URL and reloads the picker.
|
### Non-destructive "Change Instance" navigation
|
||||||
|
|
||||||
|
The tray menu, macOS app menu, and recovery surface all include a "Change Instance" option. This navigation is **non-destructive**: the saved instance URL is preserved when navigating to the picker. The picker's `init()` function reads the current saved URL via `getInstanceUrl()` IPC and, if one exists:
|
||||||
|
|
||||||
|
- Pre-fills the URL input with the current value.
|
||||||
|
- Shows a Cancel button (hidden by default; only shown when a saved URL exists).
|
||||||
|
- Switches the header copy from "Welcome to Backspace / Connect to your instance" to "Switch instance / Connect to a different Backspace instance, or cancel to stay."
|
||||||
|
|
||||||
|
**Cancel button behavior:** Clicking Cancel re-saves the existing URL via `setInstanceUrl` (idempotent) and navigates back to it. The saved URL is only overwritten when the user explicitly clicks Connect on a *different* URL. This means the user can always back out of an accidental "Change Instance" click.
|
||||||
|
|
||||||
|
**Loading state:** `setLoading(true)` — invoked when Connect is clicked — disables both the Connect button and the Cancel button to prevent a race between the `setInstanceUrl` calls.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,7 +195,7 @@ All four assets are produced by `scripts/gen-icons.mjs` from `assets/brand/{mark
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| Show Backspace | `window.show()` + `focus()` |
|
| Show Backspace | `window.show()` + `focus()` |
|
||||||
| Hide | `window.hide()` |
|
| Hide | `window.hide()` |
|
||||||
| Change Instance | Clear saved URL, load picker, show + focus |
|
| Change Instance | Load picker (non-destructive — saved URL preserved), show + focus |
|
||||||
| Quit | Set `isQuitting = true`, `app.quit()` |
|
| Quit | Set `isQuitting = true`, `app.quit()` |
|
||||||
|
|
||||||
Tray click toggles window visibility (show/hide).
|
Tray click toggles window visibility (show/hide).
|
||||||
@@ -326,12 +336,26 @@ Page reads initial state via `getRecoveryState()` IPC and subscribes to `recover
|
|||||||
| Open Releases Page | `updateState === 'error'` | always when visible |
|
| Open Releases Page | `updateState === 'error'` | always when visible |
|
||||||
| Quit Backspace | always | always |
|
| Quit Backspace | always | always |
|
||||||
|
|
||||||
|
**Change Instance from recovery is non-destructive.** The saved URL is not cleared when navigating to the picker; see the Instance Picker section above for the full behavior (pre-filled input, Cancel button, header copy update).
|
||||||
|
|
||||||
Hint text is computed as a function of `(reason.code, updateState)` — see code in `recovery.html`. The `renderer-stalled` text intentionally avoids claiming an update is the cause (slow Pi/cold cache could also trigger it).
|
Hint text is computed as a function of `(reason.code, updateState)` — see code in `recovery.html`. The `renderer-stalled` text intentionally avoids claiming an update is the cause (slow Pi/cold cache could also trigger it).
|
||||||
|
|
||||||
`lastCheckResult` provides transient inline feedback ("You're up to date" / "Update check failed") with 5s auto-decay. Without this, the user has no signal that a Check for Updates click ran when the result is no-update.
|
`lastCheckResult` provides transient inline feedback ("You're up to date" / "Update check failed") with 5s auto-decay. Without this, the user has no signal that a Check for Updates click ran when the result is no-update.
|
||||||
|
|
||||||
Cmd/Ctrl+R is wired as a keyboard shortcut for Reload.
|
Cmd/Ctrl+R is wired as a keyboard shortcut for Reload.
|
||||||
|
|
||||||
|
### Observability Logging
|
||||||
|
|
||||||
|
`recovery.ts` emits structured `console.log` lines on entry and exit so smoke-test scripts can grep stderr without UI introspection:
|
||||||
|
|
||||||
|
| Event | Log line |
|
||||||
|
|-------|----------|
|
||||||
|
| Recovery entered | `[recovery] entered: <code> — <detail>` |
|
||||||
|
| Exited via Reload | `[recovery] exited (reload)` |
|
||||||
|
| Exited via Change Instance | `[recovery] exited (change-instance)` |
|
||||||
|
|
||||||
|
The enter log fires after the state update but before the re-entry guard, so repeated entry (reason update with no re-navigation) also logs — useful for diagnostics.
|
||||||
|
|
||||||
### Loop Prevention / Contained Failure
|
### Loop Prevention / Contained Failure
|
||||||
|
|
||||||
If `recovery.html` itself fails to load (corrupt resources, packaging bug), `did-fail-load` re-fires inside the recovery context. The `isInRecoveryMode` guard prevents infinite reload loops — `state.reason` updates for display purposes but no second `loadFile()` is issued. User-visible outcome is a blank window with tray-only escape (Quit). This is **contained failure**, not graceful failure: a corrupt `recovery.html` means a corrupt build that requires a fresh install.
|
If `recovery.html` itself fails to load (corrupt resources, packaging bug), `did-fail-load` re-fires inside the recovery context. The `isInRecoveryMode` guard prevents infinite reload loops — `state.reason` updates for display purposes but no second `loadFile()` is issued. User-visible outcome is a blank window with tray-only escape (Quit). This is **contained failure**, not graceful failure: a corrupt `recovery.html` means a corrupt build that requires a fresh install.
|
||||||
|
|||||||
@@ -136,6 +136,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Button — accent-primary with white text (matches login) ── */
|
/* ── Button — accent-primary with white text (matches login) ── */
|
||||||
|
.cancel-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.625rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #a0a0aa;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.cancel-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #efefef;
|
||||||
|
}
|
||||||
|
.cancel-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.connect-btn {
|
.connect-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
@@ -264,6 +287,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="connect-btn" id="connect-btn">Connect</button>
|
<button type="submit" class="connect-btn" id="connect-btn">Connect</button>
|
||||||
|
<button type="button" class="cancel-btn" id="cancel-btn" style="display: none;">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="instance-info" class="instance-info" style="display: none;">
|
<div id="instance-info" class="instance-info" style="display: none;">
|
||||||
@@ -289,6 +313,8 @@
|
|||||||
const infoEl = document.getElementById('instance-info');
|
const infoEl = document.getElementById('instance-info');
|
||||||
const nameEl = document.getElementById('instance-name');
|
const nameEl = document.getElementById('instance-name');
|
||||||
const versionEl = document.getElementById('instance-version');
|
const versionEl = document.getElementById('instance-version');
|
||||||
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
let savedUrl = null;
|
||||||
|
|
||||||
function normalizeUrl(raw) {
|
function normalizeUrl(raw) {
|
||||||
let url = raw.trim();
|
let url = raw.trim();
|
||||||
@@ -315,12 +341,39 @@
|
|||||||
function setLoading(loading) {
|
function setLoading(loading) {
|
||||||
input.disabled = loading;
|
input.disabled = loading;
|
||||||
btn.disabled = loading;
|
btn.disabled = loading;
|
||||||
|
cancelBtn.disabled = loading;
|
||||||
loadingEl.style.display = loading ? 'block' : 'none';
|
loadingEl.style.display = loading ? 'block' : 'none';
|
||||||
if (loading) {
|
if (loading) {
|
||||||
hideError();
|
hideError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (window.backspace && window.backspace.getInstanceUrl) {
|
||||||
|
try {
|
||||||
|
savedUrl = await window.backspace.getInstanceUrl();
|
||||||
|
} catch { savedUrl = null; }
|
||||||
|
}
|
||||||
|
if (savedUrl) {
|
||||||
|
input.value = savedUrl;
|
||||||
|
cancelBtn.style.display = 'block';
|
||||||
|
// Update header copy to reflect the "switch" intent rather than the
|
||||||
|
// first-run "welcome" framing.
|
||||||
|
document.querySelector('.header h1').textContent = 'Switch instance';
|
||||||
|
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', async () => {
|
||||||
|
if (!savedUrl) return;
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
if (window.backspace && window.backspace.setInstanceUrl) {
|
||||||
|
await window.backspace.setInstanceUrl(savedUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export function setOnQuitRequested(cb: (() => void) | null): void {
|
|||||||
|
|
||||||
export function enterRecoveryMode(reason: { code: RecoveryReasonCode; detail: string }): void {
|
export function enterRecoveryMode(reason: { code: RecoveryReasonCode; detail: string }): void {
|
||||||
recoveryStore.update({ mode: 'recovery', reason });
|
recoveryStore.update({ mode: 'recovery', reason });
|
||||||
|
console.log(`[recovery] entered: ${reason.code} — ${reason.detail}`);
|
||||||
|
|
||||||
if (recoveryStore.isInRecoveryMode()) {
|
if (recoveryStore.isInRecoveryMode()) {
|
||||||
// Already in recovery — state.reason updated for display, no re-navigation.
|
// Already in recovery — state.reason updated for display, no re-navigation.
|
||||||
@@ -361,6 +362,7 @@ export function handleRecoveryAction(action: RecoveryAction): void {
|
|||||||
// fails, did-fail-load re-enters recovery. If it stalls, boot timer fires.
|
// fails, did-fail-load re-enters recovery. If it stalls, boot timer fires.
|
||||||
recoveryStore.markRecoveryExited();
|
recoveryStore.markRecoveryExited();
|
||||||
recoveryStore.update({ mode: 'normal', reason: null });
|
recoveryStore.update({ mode: 'normal', reason: null });
|
||||||
|
console.log('[recovery] exited (reload)');
|
||||||
if (!url) {
|
if (!url) {
|
||||||
mainWindowRef?.loadFile(getPickerPath());
|
mainWindowRef?.loadFile(getPickerPath());
|
||||||
return;
|
return;
|
||||||
@@ -385,9 +387,13 @@ export function handleRecoveryAction(action: RecoveryAction): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'change-instance': {
|
case 'change-instance': {
|
||||||
clearInstanceUrl();
|
// Non-destructive: don't clear the saved URL here. The picker pre-fills it
|
||||||
|
// and offers Cancel — the URL is only overwritten when the user explicitly
|
||||||
|
// Connects to a different one. (clearInstanceUrl IPC remains for explicit
|
||||||
|
// "disconnect" operations from the web settings UI.)
|
||||||
recoveryStore.markRecoveryExited();
|
recoveryStore.markRecoveryExited();
|
||||||
recoveryStore.update({ mode: 'normal', reason: null });
|
recoveryStore.update({ mode: 'normal', reason: null });
|
||||||
|
console.log('[recovery] exited (change-instance)');
|
||||||
mainWindowRef?.loadFile(getPickerPath());
|
mainWindowRef?.loadFile(getPickerPath());
|
||||||
// Ensure visible — tray clicks may happen with window hidden, and the
|
// Ensure visible — tray clicks may happen with window hidden, and the
|
||||||
// recovery surface should also remain visible during the navigation.
|
// recovery surface should also remain visible during the navigation.
|
||||||
|
|||||||
Reference in New Issue
Block a user