diff --git a/desktop/README.md b/desktop/README.md index f88df0b7e4..b811d37e2e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,6 +29,14 @@ Selecting a saved profile in the global connection menu opens that exact configu Scope selection also keeps an application-wide pending state. Escape, the close button and the backdrop can close the dialog, but do not cancel an accepted native selection. Memory editing and further Scope switching stay paused until it finishes. Closing a dialog whose trigger is disabled returns focus to the page heading. A late result still updates authoritative native state, while it cannot clear a new connection editor's draft guard. Failures after the dialog has closed appear in the shared error feedback. +## Existing Server connection journey + +Saving, checking and activating a profile are separate actions with distinct progress and completion feedback. Saving does not activate a connection; a completed check asks users to review its individual facts. A compatible activation with a verified identity opens the shared exact-Scope picker. Lookup and selection retain their individual operation restrictions. Select the intended Server-returned Scope, using its complete ID to distinguish duplicate titles; the ID also supplies the button's accessible description. The activation guide then opens Memory and focuses its page heading. Closing the guide restores the activation button without choosing a Scope. + +Opening the global Scope picker elsewhere keeps the current page after selection and restores focus to its selector. Cancelling draft confirmation preserves the current input. Confirming a successful Scope switch discards the old Memory or connection-editor draft. Native states rejected as stale cannot open the activation guide or finish Scope navigation. Empty results offer another title, an access request or another connection; loading, permission, authentication and compatibility failures remain separate. + +Reopening Desktop loads saved profiles without activating a private session. The local setup entry continues to point to Local Server guidance; approved installer results and default-Scope handoff remain owned with #2117. + ## Local Server preparation Overview remains the initial page. In disconnected Memories, **Set up locally** opens and focuses **Settings & diagnostics → Local Server**; **Connect an existing Server** opens the existing connection editor. Selecting either path does not install software, activate a connection, run diagnostics or probe readiness. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 2dc1f2f924..c6bf0d0fcb 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -29,6 +29,14 @@ Scope 选择也具有应用层等待状态。Escape、关闭按钮及遮罩可以关闭弹窗,但不会取消已提交的原生选择;完成前暂停记忆编辑及再次切换 Scope。触发按钮被禁用时,关闭弹窗将焦点返回页面标题。晚到结果仍更新原生实际状态,但不会清除新连接编辑器的草稿导航保护;弹窗关闭后发生的失败由共享错误反馈显示。 +## 已有 Server 的连接流程 + +保存配置、检查连接和激活连接是独立操作,分别显示进度与完成反馈。保存不会激活连接;检查完成后须核对各项事实。兼容性与身份验证通过的激活操作会打开共享的精确 Scope 选择器,查找和选择仍分别受逐项操作资格限制。请选择 Server 返回的目标范围,以完整 ID 区分同名 Scope;按钮的无障碍描述也包含该 ID。激活引导完成选择后进入记忆页,并将焦点移至页面标题;关闭引导会将焦点返回激活按钮,不会选择范围。 + +从其他位置打开全局 Scope 选择器,选择后仍停留在当前页面,焦点返回选择器。取消草稿确认会保留当前输入;确认切换且选择成功后,会丢弃旧记忆草稿或连接编辑草稿。被判为过期的原生状态不能打开激活引导或触发后续 Scope 导航。空结果提供修改标题、申请访问权限或选择其他连接的恢复方向;加载、权限、认证及兼容性错误继续分别呈现。 + +重新打开 Desktop 只加载已保存配置,不会激活私有会话。本地设置入口继续指向 Local Server 说明;经批准的安装结果与默认 Scope 交接仍需与 #2117 一起完成。 + ## Local Server 准备说明 默认启动页仍为总览。记忆页未连接时,“本地设置”进入并聚焦“设置与诊断 → Local Server”;“连接已有 Server”进入现有连接编辑器。选择任一路径都不会安装软件、激活连接、执行诊断或探测就绪状态。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 08904e865d..451748aee5 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -634,6 +634,40 @@ Use a disposable Server and Scope for installed save/search/read/copy checks. Un those installed workflows. Validate actual Chinese IME, accessibility, Gatekeeper, signing and notarization independently before distributing outside the internal preview. +## Existing Server connection journey validation + +On 2026-10-28, Windows 10 x64 / Python 3.11.15 / Rust 1.95.0 validated the connection +journey working tree based on `aedf7beb1ecff2ea1fade6ccdd8e4bf6a298f799`. +All 130 frontend tests passed, together with TypeScript, lint, generated-contract/icon drift, +formatting and the production build. The English/Chinese first-use scenarios exercise save, +passive check, explicit activation, exact selection among duplicate Scope titles, keyboard +focus and reopening without automatic activation. Other regressions cover cancelled/confirmed +drafts, partial operation support, rejected stale states, late Scope pages and preserving the +current page during global Scope selection. UI tests use jsdom with mocked native IPC. + +The native real-Server probe additionally asserts that both saving and passive checking leave +`active` unset before explicit activation and exact Scope selection. The full SQLite harness +passed anonymous loopback, static Bearer, HTTPS with an explicit CA/base path and enforced +Provider, followed by save/search/exact reads, unknown-write handling and identity/revocation +checks. Server source is `aedf7beb1ecff2ea1fade6ccdd8e4bf6a298f799`; wheel +`powercontext-1.4.2.dev36+gaedf7beb1.d20261028-py3-none-any.whl` has SHA-256 +`fde108a81a872a6228aad54832f2cd8f899a53bb4a7ff5c321871891fdbc5b68`. +Normalized OpenAPI SHA-256 is +`c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a`; +debug `server_probe.exe` SHA-256 is +`01bc705b75f7ccb843535b884f7d2a50b7c5d8a5e9d8432b6788dbfafe86a4bf`. +The run used the explicit disposable fixture qualification with a dirty Desktop working tree; +production qualification records are unchanged. + +Rust formatting, Clippy and IPC drift checks passed. Seven Python contract/fixture tests passed. +Repository file hooks passed; default Windows `ty check` retains existing POSIX-only attribute +diagnostics in unchanged Python files, while the Linux-target type check passed. +The installed WebDriver workflow now expects activation to open the Scope dialog, exercises +that dialog directly and verifies cleared Memory after selection. That updated installed flow +was not executed locally. Native WebView/credential behavior, IME, screen readers, small windows, +scaling and signed Windows 11/macOS acceptance remain platform qualification work. The approved +local setup/default-Scope handoff remains separate work with #2117; this does not close #2103. + ## Independent Ops bridge checks The native Ops tests exercise streaming with real child processes, malformed/oversized output, deadlines, inherited diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 31fb37bb5d..a6c9a44fb8 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -155,6 +155,15 @@ async fn main() { "authentication":if has_token { "bearer" } else { "unauthenticated_loopback" }, "caPem":fixture.ca_pem,"compatibility":compatibility,"keepCredential":false,"credential":credential })).unwrap()).unwrap().profiles[0].id.clone(); + assert!(manager.state().unwrap().active.is_none()); + let checked = manager.check(&profile, false).await.unwrap(); + assert!(checked.active.is_none()); + assert!( + checked + .reports + .iter() + .any(|report| report.connection_id == profile) + ); let generation = manager.check(&profile, true).await.unwrap().generation; let generation = manager .select_scope(generation, &fixture.scope_id) diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index 96ee1212bd..f49e930e03 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -132,7 +132,7 @@ def activate(self, name: str) -> None: self.element( f"//ul[@class='profile-list']/li[.//span[@class='profile-name'][normalize-space(.)='{name}']]//span[contains(@class,'badge')]" ) - self.button("记忆") + self.element("//div[@role='dialog' and @aria-label='选择精确范围']") def connect(self, name: str, endpoint: str) -> None: self.button("连接") @@ -144,7 +144,8 @@ def connect(self, name: str, endpoint: str) -> None: self.activate(name) def select_scope(self, scope_id: str) -> None: - self.click("//div[@class='topbar-group']/button") + if not self.observe("return !!document.querySelector('[role=dialog]');"): + self.click("//div[@class='topbar-group']/button") self.type("按范围标题查找", "Desktop installed CI") self.button("查找范围") self.click(f"//ul[@class='scope-list']/li[code[normalize-space(.)='{scope_id}']]/button") @@ -281,8 +282,8 @@ def exercise_connection_isolation( seeded.raise_for_status() reference_b = saved_reference(seeded.json()) page.connect("Desktop CI B", str(server_b.base_url).rstrip("/")) - page.expect_empty_context() page.select_scope(scope_b) + page.expect_empty_context() if page.search_read(text_b) != reference_b: raise HarnessFailure("installed_second_server_reference_mismatch") draft = "desktopunsavedci 不应写入的草稿" @@ -302,8 +303,8 @@ def exercise_connection_isolation( page.button("连接") page.profile("Desktop CI synthetic") page.activate("Desktop CI synthetic") - page.expect_empty_context() page.select_scope(scope_a) + page.expect_empty_context() if page.search_read(NOTE) != current_reference_a: raise HarnessFailure("installed_reconnected_reference_mismatch") page.button("连接") diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 241d5b95fa..fe791aed9c 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -51,11 +51,13 @@ export function App() { const [menu, setMenu] = useState(false); const [host, setHost] = useState(null); const [desktop, setDesktop] = useState(null); + const currentState = useRef(null); const [nativeError, setNativeError] = useState(null); const [stateError, setStateError] = useState(null); const [stateLoading, setStateLoading] = useState(true); const stateRequest = useRef(0); const [dirty, setDirty] = useState(false); + const [connectionDraftReset, setConnectionDraftReset] = useState(0); const [scopeOpen, setScopeOpen] = useState(false); const [refreshing, setRefreshing] = useState(false); const [connectionSelection, setConnectionSelection] = useState<{ @@ -74,9 +76,14 @@ export function App() { setPendingConnections((current) => current + (pending ? 1 : -1)); } function receiveState(next: DesktopState) { - setDesktop((current) => - !current || next.generation >= current.generation ? next : current, - ); + if ( + currentState.current && + next.generation < currentState.current.generation + ) + return false; + currentState.current = next; + setDesktop(next); + return true; } const { confirm, confirmation } = useConfirmation(language); function confirmSwitch() { @@ -90,6 +97,7 @@ export function App() { const localServerHeading = useRef(null); const settingsSection = useRef<"local-server" | undefined>(undefined); const scopeReturnFocus = useRef(null); + const scopeToMemory = useRef(false); const t = messages[language]; const ct = connectionMessages[language]; async function loadState() { @@ -123,8 +131,9 @@ export function App() { target?.focus(); if (localServer) target?.scrollIntoView?.({ block: "start" }); }, [page, connectionSelection]); - function openScopes(trigger: HTMLElement) { + function openScopes(trigger: HTMLElement, toMemory = false) { scopeReturnFocus.current = trigger; + scopeToMemory.current = toMemory; setScopeOpen(true); } async function navigate( @@ -341,6 +350,8 @@ export function App() { onState={receiveState} onDirty={setDirty} onPendingChange={connectionPending} + onActivated={(trigger) => openScopes(trigger, true)} + discardDraft={connectionDraftReset} /> )} {page === "observation" && ( @@ -423,8 +434,14 @@ export function App() { onPendingChange={scopePending} onSelectionError={setNativeError} onSelected={() => { + if (page === "connections" && dirty) + setConnectionDraftReset((current) => current + 1); setDirty(false); setScopeOpen(false); + if (scopeToMemory.current) { + scopeReturnFocus.current = null; + setPage("memories"); + } }} hideHeading onManageConnections={() => { diff --git a/desktop/ui/src/app/Connections.tsx b/desktop/ui/src/app/Connections.tsx index bf0da51867..98b9de5202 100644 --- a/desktop/ui/src/app/Connections.tsx +++ b/desktop/ui/src/app/Connections.tsx @@ -37,10 +37,12 @@ import connectionsIcon from "../assets/connections.svg"; type Props = { state: DesktopState | null; language: Language; - onState: (value: DesktopState) => void; + onState: (value: DesktopState) => boolean | void; onDirty: (value: boolean) => void; onPendingChange?: (pending: boolean) => void; initialProfileId?: string; + onActivated?: (trigger: HTMLElement) => void; + discardDraft?: number; }; export function Connections({ state, @@ -49,6 +51,8 @@ export function Connections({ onDirty, onPendingChange, initialProfileId, + onActivated, + discardDraft = 0, }: Props) { const t = connectionMessages[language]; const { confirm, confirmation } = useConfirmation(language); @@ -68,7 +72,14 @@ export function Connections({ const [ca, setCa] = useState(""); const [compatibility, setCompatibility] = useState(""); const [dirty, setDirty] = useState(false); - const [busy, setBusy] = useState(false); + const [busy, setBusy] = useState< + "save" | "check" | "activate" | "remove" | null + >(null); + const pending = useRef(false); + const [notice, setNotice] = useState<{ + id: string; + kind: "saved" | "checkedComplete" | "activationBlocked"; + } | null>(null); const [error, setError] = useState(null); const invalidated = useRef(false); const lifetime = useRef(0); @@ -98,7 +109,12 @@ export function Connections({ // Only refresh a clean form; newer input keeps its discard guard. if (!dirty) reset(profile); }, [selected, profile?.revision]); + useEffect(() => { + // A confirmed global Scope switch also discards an open connection draft. + if (dirty) reset(profile); + }, [discardDraft]); function change(target = false) { + setNotice(null); setDirty(true); onDirty(true); if (target) { @@ -118,18 +134,27 @@ export function Connections({ async function select(id: string | null) { if (dirty && !(await confirm(t.discard))) return; setSecret(""); + setNotice(null); setSelected(id); reset(state?.profiles.find((p) => p.id === id)); } - async function action(work: () => Promise) { + async function action( + kind: NonNullable, + work: () => Promise, + ) { + if (pending.current) return; + pending.current = true; const ticket = lifetime.current; - setBusy(true); + setBusy(kind); + setNotice(null); setError(null); onPendingChange?.(true); try { const next = await work(); - onState(next); - if (ticket === lifetime.current) return next; + const accepted = onState(next); + if (ticket === lifetime.current && accepted !== false) return next; + if (ticket === lifetime.current && accepted === false) + setError({ code: "stale_context" }); } catch (e) { if (ticket === lifetime.current) setError(e); try { @@ -139,7 +164,10 @@ export function Connections({ } } finally { onPendingChange?.(false); - if (ticket === lifetime.current) setBusy(false); + if (ticket === lifetime.current) { + pending.current = false; + setBusy(null); + } } } async function save() { @@ -155,7 +183,7 @@ export function Connections({ credential: secret ? { secret, storage } : null, }; setSecret(""); - const next = await action(() => desktopApi.save(input)); + const next = await action("save", () => desktopApi.save(input)); if (next) { const saved = next.profiles.find( (p) => p.id === selected || p.name === name.trim(), @@ -163,9 +191,32 @@ export function Connections({ if (saved) { setSelected(saved.id); reset(saved); + setNotice({ id: saved.id, kind: "saved" }); } } } + async function check(activate: boolean, trigger: HTMLElement) { + if (!profile || dirty) return; + const next = await action(activate ? "activate" : "check", () => + desktopApi.check(profile.id, activate), + ); + if (!next) return; + if (!activate) { + setNotice({ id: profile.id, kind: "checkedComplete" }); + return; + } + const active = next.active; + if ( + active?.connectionId === profile.id && + active.report.revision === profile.revision && + active.report.compatibilityVerified && + (active.report.anonymousAccess || active.report.identity.value) + ) { + onActivated?.(trigger); + } else { + setNotice({ id: profile.id, kind: "activationBlocked" }); + } + } function fact( value: Fact | undefined, render: (value: T) => string, @@ -203,7 +254,7 @@ export function Connections({

{t.savedConnections}

{t.removeNote}

diff --git a/desktop/ui/src/app/Scopes.tsx b/desktop/ui/src/app/Scopes.tsx index a952a07f21..02133a65d1 100644 --- a/desktop/ui/src/app/Scopes.tsx +++ b/desktop/ui/src/app/Scopes.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import type { DesktopState, ScopeDescriptor } from "../generated/ipc"; import { desktopApi } from "../shared/ipc"; import { @@ -37,7 +37,7 @@ export function Scopes({ }: { state: DesktopState | null; language: Language; - onState: (state: DesktopState) => void; + onState: (state: DesktopState) => boolean | void; confirmSwitch: () => boolean | Promise; onSelected?: () => void; hideHeading?: boolean; @@ -46,6 +46,7 @@ export function Scopes({ onSelectionError?: (error: unknown) => void; }) { const t = connectionMessages[language]; + const listId = useId(); const common = messages[language]; const manage = onManageConnections && ( @@ -132,8 +133,21 @@ export function Scopes({ setError(null); onPendingChange?.(true); try { - onState(await desktopApi.selectScope(active.generation, id)); - if (ticket === lifetime.current) onSelected?.(); + const next = await desktopApi.selectScope(active.generation, id); + const accepted = onState(next); + if ( + ticket === lifetime.current && + accepted !== false && + generation === currentGeneration.current && + next.active?.connectionId === active.connectionId && + next.active.scope?.scope_id === id + ) + onSelected?.(); + else if ( + ticket === lifetime.current && + generation === currentGeneration.current + ) + setError({ code: "stale_context" }); } catch (e) { if (ticket === lifetime.current) setError(e); else onSelectionError?.(e); @@ -252,21 +266,29 @@ export function Scopes({ {items && (
    - {items.map((scope) => ( + {items.map((scope, index) => (
  • - {scope.scope_id} + {scope.scope_id}
  • ))}
)} {items?.length === 0 && ( - + +

{t.emptyScopeHint}

+
)} {cursor && (