4 Commits
Author SHA1 Message Date
Claude 7daf668a11 Sanitize CI tag/ref before using it in file paths
github.ref_name for a workflow_dispatch run on a branch is the branch
name verbatim (e.g. claude/optimistic-davinci-2i411w), and PowerShell
treated the "/" as a path separator in Move-Item's destination,
failing with "Could not find a part of the path." Tag pushes (v0.2.0)
don't normally hit this, but sanitize unconditionally so manual runs
on any branch work too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYCT2RpUjFfBfp6CD4rREw
2026-09-08 17:22:40 +00:00
Claude 905487ce82 Replace .NET WinForms handler with the Rust build in CI; drop SshHandler
Remove the .NET/WinForms project (SshHandler/, SshHandler.sln) now that
the Rust rewrite (SshHandlerRs/) covers the same behavior with a
~250KB binary and no CLR startup cost, and its Default-Apps-style
registration is confirmed working.

Rewire the release workflow to build ssh-handler.exe natively on
windows-latest with the MSVC toolchain instead of dotnet publish, and
package it alongside the .bat and .sh handlers as release assets
(ssh-handler-rs-*.exe, openssh_protocol_handler-*.bat,
ssh-handler-*.sh) when a v* tag is pushed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYCT2RpUjFfBfp6CD4rREw
2026-09-08 17:16:38 +00:00
Claude 1bd676d2fe Rebase Rust handler's protocol registration on the .bat's Default Apps model
The direct HKCR\ssh\shell\open\command write (what SshHandler and the
initial Rust port both did) doesn't actually get honored once a
UserChoice already exists for the ssh scheme, which is the normal
state on Windows 10/11 — so registration silently no-ops from the
user's perspective.

Switch to the same "Default Programs" capability registration
openssh_protocol_handler.bat uses: a dedicated ProgID with
Capabilities\UrlAssociations plus a HKLM\SOFTWARE\RegisteredApplications
entry, so the app actually shows up in and can be selected from
Settings > Apps > Default Apps > "Choose default apps by link type".
Uses its own identifier, ssh_handler_rs, distinct from the batch
script's ssh_custom_handler so the two don't collide.

Verified end-to-end under Wine: registry keys land exactly as
expected (checked via `wine reg query`), and both the setup and
connect dialogs still render correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYCT2RpUjFfBfp6CD4rREw
2026-09-08 16:48:31 +00:00
Claude a671325a34 Add Rust prototype of the ssh:// protocol handler
Raw Win32 rewrite (windows-rs + a native dialog resource, no GUI
framework) of SshHandler, aimed at eliminating .NET's ~150MB
self-contained payload and CLR startup cost for what's just a
click-triggered dialog + process launch. Release build cross-compiles
to a ~250KB binary. Same behavior as the WinForms version: registry
setup mode, target parsing/editing dialog, legacy-algorithm option,
launches ssh.exe via ShellExecuteW.

Verified functionally under Wine (registry-setup prompt and the
connect dialog both render and round-trip correctly); real Windows
cold-start numbers still need to be gathered on actual hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYCT2RpUjFfBfp6CD4rREw
2026-09-08 16:20:44 +00:00
17 changed files with 888 additions and 415 deletions
+11 -35
View File
@@ -16,38 +16,14 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Configure Git Line Endings - name: Setup Rust
run: git config --global core.autocrlf input uses: dtolnay/rust-toolchain@stable
- name: Setup .NET
uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x targets: x86_64-pc-windows-msvc
- name: Restore dependencies - name: Build ssh-handler (Rust)
run: dotnet restore working-directory: SshHandlerRs
run: cargo build --release --target x86_64-pc-windows-msvc
# 1. Build Framework-Dependent FIRST (The tiny one)
- name: Build Framework-Dependent (Requires .NET 8)
run: >
dotnet publish SshHandler/SshHandler.csproj --configuration Release
--self-contained false
/p:PublishSingleFile=true
-o ./publish-dotnet8
# 2. Clean to prevent artifact leakage
- name: Clean intermediate files
run: dotnet clean SshHandler/SshHandler.csproj
# 3. Build Standalone SECOND (The heavy one)
- name: Build Standalone (Includes Runtime)
run: >
dotnet publish SshHandler/SshHandler.csproj --configuration Release
--runtime win-x64
--self-contained true
/p:PublishSingleFile=true
/p:IncludeNativeLibrariesForSelfExtract=true
-o ./publish-standalone
- name: Ensure Unix Line Endings for Shell Script - name: Ensure Unix Line Endings for Shell Script
shell: pwsh shell: pwsh
@@ -64,10 +40,11 @@ jobs:
run: | run: |
$tag = "${{ github.ref_name }}" $tag = "${{ github.ref_name }}"
if (-not $tag -or $tag -eq "main") { $tag = "manual" } if (-not $tag -or $tag -eq "main") { $tag = "manual" }
# Branch names (workflow_dispatch) can contain "/", which breaks file paths.
$tag = $tag -replace '[\\/]', '-'
# Rename Executables # Rename Rust executable
Move-Item ./publish-standalone/SshHandler.exe "./SshHandler-$tag-Standalone.exe" Move-Item SshHandlerRs/target/x86_64-pc-windows-msvc/release/ssh-handler.exe "./ssh-handler-rs-$tag.exe"
Move-Item ./publish-dotnet8/SshHandler.exe "./SshHandler-$tag-dotnet8.exe"
# Rename Scripts # Rename Scripts
Copy-Item "ssh-handler.sh" "./ssh-handler-$tag.sh" Copy-Item "ssh-handler.sh" "./ssh-handler-$tag.sh"
@@ -78,8 +55,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
files: | files: |
SshHandler-*-Standalone.exe ssh-handler-rs-*.exe
SshHandler-*-dotnet8.exe
ssh-handler-*.sh ssh-handler-*.sh
openssh_protocol_handler-*.bat openssh_protocol_handler-*.bat
generate_release_notes: true generate_release_notes: true
+2 -19
View File
@@ -1,21 +1,4 @@
# .gitignore # .gitignore
# User-specific files # Rust
*.rsuser /SshHandlerRs/target/
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
-21
View File
@@ -1,21 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{50924D75-2903-46D4-86DD-02269EFDBCA9}") = "SshHandler", "SshHandler\SshHandler.csproj", "{960BAB18-309B-42D0-B67E-B4EF8E7F7490}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{960BAB18-309B-42D0-B67E-B4EF8E7F7490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{960BAB18-309B-42D0-B67E-B4EF8E7F7490}.Debug|Any CPU.Build.0 = Debug|Any CPU
{960BAB18-309B-42D0-B67E-B4EF8E7F7490}.Release|Any CPU.ActiveCfg = Release|Any CPU
{960BAB18-309B-42D0-B67E-B4EF8E7F7490}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
-113
View File
@@ -1,113 +0,0 @@
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.label1 = new System.Windows.Forms.Label();
this.targetTextBox = new System.Windows.Forms.TextBox();
this.legacyCheckBox = new System.Windows.Forms.CheckBox();
this.connectButton = new System.Windows.Forms.Button();
this.cancelButton = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(12, 15);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(73, 15);
this.label1.TabIndex = 0;
this.label1.Text = "Connect to:";
//
// targetTextBox
//
this.targetTextBox.Location = new System.Drawing.Point(15, 33);
this.targetTextBox.Name = "targetTextBox";
this.targetTextBox.Size = new System.Drawing.Size(357, 23);
this.targetTextBox.TabIndex = 1;
//
// legacyCheckBox
//
this.legacyCheckBox.AutoSize = true;
this.legacyCheckBox.Location = new System.Drawing.Point(15, 71);
this.legacyCheckBox.Name = "legacyCheckBox";
this.legacyCheckBox.Size = new System.Drawing.Size(206, 19);
this.legacyCheckBox.TabIndex = 2;
this.legacyCheckBox.Text = "Enable Legacy Mode (for old devices)";
this.legacyCheckBox.UseVisualStyleBackColor = true;
//
// connectButton
//
this.connectButton.Location = new System.Drawing.Point(297, 106);
this.connectButton.Name = "connectButton";
this.connectButton.Size = new System.Drawing.Size(75, 23);
this.connectButton.TabIndex = 3;
this.connectButton.Text = "Connect";
this.connectButton.UseVisualStyleBackColor = true;
this.connectButton.Click += new System.EventHandler(this.connectButton_Click);
//
// cancelButton
//
this.cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.cancelButton.Location = new System.Drawing.Point(216, 106);
this.cancelButton.Name = "cancelButton";
this.cancelButton.Size = new System.Drawing.Size(75, 23);
this.cancelButton.TabIndex = 4;
this.cancelButton.Text = "Cancel";
this.cancelButton.UseVisualStyleBackColor = true;
this.cancelButton.Click += new System.EventHandler(this.cancelButton_Click);
//
// Form1
//
this.AcceptButton = this.connectButton;
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(384, 141);
this.Controls.Add(this.cancelButton);
this.Controls.Add(this.connectButton);
this.Controls.Add(this.legacyCheckBox);
this.Controls.Add(this.targetTextBox);
this.Controls.Add(this.label1);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "Form1";
this.ShowIcon = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "SSH Connection";
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label label1;
private System.Windows.Forms.TextBox targetTextBox;
private System.Windows.Forms.CheckBox legacyCheckBox;
private System.Windows.Forms.Button connectButton;
private System.Windows.Forms.Button cancelButton;
}
-40
View File
@@ -1,40 +0,0 @@
using System;
using System.Windows.Forms;
public partial class Form1 : Form
{
// This property will store the result for the main program
public string FinalSshCommand { get; private set; }
public Form1(string initialTarget)
{
InitializeComponent();
targetTextBox.Text = initialTarget;
// Select the text for easy editing
targetTextBox.SelectAll();
targetTextBox.Focus();
}
private void connectButton_Click(object sender, EventArgs e)
{
string target = targetTextBox.Text;
bool useLegacy = legacyCheckBox.Checked;
string legacyArgs = "-o KexAlgorithms=+diffie-hellman-group1-sha1,diffie-hellman-group14-sha1 " +
"-o HostKeyAlgorithms=+ssh-rsa " +
"-o MACs=+hmac-sha1,hmac-sha1-96";
// Build the arguments for ssh.exe
string arguments = $"-A -C {(useLegacy ? legacyArgs : "")} {target}";
FinalSshCommand = arguments;
this.DialogResult = DialogResult.OK; // Set dialog result to OK
this.Close(); // Close the form
}
private void cancelButton_Click(object sender, EventArgs e)
{
this.DialogResult = DialogResult.Cancel; // Set dialog result to Cancel
this.Close();
}
}
-67
View File
@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="$this.ClientSize" type="System.Drawing.Size, System.Drawing">
<value>384, 141</value>
</data>
<data name="$this.Text" xml:space="preserve">
<value>SSH Connection</value>
</data>
</root>
-106
View File
@@ -1,106 +0,0 @@
using Microsoft.Win32;
using System;
using System.Diagnostics;
using System.Security.Principal;
using System.Windows.Forms;
static class Program
{
private const string ProtocolName = "ssh";
[STAThread]
static void Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// If no arguments, run setup mode.
if (args.Length == 0)
{
SetupHandler();
}
// Otherwise, run handler mode.
else
{
HandleConnection(args[0]);
}
}
static void HandleConnection(string url)
{
string target = url.Replace("ssh://", "").TrimEnd('/');
if (!target.Contains("@"))
{
target = $"{Environment.UserName}@{target}";
}
// Show the form as a dialog
using (var form = new Form1(target))
{
if (form.ShowDialog() == DialogResult.OK)
{
// If user clicked "Connect", run the command
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = "ssh.exe",
Arguments = form.FinalSshCommand,
UseShellExecute = true,
CreateNoWindow = false
};
Process.Start(psi);
}
}
}
static void SetupHandler()
{
if (!IsAdmin())
{
MessageBox.Show("Please run this application as an administrator to register the URL handler.", "Admin Privileges Required", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
// The path is simple and correct because it's a native app
string exePath = Application.ExecutablePath;
string commandStr = $"\"{exePath}\" \"%1\"";
var confirmResult = MessageBox.Show(
"This will register this application to handle all ssh:// links.\n\n" +
$"Command:\n{commandStr}\n\n" +
"Do you want to proceed?",
"Registry Setup",
MessageBoxButtons.YesNo,
MessageBoxIcon.Information);
if (confirmResult == DialogResult.Yes)
{
try
{
// Write the keys to HKEY_CLASSES_ROOT
using (RegistryKey key = Registry.ClassesRoot.CreateSubKey(ProtocolName))
{
key.SetValue("", $"URL:{ProtocolName} Protocol");
key.SetValue("URL Protocol", "");
}
using (RegistryKey key = Registry.ClassesRoot.CreateSubKey($"{ProtocolName}\\shell\\open\\command"))
{
key.SetValue("", commandStr);
}
MessageBox.Show("Successfully registered ssh:// protocol handler!", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to write to registry:\n{ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
private static bool IsAdmin()
{
using (WindowsIdentity identity = WindowsIdentity.GetCurrent())
{
WindowsPrincipal principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
}
}
-12
View File
@@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>ssh_icon.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+460
View File
@@ -0,0 +1,460 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cc"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "embed-resource"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d506610004cfc74a6f5ee7e8c632b355de5eca1f03ee5e5e0ec11b77d4eb3d61"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"vswhom",
"winreg",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "ssh-handler"
version = "0.1.0"
dependencies = [
"embed-resource",
"windows",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys",
]
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "ssh-handler"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "ssh-handler"
path = "src/main.rs"
[dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_LibraryLoader",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_UI_Controls",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_System_Threading",
"Win32_System_LibraryLoader",
"Win32_Security",
] }
[build-dependencies]
embed-resource = "2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
+46
View File
@@ -0,0 +1,46 @@
# ssh-handler (Rust)
The Windows `ssh://` protocol handler, in Rust — raw Win32 (via `windows-rs`)
instead of a managed GUI framework. This started as a rewrite of an earlier
.NET/WinForms version (since removed) whose self-contained publish was
~150MB and paid CLR startup cost on every single `ssh://` link click. This
version has no runtime, no GC, no JIT — cold start is just process creation
plus a native dialog box. Release build is ~250KB.
## Building
CI (`.github/workflows/build.yml`) builds natively on `windows-latest` with
the MSVC toolchain:
```sh
cargo build --release --target x86_64-pc-windows-msvc
```
Cross-compiling from Linux also works, e.g. for local iteration:
```sh
rustup target add x86_64-pc-windows-gnu
sudo apt install mingw-w64
cargo build --release --target x86_64-pc-windows-gnu
```
The dialog is a native Win32 resource (`resources/ssh_handler.rc`), compiled
via `embed-resource` in `build.rs` — no GUI toolkit dependency.
## Behavior
- No arguments: setup mode. Checks for admin rights, confirms with the user,
then registers `ssh_handler_rs` as an `ssh://` handler using the "Default
Programs" capability model (a dedicated ProgID with
`Capabilities\UrlAssociations`, plus a
`HKLM\SOFTWARE\RegisteredApplications` entry) — the same pattern
`openssh_protocol_handler.bat` uses, and the one Windows 10/11 actually
honors in Settings > Apps > Default Apps > "Choose default apps by link
type". A bare `HKCR\ssh\shell\open\command` write (what the old .NET
version did) gets silently ignored once a `UserChoice` already exists for
the protocol, which is the common case.
- `ssh://user@host` argument: handler mode. Parses the target (defaulting the
username to the current Windows user if omitted), shows a small dialog to
confirm/edit the target and optionally enable "legacy mode" (older
KEX/HostKey/MAC/cipher algorithms for old SSH servers), then launches
`ssh.exe -A -C ... user@host` via `ShellExecuteW`.
+3
View File
@@ -0,0 +1,3 @@
fn main() {
embed_resource::compile("resources/ssh_handler.rc", embed_resource::NONE);
}
+4
View File
@@ -0,0 +1,4 @@
#define IDI_APPICON 102
#define IDD_CONNECT 101
#define IDC_EDIT_TARGET 1001
#define IDC_CHECK_LEGACY 1002
+17
View File
@@ -0,0 +1,17 @@
#include <windows.h>
#include "resource.h"
IDI_APPICON ICON "ssh_icon.ico"
IDD_CONNECT DIALOGEX 0, 0, 260, 92
STYLE DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "SSH Connect"
FONT 9, "Segoe UI"
BEGIN
LTEXT "Target:", -1, 10, 13, 240, 10
EDITTEXT IDC_EDIT_TARGET, 10, 25, 240, 14, ES_AUTOHSCROLL
CONTROL "Legacy mode (older/insecure algorithms)", IDC_CHECK_LEGACY,
"Button", BS_AUTOCHECKBOX | WS_TABSTOP, 10, 47, 240, 12
DEFPUSHBUTTON "Connect", IDOK, 94, 68, 75, 16
PUSHBUTTON "Cancel", IDCANCEL, 174, 68, 75, 16
END

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+311
View File
@@ -0,0 +1,311 @@
#![windows_subsystem = "windows"]
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::System::Registry::{
RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CLASSES_ROOT, HKEY_LOCAL_MACHINE, KEY_WRITE,
REG_OPTION_NON_VOLATILE, REG_SZ,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Controls::IsDlgButtonChecked;
use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus;
use windows::Win32::UI::Shell::{IsUserAnAdmin, ShellExecuteW};
use windows::Win32::UI::WindowsAndMessaging::{
DialogBoxParamW, EndDialog, GetDlgItem, GetDlgItemTextW, GetWindowLongPtrW, MessageBoxW,
SendDlgItemMessageW, SetDlgItemTextW, SetWindowLongPtrW, GWLP_USERDATA, IDCANCEL, IDOK, IDYES,
MB_ICONERROR, MB_ICONINFORMATION, MB_ICONQUESTION, MB_OK, MB_YESNO, SW_SHOWNORMAL, WM_CLOSE,
WM_COMMAND, WM_INITDIALOG,
};
const IDD_CONNECT: u16 = 101;
const IDC_EDIT_TARGET: i32 = 1001;
const IDC_CHECK_LEGACY: i32 = 1002;
const BST_CHECKED: usize = 1;
const EM_SETSEL: u32 = 0x00B1;
const LEGACY_ARGS: &str = "-o KexAlgorithms=+diffie-hellman-group1-sha1,diffie-hellman-group14-sha1 \
-o HostKeyAlgorithms=+ssh-rsa -o MACs=+hmac-sha1,hmac-sha1-96 -o ciphers=+aes256-cbc";
/// Our ProgID / capability-provider identifier under HKCR and RegisteredApplications.
/// Distinct from the .bat's "ssh_custom_handler" so both can coexist/be told apart.
const PROG_ID: &str = "ssh_handler_rs";
const APP_DISPLAY_NAME: &str = "SSH Handler (Rust)";
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
fn message_box(text: &str, caption: &str, flags: windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE) -> windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_RESULT {
let text = to_wide(text);
let caption = to_wide(caption);
unsafe {
MessageBoxW(
None,
PCWSTR(text.as_ptr()),
PCWSTR(caption.as_ptr()),
flags,
)
}
}
fn current_exe_path() -> String {
std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_default()
}
fn current_username() -> String {
std::env::var("USERNAME").unwrap_or_else(|_| "user".to_string())
}
/// Data passed into / out of the connect dialog.
struct DialogData {
initial_target: Vec<u16>,
result_target: String,
legacy: bool,
accepted: bool,
}
unsafe extern "system" fn dialog_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> isize {
match msg {
WM_INITDIALOG => {
// lparam carries the raw pointer to our DialogData (see DialogBoxParamW call).
SetWindowLongPtrW(hwnd, GWLP_USERDATA, lparam.0);
let data = &*(lparam.0 as *const DialogData);
let _ = SetDlgItemTextW(hwnd, IDC_EDIT_TARGET, PCWSTR(data.initial_target.as_ptr()));
// Select all text in the edit box and give it focus.
let _ = SendDlgItemMessageW(hwnd, IDC_EDIT_TARGET, EM_SETSEL, WPARAM(0), LPARAM(-1));
let edit_hwnd = GetDlgItem(hwnd, IDC_EDIT_TARGET).unwrap_or_default();
let _ = SetFocus(edit_hwnd);
1 // we set focus ourselves
}
WM_COMMAND => {
let control_id = (wparam.0 & 0xFFFF) as i32;
if control_id == IDOK.0 {
let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut DialogData;
if !ptr.is_null() {
let data = &mut *ptr;
let mut buf = [0u16; 512];
let len = GetDlgItemTextW(hwnd, IDC_EDIT_TARGET, &mut buf);
data.result_target = String::from_utf16_lossy(&buf[..len as usize]);
data.legacy =
IsDlgButtonChecked(hwnd, IDC_CHECK_LEGACY) as usize == BST_CHECKED;
data.accepted = true;
}
let _ = EndDialog(hwnd, IDOK.0 as isize);
return 1;
} else if control_id == IDCANCEL.0 {
let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut DialogData;
if !ptr.is_null() {
(&mut *ptr).accepted = false;
}
let _ = EndDialog(hwnd, IDCANCEL.0 as isize);
return 1;
}
0
}
WM_CLOSE => {
let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut DialogData;
if !ptr.is_null() {
(&mut *ptr).accepted = false;
}
let _ = EndDialog(hwnd, IDCANCEL.0 as isize);
1
}
_ => 0,
}
}
fn show_connect_dialog(initial_target: &str) -> Option<(String, bool)> {
let mut data = Box::new(DialogData {
initial_target: to_wide(initial_target),
result_target: String::new(),
legacy: false,
accepted: false,
});
let data_ptr = data.as_mut() as *mut DialogData;
unsafe {
let hinstance: windows::Win32::Foundation::HINSTANCE =
GetModuleHandleW(None).unwrap_or_default().into();
DialogBoxParamW(
hinstance,
windows::core::PCWSTR(IDD_CONNECT as *const u16),
None,
Some(dialog_proc),
LPARAM(data_ptr as isize),
);
}
if data.accepted {
Some((data.result_target.clone(), data.legacy))
} else {
None
}
}
fn is_admin() -> bool {
unsafe { IsUserAnAdmin().as_bool() }
}
fn setup_handler() {
if !is_admin() {
message_box(
"Please run this application as an administrator to register the URL handler.",
"Admin Privileges Required",
MB_OK | MB_ICONERROR,
);
return;
}
let exe_path = current_exe_path();
let command_str = format!("\"{exe_path}\" \"%1\"");
let prompt = format!(
"This will register this application ({APP_DISPLAY_NAME}) as a candidate handler for ssh:// links.\n\n\
Command:\n{command_str}\n\n\
After this, you may need to open Settings > Apps > Default Apps > \"Choose default apps by link type\", \
find \"ssh\", and select {APP_DISPLAY_NAME} explicitly — Windows does not let an installer silently \
override an existing default protocol handler.\n\n\
Do you want to proceed?"
);
let result = message_box(&prompt, "Registry Setup", MB_YESNO | MB_ICONQUESTION);
if result != IDYES {
return;
}
match register_protocol(&exe_path, &command_str) {
Ok(()) => {
message_box(
&format!(
"Registered {APP_DISPLAY_NAME} as an ssh:// handler.\n\n\
If ssh:// links don't launch it, open Settings > Apps > Default Apps > \
\"Choose default apps by link type\", find \"ssh\", and pick it there."
),
"Success",
MB_OK | MB_ICONINFORMATION,
);
}
Err(e) => {
message_box(
&format!("Failed to write to registry:\n{e:?}"),
"Error",
MB_OK | MB_ICONERROR,
);
}
}
}
/// Sets a REG_SZ value. `value_name = None` targets the key's default (unnamed) value.
fn reg_set_sz(hkey: HKEY, value_name: Option<&str>, data: &str) -> windows::core::Result<()> {
let name_wide = value_name.map(to_wide);
let name_ptr = match &name_wide {
Some(w) => PCWSTR(w.as_ptr()),
None => PCWSTR::null(),
};
let data_wide = to_wide(data);
let bytes = unsafe {
std::slice::from_raw_parts(data_wide.as_ptr() as *const u8, data_wide.len() * 2)
};
unsafe { RegSetValueExW(hkey, name_ptr, 0, REG_SZ, Some(bytes)) }.ok()
}
fn reg_create_key(root: HKEY, subkey: &str) -> windows::core::Result<HKEY> {
let subkey_wide = to_wide(subkey);
let mut key = HKEY::default();
unsafe {
RegCreateKeyExW(
root,
PCWSTR(subkey_wide.as_ptr()),
0,
None,
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut key,
None,
)
}
.ok()?;
Ok(key)
}
/// Registers via the "Default Programs" capability model (RegisteredApplications +
/// a distinct ProgID with Capabilities\UrlAssociations), the same pattern
/// `openssh_protocol_handler.bat` uses — this is what actually shows up in and gets
/// honored by Windows' "Choose default apps by link type" UI. A bare
/// HKCR\ssh\shell\open\command write is ignored once a UserChoice already exists for
/// the protocol, which is the common case on Windows 10/11.
fn register_protocol(exe_path: &str, command_str: &str) -> windows::core::Result<()> {
// Declare "ssh" as a URL protocol scheme (legacy marker some apps still check for).
let ssh_key = reg_create_key(HKEY_CLASSES_ROOT, "ssh")?;
reg_set_sz(ssh_key, None, "URL:ssh Protocol")?;
reg_set_sz(ssh_key, Some("URL Protocol"), "")?;
// Our ProgID: the actual open command plus Default-Apps display metadata.
let cmd_key = reg_create_key(HKEY_CLASSES_ROOT, &format!("{PROG_ID}\\shell\\open\\command"))?;
reg_set_sz(cmd_key, None, command_str)?;
let app_key = reg_create_key(HKEY_CLASSES_ROOT, &format!("{PROG_ID}\\Application"))?;
reg_set_sz(app_key, Some("ApplicationIcon"), &format!("\"{exe_path}\",0"))?;
reg_set_sz(app_key, Some("ApplicationName"), APP_DISPLAY_NAME)?;
reg_set_sz(
app_key,
Some("ApplicationDescription"),
"Handles ssh:// links via Windows OpenSSH",
)?;
let caps_key = reg_create_key(
HKEY_CLASSES_ROOT,
&format!("{PROG_ID}\\Capabilities\\UrlAssociations"),
)?;
reg_set_sz(caps_key, Some("ssh"), PROG_ID)?;
// Tell Windows' Default Apps UI this app exists as a capability provider.
let registered_key = reg_create_key(HKEY_LOCAL_MACHINE, "SOFTWARE\\RegisteredApplications")?;
reg_set_sz(
registered_key,
Some(APP_DISPLAY_NAME),
&format!("Software\\Classes\\{PROG_ID}\\Capabilities"),
)?;
Ok(())
}
fn handle_connection(url: &str) {
let mut target = url.trim_start_matches("ssh://").trim_end_matches('/').to_string();
if !target.contains('@') {
target = format!("{}@{}", current_username(), target);
}
let Some((final_target, legacy)) = show_connect_dialog(&target) else {
return;
};
let legacy_args = if legacy { LEGACY_ARGS } else { "" };
let arguments = format!("-A -C {legacy_args} {final_target}");
let ssh_exe = w!("ssh.exe");
let args_wide = to_wide(&arguments);
let verb = w!("open");
unsafe {
ShellExecuteW(
None,
verb,
ssh_exe,
PCWSTR(args_wide.as_ptr()),
None,
SW_SHOWNORMAL,
);
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
setup_handler();
} else {
handle_connection(&args[0]);
}
}