Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/linux-decorations.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<picture>
Expand Down
2 changes: 1 addition & 1 deletion crates/adapter-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/filesystem-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions crates/service-contracts/src/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn FileTransferService>,
path: &str,
Expand Down
5 changes: 5 additions & 0 deletions crates/service-contracts/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TerminalStream>;

/// Open in a directory from this connection's own filesystem namespace.
async fn open_directory(&self, _size: TerminalSize, _path: &str) -> Result<TerminalStream> {
anyhow::bail!("This console does not support opening in a directory")
}
}
15 changes: 14 additions & 1 deletion crates/ssh-core/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,15 @@ impl Connection {
}

pub async fn terminal(&self, cols: u32, rows: u32) -> Result<Channel<client::Msg>> {
self.terminal_command(cols, rows, None).await
}

pub(crate) async fn terminal_command(
&self,
cols: u32,
rows: u32,
command: Option<&str>,
) -> Result<Channel<client::Msg>> {
timeout(OP_TIMEOUT, async {
let mut channel = self.handle.channel_open_session().await?;
channel
Expand All @@ -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)
})
Expand Down
143 changes: 142 additions & 1 deletion crates/ssh-core/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection>,
pub provider: String,
}

fn directory_command(provider: &str, path: &str) -> Result<String> {
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<u8> = 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<TerminalStream> {
self.connection.open(size).await
}

async fn open_directory(&self, size: TerminalSize, path: &str) -> Result<TerminalStream> {
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<ChannelWriteHalf<client::Msg>>);
Expand Down Expand Up @@ -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<u16> = 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());
}
}
4 changes: 4 additions & 0 deletions docs/adapter-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/connection-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading