diff --git a/.github/actions/deploy-aur/action.yml b/.github/actions/deploy-aur/action.yml
index d4cdadb..aeb1e97 100644
--- a/.github/actions/deploy-aur/action.yml
+++ b/.github/actions/deploy-aur/action.yml
@@ -87,11 +87,19 @@ runs:
cp "$GITHUB_WORKSPACE/${{ inputs.pkgbuild }}" ./PKGBUILD
+ if [[ "${{ inputs.updpkgsums }}" == "true" ]]; then
+ if command -v updpkgsums >/dev/null 2>&1; then
+ updpkgsums PKGBUILD
+ else
+ echo "::warning::updpkgsums requested but pacman-contrib is not installed in the fallback environment; sha256sums left unchanged"
+ fi
+ fi
+
if [[ "${{ inputs.regenerate_srcinfo }}" == "true" ]]; then
docker run --rm -v "$(pwd)":/pkg archlinux:latest \
bash -c "useradd -m builder \
&& cp /pkg/PKGBUILD /home/builder/ \
- && su builder -c 'cd /home/builder && makepkg --printsrcinfo >> /pkg/.SRCINFO'"
+ && su builder -c 'cd /home/builder && makepkg --printsrcinfo > /pkg/.SRCINFO'"
fi
git config user.name "${{ inputs.commit_username }}"
diff --git a/.github/actions/determine-version/action.yml b/.github/actions/determine-version/action.yml
index ae0f8e9..9060a79 100644
--- a/.github/actions/determine-version/action.yml
+++ b/.github/actions/determine-version/action.yml
@@ -1,17 +1,21 @@
name: Determine Version
-description: Extract and validate a semantic version from Directory.Build.props and optionally validate against a branch or tag name
+description: Extract and validate a semantic version from project
inputs:
file-name:
description: The filename where the dotnet msbuild command can extract the version. Provide the full path from repo root!
required: false
- default: 'Directory.Build.props'
+ default: 'OpenSSH_GUI/OpenSSH_GUI.csproj'
property:
description: The property where the dotnet msbuild command must look for the verison
required: false
- default: 'BaseVersion'
-
+ default: 'Version'
+
+ suffix:
+ description: The Suffix to use, when any
+ required: false
+ default: ''
outputs:
version:
@@ -25,31 +29,34 @@ runs:
using: composite
steps:
- - name: Extract BaseVersion
+ - name: Extract Version
id: extract
shell: bash
run: |
set -euo pipefail
FILE="${{ inputs.file-name }}"
+ SUFFIX="${{ inputs.suffix }}"
if [[ ! -f "$FILE" ]]; then
echo "::error::$FILE not found"
exit 1
fi
- VERSION="$(dotnet msbuild "$FILE" -nologo -getProperty:${{ inputs.property }} | tr -d '\r')"
+ BUILD_VERSION="$(dotnet msbuild "$FILE" -nologo -getProperty:${{ inputs.property }} -p:VersionSuffix="$SUFFIX" | tr -d '\r')"
- if [[ -z "$VERSION" ]]; then
+ if [[ -z "$BUILD_VERSION" ]]; then
echo "::error::${{ inputs.property }} not found in $FILE"
exit 1
fi
+ VERSION="${BUILD_VERSION%%-*}"
+
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid Property ${{ inputs.property }}: $VERSION"
exit 1
fi
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "version=$BUILD_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- echo "::notice::Detected version: $VERSION (tag: v$VERSION)"
\ No newline at end of file
+ echo "::notice::Detected version: $BUILD_VERSION (tag: v$VERSION)"
diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml
index 36e9f4a..0f5854f 100644
--- a/.github/workflows/auto-tag.yml
+++ b/.github/workflows/auto-tag.yml
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout merge commit
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.merge_commit_sha }}
diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml
index d8ddf24..34bcfd8 100644
--- a/.github/workflows/build-and-package.yml
+++ b/.github/workflows/build-and-package.yml
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout Repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Setup .NET environment
uses: actions/setup-dotnet@v5
@@ -41,28 +41,9 @@ jobs:
**/Directory.Packages.props
**/Directory.Build.props
- - name: Normalize version and build flags
- id: meta
- shell: bash
- run: |
- set -euo pipefail
- VERSION="${{ inputs.version }}"
- VERSION="${VERSION#v}"
- echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
-
- name: Install/Restore dependencies
shell: bash
run: dotnet restore --runtime "${{ matrix.target }}"
-
- - name: Build application
- shell: bash
- run: |
- set -euo pipefail
- dotnet build OpenSSH_GUI/OpenSSH_GUI.csproj \
- --configuration Release \
- --no-restore \
- --runtime "${{ matrix.target }}" \
- -p:Version="${{ steps.meta.outputs.VERSION }}"
- name: Publish application
shell: bash
@@ -75,7 +56,7 @@ jobs:
-p:PublishSingleFile=true \
-p:PublishReadyToRun=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
- -p:Version="${{ steps.meta.outputs.VERSION }}"
+ -p:Version="${{ inputs.version }}"
- name: Rename artifact for distribution
id: rename
@@ -97,7 +78,7 @@ jobs:
uses: ./.github/actions/build-appimage
with:
binary-path: ./publish/${{ steps.rename.outputs.ASSET_NAME }}
- version: ${{ steps.meta.outputs.VERSION }}
+ version: ${{ inputs.version }}
asset-prefix: ${{ inputs.asset_name_prefix }}
- name: Upload AppImage
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 48d2b8e..266d51a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,8 +4,8 @@ on:
push:
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
- workflow_dispatch:
-
+ branches:
+ - master
permissions:
contents: write
@@ -29,7 +29,7 @@ jobs:
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: '1'
ref: ${{ github.ref }}
@@ -50,11 +50,15 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [test, prepare, build]
+ if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout Repository
- uses: actions/checkout@v6
-
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: '1'
+ ref: ${{ github.ref }}
+
- name: Download Windows binary
uses: actions/download-artifact@v8
with:
@@ -105,10 +109,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [test, prepare, build, create_installer]
+ if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Download all build artifacts
uses: actions/download-artifact@v8
@@ -171,4 +176,4 @@ jobs:
with:
event-type: deploy
token: ${{ secrets.PAT_TOKEN }}
- client_payload: '{"tag": "${{ needs.prepare.outputs.tag }}"}'
\ No newline at end of file
+ client-payload: '{"tag": "${{ needs.prepare.outputs.tag }}", "version": "${{ needs.prepare.outputs.version }}"}'
\ No newline at end of file
diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml
index 7bd6fa0..9c5de6f 100644
--- a/.github/workflows/deploy-release.yml
+++ b/.github/workflows/deploy-release.yml
@@ -3,58 +3,27 @@ name: Deploy release to consumers
on:
repository_dispatch:
types: [deploy]
- workflow_dispatch:
- inputs:
- tag:
- description: 'Release tag (e.g. v1.2.3)'
- required: true
- type: string
permissions:
contents: read
jobs:
- get_version:
- name: Extract Version
- runs-on: ubuntu-latest
- outputs:
- version: ${{ steps.parse.outputs.version }}
- tag: ${{ steps.parse.outputs.tag }}
-
- steps:
- - name: Parse release metadata
- id: parse
- run: |
- set -euo pipefail
-
- TAG="${{ github.event.client_payload.tag || inputs.tag }}"
-
- if [[ -z "$TAG" ]]; then
- echo "::error::No tag available — triggered via workflow_dispatch without tag input"
- exit 1
- fi
-
- VERSION="${TAG#v}"
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "tag=$TAG" >> "$GITHUB_OUTPUT"
-
aur_publish:
name: Publish AUR packages
runs-on: ubuntu-latest
timeout-minutes: 10
- needs: get_version
steps:
- name: Checkout PKGBUILD only
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
- ref: ${{ github.event.release.tag_name || inputs.tag }}
+ ref: ${{ github.event.client_payload.tag }}
- name: Update PKGBUILD version
run: |
set -euo pipefail
- VERSION="${{ needs.get_version.outputs.version }}"
+ VERSION="${{ github.event.client_payload.version }}"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" openssh-gui-bin/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" openssh-gui-bin/PKGBUILD
@@ -67,7 +36,7 @@ jobs:
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: ${{ github.repository_owner }}
commit_email: ${{ github.repository_owner }}@users.noreply.github.com
- commit_message: "Update to ${{ needs.get_version.outputs.tag }}"
+ commit_message: "Update to ${{ github.event.client_payload.tag }}"
force_push: 'false'
regenerate_srcinfo: 'false'
updpkgsums: 'true'
@@ -75,7 +44,7 @@ jobs:
winget_publish:
name: Update Winget Package
runs-on: ubuntu-slim
- needs: [aur_publish, get_version]
+ needs: aur_publish
timeout-minutes: 10
steps:
- name: Update Winget manifest
@@ -84,7 +53,7 @@ jobs:
max-versions-to-keep: 5
identifier: frequency403.OpenSSHGUI
fork-user: ${{ github.repository_owner }}
- version: ${{ needs.get_version.outputs.version }}
- release-tag: ${{ needs.get_version.outputs.tag }}
+ version: ${{ github.event.client_payload.version }}
+ release-tag: ${{ github.event.client_payload.tag }}
installers-regex: 'Setup\.exe$'
token: ${{ secrets.WINGET_PUBLISH_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml
index f953da4..e95fa7e 100644
--- a/.github/workflows/staging.yml
+++ b/.github/workflows/staging.yml
@@ -1,72 +1,33 @@
-name: Staging / Development Build
-
+name: staging.yml
on:
push:
branches:
- - development
-
-permissions:
- contents: write
-
+ - 'development'
+ paths:
+ - 'openssh-gui-git/PKGBUILD'
concurrency:
group: staging-${{ github.ref }}
cancel-in-progress: true
-
jobs:
- test:
- name: Run Tests
- uses: ./.github/workflows/test.yml
-
- prepare:
- name: Prepare Metadata
+ aur_publish:
+ name: Update GIT AUR package
runs-on: ubuntu-latest
- timeout-minutes: 5
- needs: test
- outputs:
- version: ${{ steps.version.outputs.version }}
- tag: ${{ steps.version.outputs.tag }}
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Determine Version
- id: version
- uses: ./.github/actions/determine-version
-
- deploy-aur-git:
- name: Update AUR Git Package
- runs-on: ubuntu-latest
- timeout-minutes: 15
- needs: [ test, prepare ]
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - name: Update PKGBUILD
- run: |
- set -euo pipefail
- DATE=$(date +%Y%m%d)
- HASH=$(git rev-parse --short HEAD)
- AUR_VERSION="${{ needs.prepare.outputs.version }}.${DATE}.${HASH}"
-
- sed -i "s/^pkgver=.*/pkgver=$AUR_VERSION/" openssh-gui-git/PKGBUILD
- sed -i "s/^pkgrel=.*/pkgrel=1/" openssh-gui-git/PKGBUILD
-
- echo "Updated PKGBUILD:"
- grep -E '^(pkgver=|pkgrel=)' openssh-gui-git/PKGBUILD
-
- - name: Deploy to AUR
- uses: ./.github/actions/deploy-aur
- with:
- pkgname: openssh-gui-git
- pkgbuild: openssh-gui-git/PKGBUILD
- ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
- commit_username: ${{ github.repository_owner }}
- commit_email: ${{ github.repository_owner }}@users.noreply.github.com
- commit_message: "Development update ${{ needs.prepare.outputs.tag }}"
- force_push: 'true'
- regenerate_srcinfo: 'true'
\ No newline at end of file
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: '1'
+ sparse-checkout: |
+ openssh-gui-git/PKGBUILD
+ ref: ${{ github.ref }}
+ - name: Deploy
+ uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
+ with:
+ pkgname: openssh-gui-git
+ pkgbuild: openssh-gui-git/PKGBUILD
+ commit_username: ${{ github.repository_owner }}
+ commit_email: ${{ github.repository_owner }}@users.noreply.github.com
+ ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
+ commit_message: ${{ github.event.head_commit.message }}
+ allow_empty_commits: true
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2b9a5d5..76f7331 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,30 +1,31 @@
name: Unit Tests
-
on:
- push:
- branches:
- - master
- - main
- pull_request:
- branches:
- - master
- - main
- - development
workflow_call:
+ pull_request:
+ types:
+ - opened
+ - reopened
+ - synchronize
+
concurrency:
group: tests-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
jobs:
test:
- name: Run Tests
- runs-on: ubuntu-latest
+ name: ${{matrix.os}}-tests
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ ubuntu-latest, windows-latest, macOS-latest ]
timeout-minutes: 20
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ github.ref }}
- name: Setup .NET environment
uses: actions/setup-dotnet@v5
diff --git a/.github/workflows/version-guard.yml b/.github/workflows/version-guard.yml
index a184f9e..a69e16c 100644
--- a/.github/workflows/version-guard.yml
+++ b/.github/workflows/version-guard.yml
@@ -13,10 +13,13 @@ on:
jobs:
validate-version:
runs-on: ubuntu-latest
+ if: startsWith(github.event.pull_request.head.ref, 'release/')
+ outputs:
+ version: ${{ steps.release_version.outputs.version }}
steps:
- name: Checkout target (master)
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
ref: master
fetch-depth: 0
@@ -24,12 +27,13 @@ jobs:
- name: Extract master version
id: master_version
uses: ./.github/actions/determine-version
-
+
- name: Checkout PR branch
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
- ref: ${{ github.event.pull_request.head.ref }}
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
+ ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Extract release version
@@ -45,7 +49,7 @@ jobs:
echo "Master: $MASTER"
echo "Release: $RELEASE"
- python3 - << 'EOF'
+ python3 - << 'PYEOF'
import sys
from packaging.version import Version
@@ -57,4 +61,4 @@ jobs:
sys.exit(1)
print("Version check passed")
- EOF
\ No newline at end of file
+ PYEOF
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
index b7cb11c..4851674 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,10 +5,11 @@
enable
default
https://github.com/frequency403/OpenSSH-GUI
- 3.2.3
+ 3.2.4
true
+ false
-
+
<_Parameter1>ProjectUrl
diff --git a/Directory.Build.targets b/Directory.Build.targets
deleted file mode 100644
index 35e4484..0000000
--- a/Directory.Build.targets
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
- 1.0.0
-
- $(BaseVersion)
- $(BaseVersion)
-
-
-
-
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 253ac32..82606a2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,15 +5,16 @@
$(NoWarn);NU1507
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
@@ -25,7 +26,7 @@
-
+
@@ -40,12 +41,12 @@
-
+
-
+
diff --git a/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs b/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs
index 471d551..b16aee3 100644
--- a/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs
+++ b/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs
@@ -46,8 +46,9 @@ public readonly record struct BasicSshKeyFileInformation()
///
/// Extracts metadata from any supported SSH key file without requiring a passphrase.
/// Supports OpenSSH public keys (.pub), OpenSSH private keys, and PuTTY keys (PPK v1/v2/v3).
- /// The comment will be empty for passphrase-protected OpenSSH private keys
- /// when no corresponding .pub file is present.
+ /// The comment will be empty when no corresponding .pub file is present,
+ /// because the OpenSSH private key format does not expose the key comment
+ /// without decrypting the private section.
///
/// Descriptor of the key file(s) on disk.
/// Parsed metadata, or an empty instance if the key cannot be read.
@@ -64,7 +65,7 @@ public static BasicSshKeyFileInformation FromKeyFileInfo(SshKeyFileInformation k
// PPK — comment lives in the unencrypted plaintext header regardless of encryption
if (files.FirstOrDefault(f =>
- f.Extension.Equals(PathExtensions.PuttyKeyFileExtension, StringComparison.OrdinalIgnoreCase)) is
+ f.Extension.Replace(".", string.Empty).Equals(PathExtensions.PuttyKeyFileExtension, StringComparison.OrdinalIgnoreCase)) is
{ } ppkFile)
return TryParseOrEmpty(() => ParsePpkFile(File.ReadAllText(ppkFile.FullName)));
@@ -341,12 +342,12 @@ private static BasicSshKeyFileInformation TryParseOrEmpty(Func IsEmpty
- ? hashAlgorithmName == HashAlgorithmName
+ ? string.Empty
+ : hashAlgorithmName == HashAlgorithmName
? string.Format(outputFormat, BitLength, HashAlgorithmName, FingerPrint, Comment, KeyType)
: string.Format(
outputFormat, BitLength, hashAlgorithmName, ComputeFingerprint([], hashAlgorithmName),
- Comment, KeyType)
- : string.Empty;
+ Comment, KeyType);
///
/// Returns a human-readable string matching the output format of ssh-keygen -lf:
diff --git a/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs b/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs
index ae63173..9650142 100644
--- a/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs
+++ b/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs
@@ -34,12 +34,7 @@ public virtual ValueTask InitializeAsync(
public abstract partial class ViewModelBase : ReactiveObject, IDisposable, IAsyncDisposable, IActivatableViewModel,
IInitializableViewModel
{
- ///
- /// The TopLevel this view is hosted in. Set by the view once attached to the visual tree,
- /// since clipboard access must go through the actual hosting window on Windows (OLE clipboard
- /// is bound to the calling HWND, not a globally cached one).
- ///
- public TopLevel? OwnerTopLevel { get; set; }
+ public Func GetTopLevel { get; set; }
protected readonly CompositeDisposable Disposables;
diff --git a/OpenSSH_GUI.Core/Resources/AppIconStore.cs b/OpenSSH_GUI.Core/Resources/AppIconStore.cs
index b5bb543..f63fcd0 100644
--- a/OpenSSH_GUI.Core/Resources/AppIconStore.cs
+++ b/OpenSSH_GUI.Core/Resources/AppIconStore.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
@@ -9,18 +10,43 @@ namespace OpenSSH_GUI.Core.Resources;
///
public sealed class AppIconStore
{
- private readonly Dictionary _bitmaps = new();
- private readonly Dictionary _windowIcons = new();
+ private FrozenDictionary bitmaps = FrozenDictionary.Empty;
+ private FrozenDictionary windowIcons = FrozenDictionary.Empty;
- /// Stores a rendered under the given key.
- public void AddBitmap(string key, Bitmap bitmap) { _bitmaps[key] = bitmap; }
+ private Dictionary? bitmapStage = new();
+ private Dictionary? windowIconStage = new();
+ /// Stores a rendered under the given key.
+ public void AddBitmap(string key, Bitmap bitmap)
+ {
+ if (bitmapStage is null) throw new InvalidOperationException("AppIconStore is already frozen.");
+ bitmapStage[key] = bitmap;
+ }
+
/// Stores a under the given key.
- public void AddWindowIcon(string key, WindowIcon icon) { _windowIcons[key] = icon; }
+ public void AddWindowIcon(string key, WindowIcon icon)
+ {
+ if (windowIconStage is null) throw new InvalidOperationException("AppIconStore is already frozen.");
+ windowIconStage[key] = icon;
+ }
+
+ ///
+ /// Freezes both dictionaries
+ ///
+ public void Freeze()
+ {
+ bitmaps = bitmapStage?.ToFrozenDictionary() ?? FrozenDictionary.Empty;
+ windowIcons = windowIconStage?.ToFrozenDictionary() ?? FrozenDictionary.Empty;
+ bitmapStage?.Clear();
+ windowIconStage?.Clear();
+ bitmapStage = null;
+ windowIconStage = null;
+ }
+
/// Retrieves a by key, or if not found.
- public Bitmap? GetBitmap(string key) => _bitmaps.GetValueOrDefault(key);
+ public Bitmap? GetBitmap(string key) => bitmaps.GetValueOrDefault(key);
/// Retrieves a by key, or if not found.
- public WindowIcon? GetWindowIcon(string key) => _windowIcons.GetValueOrDefault(key);
+ public WindowIcon? GetWindowIcon(string key) => windowIcons.GetValueOrDefault(key);
}
\ No newline at end of file
diff --git a/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs b/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs
index 711adb7..21e8acd 100644
--- a/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs
+++ b/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs
@@ -46,7 +46,7 @@ protected void WindowInitialize(WindowStartupLocation startupLocation = WindowSt
try
{
ViewModel = Services.GetRequiredKeyedService(typeof(TViewModel).Name);
- ViewModel.OwnerTopLevel = GetTopLevel(this);
+ ViewModel.GetTopLevel = () => GetTopLevel(this);
}
catch (Exception e)
{
diff --git a/OpenSSH_GUI.Tests/App.axaml b/OpenSSH_GUI.Tests/App.axaml
new file mode 100644
index 0000000..cb31e6e
--- /dev/null
+++ b/OpenSSH_GUI.Tests/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/App.axaml.cs b/OpenSSH_GUI.Tests/App.axaml.cs
new file mode 100644
index 0000000..43b14f2
--- /dev/null
+++ b/OpenSSH_GUI.Tests/App.axaml.cs
@@ -0,0 +1,36 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Headless;
+using Avalonia.Markup.Xaml;
+using OpenSSH_GUI.Tests;
+using ReactiveUI.Avalonia;
+
+[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
+namespace OpenSSH_GUI.Tests;
+
+public partial class App : Application
+{
+ public override void Initialize() { AvaloniaXamlLoader.Load(this); }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new Window();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
+
+
+
+public class TestAppBuilder
+{
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UseSkia()
+ .UsePlatformDetect()
+ .UseReactiveUI(builder => {})
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions());
+}
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/PathExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/PathExtensionsTests.cs
new file mode 100644
index 0000000..a5abd49
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Extensions/PathExtensionsTests.cs
@@ -0,0 +1,194 @@
+using Avalonia.Headless.XUnit;
+using OpenSSH_GUI.Core.Extensions;
+using Shouldly;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Extensions;
+
+public class PathExtensionsTests
+{
+ [AvaloniaFact]
+ public void WithJsonExtension_AppendsJson() { Path.WithJsonExtension("config").ShouldBe("config.json"); }
+
+ [AvaloniaFact]
+ public void WithLogExtension_AppendsLog() { Path.WithLogExtension("app").ShouldBe("app.log"); }
+
+ [AvaloniaFact]
+ public void WithOpenSshPublicKeyExtension_AppendsPub() { Path.WithOpenSshPublicKeyExtension("id_rsa").ShouldBe("id_rsa.pub"); }
+
+ [AvaloniaFact]
+ public void WithPuTTYKeyExtension_AppendsPpk() { Path.WithPuTTYKeyExtension("id_rsa").ShouldBe("id_rsa.ppk"); }
+
+ [AvaloniaTheory]
+ [InlineData("file.json", true)]
+ [InlineData("file.JSON", true)]
+ [InlineData("file.txt", false)]
+ public void IsJson_Tests(string path, bool expected) { Path.IsJson(path).ShouldBe(expected); }
+
+ [AvaloniaTheory]
+ [InlineData("file.log", true)]
+ [InlineData("file.LOG", true)]
+ [InlineData("file.txt", false)]
+ public void IsLog_Tests(string path, bool expected) { Path.IsLog(path).ShouldBe(expected); }
+
+ [AvaloniaTheory]
+ [InlineData("key.ppk", true)]
+ [InlineData("key.PPK", true)]
+ [InlineData("key.pub", false)]
+ public void IsPuTTYKey_Tests(string path, bool expected) { Path.IsPuTTYKey(path).ShouldBe(expected); }
+
+ [AvaloniaTheory]
+ [InlineData("id_rsa.pub", true)]
+ [InlineData("id_rsa.PUB", true)]
+ [InlineData("id_rsa", false)]
+ public void IsOpenSshPublicKey_Tests(string path, bool expected) { Path.IsOpenSshPublicKey(path).ShouldBe(expected); }
+
+ [AvaloniaTheory]
+ [InlineData("file.txt", "txt", true)]
+ [InlineData("file.TXT", "txt", true)]
+ [InlineData("file.txt", "json", false)]
+ public void HasExtension_Tests(string path, string extension, bool expected) { Path.HasExtension(path, extension).ShouldBe(expected); }
+
+ [AvaloniaTheory]
+ [InlineData("file.TXT", ".txt")]
+ [InlineData("file", "")]
+ [InlineData("archive.tar.gz", ".gz")]
+ public void GetNormalizedExtension_Tests(string path, string expected) { Path.GetNormalizedExtension(path).ShouldBe(expected); }
+
+ [AvaloniaFact]
+ public void Directory_CreateIfNotExists_CreatesMissingDirectory()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ var target = System.IO.Path.Combine(dir.FullName, "nested", "dir");
+
+ Directory.CreateIfNotExists(target);
+
+ System.IO.Directory.Exists(target).ShouldBeTrue();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Directory_CreateIfNotExists_ExistingDirectory_DoesNotThrow()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ Should.NotThrow(() => Directory.CreateIfNotExists(dir.FullName));
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Directory_CreateIfNotExists_NullOrWhitespace_DoesNothing()
+ {
+ Should.NotThrow(() => Directory.CreateIfNotExists(null));
+ Should.NotThrow(() => Directory.CreateIfNotExists(" "));
+ }
+
+ [AvaloniaFact]
+ public void File_CreateIfNotExists_CreatesFileWithContent()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ var target = System.IO.Path.Combine(dir.FullName, "sub", "file.txt");
+
+ File.CreateIfNotExists(target, "hello world");
+
+ System.IO.File.Exists(target).ShouldBeTrue();
+ System.IO.File.ReadAllText(target).ShouldBe("hello world");
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void File_CreateIfNotExists_CreatesEmptyFileWithoutContent()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ var target = System.IO.Path.Combine(dir.FullName, "file.txt");
+
+ File.CreateIfNotExists(target);
+
+ System.IO.File.Exists(target).ShouldBeTrue();
+ System.IO.File.ReadAllText(target).ShouldBeEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void File_CreateIfNotExists_ExistingFile_DoesNotOverwrite()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ var target = System.IO.Path.Combine(dir.FullName, "file.txt");
+ System.IO.File.WriteAllText(target, "original");
+
+ File.CreateIfNotExists(target, "new content");
+
+ System.IO.File.ReadAllText(target).ShouldBe("original");
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void File_CreateIfNotExists_NullPath_Throws()
+ {
+ Should.Throw(() => File.CreateIfNotExists(null!));
+ }
+
+ [AvaloniaFact]
+ public void File_RemoveIfExists_DeletesExistingFile()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ var target = System.IO.Path.Combine(dir.FullName, "file.txt");
+ System.IO.File.WriteAllText(target, "content");
+
+ File.RemoveIfExists(target);
+
+ System.IO.File.Exists(target).ShouldBeFalse();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void File_RemoveIfExists_NonExistentFile_DoesNotThrow()
+ {
+ var dir = System.IO.Directory.CreateTempSubdirectory();
+ try
+ {
+ var target = System.IO.Path.Combine(dir.FullName, "does-not-exist.txt");
+
+ Should.NotThrow(() => File.RemoveIfExists(target));
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/PlatformIdExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/PlatformIdExtensionsTests.cs
new file mode 100644
index 0000000..2429712
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Extensions/PlatformIdExtensionsTests.cs
@@ -0,0 +1,19 @@
+using Avalonia.Headless.XUnit;
+using OpenSSH_GUI.Core.Extensions;
+using Shouldly;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Extensions;
+
+public class PlatformIdExtensionsTests
+{
+ [AvaloniaTheory]
+ [InlineData(PlatformID.Win32NT, "\r\n")]
+ [InlineData(PlatformID.Win32Windows, "\r\n")]
+ [InlineData(PlatformID.Win32S, "\r\n")]
+ [InlineData(PlatformID.WinCE, "\r\n")]
+ [InlineData(PlatformID.Unix, "\n")]
+ [InlineData(PlatformID.MacOSX, "\n")]
+ [InlineData(PlatformID.Other, "\n")]
+ public void GetLineSeparator_Tests(PlatformID platformId, string expected) { platformId.GetLineSeparator().ShouldBe(expected); }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs
index 9afac9c..0f07ab5 100644
--- a/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs
+++ b/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using OpenSSH_GUI.Core.Extensions;
@@ -9,7 +10,7 @@ namespace OpenSSH_GUI.Tests.Core.Extensions;
public class DependencyInjectionExtensionsTests
{
- [Fact]
+ [AvaloniaFact]
public void RegisterViewWithViewModel_ValidNaming_ShouldRegister()
{
// Arrange
@@ -28,7 +29,7 @@ public void RegisterViewWithViewModel_ValidNaming_ShouldRegister()
});
}
- [Fact]
+ [AvaloniaFact]
public void RegisterViewWithViewModel_InvalidNaming_ShouldThrow()
{
// Arrange
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigExtensionsTests.cs
new file mode 100644
index 0000000..47aed9e
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigExtensionsTests.cs
@@ -0,0 +1,224 @@
+using Avalonia.Headless.XUnit;
+using OpenSSH_GUI.Core.Extensions;
+using OpenSSH_GUI.Core.Lib.Misc;
+using OpenSSH_GUI.SshConfig.Parsers;
+using Shouldly;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Extensions;
+
+public class SshConfigExtensionsTests
+{
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_HostWithIdentityFile_ReturnsKeyConnectionCredentials()
+ {
+ const string config = """
+ Host myserver
+ HostName example.com
+ User bob
+ IdentityFile ~/.ssh/id_rsa
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(1);
+ var credentials = result[0].ShouldBeOfType();
+ credentials.Hostname.ShouldBe("example.com");
+ credentials.Username.ShouldBe("bob");
+ credentials.Port.ShouldBe(22);
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_HostWithoutIdentityFile_ReturnsPasswordConnectionCredentials()
+ {
+ const string config = """
+ Host myserver
+ HostName example.com
+ User bob
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(1);
+ result[0].ShouldBeOfType();
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_NoHostName_FallsBackToPattern()
+ {
+ const string config = """
+ Host myserver
+ User bob
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(1);
+ result[0].Hostname.ShouldBe("myserver");
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_NoUser_FallsBackToGlobalUser()
+ {
+ const string config = """
+ User globaluser
+
+ Host myserver
+ HostName example.com
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(1);
+ result[0].Username.ShouldBe("globaluser");
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_NoUserAtAll_FallsBackToEnvironmentUserName()
+ {
+ const string config = """
+ Host myserver
+ HostName example.com
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(1);
+ result[0].Username.ShouldBe(Environment.UserName);
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_NonDefaultPort_IsUsed()
+ {
+ const string config = """
+ Host myserver
+ HostName example.com
+ User bob
+ Port 2222
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(1);
+ result[0].Hostname.ShouldBe("example.com");
+ result[0].Port.ShouldBe(2222);
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_DefaultPort22_IsNotAppended()
+ {
+ const string config = """
+ Host myserver
+ HostName example.com
+ User bob
+ Port 22
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result[0].Hostname.ShouldBe("example.com");
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_GlobalPort_UsedWhenHostHasNone()
+ {
+ const string config = """
+ Port 2200
+
+ Host myserver
+ HostName example.com
+ User bob
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result[0].Hostname.ShouldBe("example.com");
+ result[0].Port.ShouldBe(2200);
+ result[0].Username.ShouldBe("bob");
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_GlobalIdentityFile_UsedWhenHostHasNone()
+ {
+ const string config = """
+ IdentityFile ~/.ssh/global_key
+
+ Host myserver
+ HostName example.com
+ User bob
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result[0].ShouldBeOfType();
+ }
+
+ [AvaloniaTheory]
+ [InlineData("Host *\n User bob")]
+ [InlineData("Host ?erver\n User bob")]
+ [InlineData("Host !excluded\n User bob")]
+ public void GetConnectionEntriesFromConfig_WildcardOrNegatedPatterns_AreSkipped(string config)
+ {
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.ShouldBeEmpty();
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_MultiplePatternsOnOneHostLine_YieldsOneEntryPerPattern()
+ {
+ const string config = """
+ Host server1 server2
+ User bob
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(2);
+ result.Select(r => r.Hostname).ShouldBe(["server1", "server2"], ignoreOrder: true);
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_EmptyDocument_ReturnsEmpty()
+ {
+ var document = SshConfigParser.Parse(string.Empty);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.ShouldBeEmpty();
+ }
+
+ [AvaloniaFact]
+ public void GetConnectionEntriesFromConfig_MultipleHosts_ReturnsEntryPerHost()
+ {
+ const string config = """
+ Host alpha
+ HostName alpha.example.com
+ User alice
+
+ Host beta
+ HostName beta.example.com
+ User bob
+ IdentityFile ~/.ssh/beta_key
+ """;
+ var document = SshConfigParser.Parse(config);
+
+ var result = document.GetConnectionEntriesFromConfig().ToArray();
+
+ result.Length.ShouldBe(2);
+ result.ShouldContain(r => r.Hostname == "alpha.example.com" && r is PasswordConnectionCredentials);
+ result.ShouldContain(r => r.Hostname == "beta.example.com" && r is KeyConnectionCredentials);
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs
index f0d8f8b..3833777 100644
--- a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs
+++ b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.Core.Enums;
using OpenSSH_GUI.Core.Extensions;
using Shouldly;
@@ -7,13 +8,13 @@ namespace OpenSSH_GUI.Tests.Core.Extensions;
public class SshConfigFilesExtensionTests
{
- [Theory, InlineData(PlatformID.Win32NT, false, "%PROGRAMDATA%\\ssh"), InlineData(PlatformID.Unix, false, "/etc/ssh")]
+ [AvaloniaTheory, InlineData(PlatformID.Win32NT, false, "%PROGRAMDATA%\\ssh"), InlineData(PlatformID.Unix, false, "/etc/ssh")]
public void GetRootSshPath_Tests(PlatformID platform, bool resolve, string expected) { SshConfigFilesExtension.GetRootSshPath(resolve, platform).ShouldBe(expected); }
- [Theory, InlineData(PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh"), InlineData(PlatformID.Unix, false, "%HOME%/.ssh")]
+ [AvaloniaTheory, InlineData(PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh"), InlineData(PlatformID.Unix, false, "%HOME%/.ssh")]
public void GetBaseSshPath_Tests(PlatformID platform, bool resolve, string expected) { SshConfigFilesExtension.GetBaseSshPath(resolve, platform).ShouldBe(expected); }
- [Theory, InlineData(SshConfigFiles.Config, PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh\\config"),
+ [AvaloniaTheory, InlineData(SshConfigFiles.Config, PlatformID.Win32NT, false, "%USERPROFILE%\\.ssh\\config"),
InlineData(SshConfigFiles.Config, PlatformID.Unix, false, "%HOME%/.ssh/config"), InlineData(SshConfigFiles.Sshd_Config, PlatformID.Unix, false, "/etc/ssh/sshd_config")]
public void GetPathOfFile_Tests(SshConfigFiles file, PlatformID platform, bool resolve, string expected) { file.GetPathOfFile(resolve, platform).ShouldBe(expected); }
}
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs
index 8960a62..021c314 100644
--- a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs
+++ b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.Core.Extensions;
using Shouldly;
using SshNet.Keygen;
@@ -7,10 +8,10 @@ namespace OpenSSH_GUI.Tests.Core.Extensions;
public class SshKeyFormatExtensionTests
{
- [Theory, InlineData(SshKeyFormat.OpenSSH, true, "pub"), InlineData(SshKeyFormat.OpenSSH, false, null), InlineData(SshKeyFormat.PuTTYv2, false, "ppk"),
+ [AvaloniaTheory, InlineData(SshKeyFormat.OpenSSH, true, "pub"), InlineData(SshKeyFormat.OpenSSH, false, null), InlineData(SshKeyFormat.PuTTYv2, false, "ppk"),
InlineData(SshKeyFormat.PuTTYv3, true, "ppk")]
public void GetExtension_Tests(SshKeyFormat format, bool isPublic, string? expected) { format.GetExtension(isPublic).ShouldBe(expected); }
- [Theory, InlineData(SshKeyFormat.OpenSSH, "test.key", true, "test.pub"), InlineData(SshKeyFormat.PuTTYv3, "test.key", false, "test.ppk")]
+ [AvaloniaTheory, InlineData(SshKeyFormat.OpenSSH, "test.key", true, "test.pub"), InlineData(SshKeyFormat.PuTTYv3, "test.key", false, "test.ppk")]
public void ChangeExtension_Tests(SshKeyFormat format, string path, bool isPublic, string expected) { format.ChangeExtension(path, isPublic).ShouldBe(expected); }
}
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs
index 2e762d7..69cfcdb 100644
--- a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs
+++ b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.Core.Extensions;
using SshNet.Keygen;
using Xunit;
@@ -6,7 +7,7 @@ namespace OpenSSH_GUI.Tests.Core.Extensions;
public class SshKeyTypeExtensionTests
{
- [Theory, InlineData(SshKeyType.RSA), InlineData(SshKeyType.ECDSA), InlineData(SshKeyType.ED25519)]
+ [AvaloniaTheory, InlineData(SshKeyType.RSA), InlineData(SshKeyType.ECDSA), InlineData(SshKeyType.ED25519)]
public static void SshKeyType_Tests(SshKeyType sshKeyType)
{
var bitValues = sshKeyType.SupportedKeySizes;
diff --git a/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs
index a99e794..16309f3 100644
--- a/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs
+++ b/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.Core.Extensions;
using Shouldly;
using Xunit;
@@ -6,42 +7,42 @@ namespace OpenSSH_GUI.Tests.Core.Extensions;
public class StringExtensionsTests
{
- [Theory, InlineData("HelloWorld", "hello_world")]
+ [AvaloniaTheory, InlineData("HelloWorld", "hello_world")]
public void ToSnakeCase_Tests(string input, string expected) { input.ToSnakeCase().ShouldBe(expected); }
- [Theory, InlineData("HelloWorld", "helloWorld")]
+ [AvaloniaTheory, InlineData("HelloWorld", "helloWorld")]
public void ToCamelCase_Tests(string input, string expected) { input.ToCamelCase().ShouldBe(expected); }
- [Theory, InlineData("HelloWorld", "hello-world")]
+ [AvaloniaTheory, InlineData("HelloWorld", "hello-world")]
public void ToKebabCase_Tests(string input, string expected) { input.ToKebabCase().ShouldBe(expected); }
- [Theory, InlineData("hello world", "HelloWorld")]
+ [AvaloniaTheory, InlineData("hello world", "HelloWorld")]
public void ToPascalCase_Tests(string input, string expected) { input.ToPascalCase().ShouldBe(expected); }
- [Fact]
+ [AvaloniaFact]
public void SplitToChunks_Tests()
{
"abcdef".SplitToChunks(2).ShouldBe(["ab", "cd", "ef"]);
"abcde".SplitToChunks(2).ShouldBe(["ab", "cd", "e"]);
}
- [Fact]
+ [AvaloniaFact]
public void Wrap_Tests()
{
var input = "abcdef";
input.Wrap(2, "|").ShouldBe("ab|cd|ef");
}
- [Fact]
+ [AvaloniaFact]
public void ToTitleCase_Tests() { "this is a title".ToTitleCase().ShouldBe("This Is A Title"); }
- [Fact]
+ [AvaloniaFact]
public void ToSentenceCase_Tests() { "THIS IS A SENTENCE.".ToSentenceCase().ShouldBe("This is a sentence."); }
- [Fact]
+ [AvaloniaFact]
public void ToLeetSpeak_Tests() { "leetspeak".ToLeetSpeak().ShouldBe("l33t5p34k"); }
- [Fact]
+ [AvaloniaFact]
public void ToStudlyCaps_Tests()
{
// Random, so we just check it doesn't throw and length is same
diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/BasicSshKeyFileInformationTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/BasicSshKeyFileInformationTests.cs
new file mode 100644
index 0000000..4a8fb01
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/BasicSshKeyFileInformationTests.cs
@@ -0,0 +1,311 @@
+using Avalonia.Headless.XUnit;
+using OpenSSH_GUI.Core.Lib.Keys;
+using Shouldly;
+using SshNet.Keygen;
+using SshNet.Keygen.Extensions;
+using SshNet.Keygen.SshKeyEncryption;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Lib.Keys;
+
+public class BasicSshKeyFileInformationTests
+{
+ private static (string PrivatePath, string PublicPath) GenerateOpenSshKeyPair(string dir, SshKeyType type = SshKeyType.ED25519, string comment = "test-comment",
+ string? passphrase = null, string name = "id_test", int? length = null)
+ {
+ var privatePath = Path.Combine(dir, name);
+ var info = new SshKeyGenerateInfo(type) { Comment = comment };
+ if (length is { } l) info.KeyLength = l;
+ if (passphrase is not null) info.Encryption = new SshKeyEncryptionAes256(passphrase);
+ var generated = SshKey.Generate(privatePath, FileMode.Create, info);
+ var publicPath = privatePath + ".pub";
+ File.WriteAllText(publicPath, generated.ToOpenSshPublicFormat());
+ return (privatePath, publicPath);
+ }
+
+ private static string GeneratePpkKey(string dir, SshKeyType type = SshKeyType.RSA, SshKeyFormat format = SshKeyFormat.PuTTYv3, string comment = "ppk-comment",
+ string name = "id_test.ppk")
+ {
+ var path = Path.Combine(dir, name);
+ var info = new SshKeyGenerateInfo(type) { Comment = comment, KeyFormat = format };
+ SshKey.Generate(path, FileMode.Create, info);
+ return path;
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_NonExistentFile_ReturnsEmpty()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(Path.Combine(dir.FullName, "does_not_exist")));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.FingerPrint.ShouldBeEmpty();
+ result.Comment.ShouldBeEmpty();
+ result.BitLength.ShouldBe(0);
+ result.KeyType.ShouldBe(SshKeyType.RSA);
+ result.Format.ShouldBe(SshKeyFormat.OpenSSH);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_OpenSshEd25519WithPubFile_ParsesCorrectly()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, "alice@host");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.Comment.ShouldBe("alice@host");
+ result.KeyType.ShouldBe(SshKeyType.ED25519);
+ result.BitLength.ShouldBe(256);
+ result.Format.ShouldBe(SshKeyFormat.OpenSSH);
+ result.HashAlgorithmName.ShouldBe(SshKeyHashAlgorithmName.SHA256);
+ result.FingerPrint.ShouldNotBeNullOrEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_OpenSshRsaWithPubFile_ParsesCorrectBitLength()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.RSA, "rsa-comment", length: 2048);
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.KeyType.ShouldBe(SshKeyType.RSA);
+ result.BitLength.ShouldBe(2048);
+ result.Comment.ShouldBe("rsa-comment");
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaTheory]
+ [InlineData(256)]
+ [InlineData(384)]
+ [InlineData(521)]
+ public void FromKeyFileInfo_OpenSshEcdsaWithPubFile_ParsesCorrectBitLength(int length)
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ECDSA, "ecdsa-comment", length: length);
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.KeyType.ShouldBe(SshKeyType.ECDSA);
+ result.BitLength.ShouldBe(length);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_EmptyCommentInPubFile_CommentIsEmpty()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, string.Empty);
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.Comment.ShouldBeEmpty();
+ result.FingerPrint.ShouldNotBeNullOrEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_MalformedPubFileContent_ReturnsEmpty()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var privatePath = Path.Combine(dir.FullName, "id_bad");
+ File.WriteAllText(privatePath, "not a real key");
+ File.WriteAllText(privatePath + ".pub", "onlyoneword");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.FingerPrint.ShouldBeEmpty();
+ result.Comment.ShouldBeEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_PubFileWithInvalidBase64_ReturnsEmpty()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var privatePath = Path.Combine(dir.FullName, "id_bad2");
+ File.WriteAllText(privatePath, "not a real key");
+ File.WriteAllText(privatePath + ".pub", "ssh-ed25519 not-valid-base64!!! comment");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.FingerPrint.ShouldBeEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_PuttyV3Key_ParsesCorrectly()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = GeneratePpkKey(dir.FullName, SshKeyType.RSA, SshKeyFormat.PuTTYv3, "putty-comment");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.Format.ShouldBe(SshKeyFormat.PuTTYv3);
+ result.KeyType.ShouldBe(SshKeyType.RSA);
+ result.Comment.ShouldBe("putty-comment");
+ result.FingerPrint.ShouldNotBeNullOrEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_PuttyV2Key_FormatIsPuTTYv2()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = GeneratePpkKey(dir.FullName, SshKeyType.ED25519, SshKeyFormat.PuTTYv2, "putty2-comment");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.Format.ShouldBe(SshKeyFormat.PuTTYv2);
+ result.KeyType.ShouldBe(SshKeyType.ED25519);
+ result.Comment.ShouldBe("putty2-comment");
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_EncryptedPuttyKey_StillReadsCommentAndFingerprintFromPlaintextHeader()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = Path.Combine(dir.FullName, "id_enc.ppk");
+ var genInfo = new SshKeyGenerateInfo(SshKeyType.ED25519)
+ {
+ Comment = "encrypted-comment",
+ KeyFormat = SshKeyFormat.PuTTYv3,
+ Encryption = new SshKeyEncryptionAes256("s3cr3t", new PuttyV3Encryption())
+ };
+ SshKey.Generate(path, FileMode.Create, genInfo);
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.Comment.ShouldBe("encrypted-comment");
+ result.FingerPrint.ShouldNotBeNullOrEmpty();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void FromKeyFileInfo_OpenSshPrivateKeyWithoutPubFile_ReturnsEmpty_DueToUnconditionalPubFileLookup()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var (privatePath, publicPath) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, "bob@host");
+ File.Delete(publicPath);
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.FingerPrint.ShouldBeEmpty();
+ result.Comment.ShouldBeEmpty();
+ result.BitLength.ShouldBe(0);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void ToString_ForSuccessfullyParsedKey_ReturnsPopulatedString()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var (privatePath, _) = GenerateOpenSshKeyPair(dir.FullName, SshKeyType.ED25519, "carol@host");
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(privatePath));
+ var result = BasicSshKeyFileInformation.FromKeyFileInfo(info);
+
+ result.FingerPrint.ShouldNotBeNullOrEmpty();
+ result.ToString().ShouldNotBe(string.Empty);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void ToString_ForEmptyInstance_ReturnsEmptyString()
+ => new BasicSshKeyFileInformation().ToString().ShouldBeNullOrWhiteSpace();
+
+ [AvaloniaFact]
+ public void Equals_TwoDefaultInstances_AreEqual()
+ {
+ var a = new BasicSshKeyFileInformation();
+ var b = new BasicSshKeyFileInformation();
+
+ a.ShouldBe(b);
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFactoryTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFactoryTests.cs
new file mode 100644
index 0000000..d318e43
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFactoryTests.cs
@@ -0,0 +1,54 @@
+using Avalonia.Headless.XUnit;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using OpenSSH_GUI.Core.Lib.Keys;
+using Shouldly;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Lib.Keys;
+
+public class SshKeyFactoryTests
+{
+ [AvaloniaFact]
+ public void Create_ReturnsNewSshKeyFileInstance()
+ {
+ var logger = Substitute.For>();
+ var loggerFactory = Substitute.For();
+ loggerFactory.CreateLogger(Arg.Any()).Returns(Substitute.For());
+
+ var factory = new SshKeyFactory(logger, loggerFactory);
+
+ var result = factory.Create();
+
+ result.ShouldNotBeNull();
+ result.ShouldBeOfType();
+ }
+
+ [AvaloniaFact]
+ public void Create_UsesLoggerFactoryToCreateSshKeyFileLogger()
+ {
+ var logger = Substitute.For>();
+ var loggerFactory = Substitute.For();
+ loggerFactory.CreateLogger(Arg.Any()).Returns(Substitute.For());
+
+ var factory = new SshKeyFactory(logger, loggerFactory);
+ factory.Create();
+
+ loggerFactory.Received(1).CreateLogger(typeof(SshKeyFile).FullName!);
+ }
+
+ [AvaloniaFact]
+ public void Create_CalledMultipleTimes_ReturnsDistinctInstances()
+ {
+ var logger = Substitute.For>();
+ var loggerFactory = Substitute.For();
+ loggerFactory.CreateLogger(Arg.Any()).Returns(Substitute.For());
+
+ var factory = new SshKeyFactory(logger, loggerFactory);
+
+ var first = factory.Create();
+ var second = factory.Create();
+
+ first.ShouldNotBeSameAs(second);
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileInformationTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileInformationTests.cs
new file mode 100644
index 0000000..45fcf1a
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileInformationTests.cs
@@ -0,0 +1,160 @@
+using Avalonia.Headless.XUnit;
+using OpenSSH_GUI.Core.Lib.Keys;
+using Shouldly;
+using SshNet.Keygen;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Lib.Keys;
+
+public class SshKeyFileInformationTests
+{
+ [AvaloniaFact]
+ public void Constructor_ExistingOpenSshFile_ComputesMetadataCorrectly()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = Path.Combine(dir.FullName, "id_rsa");
+ File.WriteAllText(path, "dummy");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+
+ info.Exists.ShouldBeTrue();
+ info.FileName.ShouldBe("id_rsa");
+ info.FullFileName.ShouldBe(path);
+ info.DirectoryName.ShouldBe(dir.FullName);
+ info.CurrentFormat.ShouldBe(SshKeyFormat.OpenSSH);
+ info.IsOpenSshKey.ShouldBeTrue();
+ info.PublicKeyFileName.ShouldBe(path + ".pub");
+ info.CanChangeFileName.ShouldBeTrue();
+ info.Files.Length.ShouldBe(2);
+ info.AvailableFormatsForConversion.ShouldNotContain(SshKeyFormat.OpenSSH);
+ info.DefaultConversionFormat.ShouldNotBe(SshKeyFormat.OpenSSH);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Constructor_NonExistentFile_ExistsIsFalse()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = Path.Combine(dir.FullName, "missing_key");
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+
+ info.Exists.ShouldBeFalse();
+ info.FileName.ShouldBe("missing_key");
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Constructor_PpkFile_HasNoPublicKeyFileName()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = Path.Combine(dir.FullName, "id.ppk");
+ File.WriteAllText(path, "dummy");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+
+ info.CurrentFormat.ShouldBe(SshKeyFormat.PuTTYv3);
+ info.IsOpenSshKey.ShouldBeFalse();
+ info.PublicKeyFileName.ShouldBeNull();
+ info.Files.Length.ShouldBe(1);
+ info.AvailableFormatsForConversion.ShouldContain(SshKeyFormat.OpenSSH);
+ info.DefaultConversionFormat.ShouldBe(SshKeyFormat.OpenSSH);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Constructor_ProvidedByConfigSource_CanChangeFileNameIsFalse()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = Path.Combine(dir.FullName, "id_cfg");
+ File.WriteAllText(path, "dummy");
+
+ var info = new SshKeyFileInformation(SshKeyFileSource.FromConfig(path));
+
+ info.CanChangeFileName.ShouldBeFalse();
+ info.KeyFileSource.ProvidedByConfig.ShouldBeTrue();
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Constructor_EmptyPath_FallsBackToAppContextBaseDirectory()
+ {
+ var info = new SshKeyFileInformation(new SshKeyFileSource());
+
+ info.FullFileName.ShouldBe(new FileInfo(AppContext.BaseDirectory).FullName);
+ }
+
+ [AvaloniaFact]
+ public void Equals_SameKeyFileSource_AreEqual()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var path = Path.Combine(dir.FullName, "id_x");
+ File.WriteAllText(path, "dummy");
+
+ var a = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+ var b = new SshKeyFileInformation(SshKeyFileSource.FromDisk(path));
+
+ a.ShouldBe(b);
+ a.GetHashCode().ShouldBe(b.GetHashCode());
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Equals_DifferentKeyFileSource_AreNotEqual()
+ {
+ var dir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var pathA = Path.Combine(dir.FullName, "id_a");
+ var pathB = Path.Combine(dir.FullName, "id_b");
+ File.WriteAllText(pathA, "dummy");
+ File.WriteAllText(pathB, "dummy");
+
+ var a = new SshKeyFileInformation(SshKeyFileSource.FromDisk(pathA));
+ var b = new SshKeyFileInformation(SshKeyFileSource.FromDisk(pathB));
+
+ a.ShouldNotBe(b);
+ }
+ finally
+ {
+ dir.Delete(true);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Equals_Null_ReturnsFalse()
+ {
+ var info = new SshKeyFileInformation(new SshKeyFileSource());
+
+ info.Equals(null).ShouldBeFalse();
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFilePasswordTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFilePasswordTests.cs
new file mode 100644
index 0000000..3316ea2
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFilePasswordTests.cs
@@ -0,0 +1,130 @@
+using System.Text;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using OpenSSH_GUI.Core.Lib.Keys;
+using Shouldly;
+using SshNet.Keygen.SshKeyEncryption;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Lib.Keys;
+
+public class SshKeyFilePasswordTests
+{
+ [AvaloniaFact]
+ public void Default_IsNotValid_AndSpanIsEmpty()
+ {
+ using var password = new SshKeyFilePassword();
+
+ password.IsValid.ShouldBeFalse();
+ password.WrittenSpan.Length.ShouldBe(0);
+ password.GetPasswordString().ShouldBe(string.Empty);
+ }
+
+ [AvaloniaFact]
+ public void Set_WithBytes_BecomesValidAndStoresBytes()
+ {
+ using var password = new SshKeyFilePassword();
+
+ password.Set("hunter2"u8);
+
+
+ password.IsValid.ShouldBeTrue();
+ password.WrittenSpan.ToArray().ShouldBe("hunter2"u8.ToArray());
+ password.GetPasswordString().ShouldBe("hunter2");
+ }
+
+ [AvaloniaFact]
+ public void Set_WithCustomEncoding_DecodesUsingThatEncoding()
+ {
+ using var password = new SshKeyFilePassword();
+ var bytes = Encoding.Unicode.GetBytes("s3cr3t");
+
+ password.Set(bytes, Encoding.Unicode);
+
+
+ password.GetPasswordString().ShouldBe("s3cr3t");
+ }
+
+ [AvaloniaFact]
+ public void Set_CalledTwice_OverwritesPreviousValue()
+ {
+ using var password = new SshKeyFilePassword();
+
+ password.Set("first"u8);
+ password.Set("second"u8);
+
+
+ password.GetPasswordString().ShouldBe("second");
+ }
+
+ [AvaloniaFact]
+ public void Clear_ResetsToEmptyState()
+ {
+ using var password = new SshKeyFilePassword();
+ password.Set("hunter2"u8);
+
+
+ password.Clear();
+
+
+ password.IsValid.ShouldBeFalse();
+ password.WrittenSpan.Length.ShouldBe(0);
+ password.GetPasswordString().ShouldBe(string.Empty);
+ }
+
+ [AvaloniaFact]
+ public void Set_EmptyBytes_DoesNotBecomeValid()
+ {
+ using var password = new SshKeyFilePassword();
+
+ password.Set(ReadOnlySpan.Empty);
+
+
+ password.IsValid.ShouldBeFalse();
+ }
+
+ [AvaloniaFact]
+ public void ToSshKeyEncryption_WhenInvalid_ReturnsDefaultSshKeyEncryption()
+ {
+ using var password = new SshKeyFilePassword();
+
+ var encryption = password.ToSshKeyEncryption();
+
+ encryption.ShouldBeOfType();
+ }
+
+ [AvaloniaFact]
+ public void ToSshKeyEncryption_WhenValid_ReturnsAes256Encryption()
+ {
+ using var password = new SshKeyFilePassword();
+ password.Set("hunter2"u8);
+
+
+ var encryption = password.ToSshKeyEncryption();
+
+ encryption.ShouldBeOfType();
+ }
+
+ [AvaloniaFact]
+ public void Dispose_ClearsBuffer()
+ {
+ var password = new SshKeyFilePassword();
+ password.Set("hunter2"u8);
+
+
+ password.Dispose();
+
+ password.WrittenSpan.Length.ShouldBe(0);
+ }
+
+ [AvaloniaFact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var password = new SshKeyFilePassword();
+ password.Set("hunter2"u8);
+
+
+ password.Dispose();
+ Should.NotThrow(() => password.Dispose());
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileSourceTests.cs b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileSourceTests.cs
new file mode 100644
index 0000000..839a2a3
--- /dev/null
+++ b/OpenSSH_GUI.Tests/Core/Lib/Keys/SshKeyFileSourceTests.cs
@@ -0,0 +1,72 @@
+using Avalonia.Headless.XUnit;
+using OpenSSH_GUI.Core.Lib.Keys;
+using Shouldly;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.Core.Lib.Keys;
+
+public class SshKeyFileSourceTests
+{
+ [AvaloniaFact]
+ public void FromDisk_SetsAbsolutePathAndNotProvidedByConfig()
+ {
+ var source = SshKeyFileSource.FromDisk("/tmp/id_rsa");
+
+ source.AbsolutePath.ShouldBe("/tmp/id_rsa");
+ source.ProvidedByConfig.ShouldBeFalse();
+ }
+
+ [AvaloniaFact]
+ public void FromConfig_SetsAbsolutePathAndProvidedByConfig()
+ {
+ var source = SshKeyFileSource.FromConfig("/tmp/id_ed25519");
+
+ source.AbsolutePath.ShouldBe("/tmp/id_ed25519");
+ source.ProvidedByConfig.ShouldBeTrue();
+ }
+
+ [AvaloniaFact]
+ public void Default_HasEmptyPathAndNotProvidedByConfig()
+ {
+ var source = new SshKeyFileSource();
+
+ source.AbsolutePath.ShouldBe(string.Empty);
+ source.ProvidedByConfig.ShouldBeFalse();
+ }
+
+ [AvaloniaFact]
+ public void ToString_ContainsPathAndConfigFlag()
+ {
+ var source = SshKeyFileSource.FromConfig("/tmp/key");
+
+ source.ToString().ShouldBe("/tmp/key | Referenced by Config: True");
+ }
+
+ [AvaloniaFact]
+ public void Equality_SameValues_AreEqual()
+ {
+ var a = SshKeyFileSource.FromDisk("/tmp/key");
+ var b = SshKeyFileSource.FromDisk("/tmp/key");
+
+ a.ShouldBe(b);
+ (a == b).ShouldBeTrue();
+ }
+
+ [AvaloniaFact]
+ public void Equality_DifferentProvidedByConfig_AreNotEqual()
+ {
+ var a = SshKeyFileSource.FromDisk("/tmp/key");
+ var b = SshKeyFileSource.FromConfig("/tmp/key");
+
+ a.ShouldNotBe(b);
+ }
+
+ [AvaloniaFact]
+ public void Equality_DifferentPath_AreNotEqual()
+ {
+ var a = SshKeyFileSource.FromDisk("/tmp/key1");
+ var b = SshKeyFileSource.FromDisk("/tmp/key2");
+
+ a.ShouldNotBe(b);
+ }
+}
diff --git a/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs b/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs
index 273217a..a5de9a6 100644
--- a/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs
+++ b/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs
@@ -1,4 +1,5 @@
using System.Reactive.Linq;
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.Core.MVVM;
using Xunit;
@@ -6,7 +7,7 @@ namespace OpenSSH_GUI.Tests.Core.MVVM;
public class ViewModelBaseTests
{
- [Fact]
+ [AvaloniaFact]
public async Task InitializeAsync_ShouldSetIsInitialized()
{
// Arrange
@@ -19,7 +20,7 @@ public async Task InitializeAsync_ShouldSetIsInitialized()
Assert.True(vm.IsInitialized);
}
- [Fact]
+ [AvaloniaFact]
public async Task BooleanSubmit_ShouldCallOnBooleanSubmitAsync()
{
// Arrange
@@ -34,7 +35,7 @@ public async Task BooleanSubmit_ShouldCallOnBooleanSubmitAsync()
Assert.False(vm.IsInitialized);
}
- [Fact]
+ [AvaloniaFact]
public void RequestClose_ShouldInvokeCloseEvent()
{
// Arrange
diff --git a/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj b/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj
index 746a06f..11bd4af 100644
--- a/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj
+++ b/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj
@@ -3,9 +3,11 @@
false
true
Exe
+ true
+
@@ -24,6 +26,8 @@
+
+
diff --git a/OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj b/OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj
new file mode 100644
index 0000000..be2c0e3
--- /dev/null
+++ b/OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj
@@ -0,0 +1,21 @@
+
+
+ WinExe
+ net10.0
+ enable
+ app.manifest
+ true
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
diff --git a/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs b/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs
index 87cc1d2..40b45ff 100644
--- a/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs
+++ b/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs
@@ -1,49 +1,49 @@
-using Avalonia;
-using Avalonia.Headless;
-using Avalonia.Threading;
-using ReactiveUI.Builder;
-
-namespace OpenSSH_GUI.Tests;
-
-///
-/// Assembly-wide fixture that initializes ReactiveUI core services
-/// before any test runs. Required because
-/// and related types throw if ReactiveUI has not been bootstrapped.
-///
-///
-/// Assembly-wide fixture that runs a dedicated Avalonia UI thread with a
-/// live dispatcher loop. Required because
-/// enforces UI-thread access, deadlocks
-/// without a running message loop.
-///
-public sealed class ReactiveUiInitFixture : IDisposable
-{
- private readonly CancellationTokenSource _cts = new();
- private readonly ManualResetEventSlim _initialized = new();
-
- public ReactiveUiInitFixture()
- {
- var uiThread = new Thread(() =>
- {
- AppBuilder.Configure()
- .UseHeadless(new AvaloniaHeadlessPlatformOptions())
- .SetupWithoutStarting();
-
- RxAppBuilder.CreateReactiveUIBuilder()
- .WithCoreServices()
- .BuildApp();
-
- _initialized.Set();
-
- Dispatcher.UIThread.MainLoop(_cts.Token);
- });
-
- uiThread.IsBackground = true;
- uiThread.Start();
-
- _initialized.Wait();
- }
-
- ///
- public void Dispose() { _cts.Cancel(); }
-}
\ No newline at end of file
+// using Avalonia;
+// using Avalonia.Headless;
+// using Avalonia.Threading;
+// using ReactiveUI.Builder;
+//
+// namespace OpenSSH_GUI.Tests;
+//
+// ///
+// /// Assembly-wide fixture that initializes ReactiveUI core services
+// /// before any test runs. Required because
+// /// and related types throw if ReactiveUI has not been bootstrapped.
+// ///
+// ///
+// /// Assembly-wide fixture that runs a dedicated Avalonia UI thread with a
+// /// live dispatcher loop. Required because
+// /// enforces UI-thread access, deadlocks
+// /// without a running message loop.
+// ///
+// public sealed class ReactiveUiInitFixture : IDisposable
+// {
+// private readonly CancellationTokenSource _cts = new();
+// private readonly ManualResetEventSlim _initialized = new();
+//
+// public ReactiveUiInitFixture()
+// {
+// var uiThread = new Thread(() =>
+// {
+// AppBuilder.Configure()
+// .UseHeadless(new AvaloniaHeadlessPlatformOptions())
+// .SetupWithoutStarting();
+//
+// RxAppBuilder.CreateReactiveUIBuilder()
+// .WithCoreServices()
+// .BuildApp();
+//
+// _initialized.Set();
+//
+// Dispatcher.UIThread.MainLoop(_cts.Token);
+// });
+//
+// uiThread.IsBackground = true;
+// uiThread.Start();
+//
+// _initialized.Wait();
+// }
+//
+// ///
+// public void Dispose() { _cts.Cancel(); }
+// }
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs
index 99a716f..998219d 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using OpenSSH_GUI.Core.Extensions;
@@ -28,7 +29,7 @@ private string GetEmbeddedResource(string fileName)
return reader.ReadToEnd();
}
- [Fact]
+ [AvaloniaFact]
public void Parse_GlobalConfig_ShouldParseEmbeddedFile()
{
var content = GetEmbeddedResource("ssh_config_global");
@@ -44,7 +45,7 @@ public void Parse_GlobalConfig_ShouldParseEmbeddedFile()
hostStar.GetEntries().ShouldContain(e => e.Key == "ConnectTimeout" && e.Value == "20");
}
- [Fact]
+ [AvaloniaFact]
public void Parse_PersonalConfig_Into_Config_DependencyInjection()
{
var sc = new ConfigurationBuilder();
@@ -61,7 +62,7 @@ public void Parse_PersonalConfig_Into_Config_DependencyInjection()
count.ShouldBeGreaterThan(0);
}
- [Fact]
+ [AvaloniaFact]
public void Parse_PersonalConfig_ShouldParseEmbeddedFile()
{
var content = GetEmbeddedResource("ssh_config_personal");
@@ -76,7 +77,7 @@ public void Parse_PersonalConfig_ShouldParseEmbeddedFile()
prodWeb01.GetEntries().ShouldContain(e => e.Key == "HostName" && e.Value == "10.10.1.11");
}
- [Fact]
+ [AvaloniaFact]
public void Parse_SshdServerConfig_ShouldParseEmbeddedFile()
{
var content = GetEmbeddedResource("sshd_config_server");
@@ -96,7 +97,7 @@ public void Parse_SshdServerConfig_ShouldParseEmbeddedFile()
userDeploy.GetEntries().ShouldContain(e => e.Key == "AllowTcpForwarding" && e.Value == "no");
}
- [Fact]
+ [AvaloniaFact]
public void Parse_EmptyContent_ShouldReturnEmptyDocument()
{
var doc = SshConfigParser.Parse(string.Empty);
@@ -104,7 +105,7 @@ public void Parse_EmptyContent_ShouldReturnEmptyDocument()
doc.Blocks.ShouldBeEmpty();
}
- [Fact]
+ [AvaloniaFact]
public void Parse_SimpleHostBlock_ShouldParseCorrectly()
{
var content = @"
@@ -125,7 +126,7 @@ User alice
entries[1].Value.ShouldBe("alice");
}
- [Fact]
+ [AvaloniaFact]
public void Parse_GlobalItems_ShouldParseCorrectly()
{
var content = @"
@@ -143,7 +144,7 @@ User alice
doc.Blocks.Length.ShouldBe(1);
}
- [Fact]
+ [AvaloniaFact]
public void Parse_Comments_ShouldBePreserved()
{
var content = @"# Global comment
@@ -163,7 +164,7 @@ User alice
hostBlock.HeaderComment.ShouldBe("# host comment");
}
- [Fact]
+ [AvaloniaFact]
public void Parse_MultipleHosts_ShouldParseCorrectly()
{
var content = "Host host1 host2\n User bob";
@@ -174,7 +175,7 @@ public void Parse_MultipleHosts_ShouldParseCorrectly()
hostBlock.Patterns.ShouldContain("host2");
}
- [Fact]
+ [AvaloniaFact]
public void Parse_MatchBlock_ShouldParseCorrectly()
{
var content = "Match host example.com user root\n Port 22";
@@ -188,7 +189,7 @@ public void Parse_MatchBlock_ShouldParseCorrectly()
matchBlock.Criteria[1].Pattern.ShouldBe("root");
}
- [Fact]
+ [AvaloniaFact]
public void GetConnectionEntriesFromConfig_ShouldReturnCredentials()
{
var content = @"
@@ -215,7 +216,7 @@ HostName 5.6.7.8
keyHost.GetType().ShouldBe(typeof(KeyConnectionCredentials));
}
- [Fact]
+ [AvaloniaFact]
public void GetConnectionEntriesFromConfig_WithPersonalConfig_ShouldReturnCredentials()
{
var content = GetEmbeddedResource("ssh_config_personal");
@@ -231,7 +232,7 @@ public void GetConnectionEntriesFromConfig_WithPersonalConfig_ShouldReturnCreden
keyHost.ShouldNotBeNull();
}
- [Fact]
+ [AvaloniaFact]
public void Parse_IncludeRecursion_ShouldThrow()
{
// Arrange
@@ -255,7 +256,7 @@ public void Parse_IncludeRecursion_ShouldThrow()
}
}
- [Fact]
+ [AvaloniaFact]
public void Parse_UnknownKey_WithStrictOptions_ShouldThrow()
{
// Arrange
@@ -266,7 +267,7 @@ public void Parse_UnknownKey_WithStrictOptions_ShouldThrow()
Assert.Throws(() => SshConfigParser.Parse(content, options));
}
- [Fact]
+ [AvaloniaFact]
public void Parse_InvalidPort_ShouldBeHandledInSettings()
{
// Arrange
@@ -285,7 +286,7 @@ public void Parse_InvalidPort_ShouldBeHandledInSettings()
Assert.Equal("Port", settings.OtherEntries[0].Key);
}
- [Fact]
+ [AvaloniaFact]
public void Parse_QuotedValues_ShouldStripDoubleQuotes()
{
// Arrange
@@ -303,7 +304,7 @@ public void Parse_QuotedValues_ShouldStripDoubleQuotes()
Assert.Contains("~/.ssh/id rsa", settings.IdentityFiles);
}
- [Fact]
+ [AvaloniaFact]
public void Parse_EmptyLinesAndComments_ShouldPreserve()
{
// Arrange
@@ -326,7 +327,7 @@ public void Parse_EmptyLinesAndComments_ShouldPreserve()
Assert.IsType(block.Items[3]);
}
- [Fact]
+ [AvaloniaFact]
public void Parse_MatchCriteria_AllSupported()
{
// Arrange
@@ -345,7 +346,7 @@ public void Parse_MatchCriteria_AllSupported()
Assert.Contains(block.Criteria, c => c.Kind == SshMatchCriterionKind.Address);
}
- [Fact]
+ [AvaloniaFact]
public void Parse_MatchCriteria_Invalid_ShouldThrow()
{
// Arrange
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs
index 2aa1a0d..60a343a 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.SshConfig.Models;
using OpenSSH_GUI.SshConfig.Options;
using OpenSSH_GUI.SshConfig.Parsers;
@@ -9,7 +10,7 @@ namespace OpenSSH_GUI.Tests.SshConfig;
public class SshConfigSerializerTests
{
- [Fact]
+ [AvaloniaFact]
public void Serialize_SimpleDocument_ShouldProduceCorrectOutput()
{
var doc = new SshConfigDocument(
@@ -28,7 +29,7 @@ public void Serialize_SimpleDocument_ShouldProduceCorrectOutput()
output.ShouldContain(" User alice");
}
- [Fact]
+ [AvaloniaFact]
public void Serialize_RoundTrip_ShouldPreserveFormatting()
{
var input =
@@ -39,7 +40,7 @@ public void Serialize_RoundTrip_ShouldPreserveFormatting()
output.Replace("\r\n", "\n").Trim().ShouldBe(input.Replace("\r\n", "\n").Trim());
}
- [Fact]
+ [AvaloniaFact]
public void Serialize_MatchBlock_ShouldProduceCorrectOutput()
{
var criteria = new[]
@@ -55,7 +56,7 @@ public void Serialize_MatchBlock_ShouldProduceCorrectOutput()
output.ShouldContain("Match host example.com user root");
}
- [Fact]
+ [AvaloniaFact]
public void Serialize_CleanMode_WithOptions_ShouldFormatCorrectly()
{
// Arrange
@@ -78,7 +79,7 @@ public void Serialize_CleanMode_WithOptions_ShouldFormatCorrectly()
Assert.Equal("Host example\n\tUser=alice\n", output);
}
- [Fact]
+ [AvaloniaFact]
public void Serialize_RoundTrip_WithModifications_ShouldRegenerate()
{
// Arrange
@@ -117,7 +118,7 @@ public void Serialize_RoundTrip_WithModifications_ShouldRegenerate()
Assert.DoesNotContain("User alice", output);
}
- [Fact]
+ [AvaloniaFact]
public void QuoteIfNeeded_ShouldQuoteWhitespace()
{
// Arrange
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs
index 2c22f27..24bea66 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using OpenSSH_GUI.SshConfig.Extensions;
@@ -11,7 +12,7 @@ public class SshConfigurationBindingTests
{
private static IFileProvider GetEmbeddedFileProvider() => new EmbeddedFileProvider(typeof(SshConfigParserTests).Assembly, "OpenSSH_GUI.Tests.Assets.Testfiles");
- [Fact]
+ [AvaloniaFact]
public void AddSshConfig_ShouldBeBindableToObjects()
{
// Arrange
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs
index 67c996a..f00e9ff 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using Microsoft.Extensions.Configuration;
using OpenSSH_GUI.SshConfig.Extensions;
using Shouldly;
@@ -7,7 +8,7 @@ namespace OpenSSH_GUI.Tests.SshConfig;
public class SshConfigurationTests
{
- [Fact]
+ [AvaloniaFact]
public void AddSshConfig_ShouldLoadGlobalEntries()
{
// Arrange
@@ -33,7 +34,7 @@ public void AddSshConfig_ShouldLoadGlobalEntries()
}
}
- [Fact]
+ [AvaloniaFact]
public void AddSshConfig_ShouldLoadHostBlocks()
{
// Arrange
@@ -62,7 +63,7 @@ public void AddSshConfig_ShouldLoadHostBlocks()
}
}
- [Fact]
+ [AvaloniaFact]
public void AddSshConfig_Optional_ShouldNotThrowIfFileMissing()
{
// Arrange
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs
index 79f946a..c3a7bab 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.SshConfig.Extensions;
using OpenSSH_GUI.SshConfig.Models;
using Xunit;
@@ -7,7 +8,7 @@ namespace OpenSSH_GUI.Tests.SshConfig;
public class SshHostSettingsTests
{
- [Fact]
+ [AvaloniaFact]
public void GetSettings_ShouldMapCommonEntries()
{
// Arrange
@@ -40,7 +41,7 @@ public void GetSettings_ShouldMapCommonEntries()
Assert.Equal("Compression", settings.OtherEntries?[0].Key);
}
- [Fact]
+ [AvaloniaFact]
public void WithSettings_ShouldUpdateBlock()
{
// Arrange
@@ -70,7 +71,7 @@ public void WithSettings_ShouldUpdateBlock()
Assert.Equal("9000 localhost:90", reserializedSettings.LocalForwards[0]);
}
- [Fact]
+ [AvaloniaFact]
public void EmptySettings_ShouldBeCorrect()
{
// Arrange & Act
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs
index 86fb65f..7ec4fe0 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.SshConfig.Models;
using Shouldly;
using Xunit;
@@ -6,15 +7,15 @@ namespace OpenSSH_GUI.Tests.SshConfig;
public class SshKnownKeysTests
{
- [Theory, InlineData("hostname", "HostName"), InlineData("USER", "User"), InlineData("identityfile", "IdentityFile"), InlineData("UNKNOWN", "UNKNOWN")]
+ [AvaloniaTheory, InlineData("hostname", "HostName"), InlineData("USER", "User"), InlineData("identityfile", "IdentityFile"), InlineData("UNKNOWN", "UNKNOWN")]
public void Normalize_ShouldCanonicalizeCasing(string input, string expected) { SshKnownKeys.Normalize(input).ShouldBe(expected); }
- [Theory, InlineData("IdentityFile", true), InlineData("HostName", false)]
+ [AvaloniaTheory, InlineData("IdentityFile", true), InlineData("HostName", false)]
public void IsMultiOccurrenceKey_Tests(string key, bool expected) { SshKnownKeys.IsMultiOccurrenceKey(key).ShouldBe(expected); }
- [Theory, InlineData("SendEnv", true), InlineData("HostName", false)]
+ [AvaloniaTheory, InlineData("SendEnv", true), InlineData("HostName", false)]
public void IsMultiTokenKey_Tests(string key, bool expected) { SshKnownKeys.IsMultiTokenKey(key).ShouldBe(expected); }
- [Theory, InlineData("HostName", true), InlineData("SomethingRandom", false)]
+ [AvaloniaTheory, InlineData("HostName", true), InlineData("SomethingRandom", false)]
public void IsKnownKey_Tests(string key, bool expected) { SshKnownKeys.IsKnownKey(key).ShouldBe(expected); }
}
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs
index 5a11d08..631b046 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshTokenExpanderTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.SshConfig.Parsers;
using Shouldly;
using Xunit;
@@ -6,7 +7,7 @@ namespace OpenSSH_GUI.Tests.SshConfig;
public class SshTokenExpanderTests
{
- [Fact]
+ [AvaloniaFact]
public void Expand_NoTokens_ShouldReturnOriginalString()
{
var context = new SshTokenContext();
@@ -14,7 +15,7 @@ public void Expand_NoTokens_ShouldReturnOriginalString()
result.ShouldBe("nothing to see here");
}
- [Fact]
+ [AvaloniaFact]
public void Expand_PercentPercent_ShouldReturnSinglePercent()
{
var context = new SshTokenContext();
@@ -22,7 +23,7 @@ public void Expand_PercentPercent_ShouldReturnSinglePercent()
result.ShouldBe("100% sure");
}
- [Fact]
+ [AvaloniaFact]
public void Expand_CommonTokens_ShouldSubstituteCorrectly()
{
var context = new SshTokenContext(
@@ -36,7 +37,7 @@ public void Expand_CommonTokens_ShouldSubstituteCorrectly()
SshTokenExpander.Expand("user is %u", context).ShouldBe("user is bob");
}
- [Fact]
+ [AvaloniaFact]
public void Expand_UnrecognizedToken_ShouldKeepUnchanged()
{
var context = new SshTokenContext();
@@ -44,7 +45,7 @@ public void Expand_UnrecognizedToken_ShouldKeepUnchanged()
result.ShouldBe("token %z is unknown");
}
- [Fact]
+ [AvaloniaFact]
public void Expand_TrailingPercent_ShouldKeepUnchanged()
{
var context = new SshTokenContext();
@@ -52,7 +53,7 @@ public void Expand_TrailingPercent_ShouldKeepUnchanged()
result.ShouldBe("ends with %");
}
- [Fact]
+ [AvaloniaFact]
public void Expand_AllTokens_ShouldSubstituteCorrectly()
{
var context = new SshTokenContext(
diff --git a/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs
index b52c5e0..3f6ad71 100644
--- a/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs
+++ b/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs
@@ -1,3 +1,4 @@
+using Avalonia.Headless.XUnit;
using OpenSSH_GUI.SshConfig.Parsers;
using Shouldly;
using Xunit;
@@ -6,13 +7,13 @@ namespace OpenSSH_GUI.Tests.SshConfig;
public class SshWildcardMatcherTests
{
- [Theory, InlineData("example.com", "example.com", true), InlineData("example.com", "*.com", true), InlineData("example.com", "example.*", true),
+ [AvaloniaTheory, InlineData("example.com", "example.com", true), InlineData("example.com", "*.com", true), InlineData("example.com", "example.*", true),
InlineData("example.com", "*example*", true), InlineData("example.com", "ex?mple.com", true), InlineData("example.com", "other.com", false), InlineData("abc", "a?c", true),
InlineData("abc", "a*", true), InlineData("abc", "*c", true), InlineData("abc", "*", true), InlineData("abc", "abcd", false), InlineData("abc", "ab", false),
InlineData("", "*", true), InlineData("a", "", false), InlineData("", "", true), InlineData("abc", "***", true), InlineData("abc", "*b*", true), InlineData("abc", "a**c", true)]
public void MatchesGlob_Tests(string input, string pattern, bool expected) { SshWildcardMatcher.MatchesGlob(input.AsSpan(), pattern.AsSpan()).ShouldBe(expected); }
- [Theory, InlineData(
+ [AvaloniaTheory, InlineData(
"host1", new[]
{
"host1", "host2"
diff --git a/OpenSSH_GUI.Tests/TestAssemblySetup.cs b/OpenSSH_GUI.Tests/TestAssemblySetup.cs
index 31d4918..c209866 100644
--- a/OpenSSH_GUI.Tests/TestAssemblySetup.cs
+++ b/OpenSSH_GUI.Tests/TestAssemblySetup.cs
@@ -1,4 +1,4 @@
using OpenSSH_GUI.Tests;
using Xunit;
-[assembly: AssemblyFixture(typeof(ReactiveUiInitFixture))]
\ No newline at end of file
+// [assembly: AssemblyFixture(typeof(ReactiveUiInitFixture))]
\ No newline at end of file
diff --git a/OpenSSH_GUI.Tests/ViewModels/_SmokeTests.cs b/OpenSSH_GUI.Tests/ViewModels/_SmokeTests.cs
new file mode 100644
index 0000000..788a7f3
--- /dev/null
+++ b/OpenSSH_GUI.Tests/ViewModels/_SmokeTests.cs
@@ -0,0 +1,23 @@
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Threading;
+using Shouldly;
+using Xunit;
+
+namespace OpenSSH_GUI.Tests.ViewModels;
+
+public class SmokeTests
+{
+ [AvaloniaFact]
+ public async Task Clipboard_Roundtrip_Works_On_Headless_Window()
+ {
+ var window = new Window();
+ window.Clipboard.ShouldNotBeNull();
+ await window.Clipboard!.SetValueAsync(DataFormat.Text, "hello-world");
+ await window.Clipboard!.FlushAsync();
+ var text = await window.Clipboard!.TryGetTextAsync();
+ text.ShouldBe("hello-world");
+ }
+}
diff --git a/OpenSSH_GUI.slnx b/OpenSSH_GUI.slnx
index 08d6113..3866093 100644
--- a/OpenSSH_GUI.slnx
+++ b/OpenSSH_GUI.slnx
@@ -4,33 +4,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
-
-
-
+
diff --git a/OpenSSH_GUI/App.axaml.cs b/OpenSSH_GUI/App.axaml.cs
index 2fc7291..a604d53 100644
--- a/OpenSSH_GUI/App.axaml.cs
+++ b/OpenSSH_GUI/App.axaml.cs
@@ -127,6 +127,7 @@ public override async void OnFrameworkInitializationCompleted()
logger.LogError(e, "Error creating app icons");
throw;
}
+ iconStore.Freeze();
try
{
diff --git a/OpenSSH_GUI/OpenSSH_GUI.csproj b/OpenSSH_GUI/OpenSSH_GUI.csproj
index 6269eee..7c3a5a5 100644
--- a/OpenSSH_GUI/OpenSSH_GUI.csproj
+++ b/OpenSSH_GUI/OpenSSH_GUI.csproj
@@ -1,76 +1,78 @@
-
-
- WinExe
- true
- app.manifest
- true
- true
- true
- true
- ../images/openssh-gui.ico
- false
- frequency403
- en
- OpenSSH-GUI.snk
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
-
-
- Assets/%(Filename)%(Extension)
-
-
-
-
-
- PublicResXFileCodeGenerator
- StringsAndTexts.Designer.cs
-
-
-
-
- True
- True
- StringsAndTexts.resx
-
-
- SubmitButtons.axaml
- Code
-
-
-
-
- OpenSSH GUI
-
- OpenSSH GUI
- frequency403.opensshgui
- 1.0.0
- APPL
- OpenSSHGui
- OpenSSHGui.icns
-
- NSApplication
- true
-
+
+
+ WinExe
+ true
+ app.manifest
+ true
+ true
+ true
+ true
+ ../images/openssh-gui.ico
+ false
+ frequency403
+ en
+ OpenSSH-GUI.snk
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+ Assets/%(Filename)%(Extension)
+
+
+
+
+
+ PublicResXFileCodeGenerator
+ StringsAndTexts.Designer.cs
+
+
+
+
+ True
+ True
+ StringsAndTexts.resx
+
+
+ SubmitButtons.axaml
+ Code
+
+
+
+
+ OpenSSH GUI
+
+ OpenSSH GUI
+ frequency403.opensshgui
+ 1.0.0
+ APPL
+ OpenSSHGui
+ OpenSSHGui.icns
+
+ NSApplication
+ true
+
\ No newline at end of file
diff --git a/OpenSSH_GUI/Program.cs b/OpenSSH_GUI/Program.cs
index f072e6f..b11d527 100644
--- a/OpenSSH_GUI/Program.cs
+++ b/OpenSSH_GUI/Program.cs
@@ -28,11 +28,10 @@ internal sealed class Program
public const string AppName = "OpenSSH GUI";
public const string VersionEnvVar = "RUNNING_VERSION";
- private static string GetHostVersion() => Assembly.GetEntryAssembly()
- ?.GetCustomAttribute()
- ?.InformationalVersion
- ?? Assembly.GetEntryAssembly()?.GetName().Version?.ToString()
- ?? "0.0.0";
+ private static string GetHostVersion(Assembly? assembly) => assembly?.GetCustomAttribute()
+ ?.InformationalVersion
+ ?? Assembly.GetEntryAssembly()?.GetName().Version?.ToString()
+ ?? "0.0.0";
private static void ConfigureOpenSshGuiLogger(
ApplicationConfiguration bootstrapConfig,
@@ -59,7 +58,7 @@ private static void ConfigureOpenSshGuiLogger(
#pragma warning disable CA1416
[STAThread]
- public static async Task Main(string[] args)
+ public static void Main(string[] args)
{
using var mainCancellationTokenSource = new CancellationTokenSource();
@@ -91,9 +90,9 @@ public static async Task Main(string[] args)
.WithExceptionHandler(host.Services.GetRequiredService());
});
- await host.StartAsync(mainCancellationTokenSource.Token);
+ host.Start();
appBuilder.StartWithClassicDesktopLifetime(args);
- await host.StopAsync(mainCancellationTokenSource.Token);
+ host.StopAsync(mainCancellationTokenSource.Token).GetAwaiter().GetResult();
}
#pragma warning restore CA1416
@@ -108,7 +107,7 @@ private static void ConfigureAppConfiguration(HostBuilderContext hostBuilderCont
configurationBuilder.AddInMemoryCollection(
[
- new KeyValuePair(VersionEnvVar, GetHostVersion())
+ new KeyValuePair(VersionEnvVar, GetHostVersion(Assembly.GetEntryAssembly()))
]);
}
diff --git a/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs b/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs
index eccba23..c97e0e8 100644
--- a/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs
+++ b/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs
@@ -155,7 +155,7 @@ private Task DeleteLookupPathAsync(string path, CancellationToken cancellationTo
[ReactiveCommand]
private async Task AddLookupPathAsync(CancellationToken cancellationToken = default)
{
- if (OwnerTopLevel is { StorageProvider: { } storageProvider} && await storageProvider.OpenFolderPickerAsync(
+ if (GetTopLevel() is { StorageProvider: { } storageProvider} && await storageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions
{
AllowMultiple = false
@@ -276,7 +276,7 @@ private void DeleteOldLogFiles()
}
[ReactiveCommand]
- private async Task OpenCacheFolder(CancellationToken token = default) => OwnerTopLevel is { Launcher: { } launcher }
+ private async Task OpenCacheFolder(CancellationToken token = default) => GetTopLevel() is { Launcher: { } launcher }
&& await launcher.LaunchDirectoryInfoAsync(
new DirectoryInfo(
Path.Combine(
diff --git a/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs b/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs
index f961bb1..27769cb 100644
--- a/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs
+++ b/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs
@@ -1,6 +1,9 @@
-using Avalonia.Input.Platform;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Threading;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
+using OpenSSH_GUI.Core.Lib.Misc;
using OpenSSH_GUI.Core.MVVM;
using ReactiveUI.SourceGenerators;
@@ -27,9 +30,9 @@ protected override async Task BooleanSubmitAsync(bool inputParameter,
{
try
{
- if (inputParameter && OwnerTopLevel is { Clipboard: { } clipboard })
+ if (inputParameter && GetTopLevel() is { Clipboard: { } clipboard })
{
- await clipboard.SetTextAsync(Export);
+ await clipboard.SetValueAsync(DataFormat.Text, Export);
await clipboard.FlushAsync();
}
}
diff --git a/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs b/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs
index 2228c6f..379ce83 100644
--- a/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs
+++ b/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Reactive.Disposables.Fluent;
using System.Reactive.Linq;
+using Avalonia.Input;
using Avalonia.Input.Platform;
using DynamicData;
using JetBrains.Annotations;
@@ -8,6 +9,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenSSH_GUI.Core.Lib.Keys;
+using OpenSSH_GUI.Core.Lib.Misc;
using OpenSSH_GUI.Core.MVVM;
using OpenSSH_GUI.Core.Services;
using OpenSSH_GUI.Dialogs.Enums;
@@ -238,9 +240,9 @@ private async Task CopyPasswordIntoClipboardAsync(SshKeyFilePassword password, C
{
try
{
- if (OwnerTopLevel is { Clipboard: { } clipboard })
+ if (GetTopLevel() is { Clipboard: { } clipboard })
{
- await clipboard.SetTextAsync(password.GetPasswordString());
+ await clipboard.SetValueAsync(DataFormat.Text, Password);
await clipboard.FlushAsync();
}
diff --git a/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs b/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs
index 2aaadc0..8650dfc 100644
--- a/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs
+++ b/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs
@@ -229,7 +229,7 @@ private async Task OpenBrowserAsync(int commandTypeParameter, CancellationToken
break;
}
- if(OwnerTopLevel is { Launcher: { } launcher})
+ if(GetTopLevel() is { Launcher: { } launcher})
await launcher.LaunchUriAsync(uriBuilder.Uri);
}
diff --git a/appimage/AppImageBuilder.yml b/appimage/AppImageBuilder.yml
deleted file mode 100644
index 426592a..0000000
--- a/appimage/AppImageBuilder.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-version: 1
-
-AppDir:
- path: ./AppDir
-
- app_info:
- id: io.github.frequency403.openssh_gui
- name: OpenSSH GUI
- icon: openssh-gui
- version: ${APPSTREAM_VERSION}
- exec: usr/bin/openssh-gui
- exec_args: "$@"
-
- runtime:
- env:
- APPDIR_LIBRARY_PATH: "$APPDIR/usr/lib"
-
-AppImage:
- arch: x86_64
diff --git a/openssh-gui-git/PKGBUILD b/openssh-gui-git/PKGBUILD
index 6d2ef41..e44aa4e 100644
--- a/openssh-gui-git/PKGBUILD
+++ b/openssh-gui-git/PKGBUILD
@@ -1,26 +1,52 @@
pkgname=openssh-gui-git
_pkgname=OpenSSH-GUI
-pkgver=3.2.0.20260612.a1b2c3d
+_pkgdirname=OpenSSH_GUI
+pkgver=3.2.3.274+ca203b0
pkgrel=1
pkgdesc="A GUI for OpenSSH configuration and management (Sourcepackage)"
arch=('x86_64')
url="https://github.com/frequency403/OpenSSH-GUI"
license=('MIT')
-options=('!strip')
+options=('!strip')
depends=('icu' 'openssl' 'zlib' 'krb5' 'libx11')
makedepends=('git' 'dotnet-sdk-10.0' 'librsvg')
provides=('openssh-gui')
-conflicts=('openssh-gui' 'openssh-gui-bin' 'openssh-gui-nightly')
+conflicts=('openssh-gui' 'openssh-gui-bin')
source=("git+${url}.git#branch=development")
sha256sums=('SKIP')
+prepare() {
+ cd "${srcdir}/${_pkgname}"
+ git fetch --all
+ git checkout development
+ git reset --hard origin/development
+ dotnet restore ${_pkgdirname}/${_pkgdirname}.csproj
+}
+
+pkgver() {
+ cd "${srcdir}/${_pkgname}"
+ git fetch --all -q
+ git reset --hard origin/development -q
+ local version
+ version=$(dotnet msbuild ${_pkgdirname}/${_pkgdirname}.csproj \
+ -nologo \
+ -restore:false \
+ -getProperty:Version \
+ | tr -d '\r' \
+ | tr '-' '.')
+ printf "%s.%s+%s" \
+ "$version" \
+ "$(git rev-list --count HEAD)" \
+ "$(git rev-parse --short HEAD)"
+}
+
build() {
cd "${srcdir}/${_pkgname}"
- dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \
+ dotnet publish ${_pkgdirname}/${_pkgdirname}.csproj \
--configuration Release \
--runtime linux-x64 \
--output publish \
@@ -28,7 +54,8 @@ build() {
-p:PublishSingleFile=true \
-p:PublishReadyToRun=false \
-p:IncludeNativeLibrariesForSelfExtract=true \
- -p:InformationalVersion="${pkgver}"
+ -p:DebugType=none \
+ -p:Version="${pkgver}"
rsvg-convert -w 256 -h 256 images/openssh-gui.svg -o appicon-256.png
}
@@ -36,7 +63,7 @@ build() {
package() {
cd "${srcdir}/${_pkgname}"
- install -Dm755 "publish/OpenSSH_GUI" \
+ install -Dm755 "publish/${_pkgdirname}" \
"${pkgdir}/usr/bin/openssh-gui"
install -Dm644 "appicon-256.png" \
@@ -50,4 +77,4 @@ package() {
install -Dm644 "LICENSE" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
-}
\ No newline at end of file
+}