Author SHA1 Message Date
Claude 859658f778 Document a Linux cross-compile path in build.yml, inactive by default
Add commented-out alternative runner/steps for cross-compiling
ssh-handler.exe from Linux (x86_64-pc-windows-gnu + mingw-w64), for
mirrors (e.g. Gitea instances) that only have Linux Actions runners
available. Every line that would need to change is marked in both
directions ("comment out" / "uncomment") so switching is a direct
swap rather than a rewrite. GitHub's copy keeps building natively on
windows-latest/MSVC; nothing here is active by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYCT2RpUjFfBfp6CD4rREw
2026-09-08 17:56:41 +00:00
ergosteurandGitHub 89ec41a0cd Merge pull request #1 from ergosteur/claude/optimistic-davinci-2i411w
Rewrite SSH handler from C# to Rust for minimal footprint
2026-09-08 13:34:12 -04:00
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 927 additions and 415 deletions
+50 -35
View File
@@ -8,7 +8,21 @@ on:
jobs: jobs:
build: build:
runs-on: windows-latest # Builds natively on Windows (MSVC) via GitHub's windows-latest runner.
#
# If this workflow runs somewhere without a Windows runner available (e.g. a
# Gitea instance with only Linux act_runners registered), ssh-handler.exe can
# instead be cross-compiled from Linux with the GNU target + mingw-w64 — see
# the commented-out alternative lines throughout this file. To switch:
# 1. Swap the `runs-on` line below.
# 2. Uncomment the "Install mingw-w64" step.
# 3. In "Setup Rust" and "Build ssh-handler", use the x86_64-pc-windows-gnu
# target instead of x86_64-pc-windows-msvc.
# 4. Swap "Ensure Unix Line Endings" and "Prepare Assets with Versioning"
# for their bash equivalents (pwsh isn't guaranteed present on a bare
# Linux runner the way it is on GitHub's ubuntu-latest).
runs-on: windows-latest # Linux cross-compile: comment out this line
# runs-on: ubuntu-latest # Linux cross-compile: uncomment this line
permissions: permissions:
contents: write contents: write
@@ -16,39 +30,22 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Configure Git Line Endings # Linux cross-compile: uncomment this step
run: git config --global core.autocrlf input # - name: Install mingw-w64 (Linux cross-compile only)
# run: sudo apt-get update && sudo apt-get install -y mingw-w64
- name: Setup .NET - name: Setup Rust
uses: actions/setup-dotnet@v4 uses: dtolnay/rust-toolchain@stable
with: with:
dotnet-version: 8.0.x targets: x86_64-pc-windows-msvc # Linux cross-compile: comment out
# targets: x86_64-pc-windows-gnu # Linux cross-compile: uncomment
- name: Restore dependencies - name: Build ssh-handler (Rust)
run: dotnet restore working-directory: SshHandlerRs
run: cargo build --release --target x86_64-pc-windows-msvc # Linux cross-compile: comment out
# 1. Build Framework-Dependent FIRST (The tiny one) # run: cargo build --release --target x86_64-pc-windows-gnu # Linux cross-compile: uncomment
- 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
# Linux cross-compile: comment out this whole step (through the closing "}")
- name: Ensure Unix Line Endings for Shell Script - name: Ensure Unix Line Endings for Shell Script
shell: pwsh shell: pwsh
run: | run: |
@@ -59,27 +56,45 @@ jobs:
[System.IO.File]::WriteAllText((Get-Item $path).FullName, $content, (New-Object System.Text.UTF8Encoding($false))) [System.IO.File]::WriteAllText((Get-Item $path).FullName, $content, (New-Object System.Text.UTF8Encoding($false)))
} }
# Linux cross-compile: uncomment this step (pwsh isn't guaranteed present on a bare Linux runner)
# - name: Ensure Unix Line Endings for Shell Script (Linux cross-compile only)
# shell: bash
# run: sed -i 's/\r$//' ssh-handler.sh
# Linux cross-compile: comment out this whole step (through the last Copy-Item line)
- name: Prepare Assets with Versioning - name: Prepare Assets with Versioning
shell: pwsh shell: pwsh
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"
Copy-Item "openssh_protocol_handler.bat" "./openssh_protocol_handler-$tag.bat" Copy-Item "openssh_protocol_handler.bat" "./openssh_protocol_handler-$tag.bat"
# Linux cross-compile: uncomment this step
# - name: Prepare Assets with Versioning (Linux cross-compile only)
# shell: bash
# run: |
# tag="${{ github.ref_name }}"
# if [ -z "$tag" ] || [ "$tag" == "main" ]; then tag="manual"; fi
# tag="${tag//\//-}"
#
# mv SshHandlerRs/target/x86_64-pc-windows-gnu/release/ssh-handler.exe "./ssh-handler-rs-$tag.exe"
# cp ssh-handler.sh "./ssh-handler-$tag.sh"
# cp openssh_protocol_handler.bat "./openssh_protocol_handler-$tag.bat"
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
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]);
}
}