From fd19d439cbd3657d511201ba79d1f5607be77259 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 13:55:40 -0400 Subject: [PATCH] refactor(desktop): split remote setup into probe, test and OAuth hooks useRemoteSetup covered the credential form, the connection test and the OAuth login in one hook. The test/feedback leg now lives in useRemoteConnectionTest and the login/logout leg in useRemoteOAuth, both fenced by the probe's target generation as before. useRemoteSetup keeps the form state and composes the three legs; its public shape is unchanged. --- .../use-remote-connection-test.ts | 141 +++++++++++ .../remote-setup/use-remote-oauth.ts | 130 +++++++++++ .../remote-setup/use-remote-setup.ts | 218 ++++-------------- 3 files changed, 311 insertions(+), 178 deletions(-) create mode 100644 apps/desktop/src/components/remote-setup/use-remote-connection-test.ts create mode 100644 apps/desktop/src/components/remote-setup/use-remote-oauth.ts diff --git a/apps/desktop/src/components/remote-setup/use-remote-connection-test.ts b/apps/desktop/src/components/remote-setup/use-remote-connection-test.ts new file mode 100644 index 0000000000..df93056500 --- /dev/null +++ b/apps/desktop/src/components/remote-setup/use-remote-connection-test.ts @@ -0,0 +1,141 @@ +import { type RefObject, useRef, useState } from 'react' + +import type { DesktopConnectionConfigInput, DesktopConnectionProbeResult } from '@/global' +import { useI18n } from '@/i18n' +import type { NotificationInput } from '@/store/notifications' + +import type { RemoteSetupHost } from './use-remote-setup' + +interface RemoteConnectionTestOptions { + host: RemoteSetupHost + url: string + payload: DesktopConnectionConfigInput + canTest: boolean + authResolved: boolean + targetSeq: RefObject + acceptProbe: (result: DesktopConnectionProbeResult) => void + notify: (notice: NotificationInput) => void +} + +export interface RemoteConnectionTest { + testing: boolean + error: string | null + success: string | null + // The last passing test covered exactly the payload on screen. + passed: boolean + invalidateTest: () => void + clearTestOutcome: () => void + reportError: (err: unknown, title?: string, kind?: 'error' | 'warning') => void + test: () => Promise +} + +/** Test/feedback leg of the remote editor: one generation per test, fenced by target and payload. */ +export function useRemoteConnectionTest(options: RemoteConnectionTestOptions): RemoteConnectionTest { + const { t } = useI18n() + const g = t.settings.gateway + const { host, url, payload, canTest, authResolved, targetSeq, acceptProbe, notify } = options + const [testing, setTesting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [testedKey, setTestedKey] = useState(null) + const testSeq = useRef(0) + const payloadKey: string = JSON.stringify(payload) + const currentKey = useRef(payloadKey) + currentKey.current = payloadKey + + const invalidateTest = (): void => { + testSeq.current += 1 + setTesting(false) + setTestedKey(null) + setError(null) + setSuccess(null) + } + + // A retargeted probe drops the verdict but keeps the last error visible. + const clearTestOutcome = (): void => { + setTesting(false) + setTestedKey(null) + setSuccess(null) + } + + const reportError = (err: unknown, title: string = g.testFailed, kind: 'error' | 'warning' = 'error'): void => { + const message: string = err instanceof Error ? err.message : String(err || g.testFailed) + setError(message) + notify({ kind, title, message }) + } + + const reportSuccess = (message: string): void => { + setSuccess(message) + notify({ kind: 'success', title: g.reachableTitle, message }) + } + + const test = async (): Promise => { + if (!canTest) { + return + } + + const target: number = targetSeq.current + const seq: number = ++testSeq.current + + const current = (): boolean => + target === targetSeq.current && seq === testSeq.current && payloadKey === currentKey.current + + setTesting(true) + setError(null) + setSuccess(null) + setTestedKey(null) + + try { + if (!authResolved) { + const result = await window.hermesDesktop.probeConnectionConfig(url) + + if (current()) { + acceptProbe(result) + + if (!result.reachable || result.authMode === 'unknown') { + reportError(result.error || g.probeError) + } + } + + return + } + + const result = await window.hermesDesktop.testConnectionConfig(payload) + + if (!current()) { + return + } + + if (result.ok === false || result.reachable === false) { + throw new Error(result.error || g.testFailed) + } + + reportSuccess( + (host === 'first-run' ? t.install.testSucceeded : g.connectedTo)( + result.baseUrl || url, + result.version ?? undefined + ) + ) + setTestedKey(payloadKey) + } catch (err) { + if (current()) { + reportError(err) + } + } finally { + if (current()) { + setTesting(false) + } + } + } + + return { + testing, + error, + success, + passed: testedKey === payloadKey, + invalidateTest, + clearTestOutcome, + reportError, + test + } +} diff --git a/apps/desktop/src/components/remote-setup/use-remote-oauth.ts b/apps/desktop/src/components/remote-setup/use-remote-oauth.ts new file mode 100644 index 0000000000..b7ff4a2f40 --- /dev/null +++ b/apps/desktop/src/components/remote-setup/use-remote-oauth.ts @@ -0,0 +1,130 @@ +import { type RefObject, useRef, useState } from 'react' + +import type { DesktopConnectionConfigInput } from '@/global' +import { useI18n } from '@/i18n' +import type { NotificationInput } from '@/store/notifications' + +import type { RemoteSetupHost } from './use-remote-setup' + +interface RemoteOAuthOptions { + host: RemoteSetupHost + url: string + providerLabel: string + targetSeq: RefObject + beforeOAuthLogin: (payload: DesktopConnectionConfigInput) => Promise | undefined + setOAuthConnected: (connected: boolean) => void + invalidateTest: () => void + reportError: (err: unknown, title?: string, kind?: 'error' | 'warning') => void + notify: (notice: NotificationInput) => void +} + +export interface RemoteOAuth { + signingIn: boolean + // Retargeting orphans any in-flight login; its result must not land. + invalidateLogin: () => void + clearSigningIn: () => void + signIn: () => Promise + signOut: () => Promise +} + +/** OAuth leg of the remote editor: login/logout generations fenced by the probe target. */ +export function useRemoteOAuth(options: RemoteOAuthOptions): RemoteOAuth { + const { t } = useI18n() + const g = t.settings.gateway + const { + host, + url, + providerLabel, + targetSeq, + beforeOAuthLogin, + setOAuthConnected, + invalidateTest, + reportError, + notify + } = options + const [signingIn, setSigningIn] = useState(false) + const loginSeq = useRef(0) + + const invalidateLogin = (): void => { + loginSeq.current += 1 + setSigningIn(false) + } + + const clearSigningIn = (): void => { + setSigningIn(false) + } + + const signIn = async (): Promise => { + if (!url || signingIn) { + return + } + + const target: number = targetSeq.current + const seq: number = ++loginSeq.current + const current = (): boolean => target === targetSeq.current && seq === loginSeq.current + invalidateTest() + setSigningIn(true) + + try { + await beforeOAuthLogin({ mode: 'remote', remoteAuthMode: 'oauth', remoteUrl: url }) + + if (!current()) { + return + } + + const result = await window.hermesDesktop.oauthLoginConnectionConfig(url) + + if (!current()) { + return + } + + setOAuthConnected(Boolean(result.connected)) + + if (result.connected) { + notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) }) + } else { + const message = host === 'first-run' ? t.install.signInIncomplete : t.boot.failure.signInIncompleteMessage + reportError( + result.error ? `${message}: ${result.error}` : message, + t.boot.failure.signInIncompleteTitle, + 'warning' + ) + } + } catch (err) { + if (current()) { + reportError(err, g.signInFailed) + } + } finally { + if (current()) { + setSigningIn(false) + } + } + } + + const signOut = async (): Promise => { + const target: number = targetSeq.current + const seq: number = ++loginSeq.current + const current = (): boolean => target === targetSeq.current && seq === loginSeq.current + invalidateTest() + setSigningIn(true) + + try { + await window.hermesDesktop.oauthLogoutConnectionConfig(url) + + if (current()) { + setOAuthConnected(false) + notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) + } + } catch (err) { + if (current()) { + reportError(err, g.signOutFailed) + } + } finally { + if (current()) { + setSigningIn(false) + } + } + } + + return { signingIn, invalidateLogin, clearSigningIn, signIn, signOut } +} diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.ts b/apps/desktop/src/components/remote-setup/use-remote-setup.ts index 2692c431bf..de587da31c 100644 --- a/apps/desktop/src/components/remote-setup/use-remote-setup.ts +++ b/apps/desktop/src/components/remote-setup/use-remote-setup.ts @@ -6,6 +6,8 @@ import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth' import { coerceRemoteUrlScheme } from '@/lib/remote-url' import type { NotificationInput } from '@/store/notifications' +import { type RemoteConnectionTest, useRemoteConnectionTest } from './use-remote-connection-test' +import { type RemoteOAuth, useRemoteOAuth } from './use-remote-oauth' import { useRemoteProbe } from './use-remote-probe' export type RemoteSetupHost = 'first-run' | 'settings' | 'registry' @@ -64,37 +66,27 @@ function credentialsFrom(saved: Partial = {}): RemoteCredenti */ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { const { t } = useI18n() - const g = t.settings.gateway const { host, enabled = true } = options const callbacks = useRef(options) callbacks.current = options const [credentials, setCredentials] = useState(credentialsFrom) const [revision, setRevision] = useState(0) - const [signingIn, setSigningIn] = useState(false) - const [testing, setTesting] = useState(false) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) - const [testedKey, setTestedKey] = useState(null) - - const testSeq = useRef(0) - const loginSeq = useRef(0) const url = coerceRemoteUrlScheme(credentials.url) const manualAuth = host === 'registry' const probeEnabled = enabled && (!manualAuth || credentials.authMode === 'oauth') + const notify = (notice: NotificationInput): void => callbacks.current.onNotice?.(notice) const { probe, probeStatus, targetSeq, invalidateProbe, acceptProbe } = useRemoteProbe({ enabled: probeEnabled, url, revision, onReset: (): void => { - setSigningIn(false) - setTesting(false) - setTestedKey(null) - setSuccess(null) + oauth.clearSigningIn() + connectionTest.clearTestOutcome() }, onResult: (result: DesktopConnectionProbeResult): void => { - invalidateTest() + connectionTest.invalidateTest() if (!manualAuth && result.reachable && result.authMode !== 'unknown') { const authMode = result.authMode @@ -114,9 +106,6 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { remoteUrl: url } - const payloadKey = JSON.stringify(payload) - const currentKey = useRef(payloadKey) - currentKey.current = payloadKey const { isPassword, providerLabel } = deriveRemoteAuthProviderShape(probe?.providers, t.boot.failure.identityProvider) const authResolved = @@ -131,19 +120,34 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { const canTest = enabled && Boolean(url) && ((authResolved && credentialReady) || probeStatus === 'error') - const invalidateTest = (): void => { - testSeq.current += 1 - setTesting(false) - setTestedKey(null) - setError(null) - setSuccess(null) - } + const connectionTest: RemoteConnectionTest = useRemoteConnectionTest({ + host, + url, + payload, + canTest, + authResolved, + targetSeq, + acceptProbe, + notify + }) + + const oauth: RemoteOAuth = useRemoteOAuth({ + host, + url, + providerLabel, + targetSeq, + beforeOAuthLogin: (value: DesktopConnectionConfigInput): Promise | undefined => + callbacks.current.beforeOAuthLogin?.(value), + setOAuthConnected: (oauthConnected: boolean): void => setCredentials(value => ({ ...value, oauthConnected })), + invalidateTest: connectionTest.invalidateTest, + reportError: connectionTest.reportError, + notify + }) const invalidateTarget = (): void => { invalidateProbe() - loginSeq.current += 1 - setSigningIn(false) - invalidateTest() + oauth.invalidateLogin() + connectionTest.invalidateTest() } const reset = (saved?: Partial): void => { @@ -159,7 +163,7 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { } const setToken = (token: string): void => { - invalidateTest() + connectionTest.invalidateTest() setCredentials(current => ({ ...current, token })) } @@ -169,148 +173,6 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { setRevision(value => value + 1) } - const reportError = (err: unknown, title: string = g.testFailed, kind: 'error' | 'warning' = 'error'): void => { - const message = err instanceof Error ? err.message : String(err || g.testFailed) - setError(message) - callbacks.current.onNotice?.({ kind, title, message }) - } - - const reportSuccess = (message: string): void => { - setSuccess(message) - callbacks.current.onNotice?.({ kind: 'success', title: g.reachableTitle, message }) - } - - const signIn = async (): Promise => { - if (!url || signingIn) { - return - } - - const target = targetSeq.current - const seq = ++loginSeq.current - const current = (): boolean => target === targetSeq.current && seq === loginSeq.current - invalidateTest() - setSigningIn(true) - - try { - await callbacks.current.beforeOAuthLogin?.({ mode: 'remote', remoteAuthMode: 'oauth', remoteUrl: url }) - - if (!current()) { - return - } - - const result = await window.hermesDesktop.oauthLoginConnectionConfig(url) - - if (!current()) { - return - } - - setCredentials(value => ({ ...value, oauthConnected: Boolean(result.connected) })) - - if (result.connected) { - callbacks.current.onNotice?.({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) }) - } else { - const message = host === 'first-run' ? t.install.signInIncomplete : t.boot.failure.signInIncompleteMessage - reportError( - result.error ? `${message}: ${result.error}` : message, - t.boot.failure.signInIncompleteTitle, - 'warning' - ) - } - } catch (err) { - if (current()) { - reportError(err, g.signInFailed) - } - } finally { - if (current()) { - setSigningIn(false) - } - } - } - - const signOut = async (): Promise => { - const target = targetSeq.current - const seq = ++loginSeq.current - const current = (): boolean => target === targetSeq.current && seq === loginSeq.current - invalidateTest() - setSigningIn(true) - - try { - await window.hermesDesktop.oauthLogoutConnectionConfig(url) - - if (current()) { - setCredentials(value => ({ ...value, oauthConnected: false })) - callbacks.current.onNotice?.({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) - } - } catch (err) { - if (current()) { - reportError(err, g.signOutFailed) - } - } finally { - if (current()) { - setSigningIn(false) - } - } - } - - const test = async (): Promise => { - if (!canTest) { - return - } - - const target = targetSeq.current - const seq = ++testSeq.current - - const current = (): boolean => - target === targetSeq.current && seq === testSeq.current && payloadKey === currentKey.current - - setTesting(true) - setError(null) - setSuccess(null) - setTestedKey(null) - - try { - if (!authResolved) { - const result = await window.hermesDesktop.probeConnectionConfig(url) - - if (current()) { - acceptProbe(result) - - if (!result.reachable || result.authMode === 'unknown') { - reportError(result.error || g.probeError) - } - } - - return - } - - const result = await window.hermesDesktop.testConnectionConfig(payload) - - if (!current()) { - return - } - - if (result.ok === false || result.reachable === false) { - throw new Error(result.error || g.testFailed) - } - - reportSuccess( - (host === 'first-run' ? t.install.testSucceeded : g.connectedTo)( - result.baseUrl || url, - result.version ?? undefined - ) - ) - setTestedKey(payloadKey) - } catch (err) { - if (current()) { - reportError(err) - } - } finally { - if (current()) { - setTesting(false) - } - } - } - return { host, credentials, @@ -319,18 +181,18 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup { authResolved, providerLabel, isPassword, - signingIn, - testing, - error, - success, + signingIn: oauth.signingIn, + testing: connectionTest.testing, + error: connectionTest.error, + success: connectionTest.success, canTest, - canCommit: enabled && (host === 'first-run' ? testedKey === payloadKey : host === 'registry' || credentialReady), + canCommit: enabled && (host === 'first-run' ? connectionTest.passed : host === 'registry' || credentialReady), setUrl, setToken, setAuthMode, reset, - signIn, - signOut, - test + signIn: oauth.signIn, + signOut: oauth.signOut, + test: connectionTest.test } }