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.
This commit is contained in:
ethernet
2026-09-24 13:55:40 -04:00
parent a36dd91ef1
commit fd19d439cb
3 changed files with 311 additions and 178 deletions

View File

@@ -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<number>
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<void>
}
/** 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<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [testedKey, setTestedKey] = useState<string | null>(null)
const testSeq = useRef<number>(0)
const payloadKey: string = JSON.stringify(payload)
const currentKey = useRef<string>(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<void> => {
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
}
}

View File

@@ -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<number>
beforeOAuthLogin: (payload: DesktopConnectionConfigInput) => Promise<void> | 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<void>
signOut: () => Promise<void>
}
/** 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<boolean>(false)
const loginSeq = useRef<number>(0)
const invalidateLogin = (): void => {
loginSeq.current += 1
setSigningIn(false)
}
const clearSigningIn = (): void => {
setSigningIn(false)
}
const signIn = async (): Promise<void> => {
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<void> => {
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 }
}

View File

@@ -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<RemoteCredentials> = {}): RemoteCredenti
*/
export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
const { t } = useI18n()
const g = t.settings.gateway
const { host, enabled = true } = options
const callbacks = useRef<RemoteSetupOptions>(options)
callbacks.current = options
const [credentials, setCredentials] = useState<RemoteCredentials>(credentialsFrom)
const [revision, setRevision] = useState<number>(0)
const [signingIn, setSigningIn] = useState<boolean>(false)
const [testing, setTesting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [testedKey, setTestedKey] = useState<string | null>(null)
const testSeq = useRef<number>(0)
const loginSeq = useRef<number>(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<string>(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<void> | 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<RemoteCredentials>): 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<void> => {
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<void> => {
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<void> => {
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
}
}