diff --git a/.github/workflows/linux-decorations.yml b/.github/workflows/linux-decorations.yml new file mode 100644 index 0000000..c231527 --- /dev/null +++ b/.github/workflows/linux-decorations.yml @@ -0,0 +1,61 @@ +name: Verify GNOME Wayland decorations + +on: + pull_request: + branches: [main] + paths: + - 'src-tauri/src/linux_decorations*' + - 'src-tauri/src/lib.rs' + - 'src-tauri/Cargo.toml' + - '.github/workflows/linux-decorations.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: linux-decorations-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-decorations: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + CARGO_INCREMENTAL: 0 + CARGO_BUILD_JOBS: 2 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev libsecret-1-dev weston dbus-x11 + - name: Build native regression tests + run: cargo test -p shellcanvas --lib linux_decorations --locked --no-run + - name: Exercise GTK's actual Wayland decorations + run: | + export XDG_RUNTIME_DIR="$(mktemp -d)" + chmod 700 "$XDG_RUNTIME_DIR" + export WAYLAND_DISPLAY=wayland-shellcanvas + export GDK_BACKEND=wayland + export XDG_CURRENT_DESKTOP=GNOME + export SC_DECORATION_SNAPSHOTS="$PWD/decoration-snapshots" + unset GTK_THEME + weston --backend=headless-backend.so --socket="$WAYLAND_DISPLAY" --idle-time=0 --width=1280 --height=720 > weston.log 2>&1 & + weston_pid=$! + trap 'kill "$weston_pid" || true' EXIT + for attempt in $(seq 1 50); do + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" && break + sleep 0.1 + done + test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" + dbus-run-session -- cargo test -p shellcanvas --lib linux_decorations --locked -- --include-ignored --test-threads=1 --nocapture + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: wayland-decoration-comparison + path: | + decoration-snapshots/ + weston.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d4529..aeccbf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ Notable changes to ShellCanvas, newest first. Published SDK packages follow [semantic versioning](https://semver.org/), and desktop releases use the same version where practical. The project is pre-1.0, so a minor release may include breaking changes; those come with migration notes. +## [0.1.15] - 2026-09-28 + +### Added + +- Save SSH host settings and passwords or key passphrases together with **Save and connect**, using the operating system's credential store. Saved hosts reconnect automatically, with a field-level action to forget the stored secret and advanced options for legacy SSH or connecting without saving. +- Store adapter workspace credentials separately from public profile files, with checks that bind them to the saved connection settings. +- Offer the hash-pinned FTP adapter from App Manager, including a setup path for combining FTP file browsing with an SSH terminal. +- Open a terminal at the current or selected folder from Files when the file and terminal services belong to the same supported SSH connection. + +### Improved + +- Compact the connection dialog and put the new-host action beside the host picker. +- Use icons in file context menus and present host capabilities as cards in a larger Host details window. +- Make text selections visible in form fields, the editor and terminal. + +### Fixed + +- Give stock Adwaita's native title bar on GNOME Wayland a flat header and round window buttons, with light/dark tracking. Keep GTK's native window controls and leave custom themes, high contrast and X11 decorations unchanged. +- Prompt before replacing existing files during clipboard uploads and copies, with per-item decisions and an apply-to-all option. Merge folders while preserving unrelated contents. +- Allow deleting non-empty folders after explicit confirmation. +- Forward conflict reviews and replacement decisions through app-scoped file services, fixing clipboard transfers that still failed when destinations existed. + +### Known limitations + +- The GNOME Wayland title-bar compatibility fix for issue #27 still needs visual confirmation on the reporter's Fedora 44 / GNOME 50 setup before the issue is closed. +- The FTP adapter currently supports browsing, text preview and downloads; uploads and file changes are not supported by that adapter. +- Database and Assistant package icons are separate companion-app updates and require their own reviewed package releases. + ## [0.1.14] - 2026-09-26 ### Added diff --git a/Cargo.lock b/Cargo.lock index 7ccd9ab..6cf5f94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4978,11 +4978,12 @@ dependencies = [ [[package]] name = "shellcanvas" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "gtk", "keyring", "libc", "minisign-verify", @@ -5030,7 +5031,7 @@ dependencies = [ [[package]] name = "shellcanvas-adapter-sdk" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "async-trait", @@ -5068,7 +5069,7 @@ dependencies = [ [[package]] name = "shellcanvas-filesystem-sdk" -version = "0.1.14" +version = "0.1.15" dependencies = [ "async-trait", "serde", diff --git a/README.md b/README.md index 1db4b70..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 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. 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/adapter-sdk/Cargo.toml b/crates/adapter-sdk/Cargo.toml index d126149..c771c93 100644 --- a/crates/adapter-sdk/Cargo.toml +++ b/crates/adapter-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas-adapter-sdk" -version = "0.1.14" +version = "0.1.15" edition = "2021" license = "MPL-2.0" description = "Versioned process protocol and concurrent server for ShellCanvas connection adapters" diff --git a/crates/filesystem-sdk/Cargo.toml b/crates/filesystem-sdk/Cargo.toml index 9fca364..2945ece 100644 --- a/crates/filesystem-sdk/Cargo.toml +++ b/crates/filesystem-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas-filesystem-sdk" -version = "0.1.14" +version = "0.1.15" edition = "2021" license = "MPL-2.0" description = "Optional filesystem handles and native bridge protocol for ShellCanvas" diff --git a/crates/service-contracts/src/copy.rs b/crates/service-contracts/src/copy.rs index 897b3ac..d788943 100644 --- a/crates/service-contracts/src/copy.rs +++ b/crates/service-contracts/src/copy.rs @@ -28,6 +28,8 @@ pub async fn copy_regular_file( .await } +// Keep the original helper's call shape while adding the destination revision guard. +#[allow(clippy::too_many_arguments)] pub async fn copy_regular_file_with_replace( service: Arc, path: &str, 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/adapter-packages.md b/docs/adapter-packages.md index ccd6998..17894ae 100644 --- a/docs/adapter-packages.md +++ b/docs/adapter-packages.md @@ -8,10 +8,14 @@ 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. +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/linux-window-decorations.md b/docs/linux-window-decorations.md new file mode 100644 index 0000000..33ce434 --- /dev/null +++ b/docs/linux-window-decorations.md @@ -0,0 +1,31 @@ +# GNOME Wayland window decorations + +Issue [#27](https://github.com/techartdev/ShellCanvas/issues/27) contains two separate problems. The pinned Tao revision restores normal GTK decoration handling on Wayland, including native button behavior. The remaining gray header and plain close button come from GTK 3's stock Adwaita styling. + +ShellCanvas uses GTK 3 through Tauri/Tao on Linux. On GNOME, Mutter's X11 frame helper creates a GTK 4 header and loads libadwaita. The same application therefore gets different decoration styling when launched with the Wayland and X11 backends. Changing ShellCanvas's Appearance setting affects its web content, not the native toolkit's title-bar style. + +Sources: [Mutter frame initialization](https://github.com/GNOME/mutter/blob/main/src/frames/main.c), [Mutter header creation](https://github.com/GNOME/mutter/blob/main/src/frames/meta-frame-header.c), [GTK 3 window CSS nodes](https://docs.gtk.org/gtk3/class.Window.html#css-nodes). + +## Compatibility styling + +`src-tauri/src/linux_decorations.rs` installs a small GTK CSS provider for the main window, only when the actual GDK display is Wayland and `XDG_CURRENT_DESKTOP` contains the `GNOME` token. It activates only for the stock `Adwaita` or `Adwaita-dark` theme, and respects an explicit `GTK_THEME` override by not activating. + +The stylesheet supplies a flat light/dark header and circular window buttons. It does not replace the title-bar widget, intercept input, choose a button layout, change application theme preferences, or force X11. GTK still handles dragging, resizing, maximizing and close requests. Windows and macOS do not compile this integration. + +GTK settings notifications update the dark variant and remove the styling if the user switches to a different theme, including HighContrast. The CSS applies only below the main window's own class and uses application priority, below user CSS. Settings handlers and the provider are removed when the window is destroyed. + +This is a GTK 3 visual compatibility layer, not a migration to GTK 4 or a promise of pixel-identical decorations across GNOME releases. + +## Validation + +The `Verify GNOME Wayland decorations` workflow compiles the desktop's Linux test target and runs a native GTK test under a headless Weston Wayland compositor. It checks the actual header colors before/after, dark-mode changes, high-contrast opt-out, native button-layout changes and delivery of the close request to an application close guard. It captures header images as a CI artifact. The scope test also covers X11, other desktops, custom themes and an explicit theme override. + +[The initial CI run](https://github.com/techartdev/ShellCanvas/actions/runs/36354108473) passed both tests, and its before/light/dark header images were inspected. The headless compositor captures unfocused headers. [The existing Linux and macOS native app-frame checks](https://github.com/techartdev/ShellCanvas/actions/runs/36354108344) also passed with this integration. + +Run the native regression inside a Wayland session with `XDG_CURRENT_DESKTOP=GNOME`, no `GTK_THEME` override and a writable `SC_DECORATION_SNAPSHOTS` directory: + +```sh +cargo test -p shellcanvas --lib linux_decorations --locked -- --include-ignored --test-threads=1 +``` + +Before closing #27, ask the reporter to compare on Fedora 44 / GNOME 50: Follow system with a light and dark system preference, focused/unfocused windows, maximize/restore, and close. Weston validation exercises the real GTK Wayland path but cannot certify the exact rendering of that Fedora/GNOME installation. diff --git a/docs/workspace-profiles.md b/docs/workspace-profiles.md index cd7ba41..e874aaa 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. + +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/package-lock.json b/package-lock.json index 97cca0a..c8efb69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shellcanvas", - "version": "0.1.14", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shellcanvas", - "version": "0.1.14", + "version": "0.1.15", "hasInstallScript": true, "license": "MPL-2.0", "workspaces": [ @@ -2556,7 +2556,7 @@ }, "packages/app-sdk": { "name": "@techartdev/shellcanvas-app-sdk", - "version": "0.1.14", + "version": "0.1.15", "license": "MPL-2.0", "dependencies": { "esbuild": "0.25.12" diff --git a/package.json b/package.json index 97f9ba9..85e65f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shellcanvas", - "version": "0.1.14", + "version": "0.1.15", "private": true, "type": "module", "license": "MPL-2.0", diff --git a/packages/app-sdk/package.json b/packages/app-sdk/package.json index a86f507..c4800ef 100644 --- a/packages/app-sdk/package.json +++ b/packages/app-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@techartdev/shellcanvas-app-sdk", - "version": "0.1.14", + "version": "0.1.15", "description": "Typed runtime app API and app packaging tools for ShellCanvas", "keywords": [ "shellcanvas", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e5870d2..071c37c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas" -version = "0.1.14" +version = "0.1.15" edition = "2021" license = "MPL-2.0" @@ -45,5 +45,8 @@ windows = { version = "0.61.3", features = ["Win32_NetworkManagement_WNet", "Win [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" + [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/adapters.rs b/src-tauri/src/adapters.rs index 1a3991b..0496456 100644 --- a/src-tauri/src/adapters.rs +++ b/src-tauri/src/adapters.rs @@ -69,8 +69,11 @@ impl AdapterConnectionOptions { } } #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri injects app/state/window beside the connection request. 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 +81,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 +99,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..0b73ee8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,8 @@ mod extension_frames; #[cfg(debug_assertions)] mod extension_probe; mod host_trust; +#[cfg(any(target_os = "linux", test))] +mod linux_decorations; mod local_mounts; mod native_ipc; mod prepared_source; @@ -40,6 +42,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 +113,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 +234,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 +243,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 = [ @@ -335,7 +451,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; @@ -705,21 +824,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()); @@ -752,6 +887,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, @@ -867,6 +1008,16 @@ pub fn run() { .plugin(extension_frames::plugin()) .manage(extension_frames::FrameDocuments::default()) .setup(|app| { + #[cfg(target_os = "linux")] + if let Some(window) = app.get_webview_window("main") { + use gtk::prelude::Cast; + // Cosmetic compatibility must never prevent the desktop from opening. + if let Ok(native) = window.gtk_window() { + if let Err(error) = linux_decorations::install(native.upcast_ref()) { + eprintln!("Could not style the native title bar: {error}"); + } + } + } #[cfg(desktop)] { app.handle() @@ -967,6 +1118,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 +1141,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/linux_decorations.css b/src-tauri/src/linux_decorations.css new file mode 100644 index 0000000..a1dc627 --- /dev/null +++ b/src-tauri/src/linux_decorations.css @@ -0,0 +1,52 @@ +/* SPDX-License-Identifier: MPL-2.0 + * GTK 3 CSS, scoped to ShellCanvas on GNOME Wayland with stock Adwaita. + * Keep native geometry and behavior, with the flat header and round controls + * used by GNOME's modern frames. User CSS has higher priority than this sheet. + */ +window.sc-gnome-frame headerbar.titlebar { + background-image: none; + background-color: #ffffff; + color: #2e3436; + border-bottom-color: rgba(0, 0, 0, 0.12); + box-shadow: none; + text-shadow: none; + -gtk-icon-shadow: none; +} +window.sc-gnome-frame headerbar.titlebar:backdrop { + background-color: #fafafa; + color: #929595; +} +window.sc-gnome-frame headerbar.titlebar button.titlebutton { + border-radius: 999px; + border-color: transparent; + background-image: none; + background-color: rgba(0, 0, 0, 0.06); + box-shadow: none; + text-shadow: none; + -gtk-icon-shadow: none; + color: inherit; +} +window.sc-gnome-frame headerbar.titlebar button.titlebutton:hover { + background-color: rgba(0, 0, 0, 0.12); +} +window.sc-gnome-frame headerbar.titlebar button.titlebutton:active { + background-color: rgba(0, 0, 0, 0.18); +} +window.sc-gnome-frame-dark headerbar.titlebar { + background-color: #303030; + color: #ffffff; + border-bottom-color: rgba(0, 0, 0, 0.36); +} +window.sc-gnome-frame-dark headerbar.titlebar:backdrop { + background-color: #242424; + color: #999999; +} +window.sc-gnome-frame-dark headerbar.titlebar button.titlebutton { + background-color: rgba(255, 255, 255, 0.1); +} +window.sc-gnome-frame-dark headerbar.titlebar button.titlebutton:hover { + background-color: rgba(255, 255, 255, 0.16); +} +window.sc-gnome-frame-dark headerbar.titlebar button.titlebutton:active { + background-color: rgba(255, 255, 255, 0.22); +} diff --git a/src-tauri/src/linux_decorations.rs b/src-tauri/src/linux_decorations.rs new file mode 100644 index 0000000..8881bc1 --- /dev/null +++ b/src-tauri/src/linux_decorations.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MPL-2.0 +//! GNOME uses libadwaita for its X11 frames, but our Wayland frame is GTK 3. +//! Restyle only stock Adwaita's native title bar; GTK still owns all controls, +//! button layout, dragging, close requests and window state transitions. + +fn use_gnome_frame(wayland: bool, desktop: &str, theme: &str, theme_override: bool) -> bool { + wayland + && desktop + .split(':') + .any(|part| part.eq_ignore_ascii_case("GNOME")) + && matches!(theme, "Adwaita" | "Adwaita-dark") + && !theme_override +} + +#[cfg(target_os = "linux")] +pub fn install(window: >k::Window) -> Result<(), gtk::glib::Error> { + use gtk::prelude::*; + + let wayland = window.display().type_().name() == "GdkWaylandDisplay"; + let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let theme_override = std::env::var_os("GTK_THEME").is_some_and(|value| !value.is_empty()); + if !wayland + || !desktop + .split(':') + .any(|part| part.eq_ignore_ascii_case("GNOME")) + { + return Ok(()); + } + let Some(settings) = window.settings() else { + return Ok(()); + }; + let Some(screen) = gtk::prelude::GtkWindowExt::screen(window) else { + return Ok(()); + }; + let provider = gtk::CssProvider::new(); + provider.load_from_data(include_bytes!("linux_decorations.css"))?; + gtk::StyleContext::add_provider_for_screen( + &screen, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let weak_window = window.downgrade(); + let update = move |settings: >k::Settings| { + let Some(window) = weak_window.upgrade() else { + return; + }; + let theme = settings.gtk_theme_name().unwrap_or_default(); + let context = window.style_context(); + for class in ["sc-gnome-frame", "sc-gnome-frame-dark"] { + context.remove_class(class); + } + if use_gnome_frame(wayland, &desktop, &theme, theme_override) { + context.add_class("sc-gnome-frame"); + if settings.is_gtk_application_prefer_dark_theme() || theme == "Adwaita-dark" { + context.add_class("sc-gnome-frame-dark"); + } + } + }; + update(&settings); + let update_theme = update.clone(); + let theme_handler = + settings.connect_gtk_theme_name_notify(move |settings| update_theme(settings)); + let dark_handler = settings.connect_gtk_application_prefer_dark_theme_notify(update); + // Disconnect Settings' callbacks and remove the screen provider with the window. + // The callbacks hold only a weak window reference. + let handlers = std::cell::RefCell::new(Some((theme_handler, dark_handler))); + window.connect_destroy(move |_| { + if let Some((theme_handler, dark_handler)) = handlers.take() { + settings.disconnect(theme_handler); + settings.disconnect(dark_handler); + gtk::StyleContext::remove_provider_for_screen(&screen, &provider); + } + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_preserves_other_backends_desktops_and_user_themes() { + assert!(use_gnome_frame(true, "GNOME", "Adwaita", false)); + assert!(use_gnome_frame(true, "ubuntu:GNOME", "Adwaita-dark", false)); + assert!(!use_gnome_frame(false, "GNOME", "Adwaita", false)); + assert!(!use_gnome_frame(true, "KDE", "Adwaita", false)); + assert!(!use_gnome_frame(true, "GNOME-Classic", "Adwaita", false)); + for theme in [ + "HighContrast", + "HighContrastInverse", + "adw-gtk3", + "Yaru", + "", + ] { + assert!(!use_gnome_frame(true, "GNOME", theme, false)); + } + assert!(!use_gnome_frame(true, "GNOME", "Adwaita", true)); + } +} + +#[cfg(all(test, target_os = "linux"))] +#[path = "linux_decorations_tests.rs"] +mod native_tests; diff --git a/src-tauri/src/linux_decorations_tests.rs b/src-tauri/src/linux_decorations_tests.rs new file mode 100644 index 0000000..943b382 --- /dev/null +++ b/src-tauri/src/linux_decorations_tests.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +use gtk::prelude::*; + +fn settle() { + let until = std::time::Instant::now() + std::time::Duration::from_millis(150); + while std::time::Instant::now() < until { + while gtk::events_pending() { + gtk::main_iteration_do(false); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +fn descendants(widget: >k::Widget) -> Vec { + let mut found = vec![widget.clone()]; + if let Some(container) = widget.downcast_ref::() { + container.forall(|child| found.extend(descendants(child))); + } + found +} + +fn background(widget: &impl IsA) -> gtk::gdk::RGBA { + let context = widget.style_context(); + context + .style_property_for_state("background-color", context.state()) + .get() + .unwrap() +} + +fn snapshot(header: >k::Widget, name: &str) { + let Ok(directory) = std::env::var("SC_DECORATION_SNAPSHOTS") else { + return; + }; + std::fs::create_dir_all(&directory).unwrap(); + let width = header.allocated_width(); + let height = header.allocated_height(); + assert!(width > 0 && height > 0); + let surface = + gtk::cairo::ImageSurface::create(gtk::cairo::Format::ARgb32, width, height).unwrap(); + let context = gtk::cairo::Context::new(&surface).unwrap(); + header.draw(&context); + gtk::gdk::pixbuf_get_from_surface(&surface, 0, 0, width, height) + .unwrap() + .savev( + std::path::Path::new(&directory).join(format!("{name}.png")), + "png", + &[], + ) + .unwrap(); +} + +#[test] +#[ignore = "requires a Wayland compositor, XDG_CURRENT_DESKTOP=GNOME and no GTK_THEME"] +fn native_frame_tracks_settings_and_keeps_native_controls() { + gtk::init().unwrap(); + let settings = gtk::Settings::default().unwrap(); + settings.set_gtk_theme_name(Some("Adwaita")); + settings.set_gtk_application_prefer_dark_theme(false); + settings.set_gtk_enable_animations(false); + settings.set_gtk_decoration_layout(Some(":close")); + let window = gtk::Window::new(gtk::WindowType::Toplevel); + window.set_title("ShellCanvas"); + window.set_default_size(800, 180); + window.add(>k::Label::new(Some( + "Native Wayland decoration regression", + ))); + window.show_all(); + settle(); + assert_eq!(window.display().type_().name(), "GdkWaylandDisplay"); + let header = descendants(window.upcast_ref()) + .into_iter() + .find(|widget| widget.is::()) + .expect("GTK must create its own header bar"); + let original_background = background(&header); + let original_titlebar = window.titlebar(); + snapshot(&header, "before-light"); + + super::install(&window).unwrap(); + settle(); + assert!(window.style_context().has_class("sc-gnome-frame")); + assert_eq!(window.titlebar(), original_titlebar); + assert!(background(&header).red() > 0.95); + assert!(background(&header).green() > 0.95); + assert_ne!(background(&header), original_background); + snapshot(&header, "after-light"); + + settings.set_gtk_application_prefer_dark_theme(true); + settle(); + assert!(window.style_context().has_class("sc-gnome-frame-dark")); + assert!(background(&header).red() < 0.25); + snapshot(&header, "after-dark"); + + settings.set_gtk_theme_name(Some("HighContrast")); + settle(); + assert!(!window.style_context().has_class("sc-gnome-frame")); + assert!(!window.style_context().has_class("sc-gnome-frame-dark")); + + settings.set_gtk_theme_name(Some("Adwaita")); + settings.set_gtk_application_prefer_dark_theme(false); + settings.set_gtk_decoration_layout(Some(":minimize,maximize,close")); + settle(); + for class in ["minimize", "maximize", "close"] { + assert!( + descendants(&header).iter().any(|widget| { + widget.is::() && widget.style_context().has_class(class) + }), + "native {class} button must respect GTK's configured layout" + ); + } + let close = descendants(&header) + .into_iter() + .find(|widget| widget.is::() && widget.style_context().has_class("close")) + .unwrap() + .downcast::() + .unwrap(); + let closed = std::rc::Rc::new(std::cell::Cell::new(false)); + let requested = closed.clone(); + window.connect_delete_event(move |_, _| { + requested.set(true); + gtk::glib::Propagation::Stop + }); + close.emit_clicked(); + settle(); + assert!( + closed.get(), + "native close must still reach the application's close guard" + ); + window.close(); +} diff --git a/src-tauri/src/native_ipc.rs b/src-tauri/src/native_ipc.rs index e6dca7e..555523f 100644 --- a/src-tauri/src/native_ipc.rs +++ b/src-tauri/src/native_ipc.rs @@ -23,12 +23,10 @@ pub fn initialization_script() -> String { // constructing the invocation-key closure. let trusted = if cfg!(dev) { "http://127.0.0.1:1420" + } else if cfg!(windows) { + "http://tauri.localhost" } else { - if cfg!(windows) { - "http://tauri.localhost" - } else { - "tauri://localhost" - } + "tauri://localhost" }; format!( "if (window === window.top && window.location.protocol + '//' + window.location.host === {trusted:?}) {{\n{transport}\n}}" 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/transfers.rs b/src-tauri/src/transfers.rs index f3525e2..0926597 100644 --- a/src-tauri/src/transfers.rs +++ b/src-tauri/src/transfers.rs @@ -332,8 +332,13 @@ enum Job { revision: String, }, } +// Each root contains its destination name and entry kind. +type DestinationRoot = (String, String); +type DestinationRoots = (String, Vec); +type ConflictCandidates = (Arc, String, Vec); + impl Job { - fn destination_roots(&self) -> Result)>> { + fn destination_roots(&self) -> Result> { match self { Job::Selection { catalog, parent } => { let mut roots = Vec::new(); @@ -397,7 +402,7 @@ impl TransferRegistry { &self, owner: u64, id: u64, - ) -> Result, String, Vec<(String, String)>)>, String> { + ) -> Result, String> { let pending = self .jobs .get(&id) 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-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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8cbc0f8..a29ced7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShellCanvas", - "version": "0.1.14", + "version": "0.1.15", "identifier": "dev.shellcanvas.client", "build": { "beforeDevCommand": "npm run dev", 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/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..ccebb9b 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,10 @@ import { Scissors, ClipboardPaste, Copy, + ListFilter, + Trash2, + TerminalSquare, + type LucideIcon, } from "lucide-react"; import type { AppContext, @@ -67,9 +73,57 @@ 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"; + +const fileMenuIcons: Record = { + "open-terminal": TerminalSquare, + 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` @@ -1033,8 +1087,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 [ + return withFileIcons([ + terminalAction, { id: "copy-files", label: `Copy ${selectedEntries.length} ${selectedEntries.some((item) => item.kind === "directory") ? "items" : "files"}`, @@ -1101,8 +1170,9 @@ export function Files({ if (menu) setMenu({ x: menu.x, y: menu.y, sort: true }); }, }, - ]; - return [ + ]); + return withFileIcons([ + terminalAction, { id: "upload", label: "Upload files…", @@ -1343,7 +1413,7 @@ export function Files({ run: () => setPreference("filesShowHidden", !preferences.filesShowHidden), }, - ]; + ]); } function sortBy(key: typeof preferences.filesSort) { if (key === preferences.filesSort) @@ -1351,7 +1421,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 +1455,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/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/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/ConnectAdapterDialog.tsx b/src/components/ConnectAdapterDialog.tsx index 1804fa6..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", @@ -54,6 +55,7 @@ export function ConnectAdapterDialog({ submit( options: AdapterConnectionOptions, profile: AdapterProfile, + saved?: { id: string; revision: string }, ): Promise; }) { const [installed, setInstalled] = useState([]), @@ -65,8 +67,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 +104,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 +138,7 @@ export function ConnectAdapterDialog({ configuration: defaults( items.find((item) => item.enabled)!, ), - roles: ["files", "console"], + roles: defaultRoles(items.find((item) => item.enabled)!), }, ] : [], @@ -130,18 +159,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 +199,7 @@ export function ConnectAdapterDialog({ key: crypto.randomUUID(), id: adapter.id, configuration: defaults(adapter), - roles: ["files", "console"], + roles: defaultRoles(adapter), }, ] : [], @@ -185,7 +232,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 +274,49 @@ 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.", + ); + return result; } catch (error) { setFailure(String(error)); } finally { @@ -254,6 +337,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 +349,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 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), + savedCredentials, + ); } catch (error) { setFailure(String(error)); } @@ -393,6 +508,15 @@ export function ConnectAdapterDialog({ : "Remove saved profile"} )} + {saved && ( + + )} {confirmRemove && (
    + {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 && (

    {profileMessage} @@ -410,6 +554,11 @@ export function ConnectAdapterDialog({ )}

    )} + {initial?.savedCredentials && credentialStored && ( + + Use saved credentials from this PC + + )} {!replacing && ( @@ -570,7 +722,15 @@ export function ConnectAdapterDialog({ ? "number" : "text" } - required={field.required} + required={ + field.required && + !(field.kind === "password" && useStored) + } + placeholder={ + field.kind === "password" && useStored + ? "Saved password - used automatically" + : undefined + } 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..80e0ca3 100644 --- a/src/components/ConnectDialog.tsx +++ b/src/components/ConnectDialog.tsx @@ -12,6 +12,8 @@ import { import type { ConnectOptions, HostProfile, HostKeyChallenge } from "../sdk"; import { HostProfilePicker } from "./HostProfilePicker"; import { HostKeyReviewPanel } from "./HostKeyReviewPanel"; +import { ConnectionCheckbox } from "./ConnectionCheckbox"; +import { matchesSavedHost, openHostWorkspace } from "../host-connect"; export function ConnectDialog({ profiles, profilesError, @@ -24,6 +26,9 @@ export function ConnectDialog({ preview, save, remove, + credentialStatus, + saveCredential, + forgetCredential, initialProfile, reconnecting = false, cancelConnect, @@ -45,10 +50,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 +74,23 @@ export function ConnectDialog({ const [saveMessage, setSaveMessage] = useState(""); const [saveError, setSaveError] = useState(""); const [confirmRemove, setConfirmRemove] = useState(false); - const locked = busy || saving; + const [credentialStored, setCredentialStored] = useState(false); + const [savedProfile, setSavedProfile] = useState(); + const [checkingCredential, setCheckingCredential] = useState(false); + const selectionVersion = useRef(0); + const locked = busy || saving || checkingCredential; const [method, setMethod] = useState("key"); + const matchingProfile = matchesSavedHost(options, method, savedProfile); + const enteredSecret = + method === "key" ? options.passphrase : options.password; + const useStored = credentialStored && matchingProfile && !enteredSecret; + const saveFirst = + !savedId || + !matchingProfile || + label !== savedProfile?.name || + !!enteredSecret; function select(profile: HostProfile) { + const version = ++selectionVersion.current; setOptions({ host: profile.host, port: profile.port, @@ -80,6 +102,23 @@ export function ConnectDialog({ }); setLabel(profile.name); setSavedId(profile.id); + setCredentialStored(false); + setSavedProfile(profile); + setCheckingCredential(!!profile.id && !!credentialStatus); + if (profile.id && credentialStatus) { + void credentialStatus(profile.id) + .then((stored) => { + if (selectionVersion.current !== version) return; + setCredentialStored(stored); + }) + .catch((error) => { + if (selectionVersion.current === version) setSaveError(String(error)); + }) + .finally(() => { + if (selectionVersion.current === version) + setCheckingCredential(false); + }); + } setSaveMessage(""); setSaveError(""); setConfirmRemove(false); @@ -96,6 +135,8 @@ export function ConnectDialog({ } }, []); function newProfile() { + ++selectionVersion.current; + setCheckingCredential(false); setSelected(""); setSavedId(undefined); setLabel(""); @@ -111,6 +152,8 @@ export function ConnectDialog({ setSaveError(""); setSaveMessage(""); setConfirmRemove(false); + setCredentialStored(false); + setSavedProfile(undefined); } async function saveHost() { setSaving(true); @@ -127,15 +170,62 @@ export function ConnectDialog({ ...(options.allowLegacyMac ? { allowLegacyMac: true } : {}), }); setSavedId(saved.id); + setSavedProfile(saved); + setCredentialStored(false); + let stored = false; + if (enteredSecret && 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, + }); + stored = true; + } else if ( + credentialStored && + matchingProfile && + saved.id && + credentialStatus + ) { + stored = await credentialStatus(saved.id); + } + setCredentialStored(stored); + setOptions((current) => ({ + ...current, + host: saved.host, + port: saved.port, + username: saved.username, + keyPath: saved.keyPath, + allowLegacyMac: saved.allowLegacyMac, + password: stored ? "" : current.password, + passphrase: stored ? "" : current.passphrase, + })); setSelected(saved.id!); setLabel(saved.name); - setSaveMessage("Host saved on this device."); + setSaveMessage( + stored + ? `Host saved. Your ${method === "key" ? "passphrase" : "password"} will be used automatically.` + : "Host saved on this device.", + ); + return { profile: saved, credentialStored: stored }; } catch (error) { setSaveError(String(error)); } finally { setSaving(false); } } + async function openWorkspace(saveChanges = saveFirst) { + if (locked) return; + await openHostWorkspace( + { options, label, method, saveFirst: saveChanges, useStored, savedId }, + saveHost, + submit, + ); + } async function removeHost() { if (!savedId) return; setSaving(true); @@ -152,14 +242,28 @@ export function ConnectDialog({ setSaving(false); } } + async function forgetSavedCredential() { + if (!savedId || !forgetCredential) return; + setSaving(true); + setSaveError(""); + try { + await forgetCredential(savedId); + setCredentialStored(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 = () => Array.from( dialog.current?.querySelectorAll( - "button:not(:disabled), input:not(:disabled), select:not(:disabled)", + "button:not(:disabled), input:not(:disabled), select:not(:disabled), summary", ) || [], - ); + ).filter((element) => element.getClientRects().length > 0); controls()[0]?.focus(); function trap(e: KeyboardEvent) { if (e.key !== "Tab") return; @@ -253,16 +357,7 @@ export function ConnectDialog({
    { e.preventDefault(); - submit( - { - ...options, - keyPath: method === "key" ? options.keyPath : "", - password: - method === "password" ? options.password : undefined, - passphrase: method === "key" ? options.passphrase : undefined, - }, - label || options.host, - ); + void openWorkspace(); }} > {openAdapters && !reconnecting && ( @@ -367,14 +462,18 @@ export function ConnectDialog({ @@ -396,6 +495,11 @@ export function ConnectDialog({ field("passphrase", e.target.value)} /> @@ -406,27 +510,56 @@ export function ConnectDialog({ Password field("password", e.target.value)} /> )} -
    -
    + +
    {savedId && ( - ))} + {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/desktop.ts b/src/desktop.ts index b4e2641..ac9fd60 100644 --- a/src/desktop.ts +++ b/src/desktop.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -import type { DesktopApp } from "./sdk"; +import type { AppLaunch, DesktopApp } from "./sdk"; export interface DesktopState { /** Back-to-front order. Minimized apps remain mounted and keep their state. */ @@ -12,7 +12,7 @@ export interface DesktopState { ordinal: number; /** Host-owned runtime lease; never supplied by an extension RPC request. */ extension?: string; - launch?: { path?: string; directory?: string }; + launch?: AppLaunch; dirty?: boolean; busy?: boolean; title?: string; @@ -25,7 +25,7 @@ export type DesktopAction = | { type: "new"; id: string; - launch?: { path?: string; directory?: string }; + launch?: AppLaunch; extension?: string; } | { type: "show-desktop" } 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", + }, + ], +}; diff --git a/src/host-connect.test.ts b/src/host-connect.test.ts new file mode 100644 index 0000000..e635bd5 --- /dev/null +++ b/src/host-connect.test.ts @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MPL-2.0 +import { expect, it, vi } from "vitest"; +import { + matchesSavedHost, + openHostWorkspace, + type SavedHostConnection, +} from "./host-connect"; + +const request = { + options: { + host: " host.example ", + port: 22, + username: "alice", + keyPath: "", + password: "entered-password", + }, + label: "Host", + method: "password", + saveFirst: true, + useStored: false, +}; +const profile = { + host: "host.example", + port: 22, + username: "alice", + keyPath: "", + name: "Saved host", + id: "saved-id", +}; + +it.each(["password", "key"])( + "saves an entered %s secret before connecting without passing plaintext", + async (method) => { + let finish!: (result: SavedHostConnection) => void; + const save = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const submit = vi.fn(); + const options = { + ...request.options, + keyPath: method === "key" ? "~/.ssh/key" : "", + passphrase: method === "key" ? "entered-passphrase" : undefined, + }; + const pending = openHostWorkspace( + { ...request, options, method }, + save, + submit, + ); + expect(save).toHaveBeenCalledOnce(); + expect(submit).not.toHaveBeenCalled(); + finish({ + profile: { ...profile, keyPath: options.keyPath }, + credentialStored: true, + }); + await pending; + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ + host: "host.example", + keyPath: options.keyPath, + password: undefined, + passphrase: undefined, + }), + "Saved host", + "saved-id", + ); + }, +); + +it("keeps the dialog open when saving fails", async () => { + const submit = vi.fn(); + await openHostWorkspace(request, async () => undefined, submit); + expect(submit).not.toHaveBeenCalled(); +}); + +it("allows a one-time connection without writing the profile or credential", async () => { + const save = vi.fn(); + const submit = vi.fn(); + await openHostWorkspace({ ...request, saveFirst: false }, save, submit); + expect(save).not.toHaveBeenCalled(); + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ password: "entered-password" }), + "Host", + undefined, + ); +}); + +it("connects an unchanged saved host without saving again", async () => { + const save = vi.fn(); + const submit = vi.fn(); + await openHostWorkspace( + { + ...request, + options: { ...request.options, password: "" }, + saveFirst: false, + useStored: true, + savedId: profile.id, + }, + save, + submit, + ); + expect(save).not.toHaveBeenCalled(); + expect(submit.mock.calls[0][2]).toBe(profile.id); + expect(submit.mock.calls[0][0].host).toBe(profile.host); +}); + +it("saves an unencrypted key connection without requesting a nonexistent credential", async () => { + const submit = vi.fn(); + const keyProfile = { ...profile, keyPath: "~/.ssh/key" }; + await openHostWorkspace( + { + ...request, + method: "key", + options: { + ...request.options, + keyPath: keyProfile.keyPath, + password: "", + passphrase: "", + }, + }, + async () => ({ profile: keyProfile, credentialStored: false }), + submit, + ); + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ + keyPath: keyProfile.keyPath, + password: undefined, + passphrase: "", + }), + "Saved host", + undefined, + ); +}); + +it("preserves saved credentials on a name-only update", async () => { + const submit = vi.fn(); + await openHostWorkspace( + { + ...request, + options: { ...request.options, password: "" }, + useStored: true, + savedId: profile.id, + }, + async () => ({ + profile: { ...profile, name: "Renamed" }, + credentialStored: true, + }), + submit, + ); + expect(submit.mock.calls[0].slice(1)).toEqual(["Renamed", profile.id]); +}); + +it("only offers a saved secret for matching endpoint and authentication settings", () => { + expect(matchesSavedHost(request.options, "password", profile)).toBe(true); + for (const patch of [ + { host: "other.example" }, + { port: 2222 }, + { username: "bob" }, + { allowLegacyMac: true }, + ]) { + expect( + matchesSavedHost({ ...request.options, ...patch }, "password", profile), + ).toBe(false); + } + expect( + matchesSavedHost( + { ...request.options, keyPath: "~/.ssh/key" }, + "key", + profile, + ), + ).toBe(false); + expect(matchesSavedHost(request.options, "password", undefined)).toBe(false); +}); diff --git a/src/host-connect.ts b/src/host-connect.ts new file mode 100644 index 0000000..f823437 --- /dev/null +++ b/src/host-connect.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MPL-2.0 +import type { ConnectOptions, HostProfile } from "./sdk"; + +export interface SavedHostConnection { + profile: HostProfile; + credentialStored: boolean; +} + +// A saved secret is only reusable for the connection it was saved with. +export function matchesSavedHost( + options: ConnectOptions, + method: string, + profile?: HostProfile, +) { + return ( + !!profile && + options.host.trim() === profile.host && + options.port === profile.port && + options.username.trim() === profile.username && + (method === "key" ? options.keyPath.trim() : "") === profile.keyPath && + !!options.allowLegacyMac === !!profile.allowLegacyMac + ); +} + +export async function openHostWorkspace( + request: { + options: ConnectOptions; + label: string; + method: string; + saveFirst: boolean; + useStored: boolean; + savedId?: string; + }, + save: () => Promise, + submit: (options: ConnectOptions, label: string, savedId?: string) => void, +) { + const { options, method } = request; + const result = request.saveFirst ? await save() : undefined; + // A failed credential save must leave the dialog open, not silently connect. + if (request.saveFirst && !result) return; + const saved = result?.profile; + const credentialId = result + ? result.credentialStored + ? saved?.id + : undefined + : request.useStored + ? request.savedId + : undefined; + submit( + { + ...options, + host: options.host.trim(), + username: options.username.trim(), + ...(saved + ? { + host: saved.host, + port: saved.port, + username: saved.username, + allowLegacyMac: saved.allowLegacyMac, + } + : {}), + keyPath: method === "key" ? (saved?.keyPath ?? options.keyPath.trim()) : "", + password: + method === "password" && !credentialId ? options.password : undefined, + passphrase: + method === "key" && !credentialId ? options.passphrase : undefined, + }, + saved?.name || request.label || options.host, + credentialId, + ); +} diff --git a/src/sdk.ts b/src/sdk.ts index c2f7c91..d99ca35 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -121,6 +121,15 @@ export interface ConnectionIdentity { generation: number; adapter: string; } +export interface TerminalDirectory { + path: string; + source: ConnectionIdentity; +} +export interface AppLaunch { + path?: string; + directory?: string; + terminalDirectory?: TerminalDirectory; +} export interface Session { sourceRevision?: number; id: number; @@ -374,10 +383,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; @@ -402,6 +415,7 @@ export interface HostServices { cols: number, rows: number, onEvent: (event: TerminalEvent) => void | Promise, + terminalDirectory?: TerminalDirectory, ): Promise; } /** Apps receive a fixed session handle, never connection administration. */ @@ -492,6 +506,7 @@ export interface SessionServices { cols: number, rows: number, onEvent: (event: TerminalEvent) => void | Promise, + terminalDirectory?: TerminalDirectory, ): Promise; } export interface AppContext { @@ -513,8 +528,8 @@ export interface AppContext { busy: boolean; title?: string; }): void; - launch?: { path?: string; directory?: string }; - openApp?(appId: string, launch?: { path?: string; directory?: string }): void; + launch?: AppLaunch; + openApp?(appId: string, launch?: AppLaunch): void; /** * Put a folder shortcut on this workspace's desktop. The path is the * provider's own token and is stored exactly as given. diff --git a/src/services.test.ts b/src/services.test.ts index c13cb09..b03f334 100644 --- a/src/services.test.ts +++ b/src/services.test.ts @@ -109,8 +109,18 @@ it("captures each source once and attaches it to all native service requests", a expect(invoke.mock.lastCall?.[1].binding).toEqual(settings); await bound.applyHostSetting(700, "timezone", "UTC", "rev"); expect(invoke.mock.lastCall?.[1].binding).toEqual(settings); - const terminal = await bound.terminal(700, 80, 24, () => {}); + const terminalDirectory = { path: "/srv/John's site", source: consoleSource }; + const terminal = await bound.terminal( + 700, + 80, + 24, + () => {}, + terminalDirectory, + ); expect(invoke.mock.lastCall?.[1].binding).toEqual(consoleSource); + expect(invoke.mock.lastCall?.[1].terminalDirectory).toEqual( + terminalDirectory, + ); // Existing stream/ticket handles remain usable for cleanup after retirement. await terminal.close(); expect(invoke.mock.lastCall?.[1]).not.toHaveProperty("binding"); diff --git a/src/services.ts b/src/services.ts index c5bc43a..cbddede 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, }); @@ -255,7 +260,7 @@ function createNativeServices(pins?: SourcePins): HostServices { readText: (sessionId, path) => invoke("read_text", { sessionId, path }), saveText: (sessionId, path, text, revision, allowNonAtomic) => invoke("save_text", { sessionId, path, text, revision, allowNonAtomic }), - terminal: async (sessionId, cols, rows, onEvent) => { + terminal: async (sessionId, cols, rows, onEvent, terminalDirectory) => { const channel = new Channel< TerminalEvent & { sequence?: number | null } >(); @@ -298,6 +303,7 @@ function createNativeServices(pins?: SourcePins): HostServices { cols, rows, onEvent: channel, + terminalDirectory, }); const { id: terminalId, resizable } = await opening; // Preserve input ordering across IPC calls, including large pasted text. diff --git a/src/session-services.test.ts b/src/session-services.test.ts index e9a687d..6312e1a 100644 --- a/src/session-services.test.ts +++ b/src/session-services.test.ts @@ -4,6 +4,18 @@ import { bindSession } from "./session-services"; import { previewServices, previewSession } from "./preview"; import type { Directory, TerminalSession, FileRelocation } from "./sdk"; import { watchFileChanges, watchFileLocations } from "./file-events"; +it("forwards the captured terminal directory through the session handle", async () => { + const terminal = vi.fn(previewServices.terminal); + const binding = bindSession({ ...previewServices, terminal }, previewSession); + const directory = { + path: "/srv/site", + source: { instance: 1, generation: 1, adapter: "ssh" }, + }; + const handle = await binding.services.terminal(80, 24, () => {}, directory); + expect(terminal.mock.calls[0][4]).toEqual(directory); + await handle.close(); + binding.dispose(); +}); it("rejects late drive discovery and mount actions after the file source changes", async () => { let finish!: (value: import("./sdk").FileVolumes) => void; const volumes = vi.fn( diff --git a/src/session-services.ts b/src/session-services.ts index 966a8a5..732a352 100644 --- a/src/session-services.ts +++ b/src/session-services.ts @@ -655,7 +655,7 @@ export function bindSession( check("files.read", expected); return result; }, - terminal: async (cols, rows, onEvent) => { + terminal: async (cols, rows, onEvent, terminalDirectory) => { const expected = generation; const handle = await backend.terminal( check("terminal"), @@ -664,6 +664,7 @@ export function bindSession( (event) => { if (valid("terminal", expected)) return onEvent(event); }, + terminalDirectory, ); if (!valid("terminal", expected)) { await handle.close(); diff --git a/src/styles.css b/src/styles.css index ac662ab..eb16853 100644 --- a/src/styles.css +++ b/src/styles.css @@ -65,7 +65,8 @@ input { min-width: 0; } ::selection { - background: var(--sc-hover, #779da566); + background: var(--sc-accent, #7bb8ff); + color: var(--sc-onAccent, #102033); } svg { vertical-align: middle; @@ -269,7 +270,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,20 +323,55 @@ 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; font-size: 0.846154rem; } -.ssh-compatibility label { +.ssh-compatibility summary { + cursor: pointer; + color: var(--sc-muted); + padding: 0.384615rem 0; +} +.ssh-compatibility[open] summary { + margin-bottom: 0.615385rem; +} +.saved-password-status { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.769231rem; + margin: -0.461538rem 0 1rem; + font-size: 0.769231rem; + color: var(--sc-muted); +} +.saved-password-status button { + flex-shrink: 0; + border: 0; + padding: 0; + background: transparent; + color: var(--sc-accent); + font: inherit; + cursor: pointer; +} +.connection-checkbox { display: flex; align-items: center; gap: 0.615385rem; cursor: pointer; + font-size: 0.846154rem; + font-weight: 400; + color: var(--sc-text, #b4c9d1); + margin-bottom: 1.076923rem; +} +.connection-checkbox > span { + font: inherit; + color: inherit; } -.ssh-compatibility input { +.connection-checkbox input[type="checkbox"] { width: auto; + flex-shrink: 0; accent-color: var(--sc-accent); } .ssh-compatibility p { @@ -368,7 +404,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 +586,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 +597,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 +610,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 +643,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 +682,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 +784,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 +1142,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 { @@ -1168,6 +1227,7 @@ button:active:not(:disabled) { color: var(--sc-muted, #8ca3b1); } .terminal-tabs > span:first-child { + min-width: 0; color: var(--sc-text, #b8cbd4); display: flex; align-items: center; @@ -1179,6 +1239,9 @@ button:active:not(:disabled) { .terminal-tab-path { color: var(--sc-muted, #7d97a6); margin-left: 0.615385rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .terminal-tabs > span:last-child { font-size: 0.615385rem; @@ -1253,7 +1316,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 +1359,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 +1373,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 +1386,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 +1490,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 +1674,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 +1690,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 +1707,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 +1726,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 +1771,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; @@ -1694,7 +1786,7 @@ button:active:not(:disabled) { color: var(--sc-text, #b4c9d1); margin-bottom: 1.076923rem; } -.form-field input, +.form-field input:not([type="checkbox"]), .form-field select { display: block; width: 100%; @@ -1756,7 +1848,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%; diff --git a/src/terminal-directory.test.ts b/src/terminal-directory.test.ts new file mode 100644 index 0000000..b395716 --- /dev/null +++ b/src/terminal-directory.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 +import { expect, it } from "vitest"; +import { previewSession } from "./preview"; +import type { Session } from "./sdk"; +import { terminalDirectoryFor } from "./terminal-directory"; + +const source = { instance: 12, generation: 1, adapter: "ssh" }; +const session: Session = { + ...previewSession, + info: { ...previewSession.info, provider: "linux" }, + services: [ + { capability: "files.read", state: "available", source }, + { capability: "terminal", state: "available", source }, + ], +}; +it("retains the literal remote path and captures its source", () => { + const result = terminalDirectoryFor(session, "/srv/John's site"); + expect(result).toEqual({ path: "/srv/John's site", source }); + expect(result?.source).not.toBe(source); +}); +it("does not send paths between FTP and SSH or replaced connections", () => { + for (const replacement of [ + { ...source, adapter: "ftp" }, + { ...source, instance: 13 }, + { ...source, generation: 2 }, + ]) { + expect( + terminalDirectoryFor( + { + ...session, + services: [ + session.services![0], + { ...session.services![1], source: replacement }, + ], + }, + "/srv", + ), + ).toBeUndefined(); + } +}); +it("disables directory startup for unavailable consoles and RouterOS", () => { + expect( + terminalDirectoryFor( + { + ...session, + services: [ + session.services![0], + { ...session.services![1], state: "disconnected" }, + ], + }, + "/srv", + ), + ).toBeUndefined(); + expect( + terminalDirectoryFor( + { ...session, info: { ...session.info, provider: "routeros" } }, + "/srv", + ), + ).toBeUndefined(); +}); diff --git a/src/terminal-directory.ts b/src/terminal-directory.ts new file mode 100644 index 0000000..ff1682a --- /dev/null +++ b/src/terminal-directory.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +import { capabilityStatus, type Session, type TerminalDirectory } from "./sdk"; + +/** A file path is meaningful only in the console's own source namespace. */ +export function terminalDirectoryFor( + session: Session | null, + path: string, +): TerminalDirectory | undefined { + if ( + !session || + !path || + !["linux", "macos", "windows"].includes(session.info.provider) + ) + return; + const files = capabilityStatus(session, "files.read"); + const terminal = capabilityStatus(session, "terminal"); + const source = files.source; + const consoleSource = terminal.source; + if ( + files.state !== "available" || + terminal.state !== "available" || + !source || + !consoleSource || + source.adapter !== "ssh" || + source.adapter !== consoleSource.adapter || + source.instance !== consoleSource.instance || + source.generation !== consoleSource.generation + ) + return; + return { path, source: { ...source } }; +} 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;