fix(tmux): harden windows installer banner flow
This commit is contained in:
parent
1062fe3b65
commit
44f4af1756
9 changed files with 336 additions and 47 deletions
|
|
@ -33,9 +33,14 @@ export class TmuxCommandRunner {
|
|||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.#activeChild = child;
|
||||
const platform = process.platform;
|
||||
|
||||
const createBufferedLineWriter = (): { push: (chunk: string) => void; flush: () => void } => {
|
||||
const createBufferedLineWriter = (): {
|
||||
push: (chunk: Buffer | string) => void;
|
||||
flush: () => void;
|
||||
} => {
|
||||
let pending = '';
|
||||
let pendingBytes = Buffer.alloc(0);
|
||||
|
||||
const emitLine = (line: string): void => {
|
||||
const normalizedLine = line.replace(/\r$/, '');
|
||||
|
|
@ -45,8 +50,24 @@ export class TmuxCommandRunner {
|
|||
};
|
||||
|
||||
return {
|
||||
push: (chunk: string): void => {
|
||||
pending += chunk;
|
||||
push: (chunk: Buffer | string): void => {
|
||||
let decodedChunk = '';
|
||||
if (typeof chunk === 'string') {
|
||||
decodedChunk = chunk;
|
||||
} else {
|
||||
let nextBuffer =
|
||||
pendingBytes.length > 0 ? Buffer.concat([pendingBytes, chunk]) : chunk;
|
||||
if (platform === 'win32' && nextBuffer.length % 2 === 1) {
|
||||
pendingBytes = nextBuffer.subarray(nextBuffer.length - 1);
|
||||
nextBuffer = nextBuffer.subarray(0, nextBuffer.length - 1);
|
||||
} else {
|
||||
pendingBytes = Buffer.alloc(0);
|
||||
}
|
||||
if (nextBuffer.length > 0) {
|
||||
decodedChunk = decodeInstallerProcessOutput(nextBuffer, platform);
|
||||
}
|
||||
}
|
||||
pending += decodedChunk;
|
||||
const normalizedPending = pending.replace(/\r(?!\n)/g, '\n');
|
||||
const lines = normalizedPending.split('\n');
|
||||
pending = lines.pop() ?? '';
|
||||
|
|
@ -55,6 +76,10 @@ export class TmuxCommandRunner {
|
|||
}
|
||||
},
|
||||
flush: (): void => {
|
||||
if (pendingBytes.length > 0) {
|
||||
pending += decodeInstallerProcessOutput(pendingBytes, platform);
|
||||
pendingBytes = Buffer.alloc(0);
|
||||
}
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -67,12 +92,8 @@ export class TmuxCommandRunner {
|
|||
const stdoutWriter = createBufferedLineWriter();
|
||||
const stderrWriter = createBufferedLineWriter();
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer | string) =>
|
||||
stdoutWriter.push(decodeInstallerProcessOutput(chunk))
|
||||
);
|
||||
child.stderr.on('data', (chunk: Buffer | string) =>
|
||||
stderrWriter.push(decodeInstallerProcessOutput(chunk))
|
||||
);
|
||||
child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(chunk));
|
||||
child.on('error', (error) => {
|
||||
this.#activeChild = null;
|
||||
reject(error);
|
||||
|
|
|
|||
|
|
@ -15,9 +15,38 @@ describe('decodeInstallerProcessOutput', () => {
|
|||
expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('tmux is available\n');
|
||||
});
|
||||
|
||||
it('keeps utf8 Cyrillic output readable on non-Windows platforms', () => {
|
||||
const buffer = Buffer.from('Привет мир\n', 'utf8');
|
||||
|
||||
expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('Привет мир\n');
|
||||
});
|
||||
|
||||
it('keeps utf8 output readable on Windows too', () => {
|
||||
const buffer = Buffer.from('tmux is available\n', 'utf8');
|
||||
|
||||
expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('tmux is available\n');
|
||||
});
|
||||
|
||||
it('keeps utf8 Cyrillic output readable on Windows too', () => {
|
||||
const buffer = Buffer.from('Привет мир\n', 'utf8');
|
||||
|
||||
expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет мир\n');
|
||||
});
|
||||
|
||||
it('decodes utf16le output when it contains a BOM', () => {
|
||||
const utf16le = Buffer.from('\uFEFFWSL core installation command completed.', 'utf16le');
|
||||
|
||||
expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain('WSL core installation');
|
||||
});
|
||||
|
||||
it('decodes utf16le Cyrillic output without a BOM', () => {
|
||||
const utf16le = Buffer.from(
|
||||
'Требуемая операция выполнена успешно. Чтобы заданные изменения вступили в силу, следует перезагрузить систему.',
|
||||
'utf16le'
|
||||
);
|
||||
|
||||
expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain(
|
||||
'Требуемая операция выполнена успешно'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const UTF8_DECODER = new TextDecoder('utf-8');
|
|||
const UTF16LE_DECODER = new TextDecoder('utf-16le');
|
||||
const IBM866_DECODER = new TextDecoder('ibm866');
|
||||
const WINDOWS_1251_DECODER = new TextDecoder('windows-1251');
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
|
||||
export function decodeInstallerProcessOutput(
|
||||
output: string | Buffer,
|
||||
|
|
@ -14,25 +15,48 @@ export function decodeInstallerProcessOutput(
|
|||
return '';
|
||||
}
|
||||
|
||||
const utf16le = stripNulls(UTF16LE_DECODER.decode(output));
|
||||
if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) {
|
||||
return stripNulls(UTF16LE_DECODER.decode(output));
|
||||
return utf16le;
|
||||
}
|
||||
|
||||
const utf8 = stripNulls(UTF8_DECODER.decode(output));
|
||||
if (platform !== 'win32') {
|
||||
return utf8;
|
||||
}
|
||||
if (isExactUtf8RoundTrip(output, utf8)) {
|
||||
return utf8;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
utf8,
|
||||
stripNulls(IBM866_DECODER.decode(output)),
|
||||
stripNulls(WINDOWS_1251_DECODER.decode(output)),
|
||||
];
|
||||
if (platform === 'win32') {
|
||||
candidates.push(utf16le);
|
||||
}
|
||||
|
||||
return candidates.slice(1).reduce(
|
||||
(best, candidate) => (scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best),
|
||||
candidates[0] ?? utf8
|
||||
);
|
||||
return candidates
|
||||
.slice(1)
|
||||
.reduce(
|
||||
(best, candidate) =>
|
||||
scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best,
|
||||
candidates[0] ?? utf16le
|
||||
);
|
||||
}
|
||||
|
||||
function isExactUtf8RoundTrip(buffer: Buffer, decoded: string): boolean {
|
||||
const encoded = TEXT_ENCODER.encode(decoded);
|
||||
if (encoded.length !== buffer.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] !== buffer[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripNulls(value: string): string {
|
||||
|
|
@ -51,14 +75,24 @@ function looksLikeUtf16Le(buffer: Buffer): boolean {
|
|||
|
||||
let pairs = 0;
|
||||
let nullsAtOddIndex = 0;
|
||||
let likelyUtf16OddBytes = 0;
|
||||
for (let i = 0; i + 1 < sampleSize; i += 2) {
|
||||
pairs += 1;
|
||||
if (buffer[i + 1] === 0) {
|
||||
const oddByte = buffer[i + 1];
|
||||
const evenByte = buffer[i];
|
||||
if (oddByte === 0) {
|
||||
nullsAtOddIndex += 1;
|
||||
}
|
||||
if (oddByte === 0x04 || oddByte === 0x05) {
|
||||
likelyUtf16OddBytes += 1;
|
||||
continue;
|
||||
}
|
||||
if (oddByte === 0x00 && evenByte >= 0x20 && evenByte <= 0x7e) {
|
||||
likelyUtf16OddBytes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
|
||||
return pairs > 0 && (nullsAtOddIndex / pairs >= 0.3 || likelyUtf16OddBytes / pairs >= 0.3);
|
||||
}
|
||||
|
||||
function scoreDecodedText(value: string): number {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import * as fsp from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
const logger = createLogger('Feature:tmux-installer:windows-elevation');
|
||||
|
|
@ -31,6 +33,7 @@ type ExecFileLike = (
|
|||
timeout: number;
|
||||
windowsHide: boolean;
|
||||
maxBuffer: number;
|
||||
encoding: 'buffer';
|
||||
},
|
||||
callback: ExecFileCallback
|
||||
) => void;
|
||||
|
|
@ -113,6 +116,7 @@ export class WindowsElevatedStepRunner {
|
|||
timeout,
|
||||
windowsHide: true,
|
||||
maxBuffer: MAX_BUFFER_BYTES,
|
||||
encoding: 'buffer',
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
const errorCode =
|
||||
|
|
@ -121,8 +125,10 @@ export class WindowsElevatedStepRunner {
|
|||
: undefined;
|
||||
resolve({
|
||||
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
|
||||
stdout: String(stdout),
|
||||
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
|
||||
stdout: decodeInstallerProcessOutput(stdout, 'win32'),
|
||||
stderr:
|
||||
decodeInstallerProcessOutput(stderr, 'win32') ||
|
||||
(error instanceof Error ? error.message : ''),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as fsp from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { WindowsElevatedStepRunner } from '../WindowsElevatedStepRunner';
|
||||
|
||||
|
|
@ -68,4 +68,30 @@ describe('WindowsElevatedStepRunner', () => {
|
|||
expect(result.detail).toContain('cancelled');
|
||||
expect(result.resultFilePath).toBeNull();
|
||||
});
|
||||
|
||||
it('decodes localized Windows stderr from the launcher process', async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const runner = new WindowsElevatedStepRunner(
|
||||
(_command, _args, _options, callback) => {
|
||||
callback(
|
||||
Object.assign(new Error('restart required'), { code: 1 }),
|
||||
Buffer.alloc(0),
|
||||
Buffer.from(
|
||||
'Требуемая операция выполнена успешно. Чтобы заданные изменения вступили в силу, следует перезагрузить систему.',
|
||||
'utf16le'
|
||||
)
|
||||
);
|
||||
},
|
||||
(prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix))
|
||||
);
|
||||
|
||||
const result = await runner.runWslCoreInstall();
|
||||
|
||||
expect(result.outcome).toBe('elevated_unknown_outcome');
|
||||
expect(result.detail).toContain('Требуемая операция выполнена успешно');
|
||||
} finally {
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ export class TmuxInstallerBannerAdapter {
|
|||
adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
|
||||
const status = input.status;
|
||||
const snapshot = input.snapshot;
|
||||
const visible = input.loading
|
||||
? false
|
||||
: (status ? !status.effective.runtimeReady : true) || snapshot.phase !== 'idle';
|
||||
const visible =
|
||||
snapshot.phase !== 'idle' ||
|
||||
(!input.loading && (status ? !status.effective.runtimeReady : true));
|
||||
const title =
|
||||
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
|
||||
? 'tmux needs one more step'
|
||||
|
|
@ -70,7 +70,7 @@ export class TmuxInstallerBannerAdapter {
|
|||
snapshot.phase === 'needs_restart' ||
|
||||
snapshot.phase === 'needs_manual_step')
|
||||
? snapshot.message
|
||||
: formatTmuxInstallerTitle(snapshot.phase);
|
||||
: formatTmuxInstallerTitle(snapshot.phase);
|
||||
const primaryGuideUrl =
|
||||
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
|
||||
const body =
|
||||
|
|
|
|||
|
|
@ -111,6 +111,26 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.installButtonPrimary).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the banner visible while loading if installer progress is already active', () => {
|
||||
const adapter = TmuxInstallerBannerAdapter.create();
|
||||
|
||||
const result = adapter.adapt({
|
||||
status: null,
|
||||
snapshot: {
|
||||
...idleSnapshot,
|
||||
phase: 'waiting_for_external_step',
|
||||
strategy: 'wsl',
|
||||
message: 'Finish Ubuntu setup in WSL',
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
detailsOpen: false,
|
||||
});
|
||||
|
||||
expect(result.visible).toBe(true);
|
||||
expect(result.title).toBe('Finish Ubuntu setup in WSL');
|
||||
});
|
||||
|
||||
it('exposes a manual guide url when auto install is unavailable', () => {
|
||||
const adapter = TmuxInstallerBannerAdapter.create();
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,129 @@ describe('useTmuxInstallerBanner', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps the banner visible during background refreshes after installer progress updates', async () => {
|
||||
let resolveStatus: ((value: TmuxStatus) => void) | null = null;
|
||||
let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null;
|
||||
mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<TmuxStatus>((resolve) => {
|
||||
resolveStatus = resolve;
|
||||
})
|
||||
);
|
||||
mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<TmuxInstallerSnapshot>((resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(capturedHook?.viewModel.visible).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
progressListener?.(null, {
|
||||
...idleSnapshot,
|
||||
phase: 'waiting_for_external_step',
|
||||
message: 'Finish Ubuntu setup in WSL',
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(capturedHook?.viewModel.visible).toBe(true);
|
||||
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
|
||||
|
||||
await act(async () => {
|
||||
resolveStatus?.(baseStatus);
|
||||
resolveSnapshot?.({
|
||||
...idleSnapshot,
|
||||
phase: 'waiting_for_external_step',
|
||||
message: 'Finish Ubuntu setup in WSL',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(capturedHook?.viewModel.visible).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let an older refreshed snapshot overwrite newer live progress', async () => {
|
||||
let resolveStatus: ((value: TmuxStatus) => void) | null = null;
|
||||
let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null;
|
||||
const olderSnapshot = {
|
||||
...idleSnapshot,
|
||||
phase: 'idle' as const,
|
||||
updatedAt: '2099-04-14T10:00:00.000Z',
|
||||
};
|
||||
const newerProgress = {
|
||||
...idleSnapshot,
|
||||
phase: 'waiting_for_external_step' as const,
|
||||
message: 'Finish Ubuntu setup in WSL',
|
||||
updatedAt: '2099-04-14T10:00:05.000Z',
|
||||
};
|
||||
|
||||
mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<TmuxStatus>((resolve) => {
|
||||
resolveStatus = resolve;
|
||||
})
|
||||
);
|
||||
mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<TmuxInstallerSnapshot>((resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
progressListener?.(null, newerProgress);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
|
||||
|
||||
await act(async () => {
|
||||
resolveStatus?.({
|
||||
...baseStatus,
|
||||
checkedAt: '2099-04-14T10:00:00.000Z',
|
||||
});
|
||||
resolveSnapshot?.(olderSnapshot);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
|
||||
expect(capturedHook?.viewModel.title).toBe('Finish Ubuntu setup in WSL');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('stores action errors instead of letting rejected installer calls disappear', async () => {
|
||||
mockApi.tmux.install.mockRejectedValueOnce(new Error('bridge failed'));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
|
||||
|
|
@ -20,6 +20,14 @@ const IDLE_SNAPSHOT: TmuxInstallerSnapshot = {
|
|||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
function getIsoTimestamp(value: string | null | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
export function useTmuxInstallerBanner(): {
|
||||
viewModel: ReturnType<TmuxInstallerBannerAdapter['adapt']>;
|
||||
install: () => Promise<void>;
|
||||
|
|
@ -36,32 +44,50 @@ export function useTmuxInstallerBanner(): {
|
|||
const [loading, setLoading] = useState(electronMode);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const hasLoadedRef = useRef(!electronMode);
|
||||
|
||||
const getErrorMessage = useCallback((value: unknown, fallback: string): string => {
|
||||
return value instanceof Error ? value.message : fallback;
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!electronMode) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const refresh = useCallback(
|
||||
async (options?: { background?: boolean }) => {
|
||||
if (!electronMode) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextStatus, nextSnapshot] = await Promise.all([
|
||||
api.tmux.getStatus(),
|
||||
api.tmux.getInstallerSnapshot(),
|
||||
]);
|
||||
setStatus(nextStatus);
|
||||
setSnapshot(nextSnapshot);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [electronMode]);
|
||||
const background = options?.background ?? hasLoadedRef.current;
|
||||
if (!background) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const [nextStatus, nextSnapshot] = await Promise.all([
|
||||
api.tmux.getStatus(),
|
||||
api.tmux.getInstallerSnapshot(),
|
||||
]);
|
||||
setStatus((current) =>
|
||||
getIsoTimestamp(nextStatus.checkedAt) >= getIsoTimestamp(current?.checkedAt)
|
||||
? nextStatus
|
||||
: current
|
||||
);
|
||||
setSnapshot((current) =>
|
||||
getIsoTimestamp(nextSnapshot.updatedAt) >= getIsoTimestamp(current.updatedAt)
|
||||
? nextSnapshot
|
||||
: current
|
||||
);
|
||||
hasLoadedRef.current = true;
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state');
|
||||
} finally {
|
||||
if (!background) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[electronMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode) {
|
||||
|
|
@ -69,10 +95,14 @@ export function useTmuxInstallerBanner(): {
|
|||
return;
|
||||
}
|
||||
|
||||
void refresh();
|
||||
void refresh({ background: false });
|
||||
|
||||
return api.tmux.onProgress((_event, progress) => {
|
||||
setSnapshot(progress);
|
||||
setSnapshot((current) =>
|
||||
getIsoTimestamp(progress.updatedAt) >= getIsoTimestamp(current.updatedAt)
|
||||
? progress
|
||||
: current
|
||||
);
|
||||
if (
|
||||
progress.phase === 'completed' ||
|
||||
progress.phase === 'needs_manual_step' ||
|
||||
|
|
@ -81,7 +111,7 @@ export function useTmuxInstallerBanner(): {
|
|||
progress.phase === 'error' ||
|
||||
progress.phase === 'cancelled'
|
||||
) {
|
||||
void refresh();
|
||||
void refresh({ background: true });
|
||||
}
|
||||
});
|
||||
}, [electronMode, refresh]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue