From 7355de4b73d30641be44f9015c350dfbb5b5b527 Mon Sep 17 00:00:00 2001 From: techartdev Date: Sat, 26 Sep 2026 23:59:21 +0300 Subject: [PATCH 01/11] Offer hash-pinned FTP adapter in App Manager --- src/extensions/AdapterManager.tsx | 64 ++++++++++++++++++++++++++++++- src/extensions/ftp-adapter.ts | 37 ++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/extensions/ftp-adapter.ts diff --git a/src/extensions/AdapterManager.tsx b/src/extensions/AdapterManager.tsx index 5d86a1f..f81ddfd 100644 --- a/src/extensions/AdapterManager.tsx +++ b/src/extensions/AdapterManager.tsx @@ -1,8 +1,16 @@ // SPDX-License-Identifier: MPL-2.0 import { useEffect, useRef, useState } from "react"; -import { Cable, Plus, RefreshCw, ShieldAlert, X } from "lucide-react"; +import { + Cable, + FolderDown, + Plus, + RefreshCw, + ShieldAlert, + X, +} from "lucide-react"; import type { AdapterInfo, AdapterReview, AdapterServices } from "../adapters"; import { AdapterDiagnosticsPanel } from "../components/AdapterDiagnosticsPanel"; +import { ftpAdapterSource } from "./ftp-adapter"; export function AdapterManager({ services }: { services: AdapterServices }) { const [items, setItems] = useState([]), [busy, setBusy] = useState(false), @@ -63,6 +71,27 @@ export function AdapterManager({ services }: { services: AdapterServices }) { if (!result) pending.current = null; }); } + async function inspectFtp() { + if (!services.reviewRepository) return; + const id = crypto.randomUUID(); + pending.current = id; + setTrusted(false); + await run(async () => { + let result: AdapterReview; + try { + result = await services.reviewRepository!(id, ftpAdapterSource); + } catch (error) { + if (pending.current !== id) return; + pending.current = null; + throw error; + } + if (!active.current || pending.current !== id) { + await services.cancelReview(id); + return; + } + setReview(result); + }); + } async function cancel() { const id = pending.current; pending.current = null; @@ -252,6 +281,39 @@ export function AdapterManager({ services }: { services: AdapterServices }) {

)} + {services.reviewRepository && + !items.some( + (item) => + item.id === ftpAdapterSource.id && + item.version === ftpAdapterSource.version, + ) && ( +
+
+
+

SUGGESTED ADAPTER

+

+ FTP files +

+

+ Browse and download files from FTP servers. Uses explicit FTPS + by default, with an option for plain FTP. +

+

+ Uploads and file changes are not available in this preview. +

+
+ +
+
+ )}

Updates and disabling apply to new connections. Running connections keep their installed version. diff --git a/src/extensions/ftp-adapter.ts b/src/extensions/ftp-adapter.ts new file mode 100644 index 0000000..50fc00c --- /dev/null +++ b/src/extensions/ftp-adapter.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +import type { RepositoryAdapterSource } from "../adapters"; + +/** The manifest hashes are pinned to the reviewed v0.1.0 repository tag. */ +export const ftpAdapterSource: RepositoryAdapterSource = { + owner: "techartdev", + repository: "ShellCanvas-FTP", + reference: "v0.1.0", + id: "dev.shellcanvas.ftp", + version: "0.1.0", + packages: [ + { + platform: "windows-x86_64", + path: "dist/windows-x86_64/adapter.json", + sha256: + "74f1c81b25e4c93e54c73e1a53cf9ee403bb9917afe752240b206f12b9be1e89", + }, + { + platform: "linux-x86_64", + path: "dist/linux-x86_64/adapter.json", + sha256: + "709f183f80367b9fc67b3e76d7182d4de0d31d2d9c1b0c9d8712017ac3af1d77", + }, + { + platform: "macos-x86_64", + path: "dist/macos-x86_64/adapter.json", + sha256: + "34c6c43b15b1fd8f93b7272b9e86a8f02fed52085d5339c2e059a7be4a3a539f", + }, + { + platform: "macos-aarch64", + path: "dist/macos-aarch64/adapter.json", + sha256: + "280ccd267a8129de232f7bd6c6e7e0299345e21611d38964f73959e7d1d26d5b", + }, + ], +}; From d503d295cf8242491514a32933beadcd65059707 Mon Sep 17 00:00:00 2001 From: techartdev Date: Sun, 27 Sep 2026 00:03:10 +0300 Subject: [PATCH 02/11] Document FTP adapter connection scope --- docs/adapter-packages.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adapter-packages.md b/docs/adapter-packages.md index ccd6998..61dd1b2 100644 --- a/docs/adapter-packages.md +++ b/docs/adapter-packages.md @@ -8,6 +8,8 @@ Adapters run with the user's OS permissions. They are different from isolated de Choose **Connect a host → Use connection adapters**. Select an installed, enabled adapter and complete its configuration fields. A connection may provide both Files and Terminal, or use **Add another connection** to assign those roles to separate adapter processes. Roles are explicit: an unavailable service is disabled rather than silently routed elsewhere. +The [ShellCanvas FTP adapter](https://github.com/techartdev/ShellCanvas-FTP) is suggested in App Manager. It supplies Files over explicit FTPS by default, with an opt-in plain FTP mode for restricted servers. The current preview browses folders, previews text, and downloads files; it does not offer uploads or file changes. FTP connects to the named server independently of SSH and needs the server's passive data ports reachable from this PC. + The bridges provide file browsing/previews and console byte streams, with optional resize. Optional file methods enable text reading/creation/saving, folder creation, rename, move, removal, and streaming uploads/downloads/copies. Optional directory readers enable folder transfers without collecting a whole tree in memory. **Remote settings** is a separate role with provider-defined fields and optional revision-checked changes. Unsupported desktop actions remain unavailable. A device that advertises only files can still open a workspace. Failure to initialize any selected source currently fails the initial composite connection; after connection, source availability is tracked independently. Adapter connection settings survive whole-workspace reconnect in memory and can be explicitly saved through the connection dialog's **Saved workspace** controls. [Saved workspace profiles](workspace-profiles.md) persist public settings and explicit service assignments, omit password fields, and use revision-checked updates/removal. Reconnect preserves the desktop windows and creates fresh native session/console handles. It can use the currently installed version of the same adapter, while preserving the original service assignments and non-secret configuration. **SSH (built in)** can supply services alongside installed adapters and participates in source replacement with the existing host-key review. It is offered by the connection chooser, not installed or removed through the package manager. The existing saved SSH profiles continue to use their own connection flow. From c6ccc12ebc3a7c13232199d7db8f81419ec61d02 Mon Sep 17 00:00:00 2001 From: techartdev Date: Sat, 26 Sep 2026 23:33:52 +0300 Subject: [PATCH 03/11] Polish connection and workspace menus --- src/apps/Files.tsx | 63 ++++++++++-- src/apps/HostDetails.tsx | 34 ++++++- src/components/AppWindow.tsx | 1 + src/components/ContextMenu.tsx | 75 ++++++++------ src/components/HostProfilePicker.css | 42 +++++--- src/components/HostProfilePicker.tsx | 72 ++++++++------ src/styles.css | 142 ++++++++++++++++++--------- 7 files changed, 300 insertions(+), 129 deletions(-) diff --git a/src/apps/Files.tsx b/src/apps/Files.tsx index 7f8d541..9aea497 100644 --- a/src/apps/Files.tsx +++ b/src/apps/Files.tsx @@ -11,7 +11,9 @@ import { import { ArrowLeft, ArrowDown, + ArrowRight, ArrowUp, + CheckCheck, ChevronRight, Eye, FileCode2, @@ -31,6 +33,9 @@ import { Scissors, ClipboardPaste, Copy, + ListFilter, + Trash2, + type LucideIcon, } from "lucide-react"; import type { AppContext, @@ -70,6 +75,52 @@ import { fileSourceKey } from "../workspace-bindings"; import { capabilityOperationReason } from "../sdk"; import { scanDirectory } from "../directory-scan"; import { useVirtualRows } from "../components/useVirtualRows"; + +const fileMenuIcons: Record = { + upload: Upload, + "attach-drive": HardDrive, + "upload-folder": FolderPlus, + download: Download, + "download-files": Download, + "copy-clipboard": Copy, + "copy-files": Copy, + "copy-file": Copy, + mkdir: FolderPlus, + "new-file": FileText, + edit: FileText, + open: Eye, + "new-window": Folder, + "pin-to-desktop": Home, + "copy-name": Copy, + "copy-names": Copy, + "copy-path": Copy, + "copy-paths": Copy, + "copy-folder": Copy, + cut: Scissors, + rename: FileText, + move: ArrowRight, + delete: Trash2, + "delete-selection": Trash2, + "paste-move": ClipboardPaste, + back: ArrowLeft, + parent: ArrowUp, + refresh: RefreshCw, + "clipboard-path": ClipboardPaste, + "sort-view": ListFilter, + "select-all": CheckCheck, + "clear-selection": X, + "hidden-files": Eye, + "sort-name": ListFilter, + "sort-modified": ListFilter, + "sort-size": ListFilter, + ascending: ArrowUp, + descending: ArrowDown, + filesFoldersFirst: Folder, + filesShowHidden: Eye, + filesCompact: ListFilter, +}; +const withFileIcons = (actions: MenuAction[]): MenuAction[] => + actions.map((action) => ({ ...action, icon: fileMenuIcons[action.id] })); function size(bytes: number) { return bytes >= 1024 * 1024 ? `${(bytes / 1048576).toFixed(1)} MB` @@ -1034,7 +1085,7 @@ export function Files({ }, [selected, directory, query, preferences.filesShowHidden, loading]); function menuActions(entry?: FileEntry): MenuAction[] { if (selectedEntries.length > 1) - return [ + return withFileIcons([ { id: "copy-files", label: `Copy ${selectedEntries.length} ${selectedEntries.some((item) => item.kind === "directory") ? "items" : "files"}`, @@ -1101,8 +1152,8 @@ export function Files({ if (menu) setMenu({ x: menu.x, y: menu.y, sort: true }); }, }, - ]; - return [ + ]); + return withFileIcons([ { id: "upload", label: "Upload files…", @@ -1343,7 +1394,7 @@ export function Files({ run: () => setPreference("filesShowHidden", !preferences.filesShowHidden), }, - ]; + ]); } function sortBy(key: typeof preferences.filesSort) { if (key === preferences.filesSort) @@ -1351,7 +1402,7 @@ export function Files({ else setPreference("filesSort", key); } function sortActions(): MenuAction[] { - return [ + return withFileIcons([ ...(["name", "modified", "size"] as const).map((key) => ({ id: `sort-${key}`, label: { name: "Name", modified: "Modified", size: "Size" }[key], @@ -1385,7 +1436,7 @@ export function Files({ disabled: preferencesBlocked, run: () => setPreference(key, !preferences[key]), })), - ]; + ]); } return (

= { + terminal: SquareTerminal, + "files.read": FolderOpen, + "files.edit": FilePenLine, + "files.create": FilePlus2, + "files.manage": Folder, + "files.move": Move, + "files.copy": Files, + "files.folders": FolderPlus, + "files.upload": ArrowUpFromLine, + "files.download": ArrowDownToLine, + "host.settings": Settings2, +}; + export function HostDetails(context: AppContext) { const [tab, setTab] = useState<"overview" | "settings">("overview"); const [settingsOpened, setSettingsOpened] = useState(false); @@ -76,6 +104,7 @@ function HostOverview({ session, preview, connected = true }: AppContext) { aria-label="Workspace service availability" > {(Object.keys(capabilityLabels) as Capability[]).map((capability) => { + const Icon = toolIcons[capability]; const status = capabilityStatus(session, capability); const state = !connected && status.state === "available" @@ -83,6 +112,9 @@ function HostOverview({ session, preview, connected = true }: AppContext) { : status.state; return (
  • +
    {capabilityLabels[capability]} @@ -95,7 +127,7 @@ function HostOverview({ session, preview, connected = true }: AppContext) { : state)}
    - {state} + {state}
  • ); })} diff --git a/src/components/AppWindow.tsx b/src/components/AppWindow.tsx index cf764bd..54a13c1 100644 --- a/src/components/AppWindow.tsx +++ b/src/components/AppWindow.tsx @@ -324,6 +324,7 @@ export function AppWindow({ } as CSSProperties } className={`app-window window-${app.window?.layout ?? "standard"} ${focused ? "focused" : ""} ${maximized ? "maximized" : ""} ${tiled ? `tiled tiled-${tiled}` : ""} ${!visible ? "hidden-window" : ""}`} + data-app-id={app.id} onPointerDownCapture={focus} onFocusCapture={focus} aria-label={`${title} window`} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 5ff6a45..2c7d5eb 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MPL-2.0 import { useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { Check } from "lucide-react"; +import { Check, type LucideIcon } from "lucide-react"; export interface MenuAction { id: string; label: string; + icon?: LucideIcon; shortcut?: string; disabled?: boolean; separatorBefore?: boolean; @@ -103,37 +104,47 @@ export function ContextMenu({ role={group.name ? "group" : undefined} aria-label={group.name} > - {group.actions.map((action) => ( - - ))} + {group.actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })}
    ))} , diff --git a/src/components/HostProfilePicker.css b/src/components/HostProfilePicker.css index c141ebf..234a4cb 100644 --- a/src/components/HostProfilePicker.css +++ b/src/components/HostProfilePicker.css @@ -8,9 +8,15 @@ font-size: 0.769231rem; margin-bottom: 0.538462rem; } +.host-picker-row { + display: flex; + gap: 0.615385rem; + align-items: stretch; +} .host-picker-trigger { display: flex; - width: 100%; + flex: 1; + min-width: 0; align-items: center; gap: 0.846154rem; padding: 0.846154rem 0.923077rem; @@ -20,6 +26,25 @@ color: var(--sc-accent, #a2cbbc); text-align: left; } +.host-picker-new { + display: grid; + place-items: center; + width: 4rem; + flex: 0 0 4rem; + color: var(--sc-accent, #a2cbbc); + background: var(--sc-hover, #101f2b55); + border: 1px solid var(--sc-border, #aac5cd35); + border-radius: 0.615385rem; +} +.host-picker-new:hover, +.host-picker-trigger:hover { + background: var(--sc-selection, #a2cbbc16); +} +.host-picker-new:focus-visible, +.host-picker-trigger:focus-visible { + outline: 0.153846rem solid var(--sc-accent, #a2cbbc); + outline-offset: 0.153846rem; +} .host-picker-trigger > span, .host-picker-list button > span { flex: 1; @@ -54,7 +79,7 @@ border: 1px solid var(--sc-border, #9fbec644); border-radius: 0.769231rem; background: var(--sc-surface, #1a2d38); - box-shadow: 0 1.076923rem 2.923077rem rgba(var(--sc-shadow-rgb), 0.400); + box-shadow: 0 1.076923rem 2.923077rem rgba(var(--sc-shadow-rgb), 0.4); overflow: hidden; } .host-picker-search { @@ -76,7 +101,7 @@ outline: none; } .host-picker-search:focus-within { - box-shadow: inset 0 -0.153846rem rgba(var(--sc-shadow-rgb), 0.400); + box-shadow: inset 0 -0.153846rem rgba(var(--sc-shadow-rgb), 0.4); } .host-picker-list { max-height: 16rem; @@ -113,22 +138,11 @@ color: var(--sc-accent, #a2cbbc); } .host-picker-bottom { - display: flex; - align-items: center; - justify-content: space-between; border-top: 1px solid var(--sc-border, #94b4bf22); padding: 0.692308rem 0.923077rem; color: var(--sc-muted, #8eaab8); font-size: 0.692308rem; } -.host-picker-bottom button { - display: flex; - gap: 0.384615rem; - align-items: center; - color: var(--sc-accent, #bbdecf); - font-size: 0.769231rem; - padding: 0.307692rem; -} .host-picker-empty { color: var(--sc-muted, #9fb9c5); font-size: 0.846154rem; diff --git a/src/components/HostProfilePicker.tsx b/src/components/HostProfilePicker.tsx index ffba08b..9a7aeec 100644 --- a/src/components/HostProfilePicker.tsx +++ b/src/components/HostProfilePicker.tsx @@ -92,31 +92,46 @@ export function HostProfilePicker({ }} > Your hosts - +
    + + +
    {open && (
    @@ -226,12 +241,7 @@ export function HostProfilePicker({

    )}
    - - {matches.length} of {profiles.length} hosts - - + {matches.length} of {profiles.length} hosts
    )} diff --git a/src/styles.css b/src/styles.css index ac662ab..8b358fb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -269,7 +269,7 @@ button:active:not(:disabled) { border: 1px solid var(--sc-border, #96b7c238); border-radius: 1.076923rem; background: var(--sc-surface, #1b2e3bf5); - box-shadow: 0 1.384615rem 4.230769rem rgba(var(--sc-shadow-rgb), 0.400); + box-shadow: 0 1.384615rem 4.230769rem rgba(var(--sc-shadow-rgb), 0.4); -webkit-backdrop-filter: blur(1.846154rem); backdrop-filter: blur(1.846154rem); } @@ -322,7 +322,7 @@ button:active:not(:disabled) { } .connected .status-dot { background: var(--sc-accent, #a1dcb8); - box-shadow: 0 0 0.692308rem rgba(var(--sc-shadow-rgb), 0.200); + box-shadow: 0 0 0.692308rem rgba(var(--sc-shadow-rgb), 0.2); } .ssh-compatibility { margin: 0.923077rem 0; @@ -368,7 +368,10 @@ button:active:not(:disabled) { } .workspace { /* These offsets arrange new windows; they do not limit the window layer. */ - --window-gutter: max(3.692308rem, calc((100% - 138.461538rem) / 2 + 3.692308rem)); + --window-gutter: max( + 3.692308rem, + calc((100% - 138.461538rem) / 2 + 3.692308rem) + ); --window-start: 13.846154rem; --window-bottom: 3.461538rem; --dock-reserve: 6.307692rem; @@ -547,7 +550,7 @@ button:active:not(:disabled) { .app-window.focused { border-color: var(--sc-border, #b8d5d436); box-shadow: - 0 2.153846rem 5.384615rem rgba(var(--sc-shadow-rgb), 0.400), + 0 2.153846rem 5.384615rem rgba(var(--sc-shadow-rgb), 0.4), 0 0 0 1px rgba(var(--sc-shadow-rgb), 0.133); } .window-standard { @@ -558,6 +561,12 @@ button:active:not(:disabled) { width: min(52.307692rem, var(--initial-window-width)); height: min(38.461538rem, var(--initial-window-height)); } +.app-window[data-app-id="host-details"].window-standard:not(.maximized):not( + .tiled + ) { + width: min(44rem, var(--initial-window-width)); + height: min(34rem, var(--initial-window-height)); +} .app-surface { display: flex; flex-direction: column; @@ -565,26 +574,26 @@ button:active:not(:disabled) { min-height: 0; } .host-details-app { - padding: 2.153846rem; + padding: 1.538462rem; overflow: auto; } .host-details-app h2 { - margin: 0.615385rem 0; + margin: 0.461538rem 0; font-weight: 500; - font-size: 1.923077rem; + font-size: 1.615385rem; } .host-details-app > p { color: var(--sc-muted, #9eb6c3); line-height: 1.6; } .host-details-app dl { - margin: 1.846154rem 0; + margin: 1.076923rem 0 1.461538rem; } .host-details-app dl > div { display: grid; - grid-template-columns: 8.461538rem minmax(0, 1fr); + grid-template-columns: 7.307692rem minmax(0, 1fr); gap: 1.230769rem; - padding: 1rem 0; + padding: 0.692308rem 0; border-bottom: 1px solid var(--sc-border, #9ab9c01c); } .host-details-app dt { @@ -598,23 +607,32 @@ button:active:not(:disabled) { padding: 0; margin: 0.923077rem 0 1.538462rem; list-style: none; - border: 1px solid var(--sc-border, #a1c5d218); - border-radius: 0.769231rem; - overflow: hidden; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: 0.692308rem; } .bottom-status.partial-status { color: var(--sc-warning, #e7c993); } .service-status-list li { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.923077rem; - padding: 0.769231rem 0.923077rem; + display: grid; + grid-template-columns: 2rem minmax(0, 1fr); + column-gap: 0.692308rem; + align-content: start; + min-height: 6rem; + padding: 0.923077rem; background: var(--sc-hover, #10202a35); + border: 1px solid var(--sc-border, #a1c5d228); + border-radius: 0.692308rem; } -.service-status-list li + li { - border-top: 1px solid var(--sc-border, #a1c5d210); +.service-status-list .host-tool-icon { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 0.538462rem; + color: var(--sc-accent, #b4dbca); + background: var(--sc-selection, #9bc8b518); } .service-status-list strong { display: block; @@ -628,17 +646,19 @@ button:active:not(:disabled) { margin-top: 0.230769rem; overflow-wrap: anywhere; } -.service-status-list li > span { +.service-status-list .host-tool-state { + grid-column: 2; + justify-self: start; + margin-top: 0.615385rem; color: var(--sc-muted, #9caeba); font-size: 0.769231rem; text-transform: capitalize; - flex-shrink: 0; } -.service-status-list li[data-state="available"] > span { +.service-status-list li[data-state="available"] .host-tool-state { color: var(--sc-accent, #a3ddc5); } -.service-status-list li[data-state="disconnected"] > span, -.service-status-list li[data-state="denied"] > span { +.service-status-list li[data-state="disconnected"] .host-tool-state, +.service-status-list li[data-state="denied"] .host-tool-state { color: var(--sc-warning, #e7c993); } .capability-list { @@ -728,7 +748,10 @@ button:active:not(:disabled) { .window-titlebar { height: 3.307692rem; flex-shrink: 0; - background: linear-gradient(var(--sc-raised, #263642), var(--sc-raised, #25333e)); + background: linear-gradient( + var(--sc-raised, #263642), + var(--sc-raised, #25333e) + ); display: flex; align-items: center; justify-content: space-between; @@ -1083,7 +1106,7 @@ button:active:not(:disabled) { } .folder-icon { color: var(--sc-warning, #d6b77a); - fill: rgba(var(--sc-warning-rgb), .14); + fill: rgba(var(--sc-warning-rgb), 0.14); flex-shrink: 0; } .file-icon { @@ -1253,7 +1276,7 @@ button:active:not(:disabled) { border: 1px solid var(--sc-border, #96b7c238); border-radius: 0.846154rem; background: var(--sc-raised, #22333ff5); - box-shadow: 0 1.076923rem 3.461538rem rgba(var(--sc-shadow-rgb), 0.400); + box-shadow: 0 1.076923rem 3.461538rem rgba(var(--sc-shadow-rgb), 0.4); -webkit-backdrop-filter: blur(1.384615rem); backdrop-filter: blur(1.384615rem); } @@ -1296,6 +1319,11 @@ button:active:not(:disabled) { font-size: 0.769231rem; color: var(--sc-muted, #98adbb); } +.context-menu .menu-action-icon { + flex: 0 0 auto; + margin-right: 0.538462rem; + color: var(--sc-muted, #a9bcc4); +} .app-empty { display: flex; align-items: center; @@ -1305,7 +1333,11 @@ button:active:not(:disabled) { min-height: 16.923077rem; padding: 2.307692rem; text-align: center; - background: radial-gradient(ellipse at 50% 15%, var(--sc-selection, #709b9320), transparent 75%); + background: radial-gradient( + ellipse at 50% 15%, + var(--sc-selection, #709b9320), + transparent 75% + ); } .empty-icon { width: 4.923077rem; @@ -1314,14 +1346,22 @@ button:active:not(:disabled) { display: grid; place-items: center; border-radius: 1.384615rem; - background: linear-gradient(145deg, var(--sc-selection, #42665d44), var(--sc-selection, #30473822)); + background: linear-gradient( + 145deg, + var(--sc-selection, #42665d44), + var(--sc-selection, #30473822) + ); color: var(--sc-accent, #b4cfbd); margin-bottom: 1.615385rem; - box-shadow: 0 0.769231rem 1.846154rem rgba(var(--sc-shadow-rgb), 0.200); + box-shadow: 0 0.769231rem 1.846154rem rgba(var(--sc-shadow-rgb), 0.2); } .empty-icon.files { color: var(--sc-warning, #dec18b); - background: linear-gradient(145deg, var(--sc-warningSoft, #b2955630), var(--sc-warningSoft, #8f75420b)); + background: linear-gradient( + 145deg, + var(--sc-warningSoft, #b2955630), + var(--sc-warningSoft, #8f75420b) + ); } .app-empty h2 { font-size: 1.230769rem; @@ -1410,7 +1450,11 @@ button:active:not(:disabled) { gap: 0.461538rem; height: 5rem; padding: 0.615385rem 0.769231rem; - background: linear-gradient(130deg, var(--sc-selection, #c8e0dc12), var(--sc-hover, #7d9ea81c)); + background: linear-gradient( + 130deg, + var(--sc-selection, #c8e0dc12), + var(--sc-hover, #7d9ea81c) + ); border: 1px solid var(--sc-border, #c1d8d42b); box-shadow: 0 0.923077rem 2.692308rem rgba(var(--sc-shadow-rgb), 0.333), @@ -1590,7 +1634,7 @@ button:active:not(:disabled) { background: var(--sc-raised, #21353ff5); border: 1px solid var(--sc-border, #b7d4cb44); border-radius: 0.769231rem; - box-shadow: 0 0.769231rem 2.307692rem rgba(var(--sc-shadow-rgb), 0.400); + box-shadow: 0 0.769231rem 2.307692rem rgba(var(--sc-shadow-rgb), 0.4); color: var(--sc-text, #c8d9df); font-size: 0.846154rem; line-height: 1.6; @@ -1606,7 +1650,7 @@ button:active:not(:disabled) { bottom: 0; left: 0; z-index: 50; - background: rgba(var(--sc-scrim-rgb), 0.600); + background: rgba(var(--sc-scrim-rgb), 0.6); -webkit-backdrop-filter: blur(0.692308rem); backdrop-filter: blur(0.692308rem); display: grid; @@ -1623,11 +1667,15 @@ button:active:not(:disabled) { width: min(35.384615rem, calc(100vw - 3.076923rem)); min-width: 0; max-width: 100%; - padding: 2.461538rem 2.769231rem 2.076923rem; - background: linear-gradient(145deg, var(--sc-raised, #283d47), var(--sc-surface, #1b2c37) 55%); + padding: 1.846154rem 2.769231rem 2.076923rem; + background: linear-gradient( + 145deg, + var(--sc-raised, #283d47), + var(--sc-surface, #1b2c37) 55% + ); border: 1px solid var(--sc-border, #bdd8d731); box-shadow: - 0 2.692308rem 7.692308rem rgba(var(--sc-shadow-rgb), 0.400), + 0 2.692308rem 7.692308rem rgba(var(--sc-shadow-rgb), 0.4), inset 0 1px 0 rgba(var(--sc-shadow-rgb), 0.047); border-radius: 1.307692rem; color: var(--sc-text, #dce6e9); @@ -1638,17 +1686,21 @@ button:active:not(:disabled) { top: 1.076923rem; } .connection-emblem { - width: 4.230769rem; - height: 4.230769rem; + width: 3.846154rem; + height: 3.846154rem; border: 1px solid var(--sc-border, #c6e2d322); border-radius: 1.153846rem; display: grid; place-items: center; - background: linear-gradient(145deg, var(--sc-selection, #72978742), var(--sc-selection, #6b968617)); + background: linear-gradient( + 145deg, + var(--sc-selection, #72978742), + var(--sc-selection, #6b968617) + ); color: var(--sc-accent, #bbd6ca); position: relative; - box-shadow: 0 0.461538rem 1.153846rem rgba(var(--sc-shadow-rgb), 0.200); - margin-bottom: 1.692308rem; + box-shadow: 0 0.461538rem 1.153846rem rgba(var(--sc-shadow-rgb), 0.2); + margin-bottom: 1.153846rem; } .connection-emblem > span { position: absolute; @@ -1679,7 +1731,7 @@ button:active:not(:disabled) { color: var(--sc-muted, #96b0bc); line-height: 1.7; margin-top: 0.692308rem; - margin-bottom: 1.923077rem; + margin-bottom: 1.307692rem; } .connect-dialog fieldset { padding: 0; @@ -1756,7 +1808,7 @@ button:active:not(:disabled) { .auth-tabs button.active { background: var(--sc-hover, #3b555a80); color: var(--sc-accent, #c8dbd9); - box-shadow: 0 1px 0.384615rem rgba(var(--sc-shadow-rgb), 0.200); + box-shadow: 0 1px 0.384615rem rgba(var(--sc-shadow-rgb), 0.2); } .connect-submit { width: 100%; From 53a6471256c2d6e8843b0a9dbc12ec4d4538944d Mon Sep 17 00:00:00 2001 From: techartdev Date: Sun, 27 Sep 2026 01:51:57 +0300 Subject: [PATCH 04/11] Remember saved connection credentials in OS store --- README.md | 2 +- docs/adapter-packages.md | 2 + docs/connection-recovery.md | 2 +- docs/workspace-profiles.md | 6 +- src-tauri/src/adapters.rs | 19 ++- src-tauri/src/lib.rs | 130 ++++++++++++++- src-tauri/src/profile_store.rs | 11 ++ src-tauri/src/saved_credentials.rs | 123 ++++++++++++++ src-tauri/src/workspace_profiles.rs | 208 +++++++++++++++++++++++- src/App.tsx | 30 +++- src/adapters.ts | 19 ++- src/components/ConnectAdapterDialog.tsx | 184 +++++++++++++++++++-- src/components/ConnectDialog.tsx | 147 ++++++++++++++++- src/sdk.ts | 4 + src/services.ts | 7 +- src/workspaces.ts | 2 + tests/fixtures/connection-ui-probe.tsx | 3 + 17 files changed, 851 insertions(+), 48 deletions(-) create mode 100644 src-tauri/src/saved_credentials.rs diff --git a/README.md b/README.md index 1db4b70..230e9bc 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ SSH gives you a shell. ShellCanvas gives you the rest of a computer: a file mana - **A desktop, not a dashboard.** Move, resize, tile and minimize real windows between a top bar and a dock. Open several Files and Terminal windows per host. - **Nothing to install on the server.** ShellCanvas uses the SSH server your machine already runs, with SFTP for files. No agent, no daemon, only SSH. -- **Trust you can see.** A new host's key is shown for review before you sign in, and a changed key is refused. Passwords and passphrases are never saved. +- **Trust you can see.** A new host's key is shown for review before you sign in, and a changed key is refused. Passwords and passphrases stay out of profile files; you can explicitly remember them in this PC's system credential store. - **Room to grow.** Install apps straight from GitHub, switch themes, or build your own apps and connection adapters with the public SDKs. diff --git a/docs/adapter-packages.md b/docs/adapter-packages.md index 61dd1b2..17894ae 100644 --- a/docs/adapter-packages.md +++ b/docs/adapter-packages.md @@ -14,6 +14,8 @@ The bridges provide file browsing/previews and console byte streams, with option Adapter connection settings survive whole-workspace reconnect in memory and can be explicitly saved through the connection dialog's **Saved workspace** controls. [Saved workspace profiles](workspace-profiles.md) persist public settings and explicit service assignments, omit password fields, and use revision-checked updates/removal. Reconnect preserves the desktop windows and creates fresh native session/console handles. It can use the currently installed version of the same adapter, while preserving the original service assignments and non-secret configuration. **SSH (built in)** can supply services alongside installed adapters and participates in source replacement with the existing host-key review. It is offered by the connection chooser, not installed or removed through the package manager. The existing saved SSH profiles continue to use their own connection flow. +For saved workspaces, **Remember entered passwords** stores adapter passwords and SSH key passphrases separately in this PC's OS credential store. The saved profile file still omits them. On reconnect, native code checks the saved profile revision and public connection settings before using a stored secret. Manual entry remains available when the credential store is locked. + ## Replace one connection Open the top workspace selector and choose **Replace … connection** under Current connections. Choose an installed adapter and its configuration, then **Replace connection**. This replaces all service families assigned to that source and keeps the other sources running. Assignments stay fixed; the replacement may provide fewer capabilities, in which case the corresponding actions become unavailable. Active file operations must finish before opening this flow. If the whole workspace has disconnected and released its native session, use Reconnect host instead. diff --git a/docs/connection-recovery.md b/docs/connection-recovery.md index 8f35066..6862469 100644 --- a/docs/connection-recovery.md +++ b/docs/connection-recovery.md @@ -22,7 +22,7 @@ The connection dialog offers Cancel connection while connecting. Escape or closi - Workspace tests cover endpoint checks, unchanged app-instance state, rejected stale callbacks and exclusion of credentials from reconnect snapshots. - Native tests cover attempt isolation and cancellation before/during work. A local TCP fixture holds an SSH handshake open and verifies socket closure after cancellation. - Windows debug build, 29 frontend tests, 14 Rust tests and Clippy passed for this slice. -- Health reporting still depends on SSH closure/keepalives and can take roughly a minute for a silently unreachable peer. There is no automatic retry or credential vault. Cancellation during every authentication/provider phase and physical-network interruption still need native walkthroughs. +- Health reporting still depends on SSH closure/keepalives and can take roughly a minute for a silently unreachable peer. There is no automatic retry. Saved hosts and adapter workspaces can optionally use the OS credential store for reconnect; cancellation during every authentication/provider phase and physical-network interruption still need native walkthroughs. - Drafts and layout remain in memory. A crash or forced quit can lose them. Interrupted remote writes can have uncertain outcomes; verify the destination before retrying. ## Deliberate disconnect verification (2026-09-08) diff --git a/docs/workspace-profiles.md b/docs/workspace-profiles.md index cd7ba41..7468cbf 100644 --- a/docs/workspace-profiles.md +++ b/docs/workspace-profiles.md @@ -2,7 +2,7 @@ Open **Connect a host → Use connection adapters**. The **Saved workspace** selector loads a saved set of connections and explicit service assignments. Choose **SSH (built in)** or an installed adapter for each source, then assign Files, Terminal, Remote settings or additional services. For example, keep Terminal on SSH while Files comes from an installed adapter. Choose **New workspace** to start another profile. Saving is explicit and does not connect to a device. -**Save workspace profile** stores the current name, adapter identities, reviewed package revisions, public configuration and service assignments. **Save profile changes** updates the selected profile. Password fields are omitted by the native store using the installed adapter's schema, even if the caller sends them. Required passwords may be left empty when saving; enter them before connecting. This is configuration storage, not a credential vault. +**Save workspace profile** stores the current name, adapter identities, reviewed package revisions, public configuration and service assignments. **Save profile changes** updates the selected profile. Password fields are omitted by the native store using the installed adapter's schema, even if the caller sends them. Required passwords may be left empty when saving. Select **Remember entered passwords** to put entered secrets in this PC's system credential store. The profile file remains public configuration only. You can stop using or forget saved credentials in the connection dialog. Removing a profile also removes its saved credential. Reopening a profile fills only fields still declared public by the installed adapter. Removed fields, incompatible types and fields reclassified as passwords are not prefilled. A changed package, disabled adapter or missing adapter produces a review notice. A missing adapter is not silently replaced. Users can explicitly select another installed adapter and review its settings. The final connection still validates the current package revision and configuration before launching it. @@ -12,7 +12,9 @@ Reopening a profile fills only fields still declared public by the installed ada The native application-data directory contains `workspaces.json` and `workspaces.lock`. This store is separate from saved SSH hosts and installed adapter packages. Version 1 uses a tagged profile kind (`adapters`), whose sources can include the reserved `builtin:ssh` identity. Installed package IDs cannot use this reserved identity. Existing saved SSH profiles keep their current flow. -SSH uses the same host-key verification as the ordinary SSH connection dialog. Unknown keys require endpoint-specific fingerprint review; changed or revoked keys remain blocked. Each SSH source is reviewed separately. Passwords and key passphrases are omitted from profiles; private-key paths are public configuration. Replacing one source prepares its connection before committing the change, so a preparation failure preserves the existing workspace. Unrelated source handles remain usable after a successful replacement. +SSH uses the same host-key verification as the ordinary SSH connection dialog. Unknown keys require endpoint-specific fingerprint review; changed or revoked keys remain blocked. Each SSH source is reviewed separately. Passwords and key passphrases are omitted from profiles; private-key paths are public configuration. Remembered credentials are retrieved natively only for the exact saved profile revision and public connection settings. Replacing one source prepares its connection before committing the change, so a preparation failure preserves the existing workspace. Unrelated source handles remain usable after a successful replacement. + +Ordinary saved SSH hosts can also remember their password or key passphrase through the system credential store. Use **Remember entered password** when saving the host. Reopening a saved host can use that credential without displaying it in the form, and **Forget saved credential** removes it. Changing the SSH endpoint or authentication method prevents reuse until a new credential is saved. A locked or unavailable OS store leaves manual entry available. Windows uses Credential Manager, macOS uses Keychain, and Linux requires a Secret Service provider such as GNOME Keyring or KWallet. Profiles have UUID identities and revisions. Update and removal require the revision the caller reviewed; concurrent changes cause an error rather than replacing another window's changes. Close and reopen the connection dialog to reload current profiles after a conflict. Cross-process locking protects read/modify/write, and a synced temporary file is atomically published. Malformed files, future versions, duplicate identities and invalid bindings are refused without rewriting the original file. A 2 MiB bound applies to this configuration document, not file transfers or remote directory trees. diff --git a/src-tauri/src/adapters.rs b/src-tauri/src/adapters.rs index 1a3991b..d0a5daa 100644 --- a/src-tauri/src/adapters.rs +++ b/src-tauri/src/adapters.rs @@ -71,6 +71,8 @@ impl AdapterConnectionOptions { #[tauri::command] pub async fn connect_adapters( options: AdapterConnectionOptions, + saved_profile_id: Option, + saved_profile_revision: Option, request_id: u64, on_host_key: tauri::ipc::Channel, app: tauri::AppHandle, @@ -78,9 +80,17 @@ pub async fn connect_adapters( window: WebviewWindow, ) -> Result { options.validate()?; + if saved_profile_id.is_some() != saved_profile_revision.is_some() { + return Err("Saved workspace identity requires its revision".into()); + } let canceled = state.attempts.lock().await.claim(request_id)?; - let result = crate::connection_attempts::cancellable( - canceled, + let result = crate::connection_attempts::cancellable(canceled, async { + let options = if let (Some(id), Some(revision)) = (saved_profile_id, saved_profile_revision) + { + crate::workspace_profiles::resolve_credentials(&app, options, id, revision).await? + } else { + options + }; connect_workspace( options, request_id, @@ -88,8 +98,9 @@ pub async fn connect_adapters( app, &state, window.label(), - ), - ) + ) + .await + }) .await; state.attempts.lock().await.finish(request_id); result diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 672c3d7..633969f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,6 +40,7 @@ mod remote_clock; mod repository_install; #[cfg(test)] mod request_source_tests; +mod saved_credentials; mod session_registry; mod terminals; mod transfers; @@ -110,9 +111,111 @@ async fn save_profile(app: tauri::AppHandle, profile: HostProfile) -> Result Result<(), String> { let _update_operation = crate::update_gate::operation()?; let dir = profile_store::storage_dir(&app)?; - tauri::async_runtime::spawn_blocking(move || profile_store::remove(&dir, &id)) - .await - .map_err(error)? + tauri::async_runtime::spawn_blocking(move || { + profile_store::get(&dir, &id)?; + saved_credentials::remove_host(&id)?; + profile_store::remove(&dir, &id) + }) + .await + .map_err(error)? +} + +fn same_saved_host(profile: &HostProfile, options: &ConnectOptions) -> bool { + profile.host == options.host + && profile.port == options.port + && profile.username == options.username + && profile.key_path == options.key_path + && profile.allow_legacy_mac == options.allow_legacy_mac +} + +#[tauri::command] +async fn host_credential_status(app: tauri::AppHandle, id: String) -> Result { + let dir = profile_store::storage_dir(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let profile = profile_store::get(&dir, &id)?; + Ok(saved_credentials::host(&id)?.is_some_and(|secret| secret.matches_profile(&profile))) + }) + .await + .map_err(error)? +} + +#[tauri::command] +async fn save_host_credential( + app: tauri::AppHandle, + id: String, + options: ConnectOptions, +) -> Result<(), String> { + let dir = profile_store::storage_dir(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let profile = profile_store::get(&dir, &id)?; + if !same_saved_host(&profile, &options) { + return Err("Saved host settings changed. Save the host before its credential".into()); + } + let (kind, value) = if options.key_path.is_empty() { + ("password", options.password.unwrap_or_default()) + } else { + ("passphrase", options.passphrase.unwrap_or_default()) + }; + if value.is_empty() || value.len() > 2048 { + return Err("Enter a password or key passphrase before remembering it".into()); + } + saved_credentials::save_host( + &id, + &saved_credentials::HostSecret { + host: profile.host, + port: profile.port, + username: profile.username, + key_path: profile.key_path, + allow_legacy_mac: profile.allow_legacy_mac, + kind: kind.into(), + value, + }, + ) + }) + .await + .map_err(error)? +} + +#[tauri::command] +async fn forget_host_credential(app: tauri::AppHandle, id: String) -> Result<(), String> { + let dir = profile_store::storage_dir(&app)?; + tauri::async_runtime::spawn_blocking(move || { + profile_store::get(&dir, &id)?; + saved_credentials::remove_host(&id) + }) + .await + .map_err(error)? +} + +fn resolve_host_credential( + dir: &std::path::Path, + id: &str, + options: &mut ConnectOptions, +) -> Result<(), String> { + let profile = profile_store::get(dir, id)?; + if !same_saved_host(&profile, options) { + return Err( + "Saved host settings changed. Re-enter the credential or restore the saved host".into(), + ); + } + let secret = saved_credentials::host(id)?.ok_or("No credential is saved for this host")?; + if !secret.matches_profile(&profile) { + return Err("Saved credential belongs to different host settings".into()); + } + match secret.kind.as_str() { + "password" if options.key_path.is_empty() => { + if options.password.as_deref().unwrap_or_default().is_empty() { + options.password = Some(secret.value); + } + } + "passphrase" if !options.key_path.is_empty() => { + if options.passphrase.as_deref().unwrap_or_default().is_empty() { + options.passphrase = Some(secret.value); + } + } + _ => return Err("Saved credential uses a different SSH authentication method".into()), + } + Ok(()) } #[tauri::command] @@ -129,6 +232,7 @@ async fn cancel_connect(request_id: u64, state: State<'_, DesktopState>) -> Resu #[tauri::command] async fn connect( options: ConnectOptions, + saved_host_id: Option, request_id: u64, on_host_key: Channel, app: tauri::AppHandle, @@ -137,19 +241,29 @@ async fn connect( let canceled = state.attempts.lock().await.claim(request_id)?; let result = connection_attempts::cancellable( canceled, - connect_session(options, request_id, on_host_key, app, &state), + connect_session(options, saved_host_id, request_id, on_host_key, app, &state), ) .await; state.attempts.lock().await.finish(request_id); result } async fn connect_session( - options: ConnectOptions, + mut options: ConnectOptions, + saved_host_id: Option, request_id: u64, on_host_key: Channel, app: tauri::AppHandle, state: &DesktopState, ) -> Result { + if let Some(id) = saved_host_id { + let dir = profile_store::storage_dir(&app)?; + options = tauri::async_runtime::spawn_blocking(move || { + resolve_host_credential(&dir, &id, &mut options)?; + Ok::<_, String>(options) + }) + .await + .map_err(error)??; + } let source = prepare_ssh(options, request_id, on_host_key, app, state).await?; let info = source.info.clone(); let bindings = [ @@ -967,6 +1081,9 @@ pub fn run() { workspace_profiles::list_workspace_profiles, workspace_profiles::save_workspace_profile, workspace_profiles::remove_workspace_profile, + workspace_profiles::workspace_credential_status, + workspace_profiles::save_workspace_credentials, + workspace_profiles::forget_workspace_credentials, custom_services::list_custom_services, custom_services::begin_custom_call, custom_services::cancel_custom_call, @@ -987,6 +1104,9 @@ pub fn run() { apply_host_setting, save_profile, remove_profile, + host_credential_status, + save_host_credential, + forget_host_credential, session_alive, session_status, connect, diff --git a/src-tauri/src/profile_store.rs b/src-tauri/src/profile_store.rs index 9c6326f..4f5912f 100644 --- a/src-tauri/src/profile_store.rs +++ b/src-tauri/src/profile_store.rs @@ -161,6 +161,17 @@ pub fn list(dir: &Path) -> Result, String> { .collect()) }) } +pub fn get(dir: &Path, id: &str) -> Result { + uuid::Uuid::parse_str(id).map_err(|_| "Invalid saved host ID")?; + locked(dir, |path| { + load(path)? + .profiles + .iter() + .find(|profile| profile.id == id) + .map(SavedProfile::public) + .ok_or("Saved host no longer exists".into()) + }) +} pub fn save(dir: &Path, mut profile: HostProfile) -> Result { profile.name = profile.name.trim().into(); profile.host = profile.host.trim().into(); diff --git a/src-tauri/src/saved_credentials.rs b/src-tauri/src/saved_credentials.rs new file mode 100644 index 0000000..1a4a298 --- /dev/null +++ b/src-tauri/src/saved_credentials.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Secrets for explicitly saved connections. Profile files contain public settings only. +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +const HOST_SERVICE: &str = "ShellCanvas.saved-hosts.v1"; +const WORKSPACE_SERVICE: &str = "ShellCanvas.saved-workspaces.v1"; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HostSecret { + pub host: String, + pub port: u16, + pub username: String, + pub key_path: String, + pub allow_legacy_mac: bool, + pub kind: String, + pub value: String, +} +impl HostSecret { + pub fn matches_profile(&self, profile: &shellcanvas_core::HostProfile) -> bool { + self.host == profile.host + && self.port == profile.port + && self.username == profile.username + && self.key_path == profile.key_path + && self.allow_legacy_mac == profile.allow_legacy_mac + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceSecrets { + pub revision: String, + pub fields: HashMap>, +} + +fn entry(service: &str, id: &str) -> Result { + uuid::Uuid::parse_str(id).map_err(|_| "Invalid saved connection ID")?; + keyring::Entry::new(service, id).map_err(|_| "System credential store unavailable".into()) +} + +fn read Deserialize<'de>>(service: &str, id: &str) -> Result, String> { + match entry(service, id)?.get_password() { + Ok(value) => serde_json::from_str(&value) + .map(Some) + .map_err(|_| "Saved credential is invalid; forget and save it again".into()), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err("Cannot read the system credential store. Unlock it and retry".into()), + } +} + +fn write(service: &str, id: &str, secret: &T) -> Result<(), String> { + let value = serde_json::to_string(secret).map_err(|_| "Cannot encode credential")?; + if value.len() > 4096 { + return Err("Saved credential exceeds the system store limit".into()); + } + entry(service, id)? + .set_password(&value) + .map_err(|_| "Cannot save in the system credential store. Unlock it and retry".into()) +} + +fn remove(service: &str, id: &str) -> Result<(), String> { + match entry(service, id)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => { + Err("Cannot remove the saved credential. Unlock the system store and retry".into()) + } + } +} + +pub fn host(id: &str) -> Result, String> { + read(HOST_SERVICE, id) +} +pub fn save_host(id: &str, secret: &HostSecret) -> Result<(), String> { + write(HOST_SERVICE, id, secret) +} +pub fn remove_host(id: &str) -> Result<(), String> { + remove(HOST_SERVICE, id) +} +pub fn workspace(id: &str) -> Result, String> { + read(WORKSPACE_SERVICE, id) +} +pub fn save_workspace(id: &str, secret: &WorkspaceSecrets) -> Result<(), String> { + write(WORKSPACE_SERVICE, id, secret) +} +pub fn remove_workspace(id: &str) -> Result<(), String> { + remove(WORKSPACE_SERVICE, id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_secret_is_bound_to_endpoint_and_authentication_settings() { + let profile = shellcanvas_core::HostProfile { + host: "server.example".into(), + port: 22, + username: "alice".into(), + key_path: "~/.ssh/id_ed25519".into(), + ..Default::default() + }; + let secret = HostSecret { + host: profile.host.clone(), + port: profile.port, + username: profile.username.clone(), + key_path: profile.key_path.clone(), + allow_legacy_mac: false, + kind: "passphrase".into(), + value: "secret".into(), + }; + assert!(secret.matches_profile(&profile)); + let mut other = profile.clone(); + other.host = "other.example".into(); + assert!(!secret.matches_profile(&other)); + other = profile.clone(); + other.key_path = "~/.ssh/other".into(); + assert!(!secret.matches_profile(&other)); + other = profile; + other.allow_legacy_mac = true; + assert!(!secret.matches_profile(&other)); + } +} diff --git a/src-tauri/src/workspace_profiles.rs b/src-tauri/src/workspace_profiles.rs index 3960e45..449d7d4 100644 --- a/src-tauri/src/workspace_profiles.rs +++ b/src-tauri/src/workspace_profiles.rs @@ -4,7 +4,7 @@ use crate::adapters::AdapterConnectionOptions; use serde::{Deserialize, Serialize}; use shellcanvas_adapter_runtime::catalog::{AdapterInfo, FieldKind}; use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, fs::{self, File, OpenOptions}, io::{Read, Write}, path::Path, @@ -194,6 +194,35 @@ fn remove(dir: &Path, id: &str, revision: &str) -> Result<(), String> { write(path, &store) }) } + +fn get(dir: &Path, id: &str, revision: &str) -> Result { + uuid::Uuid::parse_str(id).map_err(|_| "Invalid saved workspace ID")?; + locked(dir, |path| { + load(path)? + .profiles + .into_iter() + .find(|item| item.id == id && item.revision == revision) + .ok_or("Saved workspace changed or was removed".into()) + }) +} + +fn matching_public_options( + dir: &Path, + id: &str, + revision: &str, + options: AdapterConnectionOptions, + installed: &[AdapterInfo], +) -> Result<(), String> { + let WorkspaceProfile::Adapters(saved) = get(dir, id, revision)?.profile; + let public = sanitized(options, installed)?; + if serde_json::to_value(&public).ok() != serde_json::to_value(&saved).ok() { + return Err( + "Workspace settings changed. Save the profile again or enter credentials manually" + .into(), + ); + } + Ok(()) +} #[tauri::command] pub async fn list_workspace_profiles(app: tauri::AppHandle) -> Result, String> { let dir = crate::profile_store::storage_dir(&app)?; @@ -225,9 +254,145 @@ pub async fn remove_workspace_profile( app: tauri::AppHandle, ) -> Result<(), String> { let dir = crate::profile_store::storage_dir(&app)?; - tauri::async_runtime::spawn_blocking(move || remove(&dir, &id, &revision)) - .await - .map_err(|e| e.to_string())? + tauri::async_runtime::spawn_blocking(move || { + get(&dir, &id, &revision)?; + crate::saved_credentials::remove_workspace(&id)?; + remove(&dir, &id, &revision) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn workspace_credential_status(id: String, revision: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + Ok(crate::saved_credentials::workspace(&id)? + .is_some_and(|secret| secret.revision == revision && !secret.fields.is_empty())) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn save_workspace_credentials( + app: tauri::AppHandle, + id: String, + revision: String, + options: AdapterConnectionOptions, +) -> Result<(), String> { + let dir = crate::profile_store::storage_dir(&app)?; + let catalog = crate::adapters::catalog(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let mut installed = catalog.list().map_err(|e| e.to_string())?; + installed.push(crate::builtin_ssh::info()); + matching_public_options(&dir, &id, &revision, options.clone(), &installed)?; + let mut fields: HashMap> = HashMap::new(); + for source in &options.sources { + let adapter = installed + .iter() + .find(|item| item.id == source.id && item.revision == source.revision) + .ok_or("Adapter changed before credentials could be saved")?; + let mut secrets = HashMap::new(); + for field in &adapter.configuration { + if matches!(field.kind, FieldKind::Password) { + if let Some(value) = source + .configuration + .get(&field.id) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + { + secrets.insert(field.id.clone(), value.to_owned()); + } + } + } + if !secrets.is_empty() { + fields.insert(source.key.clone(), secrets); + } + } + if fields.is_empty() { + return Err( + "Enter at least one password or passphrase before remembering credentials".into(), + ); + } + crate::saved_credentials::save_workspace( + &id, + &crate::saved_credentials::WorkspaceSecrets { revision, fields }, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn forget_workspace_credentials( + app: tauri::AppHandle, + id: String, + revision: String, +) -> Result<(), String> { + let dir = crate::profile_store::storage_dir(&app)?; + tauri::async_runtime::spawn_blocking(move || { + get(&dir, &id, &revision)?; + crate::saved_credentials::remove_workspace(&id) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn resolve_credentials( + app: &tauri::AppHandle, + mut options: AdapterConnectionOptions, + id: String, + revision: String, +) -> Result { + let dir = crate::profile_store::storage_dir(app)?; + let catalog = crate::adapters::catalog(app)?; + tauri::async_runtime::spawn_blocking(move || { + let mut installed = catalog.list().map_err(|e| e.to_string())?; + installed.push(crate::builtin_ssh::info()); + matching_public_options(&dir, &id, &revision, options.clone(), &installed)?; + let stored = crate::saved_credentials::workspace(&id)? + .ok_or("No credentials are saved for this workspace")?; + if stored.revision != revision { + return Err( + "Saved credentials belong to an older workspace revision. Enter them again".into(), + ); + } + for source in &mut options.sources { + let adapter = installed + .iter() + .find(|item| item.id == source.id && item.revision == source.revision) + .ok_or("Adapter changed before credentials could be used")?; + let Some(secrets) = stored.fields.get(&source.key) else { + continue; + }; + let config = source + .configuration + .as_object_mut() + .ok_or("Invalid adapter configuration")?; + for (field, value) in secrets { + if !adapter + .configuration + .iter() + .any(|item| item.id == *field && matches!(item.kind, FieldKind::Password)) + { + return Err( + "Saved credential field is no longer declared by this adapter".into(), + ); + } + if config + .get(field) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .is_empty() + { + config.insert(field.clone(), serde_json::Value::String(value.clone())); + } + } + } + Ok(options) + }) + .await + .map_err(|e| e.to_string())? } #[cfg(test)] @@ -235,7 +400,7 @@ mod tests { use super::*; use serde_json::json; fn installed() -> Vec { - vec![serde_json::from_value(json!({"id":"dev.fixture","name":"Fixture","version":"1.0.0","description":"","platform":"windows-x86_64","entrypoint":"fixture.exe","configuration":[{"id":"endpoint","label":"Endpoint","kind":"text","required":true},{"id":"token","label":"Token","kind":"password","required":true}],"generation":"one","revision":"one","enabled":true,"fileCount":1,"bytes":1})).unwrap()] + vec![serde_json::from_value(json!({"id":"dev.fixture","name":"Fixture","version":"1.0.0","description":"","platform":"windows-x86_64","entrypoint":"fixture.exe","configuration":[{"id":"endpoint","label":"Endpoint","kind":"text","required":true},{"id":"token","label":"Token","kind":"password","required":true}],"generation":"one","revision":"one","enabled":true,"fileCount":1,"bytes":1,"digest":"fixture"})).unwrap()] } fn options() -> AdapterConnectionOptions { serde_json::from_value(json!({"name":"Mixed workspace","sources":[{"key":"one","id":"dev.fixture","revision":"one","configuration":{"endpoint":"opaque:device","token":"never-persist"}},{"key":"two","id":"dev.fixture","revision":"one","configuration":{"endpoint":"opaque:console","token":"never-persist"}}],"bindings":{"files":"one","console":"two"}})).unwrap() @@ -281,6 +446,39 @@ mod tests { .is_empty()); } #[test] + fn stored_credentials_cannot_be_reused_for_changed_workspace_settings() { + let dir = tempfile::tempdir().unwrap(); + let saved = save(dir.path(), options(), None, None, &installed()).unwrap(); + assert!(matching_public_options( + dir.path(), + &saved.id, + &saved.revision, + options(), + &installed() + ) + .is_ok()); + let mut changed = options(); + changed.sources[0].configuration["endpoint"] = json!("opaque:other-device"); + assert!(matching_public_options( + dir.path(), + &saved.id, + &saved.revision, + changed, + &installed() + ) + .is_err()); + let mut changed = options(); + changed.bindings.insert("files".into(), "two".into()); + assert!(matching_public_options( + dir.path(), + &saved.id, + &saved.revision, + changed, + &installed() + ) + .is_err()); + } + #[test] fn unknown_fields_missing_adapters_and_invalid_roles_cannot_be_saved() { let dir = tempfile::tempdir().unwrap(); assert!(save(dir.path(), options(), None, None, &[]).is_err()); diff --git a/src/App.tsx b/src/App.tsx index 979f99d..678737a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -626,9 +626,15 @@ export default function App({ setConnecting(false); setError("Connection canceled. You can try again."); } - async function connect(options: ConnectOptions, name: string) { - return establish(connectionProfile(options, name), (signal, review) => - services.connect(options, signal, review), + async function connect( + options: ConnectOptions, + name: string, + savedHostId?: string, + ) { + return establish( + connectionProfile(options, name, savedHostId), + (signal, review) => + services.connect(options, signal, review, savedHostId), ); } async function establish( @@ -873,8 +879,7 @@ export default function App({ actions: launcherMenuActions({ app, unavailable: - !!unavailableReason(app, session) || - !!runtime.disabledReason(app.id), + !!unavailableReason(app, session) || !!runtime.disabledReason(app.id), canPin: !!desktopId && !desktopIcons.blocked, windows: ids.map((id) => ({ id, @@ -1462,8 +1467,13 @@ export default function App({ await services.removeProfile(id); await reloadProfiles(); }} + credentialStatus={services.hostCredentialStatus} + saveCredential={services.saveHostCredential} + forgetCredential={services.forgetHostCredential} close={() => setConnectOpen(false)} - submit={(options, name) => void connect(options, name)} + submit={(options, name, savedHostId) => + void connect(options, name, savedHostId) + } openAdapters={ adapterServices ? () => { @@ -1494,9 +1504,11 @@ export default function App({ setAdapterConnectOpen(false); openApp("apps"); }} - submit={(options, profile) => - establish(profile, (signal, review) => - adapterServices.connect(options, signal, review), + submit={(options, profile, saved) => + establish( + saved ? { ...profile, savedCredentials: saved } : profile, + (signal, review) => + adapterServices.connect(options, signal, review, saved), ) } /> diff --git a/src/adapters.ts b/src/adapters.ts index e9b58b0..1570197 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -44,6 +44,7 @@ export interface AdapterConnectionOptions { } export interface AdapterProfile extends AdapterConnectionOptions { kind: "adapters"; + savedCredentials?: { id: string; revision: string }; } export interface SavedWorkspaceProfile { id: string; @@ -57,6 +58,13 @@ export interface WorkspaceProfileStore { previous?: Pick, ): Promise; remove(id: string, revision: string): Promise; + credentialStatus(id: string, revision: string): Promise; + saveCredentials( + id: string, + revision: string, + options: AdapterConnectionOptions, + ): Promise; + forgetCredentials(id: string, revision: string): Promise; } /** Reopening uses only current public fields; a field reclassified as a password is never prefilled. */ export function restoredConfiguration( @@ -109,6 +117,7 @@ export interface AdapterServices { options: AdapterConnectionOptions, signal?: AbortSignal, reviewHostKey?: HostKeyReviewer, + saved?: { id: string; revision: string }, ): Promise; } export interface RepositoryAdapterSource { @@ -214,6 +223,12 @@ export const nativeAdapterServices: AdapterServices = { }), remove: (id, revision) => invoke("remove_workspace_profile", { id, revision }), + credentialStatus: (id, revision) => + invoke("workspace_credential_status", { id, revision }), + saveCredentials: (id, revision, options) => + invoke("save_workspace_credentials", { id, revision, options }), + forgetCredentials: (id, revision) => + invoke("forget_workspace_credentials", { id, revision }), }, async replaceSource(sessionId, expected, options, signal, reviewHostKey) { if (signal?.aborted) throw new Error("Connection canceled"); @@ -259,7 +274,7 @@ export const nativeAdapterServices: AdapterServices = { setEnabled: (id, revision, enabled) => invoke("set_adapter_enabled", { id, revision, enabled }), remove: (id, revision) => invoke("remove_adapter", { id, revision }), - async connect(options, signal, reviewHostKey) { + async connect(options, signal, reviewHostKey, saved) { if (signal?.aborted) throw new Error("Connection canceled"); const requestId = await invoke("begin_connect"); const cancel = () => { @@ -277,6 +292,8 @@ export const nativeAdapterServices: AdapterServices = { if (signal?.aborted) throw new Error("Connection canceled"); const result = await invoke("connect_adapters", { options, + savedProfileId: saved?.id, + savedProfileRevision: saved?.revision, requestId, onHostKey: review.channel, }); diff --git a/src/components/ConnectAdapterDialog.tsx b/src/components/ConnectAdapterDialog.tsx index 1804fa6..121db7d 100644 --- a/src/components/ConnectAdapterDialog.tsx +++ b/src/components/ConnectAdapterDialog.tsx @@ -54,6 +54,7 @@ export function ConnectAdapterDialog({ submit( options: AdapterConnectionOptions, profile: AdapterProfile, + saved?: { id: string; revision: string }, ): Promise; }) { const [installed, setInstalled] = useState([]), @@ -65,8 +66,32 @@ export function ConnectAdapterDialog({ [saved, setSaved] = useState(), [saving, setSaving] = useState(false), [profileMessage, setProfileMessage] = useState(""), - [confirmRemove, setConfirmRemove] = useState(false); + [confirmRemove, setConfirmRemove] = useState(false), + [credentialStored, setCredentialStored] = useState( + !!initial?.savedCredentials, + ), + [useStored, setUseStored] = useState(!!initial?.savedCredentials), + [rememberEntered, setRememberEntered] = useState(false); const dialog = useRef(null); + const selectionVersion = useRef(0); + useEffect(() => { + const credentials = initial?.savedCredentials; + if (!credentials || !services.profiles) return; + let active = true; + void services.profiles + .credentialStatus(credentials.id, credentials.revision) + .then((stored) => { + if (!active) return; + setCredentialStored(stored); + setUseStored(stored); + }) + .catch((error) => { + if (active) setFailure(String(error)); + }); + return () => { + active = false; + }; + }, [initial?.savedCredentials, services.profiles]); function defaults(item: AdapterInfo) { return Object.fromEntries( item.configuration @@ -78,6 +103,9 @@ export function ConnectAdapterDialog({ .map((field) => [field.id, field.default ?? false]), ) as Configuration; } + function defaultRoles(item: AdapterInfo) { + return item.id === "dev.shellcanvas.ftp" ? ["files"] : ["files", "console"]; + } useEffect(() => { let active = true; void Promise.all([ @@ -109,7 +137,7 @@ export function ConnectAdapterDialog({ configuration: defaults( items.find((item) => item.enabled)!, ), - roles: ["files", "console"], + roles: defaultRoles(items.find((item) => item.enabled)!), }, ] : [], @@ -130,18 +158,36 @@ export function ConnectAdapterDialog({ dialog.current?.querySelector("input,button")?.focus(); return () => previous?.focus(); }, []); - const change = (key: string, patch: Partial) => + const change = (key: string, patch: Partial) => { + setUseStored(false); setSources((sources) => sources.map((source) => source.key === key ? { ...source, ...patch } : source, ), ); + }; const locked = busy || loading || saving; function chooseSaved(id: string) { + const version = ++selectionVersion.current; const selected = savedProfiles.find((item) => item.id === id); setSaved(selected); setConfirmRemove(false); setFailure(""); + setCredentialStored(false); + setUseStored(false); + setRememberEntered(false); + if (selected && services.profiles) { + void services.profiles + .credentialStatus(selected.id, selected.revision) + .then((stored) => { + if (selectionVersion.current !== version) return; + setCredentialStored(stored); + setUseStored(stored); + }) + .catch((error) => { + if (selectionVersion.current === version) setFailure(String(error)); + }); + } setName(selected?.profile.name ?? ""); if (!selected) { const adapter = installed.find((item) => item.enabled); @@ -152,7 +198,7 @@ export function ConnectAdapterDialog({ key: crypto.randomUUID(), id: adapter.id, configuration: defaults(adapter), - roles: ["files", "console"], + roles: defaultRoles(adapter), }, ] : [], @@ -185,7 +231,7 @@ export function ConnectAdapterDialog({ setProfileMessage( changed ? "An adapter changed, is disabled, or is missing. Review each connection and its settings before opening this workspace." - : "Saved connections loaded. Enter any required passwords before connecting.", + : "Saved connections loaded. Stored credentials, if available, will be used from this PC.", ); } function connectionOptions(): AdapterConnectionOptions { @@ -227,13 +273,48 @@ export function ConnectAdapterDialog({ setProfileMessage(""); setConfirmRemove(false); try { - const result = await services.profiles.save(connectionOptions(), saved); + const options = connectionOptions(); + const result = await services.profiles.save(options, saved); setSaved(result); setSavedProfiles((profiles) => [ ...profiles.filter((item) => item.id !== result.id), result, ]); - setProfileMessage("Workspace profile saved. Passwords were not stored."); + setCredentialStored(false); + setUseStored(false); + if (rememberEntered) { + await services.profiles.saveCredentials( + result.id, + result.revision, + options, + ); + setCredentialStored(true); + setUseStored(true); + setRememberEntered(false); + setSources((current) => + current.map((source) => { + const secretFields = new Set( + installed + .find((item) => item.id === source.id) + ?.configuration.filter((field) => field.kind === "password") + .map((field) => field.id) ?? [], + ); + return { + ...source, + configuration: Object.fromEntries( + Object.entries(source.configuration).filter( + ([field]) => !secretFields.has(field), + ), + ), + }; + }), + ); + } + setProfileMessage( + rememberEntered + ? "Workspace and credentials saved on this PC." + : "Workspace saved without credentials. Enter passwords or save them in the system store.", + ); } catch (error) { setFailure(String(error)); } finally { @@ -254,6 +335,8 @@ export function ConnectAdapterDialog({ profiles.filter((item) => item.id !== saved.id), ); setSaved(undefined); + setCredentialStored(false); + setUseStored(false); setConfirmRemove(false); setProfileMessage( "Saved profile removed. The current connection form is still available.", @@ -264,6 +347,21 @@ export function ConnectAdapterDialog({ setSaving(false); } } + async function forgetSavedCredentials() { + if (!services.profiles || !saved || locked) return; + setSaving(true); + setFailure(""); + try { + await services.profiles.forgetCredentials(saved.id, saved.revision); + setCredentialStored(false); + setUseStored(false); + setProfileMessage("Saved credentials removed from the system store."); + } catch (error) { + setFailure(String(error)); + } finally { + setSaving(false); + } + } return (
    { try { const options = connectionOptions(); - await submit(options, adapterProfile(options, installed)); + const savedCredentials = useStored + ? saved + ? { id: saved.id, revision: saved.revision } + : initial?.savedCredentials + : undefined; + await submit( + options, + adapterProfile(options, installed), + savedCredentials, + ); } catch (error) { setFailure(String(error)); } @@ -393,6 +500,15 @@ export function ConnectAdapterDialog({ : "Remove saved profile"} )} + {saved && ( + + )} {confirmRemove && (
    + {saved && credentialStored && ( + + )} + {sources.some((source) => + installed + .find((item) => item.id === source.id) + ?.configuration.some((field) => field.kind === "password"), + ) && ( + + )} {profileMessage && (

    {profileMessage} @@ -410,6 +556,18 @@ export function ConnectAdapterDialog({ )}

    )} + {initial?.savedCredentials && credentialStored && ( + + )} {!replacing && ( @@ -570,7 +731,10 @@ export function ConnectAdapterDialog({ ? "number" : "text" } - required={field.required} + required={ + field.required && + !(field.kind === "password" && useStored) + } value={String(source.configuration[field.id] ?? "")} onChange={(event) => { const configuration = { ...source.configuration }; diff --git a/src/components/ConnectDialog.tsx b/src/components/ConnectDialog.tsx index 8f5af8b..e934be4 100644 --- a/src/components/ConnectDialog.tsx +++ b/src/components/ConnectDialog.tsx @@ -24,6 +24,9 @@ export function ConnectDialog({ preview, save, remove, + credentialStatus, + saveCredential, + forgetCredential, initialProfile, reconnecting = false, cancelConnect, @@ -45,10 +48,13 @@ export function ConnectDialog({ busy: boolean; error: string; close(): void; - submit(options: ConnectOptions, label: string): void; + submit(options: ConnectOptions, label: string, savedHostId?: string): void; preview: boolean; save(profile: HostProfile): Promise; remove(id: string): Promise; + credentialStatus?(id: string): Promise; + saveCredential?(id: string, options: ConnectOptions): Promise; + forgetCredential?(id: string): Promise; }) { const dialog = useRef(null); const [options, setOptions] = useState({ @@ -66,9 +72,14 @@ export function ConnectDialog({ const [saveMessage, setSaveMessage] = useState(""); const [saveError, setSaveError] = useState(""); const [confirmRemove, setConfirmRemove] = useState(false); + const [credentialStored, setCredentialStored] = useState(false); + const [useStored, setUseStored] = useState(false); + const [rememberEntered, setRememberEntered] = useState(false); + const selectionVersion = useRef(0); const locked = busy || saving; const [method, setMethod] = useState("key"); function select(profile: HostProfile) { + const version = ++selectionVersion.current; setOptions({ host: profile.host, port: profile.port, @@ -80,6 +91,20 @@ export function ConnectDialog({ }); setLabel(profile.name); setSavedId(profile.id); + setCredentialStored(false); + setUseStored(false); + setRememberEntered(false); + if (profile.id && credentialStatus) { + void credentialStatus(profile.id) + .then((stored) => { + if (selectionVersion.current !== version) return; + setCredentialStored(stored); + setUseStored(stored); + }) + .catch((error) => { + if (selectionVersion.current === version) setSaveError(String(error)); + }); + } setSaveMessage(""); setSaveError(""); setConfirmRemove(false); @@ -96,6 +121,7 @@ export function ConnectDialog({ } }, []); function newProfile() { + ++selectionVersion.current; setSelected(""); setSavedId(undefined); setLabel(""); @@ -111,6 +137,9 @@ export function ConnectDialog({ setSaveError(""); setSaveMessage(""); setConfirmRemove(false); + setCredentialStored(false); + setUseStored(false); + setRememberEntered(false); } async function saveHost() { setSaving(true); @@ -127,9 +156,38 @@ export function ConnectDialog({ ...(options.allowLegacyMac ? { allowLegacyMac: true } : {}), }); setSavedId(saved.id); + if (rememberEntered && saveCredential && saved.id) { + await saveCredential(saved.id, { + ...options, + host: saved.host, + port: saved.port, + username: saved.username, + keyPath: saved.keyPath, + allowLegacyMac: saved.allowLegacyMac, + password: method === "password" ? options.password : undefined, + passphrase: method === "key" ? options.passphrase : undefined, + }); + setCredentialStored(true); + setUseStored(true); + setRememberEntered(false); + } + setOptions((current) => ({ + ...current, + host: saved.host, + port: saved.port, + username: saved.username, + keyPath: saved.keyPath, + allowLegacyMac: saved.allowLegacyMac, + password: rememberEntered ? "" : current.password, + passphrase: rememberEntered ? "" : current.passphrase, + })); setSelected(saved.id!); setLabel(saved.name); - setSaveMessage("Host saved on this device."); + setSaveMessage( + rememberEntered + ? "Host and credential saved on this device." + : "Host saved on this device.", + ); } catch (error) { setSaveError(String(error)); } finally { @@ -152,6 +210,21 @@ export function ConnectDialog({ setSaving(false); } } + async function forgetSavedCredential() { + if (!savedId || !forgetCredential) return; + setSaving(true); + setSaveError(""); + try { + await forgetCredential(savedId); + setCredentialStored(false); + setUseStored(false); + setSaveMessage("Saved credential removed from the system store."); + } catch (error) { + setSaveError(String(error)); + } finally { + setSaving(false); + } + } useEffect(() => { const previous = document.activeElement as HTMLElement | null; const controls = () => @@ -192,6 +265,17 @@ export function ConnectDialog({ }, [busy, saving, close, cancelConnect]); function field(name: keyof ConnectOptions, value: string | number) { setSaveMessage(""); + if ( + [ + "host", + "port", + "username", + "keyPath", + "password", + "passphrase", + ].includes(name) + ) + setUseStored(false); setOptions((old) => ({ ...old, [name]: value })); } return ( @@ -262,6 +346,7 @@ export function ConnectDialog({ passphrase: method === "key" ? options.passphrase : undefined, }, label || options.host, + useStored ? savedId : undefined, ); }} > @@ -367,14 +452,20 @@ export function ConnectDialog({ @@ -406,7 +497,7 @@ export function ConnectDialog({ Password field("password", e.target.value)} @@ -418,12 +509,13 @@ export function ConnectDialog({ + onChange={(event) => { + setUseStored(false); setOptions((current) => ({ ...current, allowLegacyMac: event.target.checked, - })) - } + })); + }} /> Allow legacy SSH compatibility @@ -444,6 +536,34 @@ export function ConnectDialog({

    )} + {savedId && credentialStored && ( + + )} + {saveCredential && ( + + )}
    + )}
    {confirmRemove && (
    @@ -534,7 +662,8 @@ export function ConnectDialog({ Checked against OpenSSH and ShellCanvas trusted host keys.
    - Passwords and passphrases are never saved. + Passwords and passphrases are saved only when you choose to remember + them in this PC's system credential store.

    diff --git a/src/sdk.ts b/src/sdk.ts index c2f7c91..ef6f5af 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -374,10 +374,14 @@ export interface HostServices { profiles(): Promise; saveProfile(profile: HostProfile): Promise; removeProfile(id: string): Promise; + hostCredentialStatus?(id: string): Promise; + saveHostCredential?(id: string, options: ConnectOptions): Promise; + forgetHostCredential?(id: string): Promise; connect( options: ConnectOptions, signal?: AbortSignal, reviewHostKey?: HostKeyReviewer, + savedHostId?: string, ): Promise; disconnect(sessionId: number): Promise; alive(sessionId: number): Promise; diff --git a/src/services.ts b/src/services.ts index c5bc43a..a2f125c 100644 --- a/src/services.ts +++ b/src/services.ts @@ -189,7 +189,11 @@ function createNativeServices(pins?: SourcePins): HostServices { profiles: () => invoke("profiles"), saveProfile: (profile) => invoke("save_profile", { profile }), removeProfile: (id) => invoke("remove_profile", { id }), - connect: async (options, signal, reviewHostKey) => { + hostCredentialStatus: (id) => invoke("host_credential_status", { id }), + saveHostCredential: (id, options) => + invoke("save_host_credential", { id, options }), + forgetHostCredential: (id) => invoke("forget_host_credential", { id }), + connect: async (options, signal, reviewHostKey, savedHostId) => { if (signal?.aborted) throw new Error("Connection canceled"); const requestId = await invoke("begin_connect"); const cancel = () => { @@ -228,6 +232,7 @@ function createNativeServices(pins?: SourcePins): HostServices { } const result = await invoke("connect", { options, + savedHostId, requestId, onHostKey, }); diff --git a/src/workspaces.ts b/src/workspaces.ts index d8db1d0..8962f98 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -19,8 +19,10 @@ import { capabilityLabels, capabilityStatus, type Capability } from "./sdk"; export function connectionProfile( options: ConnectOptions, name: string, + id?: string, ): HostProfile { return { + ...(id ? { id } : {}), name, host: options.host, port: options.port, diff --git a/tests/fixtures/connection-ui-probe.tsx b/tests/fixtures/connection-ui-probe.tsx index b967e46..bd5e890 100644 --- a/tests/fixtures/connection-ui-probe.tsx +++ b/tests/fixtures/connection-ui-probe.tsx @@ -127,6 +127,9 @@ const adapters: AdapterServices = { list: async () => [{ id: "mixed", revision: "r1", profile }], save: unavailable, remove: unavailable, + credentialStatus: async () => false, + saveCredentials: unavailable, + forgetCredentials: unavailable, }, connect: async (options, _signal, review) => { const attempt = ++attempts; From 5c0384a64e09f0985c9ad623ec66b60eb7186086 Mon Sep 17 00:00:00 2001 From: techartdev Date: Sun, 27 Sep 2026 23:46:41 +0300 Subject: [PATCH 05/11] Simplify saved SSH login and fix clipboard conflicts, selection and terminal launch --- README.md | 2 +- crates/service-contracts/src/terminal.rs | 5 + crates/ssh-core/src/connection.rs | 15 +- crates/ssh-core/src/terminal.rs | 143 +++++++++++++- docs/workspace-profiles.md | 2 +- src-tauri/src/lib.rs | 51 +++-- src-tauri/src/workspace_services.rs | 27 ++- src-tauri/src/workspace_services_tests.rs | 34 ++++ src/app-services.test.ts | 107 +++++++++++ src/app-services.ts | 12 +- src/apps/Editor.css | 3 +- src/apps/Files.tsx | 17 ++ src/apps/Terminal.tsx | 54 ++++-- src/components/ConnectAdapterDialog.tsx | 68 ++++--- src/components/ConnectDialog.tsx | 215 ++++++++++++---------- src/components/ConnectionCheckbox.tsx | 23 +++ src/desktop.ts | 6 +- src/host-connect.test.ts | 175 ++++++++++++++++++ src/host-connect.ts | 71 +++++++ src/sdk.ts | 15 +- src/services.test.ts | 12 +- src/services.ts | 3 +- src/session-services.test.ts | 12 ++ src/session-services.ts | 3 +- src/styles.css | 116 +++++++++--- src/terminal-directory.test.ts | 60 ++++++ src/terminal-directory.ts | 31 ++++ 27 files changed, 1077 insertions(+), 205 deletions(-) create mode 100644 src/components/ConnectionCheckbox.tsx create mode 100644 src/host-connect.test.ts create mode 100644 src/host-connect.ts create mode 100644 src/terminal-directory.test.ts create mode 100644 src/terminal-directory.ts diff --git a/README.md b/README.md index 230e9bc..c8858c6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ SSH gives you a shell. ShellCanvas gives you the rest of a computer: a file mana - **A desktop, not a dashboard.** Move, resize, tile and minimize real windows between a top bar and a dock. Open several Files and Terminal windows per host. - **Nothing to install on the server.** ShellCanvas uses the SSH server your machine already runs, with SFTP for files. No agent, no daemon, only SSH. -- **Trust you can see.** A new host's key is shown for review before you sign in, and a changed key is refused. Passwords and passphrases stay out of profile files; you can explicitly remember them in this PC's system credential store. +- **Trust you can see.** A new host's key is shown for review before you sign in, and a changed key is refused. Saving an SSH host stores its password or passphrase in this PC's system credential store, separate from profile files, ready for the next connection. - **Room to grow.** Install apps straight from GitHub, switch themes, or build your own apps and connection adapters with the public SDKs. diff --git a/crates/service-contracts/src/terminal.rs b/crates/service-contracts/src/terminal.rs index ba1730d..9371c69 100644 --- a/crates/service-contracts/src/terminal.rs +++ b/crates/service-contracts/src/terminal.rs @@ -59,4 +59,9 @@ pub struct TerminalStream { pub trait TerminalService: Send + Sync { /// Open one independently owned console, bounded by the caller's deadline. async fn open(&self, size: TerminalSize) -> Result; + + /// Open in a directory from this connection's own filesystem namespace. + async fn open_directory(&self, _size: TerminalSize, _path: &str) -> Result { + anyhow::bail!("This console does not support opening in a directory") + } } diff --git a/crates/ssh-core/src/connection.rs b/crates/ssh-core/src/connection.rs index 62f8344..798960b 100644 --- a/crates/ssh-core/src/connection.rs +++ b/crates/ssh-core/src/connection.rs @@ -330,6 +330,15 @@ impl Connection { } pub async fn terminal(&self, cols: u32, rows: u32) -> Result> { + self.terminal_command(cols, rows, None).await + } + + pub(crate) async fn terminal_command( + &self, + cols: u32, + rows: u32, + command: Option<&str>, + ) -> Result> { timeout(OP_TIMEOUT, async { let mut channel = self.handle.channel_open_session().await?; channel @@ -344,7 +353,11 @@ impl Connection { ) .await?; wait_for_acceptance(&mut channel, "PTY allocation").await?; - channel.request_shell(true).await?; + if let Some(command) = command { + channel.exec(true, command).await?; + } else { + channel.request_shell(true).await?; + } wait_for_acceptance(&mut channel, "interactive shell").await?; Ok(channel) }) diff --git a/crates/ssh-core/src/terminal.rs b/crates/ssh-core/src/terminal.rs index d950cbd..2430e00 100644 --- a/crates/ssh-core/src/terminal.rs +++ b/crates/ssh-core/src/terminal.rs @@ -5,7 +5,75 @@ use crate::{ use anyhow::Result; use async_trait::async_trait; use russh::{client, ChannelMsg, ChannelReadHalf, ChannelWriteHalf}; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; + +/// Retains the detected platform of this SSH source, even in mixed workspaces. +pub struct SshTerminal { + pub connection: Arc, + pub provider: String, +} + +fn directory_command(provider: &str, path: &str) -> Result { + anyhow::ensure!( + !path.is_empty() && path.len() <= 32768 && !path.chars().any(char::is_control), + "Invalid terminal directory" + ); + match provider { + "linux" | "macos" => { + anyhow::ensure!(path.starts_with('/'), "Terminal directory must be absolute"); + let quote = |value: &str| format!("'{}'", value.replace('\'', "'\\''")); + let script = format!("cd {} && exec \"${{SHELL:-/bin/sh}}\" -i", quote(path)); + Ok(format!("sh -c {}", quote(&script))) + } + "windows" => { + use base64::{engine::general_purpose::STANDARD, Engine}; + // OpenSSH SFTP represents drive paths as /C:/directory. + let path = if path.starts_with('/') && path.as_bytes().get(2) == Some(&b':') { + &path[1..] + } else { + path + }; + anyhow::ensure!( + (path.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && path.as_bytes().get(1) == Some(&b':') + && matches!(path.as_bytes().get(2), Some(b'/' | b'\\'))) + || path.starts_with("\\\\"), + "Terminal directory must be an absolute Windows path" + ); + let script = format!( + "try {{ Set-Location -LiteralPath '{}' -ErrorAction Stop }} catch {{ Write-Error $_; exit 1 }}", + path.replace('\'', "''") + ); + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + Ok(format!( + "powershell.exe -NoLogo -NoProfile -NoExit -EncodedCommand {}", + STANDARD.encode(bytes) + )) + } + _ => anyhow::bail!("This host does not support opening a shell in a directory"), + } +} + +#[async_trait] +impl TerminalService for SshTerminal { + async fn open(&self, size: TerminalSize) -> Result { + self.connection.open(size).await + } + + async fn open_directory(&self, size: TerminalSize, path: &str) -> Result { + let command = directory_command(&self.provider, path)?; + let (reader, writer) = self + .connection + .terminal_command(size.cols, size.rows, Some(&command)) + .await? + .split(); + Ok(TerminalStream { + reader: Box::new(SshReader(reader)), + writer: Box::new(SshWriter(Some(writer))), + resizable: true, + }) + } +} struct SshReader(ChannelReadHalf); struct SshWriter(Option>); @@ -77,3 +145,76 @@ impl TerminalService for Connection { }) } } + +#[cfg(test)] +mod tests { + use super::directory_command; + use base64::{engine::general_purpose::STANDARD, Engine}; + + #[test] + fn windows_directory_is_literal_and_sftp_drive_prefix_is_removed() { + let command = directory_command("windows", "/C:/John's site/$data & files").unwrap(); + let bytes = STANDARD + .decode(command.split_whitespace().last().unwrap()) + .unwrap(); + let units: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + let script = String::from_utf16(&units).unwrap(); + assert!(command.starts_with("powershell.exe -NoLogo -NoProfile -NoExit -EncodedCommand ")); + assert_eq!(script, "try { Set-Location -LiteralPath 'C:/John''s site/$data & files' -ErrorAction Stop } catch { Write-Error $_; exit 1 }"); + } + + #[test] + fn rejects_unknown_shells_relative_paths_and_terminal_control_characters() { + for (provider, path) in [ + ("routeros", "/flash"), + ("unknown", "/tmp"), + ("linux", "relative"), + ("windows", "C:relative"), + ("linux", "/tmp\nwhoami"), + ("windows", "C:/tmp\rwhoami"), + ("linux", "/tmp\x1b[31m"), + ] { + assert!(directory_command(provider, path).is_err()); + } + } + + #[test] + fn posix_uses_a_quoted_script_and_keeps_shell_expansion_inside_it() { + assert_eq!( + directory_command("linux", "/srv/site").unwrap(), + "sh -c 'cd '\\''/srv/site'\\'' && exec \"${SHELL:-/bin/sh}\" -i'" + ); + assert_eq!( + directory_command("linux", "/a'b").unwrap(), + "sh -c 'cd '\\''/a'\\''\\'\\'''\\''b'\\'' && exec \"${SHELL:-/bin/sh}\" -i'" + ); + } + + #[cfg(unix)] + #[test] + fn posix_shell_reaches_literal_directory_without_evaluating_path_contents() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("site ' $(touch INJECTED) ; & [data]"); + std::fs::create_dir(&path).unwrap(); + let command = directory_command("linux", path.to_str().unwrap()).unwrap(); + use std::os::unix::fs::PermissionsExt; + let shell = temp.path().join("shell"); + std::fs::write(&shell, "#!/bin/sh\npwd\n").unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o700)).unwrap(); + let output = std::process::Command::new("sh") + .args(["-c", &command]) + .current_dir(temp.path()) + .env("SHELL", &shell) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + path.to_str().unwrap() + ); + assert!(!temp.path().join("INJECTED").exists()); + } +} diff --git a/docs/workspace-profiles.md b/docs/workspace-profiles.md index 7468cbf..e874aaa 100644 --- a/docs/workspace-profiles.md +++ b/docs/workspace-profiles.md @@ -14,7 +14,7 @@ The native application-data directory contains `workspaces.json` and `workspaces SSH uses the same host-key verification as the ordinary SSH connection dialog. Unknown keys require endpoint-specific fingerprint review; changed or revoked keys remain blocked. Each SSH source is reviewed separately. Passwords and key passphrases are omitted from profiles; private-key paths are public configuration. Remembered credentials are retrieved natively only for the exact saved profile revision and public connection settings. Replacing one source prepares its connection before committing the change, so a preparation failure preserves the existing workspace. Unrelated source handles remain usable after a successful replacement. -Ordinary saved SSH hosts can also remember their password or key passphrase through the system credential store. Use **Remember entered password** when saving the host. Reopening a saved host can use that credential without displaying it in the form, and **Forget saved credential** removes it. Changing the SSH endpoint or authentication method prevents reuse until a new credential is saved. A locked or unavailable OS store leaves manual entry available. Windows uses Credential Manager, macOS uses Keychain, and Linux requires a Secret Service provider such as GNOME Keyring or KWallet. +For ordinary SSH hosts, **Save and connect** saves the connection and its entered password or key passphrase in the system credential store. **Save host** / **Save changes** does the same without connecting. Selecting a saved host uses its credential automatically; the password field indicates this without exposing the secret. Entering a new password replaces it when saving. **Forget password** (or **Forget passphrase**) removes the saved secret. Changing the SSH endpoint or authentication method prevents reuse until a new credential is saved. **Advanced** contains legacy SSH compatibility and **Connect without saving changes**, which also allows manual entry when the OS store is unavailable. Windows uses Credential Manager, macOS uses Keychain, and Linux requires a Secret Service provider such as GNOME Keyring or KWallet. Profiles have UUID identities and revisions. Update and removal require the revision the caller reviewed; concurrent changes cause an error rather than replacing another window's changes. Close and reopen the connection dialog to reload current profiles after a conflict. Cross-process locking protects read/modify/write, and a synced temporary file is atomically published. Malformed files, future versions, duplicate identities and invalid bindings are refused without rewriting the original file. A 2 MiB bound applies to this configuration document, not file transfers or remote directory trees. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 633969f..512bc0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -449,7 +449,10 @@ async fn prepare_ssh( clock, ); let mut source = prepared_source::PreparedSource::new(resource, info)?; - source.terminal = Some(connection); + source.terminal = Some(Arc::new(shellcanvas_core::terminal::SshTerminal { + connection, + provider: source.info.provider.clone(), + })); source.files = files; source.text = text; source.mutations = mutations; @@ -819,21 +822,37 @@ async fn open_terminal( binding: Option, cols: u32, rows: u32, + terminal_directory: Option, on_event: Channel, state: State<'_, DesktopState>, ) -> Result { - let terminal = session_service( - &state, - session_id, - binding.as_ref(), - ServiceRole::Console, - |s| s.terminal.clone(), - ) - .await?; - let stream = tokio::time::timeout(OP_TIMEOUT, terminal.open(TerminalSize::new(cols, rows))) - .await - .map_err(error)? - .map_err(error)?; + let terminal = if let Some(directory) = &terminal_directory { + let guard = state.registry.lock().await; + let active = guard + .sessions + .get(&session_id) + .ok_or("This host session is no longer connected")?; + active.directory_terminal(&directory.source, binding.as_ref())? + } else { + session_service( + &state, + session_id, + binding.as_ref(), + ServiceRole::Console, + |s| s.terminal.clone(), + ) + .await? + }; + let stream = tokio::time::timeout(OP_TIMEOUT, async { + let size = TerminalSize::new(cols, rows); + match terminal_directory { + Some(directory) => terminal.open_directory(size, &directory.path).await, + None => terminal.open(size).await, + } + }) + .await + .map_err(error)? + .map_err(error)?; let (send, recv) = mpsc::channel(128); let resizable = stream.resizable; let gate = Arc::new(terminals::OutputGate::default()); @@ -866,6 +885,12 @@ async fn open_terminal( Ok(OpenedTerminal { id, resizable }) } +#[derive(serde::Deserialize)] +struct TerminalDirectory { + path: String, + source: ConnectionIdentity, +} + #[tauri::command] async fn acknowledge_terminal_output( session_id: u64, diff --git a/src-tauri/src/workspace_services.rs b/src-tauri/src/workspace_services.rs index 69311a1..f8ff587 100644 --- a/src-tauri/src/workspace_services.rs +++ b/src-tauri/src/workspace_services.rs @@ -153,6 +153,20 @@ macro_rules! bind_role { }; } impl WorkspaceServices { + /// Resolve both roles under the registry lock before starting a directory console. + pub fn directory_terminal( + &self, + files: &ConnectionIdentity, + console: Option<&ConnectionIdentity>, + ) -> Result, String> { + self.check_source(&ServiceRole::Console, console)?; + self.check_source(&ServiceRole::Console, Some(files))?; + self.check_source(&ServiceRole::Files, Some(files))?; + self.terminal + .clone() + .ok_or("Terminal is unavailable".into()) + } + /// Capture a particular accepted SSH source; never follow a replacement. pub fn ssh_source( &self, @@ -771,8 +785,19 @@ impl TerminalWriter for Writer { #[async_trait] impl TerminalService for Bound { async fn open(&self, size: TerminalSize) -> Result { + self.open_console(size, None).await + } + async fn open_directory(&self, size: TerminalSize, path: &str) -> Result { + self.open_console(size, Some(path)).await + } +} +impl Bound { + async fn open_console(&self, size: TerminalSize, path: Option<&str>) -> Result { self.binding.check()?; - let mut stream = self.service.open(size).await?; + let mut stream = match path { + Some(path) => self.service.open_directory(size, path).await?, + None => self.service.open(size).await?, + }; if let Err(error) = self.binding.after(false) { let _ = tokio::time::timeout(Duration::from_secs(3), stream.writer.close()).await; return Err(error); diff --git a/src-tauri/src/workspace_services_tests.rs b/src-tauri/src/workspace_services_tests.rs index 7eeb3f7..cec45ce 100644 --- a/src-tauri/src/workspace_services_tests.rs +++ b/src-tauri/src/workspace_services_tests.rs @@ -11,6 +11,40 @@ fn file_workspace(resource: &Arc) -> WorkspaceServices { workspace } +#[tokio::test] +async fn directory_terminals_reject_mixed_and_stale_sources() { + let (ssh, _) = source(191, "ssh"); + let (ftp, _) = source(192, "ftp"); + let mut workspace = WorkspaceServices::new(vec![ssh.clone(), ftp.clone()]).unwrap(); + workspace + .bind_files(&ftp, Arc::new(Files::default())) + .unwrap(); + workspace + .bind_terminal(&ssh, Arc::new(Console::default())) + .unwrap(); + assert!(workspace + .directory_terminal(ftp.identity(), Some(ssh.identity())) + .is_err()); + assert!(workspace + .directory_terminal(ssh.identity(), Some(ssh.identity())) + .is_err()); + + let mut same = file_workspace(&ssh); + same.bind_terminal(&ssh, Arc::new(Console::default())) + .unwrap(); + assert!(same + .directory_terminal(ssh.identity(), Some(ssh.identity())) + .is_ok()); + let mut stale = ssh.identity().clone(); + stale.generation += 1; + assert!(same + .directory_terminal(&stale, Some(ssh.identity())) + .is_err()); + assert!(same + .directory_terminal(ssh.identity(), Some(&stale)) + .is_err()); +} + #[tokio::test] async fn browsing_and_text_read_support_are_reported_independently_of_write_permission() { struct ReadOnlyText; diff --git a/src/app-services.test.ts b/src/app-services.test.ts index 62395db..3ba262e 100644 --- a/src/app-services.test.ts +++ b/src/app-services.test.ts @@ -6,11 +6,118 @@ import { type DesktopApp, type SessionServices, type Capability, + type TransferTicket, + type TransferConflictReview, + type HostServices, } from "./sdk"; import { bindSession } from "./session-services"; import { scopeAppServices } from "./app-services"; import { fileClipboard } from "./file-clipboard"; import { previewServices, previewSession } from "./preview"; +import { TransferQueue, type ConflictDecision } from "./transfer-queue"; + +it.each(["upload", "copy"] as const)( + "reviews mixed clipboard %s conflicts through app and session wrappers", + async (direction) => { + const ticket: TransferTicket = { + id: 71, + name: "3 clipboard items", + direction, + size: 100, + }; + const review: TransferConflictReview = { + canReplace: true, + conflicts: (["directory", "file", "file"] as const).map((kind, index) => ({ + sourceKind: kind, + destination: { + name: `item-${index}`, + path: `/site/item-${index}`, + kind, + revision: `rev-${index}`, + size: 10, + modified: null, + }, + })), + }; + const transferConflicts = vi.fn(async () => review); + const runTransfer = vi.fn(async () => ({ + status: "completed", + bytes: 100, + total: 100, + })); + const binding = bindSession( + { + ...previewServices, + pasteSystemFiles: async () => [ticket], + pasteCopiedFiles: async () => [ticket], + transferConflicts, + runTransfer, + }, + { + ...capableSession, + info: { + ...capableSession.info, + capabilities: [...capableSession.info.capabilities, "files.copy"], + }, + }, + ); + const app = scopeAppServices( + binding.services, + manifest("files", ["files.upload", "files.copy"]), + ); + const other = scopeAppServices( + binding.services, + manifest("other", ["files.upload", "files.copy"]), + ); + const tickets = + direction === "upload" + ? await app.pasteSystemFiles("/site") + : await app.pasteCopiedFiles!("/site", 1); + await expect(other.transferConflicts!(ticket)).rejects.toThrow( + "does not belong", + ); + expect(transferConflicts).not.toHaveBeenCalled(); + let decide!: (decision: ConflictDecision) => void; + const prompt = vi.fn( + () => + new Promise((resolve) => { + decide = resolve; + }), + ); + const queue = new TransferQueue(app, undefined, prompt); + try { + queue.enqueue(tickets!); + await vi.waitFor(() => expect(prompt).toHaveBeenCalledOnce()); + expect(transferConflicts).toHaveBeenCalledWith( + capableSession.id, + ticket.id, + ); + expect(prompt.mock.calls[0]).toEqual([ + review.conflicts[0], + 3, + expect.any(AbortSignal), + ]); + expect(runTransfer).not.toHaveBeenCalled(); + decide("replace-all"); + await vi.waitFor(() => + expect(queue.snapshot()[0].status).toBe("completed"), + ); + expect(runTransfer.mock.calls[0][4]).toEqual({ + replace: review.conflicts.map(({ destination }) => ({ + path: destination.path, + revision: destination.revision, + })), + skip: [], + }); + await expect(app.transferConflicts!(ticket)).rejects.toThrow( + "does not belong", + ); + } finally { + queue.dispose(); + binding.dispose(); + } + }, +); const manifest = ( id: string, diff --git a/src/app-services.ts b/src/app-services.ts index 5e0d813..498c296 100644 --- a/src/app-services.ts +++ b/src/app-services.ts @@ -196,13 +196,21 @@ export function scopeAppServices( return ticket ? adopt(ticket) : null; }, ), - runTransfer: async (ticket, onProgress) => { + transferConflicts: base.transferConflicts + ? async (ticket) => { + const owned = tickets.get(ticket.id); + if (!owned) throw new Error("Transfer does not belong to this app"); + check(transferCapability(owned.direction)); + return base.transferConflicts!({ ...owned }); + } + : undefined, + runTransfer: async (ticket, onProgress, policy) => { const owned = tickets.get(ticket.id); if (!owned) throw new Error("Transfer does not belong to this app"); check(transferCapability(owned.direction)); let keepForCleanup = false; try { - return await base.runTransfer({ ...owned }, onProgress); + return await base.runTransfer({ ...owned }, onProgress, policy); } catch (error) { keepForCleanup = error instanceof TransferCleanupError; throw error; diff --git a/src/apps/Editor.css b/src/apps/Editor.css index 4b2e173..7de7bd9 100644 --- a/src/apps/Editor.css +++ b/src/apps/Editor.css @@ -144,7 +144,8 @@ tab-size: 2; } .editor-buffer textarea::selection { - background: var(--sc-raised, #39576c); + background: var(--sc-accent, #7bb8ff); + color: var(--sc-onAccent, #102033); } .editor-footer { display: flex; diff --git a/src/apps/Files.tsx b/src/apps/Files.tsx index 7f8d541..0f81263 100644 --- a/src/apps/Files.tsx +++ b/src/apps/Files.tsx @@ -67,6 +67,7 @@ import { TransferConflictDialog } from "../components/TransferConflictDialog"; import { selectFiles } from "../file-selection"; import { DeleteFilesDialog } from "../components/DeleteFilesDialog"; import { fileSourceKey } from "../workspace-bindings"; +import { terminalDirectoryFor } from "../terminal-directory"; import { capabilityOperationReason } from "../sdk"; import { scanDirectory } from "../directory-scan"; import { useVirtualRows } from "../components/useVirtualRows"; @@ -1033,8 +1034,23 @@ export function Files({ setSelected(null); }, [selected, directory, query, preferences.filesShowHidden, loading]); function menuActions(entry?: FileEntry): MenuAction[] { + const terminalDirectory = terminalDirectoryFor( + session, + selectedEntries.length <= 1 && entry?.kind === "directory" + ? entry.path + : directory.path, + ); + const terminalAction: MenuAction = { + id: "open-terminal", + label: "Open terminal here", + disabled: !connected || loading || !openApp || !terminalDirectory, + run: () => { + if (terminalDirectory) openApp?.("terminal", { terminalDirectory }); + }, + }; if (selectedEntries.length > 1) return [ + terminalAction, { id: "copy-files", label: `Copy ${selectedEntries.length} ${selectedEntries.some((item) => item.kind === "directory") ? "items" : "files"}`, @@ -1103,6 +1119,7 @@ export function Files({ }, ]; return [ + terminalAction, { id: "upload", label: "Upload files…", diff --git a/src/apps/Terminal.tsx b/src/apps/Terminal.tsx index 43e9ceb..9a4f128 100644 --- a/src/apps/Terminal.tsx +++ b/src/apps/Terminal.tsx @@ -21,6 +21,7 @@ export function Terminal({ active = true, connected = true, unavailableReason, + launch, }: AppContext) { const container = useRef(null); const instance = useRef(null); @@ -35,7 +36,8 @@ export function Terminal({ background: colors.terminal, foreground: colors.terminalText, cursor: colors.accent, - selectionBackground: colors.selection, + selectionBackground: colors.accent, + selectionForeground: colors.onAccent, black: mode === "light" ? "#23332e" : "#182430", red: colors.danger, green: colors.success, @@ -199,22 +201,27 @@ export function Terminal({ }); }); void services - .terminal(terminal.cols, terminal.rows, (event) => { - if (disposed || !connectionState.current) return; - if (event.type === "output") - return new Promise((resolve) => - terminal.write(new Uint8Array(event.data), resolve), - ); - else if (event.type === "closed") { - closed = true; - setReady(false); - setStatus("Shell closed"); - } else { - closed = true; - setReady(false); - setStatus(event.data); - } - }) + .terminal( + terminal.cols, + terminal.rows, + (event) => { + if (disposed || !connectionState.current) return; + if (event.type === "output") + return new Promise((resolve) => + terminal.write(new Uint8Array(event.data), resolve), + ); + else if (event.type === "closed") { + closed = true; + setReady(false); + setStatus("Shell closed"); + } else { + closed = true; + setReady(false); + setStatus(event.data); + } + }, + launch?.terminalDirectory, + ) .then(async (handle) => { if (disposed || !connectionState.current) { await handle.close(); @@ -246,13 +253,22 @@ export function Terminal({ ?.close() .catch((error) => reportErrorRef.current(String(error))); }; - }, [session?.id, services, attempt]); + }, [session?.id, services, attempt, launch?.terminalDirectory]); return (
    {session?.info.hostname}{" "} - ~ + + {launch?.terminalDirectory?.path ?? "~"} + {session diff --git a/src/components/ConnectAdapterDialog.tsx b/src/components/ConnectAdapterDialog.tsx index 121db7d..030eff2 100644 --- a/src/components/ConnectAdapterDialog.tsx +++ b/src/components/ConnectAdapterDialog.tsx @@ -14,6 +14,7 @@ import { import "./ConnectAdapterDialog.css"; import { AdapterDiagnosticsPanel } from "./AdapterDiagnosticsPanel"; import { HostKeyReviewPanel } from "./HostKeyReviewPanel"; +import { ConnectionCheckbox } from "./ConnectionCheckbox"; import type { HostKeyChallenge } from "../sdk"; const standardRoles: Record = { files: "Files", @@ -315,6 +316,7 @@ export function ConnectAdapterDialog({ ? "Workspace and credentials saved on this PC." : "Workspace saved without credentials. Enter passwords or save them in the system store.", ); + return result; } catch (error) { setFailure(String(error)); } finally { @@ -440,11 +442,17 @@ export function ConnectAdapterDialog({ void (async () => { try { const options = connectionOptions(); - const savedCredentials = useStored - ? saved - ? { id: saved.id, revision: saved.revision } - : initial?.savedCredentials + const remembered = rememberEntered + ? await saveProfile() : undefined; + if (rememberEntered && !remembered) return; + const savedCredentials = remembered + ? { id: remembered.id, revision: remembered.revision } + : useStored + ? saved + ? { id: saved.id, revision: saved.revision } + : initial?.savedCredentials + : undefined; await submit( options, adapterProfile(options, installed), @@ -520,34 +528,24 @@ export function ConnectAdapterDialog({ )}
    {saved && credentialStored && ( - + + Use saved credentials from this PC + )} {sources.some((source) => installed .find((item) => item.id === source.id) ?.configuration.some((field) => field.kind === "password"), ) && ( - + + Remember passwords on this PC + )} {profileMessage && (

    @@ -557,16 +555,9 @@ export function ConnectAdapterDialog({

    )} {initial?.savedCredentials && credentialStored && ( - + + Use saved credentials from this PC + )} {!replacing && (