diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 3c4165f..aac7269 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -2,7 +2,7 @@ name: Auto-Tag on Release Merge on: pull_request: - types: [closed] + types: [ closed ] branches: - master - main @@ -12,10 +12,9 @@ permissions: jobs: create-tag: + name: Create and Push Tag # Only run when a release/* branch was actually merged (not just closed) - if: > - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release/') + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') runs-on: ubuntu-latest steps: @@ -29,18 +28,21 @@ jobs: id: version run: | BRANCH="${{ github.event.pull_request.head.ref }}" + # Strip 'release/' prefix VERSION="${BRANCH#release/}" + # Strip optional 'v' prefix VERSION="${VERSION#v}" + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" echo "TAG=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "Detected version: $VERSION (tag: v${VERSION})" + echo "::notice ::Detected version: $VERSION (tag: v${VERSION})" - name: Check if tag already exists id: check run: | if git rev-parse "refs/tags/${{ steps.version.outputs.TAG }}" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" - echo "Tag ${{ steps.version.outputs.TAG }} already exists, skipping." + echo "::warning ::Tag ${{ steps.version.outputs.TAG }} already exists, skipping." else echo "exists=false" >> "$GITHUB_OUTPUT" fi @@ -51,4 +53,5 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag -a "${{ steps.version.outputs.TAG }}" -m "Release ${{ steps.version.outputs.VERSION }}" - git push origin "${{ steps.version.outputs.TAG }}" \ No newline at end of file + git push origin "${{ steps.version.outputs.TAG }}" + echo "::notice ::Tag ${{ steps.version.outputs.TAG }} pushed successfully." \ No newline at end of file diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml new file mode 100644 index 0000000..ffc3fb1 --- /dev/null +++ b/.github/workflows/build-and-package.yml @@ -0,0 +1,153 @@ +name: Build and Package + +on: + workflow_call: + inputs: + version: + required: true + type: string + is_nightly: + required: false + type: boolean + default: false + asset_name_prefix: + required: false + type: string + default: "OpenSSH-GUI" + +jobs: + build: + name: Build for ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + matrix: + include: + - target: linux-x64 + asset_extension: '' + - target: win-x64 + asset_extension: '.exe' + - target: osx-x64 + asset_extension: '' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Determine .NET version + id: dotnet-version + run: | + TFM=$(grep -oPm1 '(?<=net)[0-9.]+' Directory.Build.props) + echo "version=${TFM}.x" >> "$GITHUB_OUTPUT" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ steps.dotnet-version.outputs.version }} + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-dotnet-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props', '**/Directory.Build.props') }} + restore-keys: | + ${{ runner.os }}-dotnet- + + - name: Publish application + run: | + dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \ + --configuration Release \ + --runtime ${{ matrix.target }} \ + --output "./publish" \ + -p:PublishSingleFile=true \ + -p:PublishReadyToRun=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:Version="${{ inputs.version }}" \ + ${{ inputs.is_nightly && '-p:IsNightly=true' || '' }} + + - name: Rename artifact + id: rename + run: | + ASSET_NAME="${{ inputs.asset_name_prefix }}-${{ matrix.target }}${{ matrix.asset_extension }}" + mv ./publish/OpenSSH_GUI${{ matrix.asset_extension }} "./publish/$ASSET_NAME" + echo "ASSET_NAME=$ASSET_NAME" >> "$GITHUB_OUTPUT" + + # --- AppImage (Linux only) --- + - name: Build AppImage + if: matrix.target == 'linux-x64' + id: appimage + run: | + sudo apt-get update && sudo apt-get install -y librsvg2-bin + + # Download appimagetool + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool + chmod +x appimagetool + + # Create AppDir structure + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + mkdir -p AppDir/usr/share/icons/hicolor/scalable/apps + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/metainfo + + cp "./publish/${{ steps.rename.outputs.ASSET_NAME }}" AppDir/usr/bin/openssh-gui + chmod +x AppDir/usr/bin/openssh-gui + + # Convert SVG to PNG for AppImage icon + # Use rsvg-convert to create a high-quality PNG icon + rsvg-convert -w 256 -h 256 images/openssh-gui.svg -o AppDir/usr/share/icons/hicolor/256x256/apps/openssh-gui.png + cp images/openssh-gui.svg AppDir/usr/share/icons/hicolor/scalable/apps/openssh-gui.svg + cp AppDir/usr/share/icons/hicolor/256x256/apps/openssh-gui.png AppDir/openssh-gui.png + cp AppDir/usr/share/icons/hicolor/256x256/apps/openssh-gui.png AppDir/appicon.png + + cp appimage/io.github.frequency403.openssh_gui.metainfo.xml AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml + + # Use ~ for nightly versions in appstream metadata + APPSTREAM_VERSION="${{ inputs.version }}" + if [[ "${{ inputs.is_nightly }}" == "true" ]]; then + APPSTREAM_VERSION="${APPSTREAM_VERSION//+/~}" + fi + + sed -i "s|||" \ + AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml + + appstreamcli make-desktop-file \ + AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml \ + AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop + + cp AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop \ + AppDir/io.github.frequency403.openssh_gui.desktop + + cp appimage/AppRun AppDir/AppRun + chmod +x AppDir/AppRun + + # Build AppImage + APPIMAGE_NAME="${{ inputs.asset_name_prefix }}-x86_64.AppImage" + ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir "$APPIMAGE_NAME" + echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> "$GITHUB_OUTPUT" + + - name: Upload AppImage artifact + if: matrix.target == 'linux-x64' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.appimage.outputs.APPIMAGE_NAME }} + path: ${{ steps.appimage.outputs.APPIMAGE_NAME }} + + - name: Upload generated desktop file + if: matrix.target == 'linux-x64' + uses: actions/upload-artifact@v4 + with: + name: io.github.frequency403.openssh_gui.desktop + path: AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop + + - name: Upload appicon artifact + if: matrix.target == 'linux-x64' + uses: actions/upload-artifact@v4 + with: + name: appicon + path: AppDir/appicon.png + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.rename.outputs.ASSET_NAME }} + path: ./publish/${{ steps.rename.outputs.ASSET_NAME }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9549a69..be9449c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,162 +3,53 @@ on: push: tags: - - 'v[0-9]*.[0-9]*.[0-9]*'# Trigger only on version tags like v1.2.3 + - 'v[0-9]*.[0-9]*.[0-9]*' # Trigger only on version tags like v1.2.3 + +permissions: + contents: write jobs: # --- JOB 1: BUILD --- - # This job runs in a matrix to build for all target platforms in parallel. build: - name: Build for ${{ matrix.target }} - runs-on: ubuntu-latest - strategy: - matrix: - include: - - target: linux-x64 - asset_name: OpenSSH-GUI-linux-x64 - asset_extension: '' - - target: win-x64 - asset_name: OpenSSH-GUI-win-x64 - asset_extension: '.exe' - - target: osx-x64 - asset_name: OpenSSH-GUI-osx-x64 - asset_extension: '' - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Determine .NET version from project - id: dotnet-version - run: | - TFM=$(grep -oPm1 '(?<=net)[0-9.]+' Directory.Build.props) - echo "version=${TFM}.x" >> "$GITHUB_OUTPUT" - echo "Detected TargetFramework: net${TFM} → installing SDK ${TFM}.x" - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ steps.dotnet-version.outputs.version }} - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-dotnet-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-dotnet- - - # Restore, build, and publish the application for the specific target - - name: Publish application - run: | - dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \ - --configuration Release \ - --runtime ${{ matrix.target }} \ - --output "./publish" \ - -p:PublishSingleFile=true \ - -p:PublishReadyToRun=true \ - -p:IncludeNativeLibrariesForSelfExtract=true \ - -p:Version="${GITHUB_REF_NAME#v}" - - # Rename the output file to the desired asset name - - name: Rename artifact - run: mv ./publish/OpenSSH_GUI${{ matrix.asset_extension }} ./publish/${{ matrix.asset_name }}${{ matrix.asset_extension }} - - # --- AppImage (Linux only) --- - - name: Build AppImage - if: matrix.target == 'linux-x64' - run: | - # Download appimagetool - wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool - chmod +x appimagetool - - # Create AppDir structure - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/metainfo - - cp ./publish/${{ matrix.asset_name }} AppDir/usr/bin/openssh-gui - chmod +x AppDir/usr/bin/openssh-gui - - cp OpenSSH_GUI/Assets/appicon.png AppDir/usr/share/icons/hicolor/256x256/apps/openssh-gui.png - cp OpenSSH_GUI/Assets/appicon.png AppDir/openssh-gui.png - cp appimage/io.github.frequency403.openssh_gui.metainfo.xml AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml - sed -i "s|||" \ - AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml - - appstreamcli make-desktop-file \ - AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml \ - AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop - - cp AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop \ - AppDir/io.github.frequency403.openssh_gui.desktop - - cp appimage/AppRun AppDir/AppRun - chmod +x AppDir/AppRun - - # Build AppImage - ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir OpenSSH-GUI-x86_64.AppImage - - - name: Upload AppImage artifact - if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v4 - with: - name: OpenSSH-GUI-x86_64.AppImage - path: OpenSSH-GUI-x86_64.AppImage - - - name: Upload generated desktop file - if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v4 - with: - name: io.github.frequency403.openssh_gui.desktop - path: AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.asset_name }}${{ matrix.asset_extension }} - path: ./publish/${{ matrix.asset_name }}${{ matrix.asset_extension }} + uses: ./.github/workflows/build-and-package.yml + with: + version: ${{ github.ref_name }} + is_nightly: false + asset_name_prefix: OpenSSH-GUI # --- JOB 2: RELEASE --- - # This job runs only ONCE after all build jobs have successfully completed. release: name: Create GitHub Release runs-on: ubuntu-latest - # The 'needs' keyword ensures that this job waits for the 'build' job to finish needs: build - permissions: - contents: write # Required to create a release and upload assets - # Ensure this job only runs for tag pushes, not for manual dispatches that should only build if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') steps: - name: Checkout repository uses: actions/checkout@v4 - # Download all artifacts (from the matrix builds) into a single directory - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: artifacts/ - - name: Display structure of downloaded files - run: ls -R artifacts - - - name: Prepare extra assets + - name: Flatten and Prepare Assets run: | - cp OpenSSH_GUI/Assets/appicon.png artifacts/ - cp LICENSE artifacts/ + rm -rf release-assets + mkdir -p release-assets + + find artifacts -type f -exec cp {} release-assets/ \; + + cp LICENSE release-assets/LICENSE + + echo "Release Assets:" + ls -la release-assets - # Create a single release and upload all files from the 'artifacts' directory - name: Create Release and Upload Assets uses: softprops/action-gh-release@v2 with: - # The release will be created from the pushed tag tag_name: ${{ github.ref_name }} - # All files downloaded into the 'artifacts' directory will be uploaded - files: "artifacts/**/*" - # Automatically generate the release body from commits since the last tag + files: "release-assets/*" generate_release_notes: true deploy-aur: @@ -172,32 +63,39 @@ jobs: with: fetch-depth: 0 - - name: Download Linux Artifact - uses: actions/download-artifact@v4 - with: - name: OpenSSH-GUI-linux-x64 - path: ./ - - - name: Download generated desktop file + - name: Download Artifacts uses: actions/download-artifact@v4 with: - name: io.github.frequency403.openssh_gui.desktop - path: ./ + path: artifacts/ - - name: Update PKGBUILD for openssh-gui-bin + - name: Prepare for PKGBUILD update run: | + rm -rf aur-assets + mkdir -p aur-assets + + find artifacts -type f -exec cp {} aur-assets/ \; + + echo "AUR asset files:" + ls -la aur-assets + + test -f aur-assets/OpenSSH-GUI-linux-x64 + test -f aur-assets/appicon.png + test -f aur-assets/io.github.frequency403.openssh_gui.desktop + test -f LICENSE + VERSION=${GITHUB_REF_NAME#v} - SHA_BIN=$(sha256sum OpenSSH-GUI-linux-x64 | cut -d' ' -f1) - SHA_ICON=$(sha256sum OpenSSH_GUI/Assets/appicon.png | cut -d' ' -f1) - SHA_DESKTOP=$(sha256sum io.github.frequency403.openssh_gui.desktop | cut -d' ' -f1) + SHA_BIN=$(sha256sum aur-assets/OpenSSH-GUI-linux-x64 | cut -d' ' -f1) + SHA_ICON=$(sha256sum aur-assets/appicon.png | cut -d' ' -f1) + SHA_DESKTOP=$(sha256sum aur-assets/io.github.frequency403.openssh_gui.desktop | cut -d' ' -f1) SHA_LICENSE=$(sha256sum LICENSE | cut -d' ' -f1) - + sed -i "s/^pkgver=.*/pkgver=$VERSION/" openssh-gui-bin/PKGBUILD - sed -i "s/sha256sums=.*/sha256sums=('$SHA_BIN' '$SHA_ICON' '$SHA_DESKTOP' '$SHA_LICENSE')/" openssh-gui-bin/PKGBUILD - - # Also update openssh-gui-git pkgver for consistency + sed -i "s/^sha256sums=.*/sha256sums=('$SHA_BIN' '$SHA_ICON' '$SHA_DESKTOP' '$SHA_LICENSE')/" openssh-gui-bin/PKGBUILD sed -i "s/^pkgver=.*/pkgver=$VERSION/" openssh-gui-git/PKGBUILD + echo "Updated openssh-gui-bin PKGBUILD:" + grep -E '^(pkgver=|sha256sums=)' openssh-gui-bin/PKGBUILD + - name: Update AUR (openssh-gui-bin) uses: KSXGitHub/github-actions-deploy-aur@v4.1.1 with: @@ -216,4 +114,32 @@ jobs: commit_username: ${{ github.repository_owner }} commit_email: ${{ github.repository_owner }}@users.noreply.github.com ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - commit_message: "Update to ${{ github.ref_name }}" \ No newline at end of file + commit_message: "Update to ${{ github.ref_name }}" + + winget: + name: Update Winget Package + runs-on: ubuntu-latest + needs: release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + + steps: + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Install Komac + run: | + curl -sL \ + "https://github.com/russellbanks/Komac/releases/latest/download/komac-linux-amd64" \ + -o komac + chmod +x komac + + - name: Update Winget manifest + run: | + ./komac update "frequency403.OpenSSHGUI" \ + --version "${{ steps.version.outputs.VERSION }}" \ + --urls "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/OpenSSH-GUI-win-x64.exe" \ + --submit \ + --token "${{ secrets.WINGET_GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 9679a76..8a50dbf 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -5,230 +5,90 @@ on: branches: - development -env: - BUILD_CONFIGURATION: Release - permissions: contents: write jobs: - # --- JOB 1: BUILD --- - build: - name: Build for ${{ matrix.target }} + # --- JOB 0: PREPARE --- + prepare: + name: Prepare Metadata runs-on: ubuntu-latest - strategy: - matrix: - include: - - target: linux-x64 - asset_name: OpenSSH-GUI-nightly-linux-x64 - asset_extension: "" - - target: win-x64 - asset_name: OpenSSH-GUI-nightly-win-x64 - asset_extension: ".exe" - - target: osx-x64 - asset_name: OpenSSH-GUI-nightly-osx-x64 - asset_extension: "" - + outputs: + version: ${{ steps.meta.outputs.version }} + base_version: ${{ steps.meta.outputs.base_version }} + git_hash: ${{ steps.meta.outputs.git_hash }} + build_date: ${{ steps.meta.outputs.build_date }} steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Determine .NET version from project - id: dotnet-version - run: | - TFM=$(grep -oPm1 '(?<=net)[0-9.]+' Directory.Build.props) - echo "version=${TFM}.x" >> "$GITHUB_OUTPUT" - echo "Detected TargetFramework: net${TFM} → installing SDK ${TFM}.x" - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ steps.dotnet-version.outputs.version }} - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-dotnet-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-dotnet- - - - name: Resolve git metadata + - name: Resolve metadata id: meta run: | HASH=$(git rev-parse --short HEAD) DATE=$(date +%Y-%m-%d) BASE_VERSION=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) VERSION="${BASE_VERSION}+${HASH}" - echo "GIT_HASH=$HASH" >> "$GITHUB_ENV" - echo "BUILD_DATE=$DATE" >> "$GITHUB_ENV" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "BASE_VERSION=$BASE_VERSION" >> "$GITHUB_ENV" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" + echo "git_hash=$HASH" >> "$GITHUB_OUTPUT" + echo "build_date=$DATE" >> "$GITHUB_OUTPUT" - - name: Publish application - run: | - dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \ - --configuration ${{ env.BUILD_CONFIGURATION }} \ - --runtime ${{ matrix.target }} \ - --output "./publish" \ - -p:PublishSingleFile=true \ - -p:PublishReadyToRun=true \ - -p:IncludeNativeLibrariesForSelfExtract=true \ - -p:Version="${{ env.VERSION }}" - - - name: Rename artifact - run: mv ./publish/OpenSSH_GUI${{ matrix.asset_extension }} ./publish/${{ matrix.asset_name }}${{ matrix.asset_extension }} - - # --- AppImage (Linux only) --- - - name: Build AppImage - if: matrix.target == 'linux-x64' - run: | - wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool - chmod +x appimagetool - - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/metainfo - - cp ./publish/${{ matrix.asset_name }} AppDir/usr/bin/openssh-gui - chmod +x AppDir/usr/bin/openssh-gui - - cp OpenSSH_GUI/Assets/appicon.png AppDir/usr/share/icons/hicolor/256x256/apps/openssh-gui.png - cp OpenSSH_GUI/Assets/appicon.png AppDir/openssh-gui.png - APPSTREAM_VERSION="${VERSION//+/~}" - cp appimage/io.github.frequency403.openssh_gui.metainfo.xml AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml - sed -i "s|||" \ - AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml - - appstreamcli make-desktop-file \ - AppDir/usr/share/metainfo/io.github.frequency403.openssh_gui.metainfo.xml \ - AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop - - cp AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop \ - AppDir/io.github.frequency403.openssh_gui.desktop - - cp appimage/AppRun AppDir/AppRun - chmod +x AppDir/AppRun - - ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir OpenSSH-GUI-nightly-x86_64.AppImage - - - name: Upload AppImage artifact - if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v4 - with: - name: OpenSSH-GUI-nightly-x86_64.AppImage - path: OpenSSH-GUI-nightly-x86_64.AppImage - - - name: Upload generated desktop file - if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v4 - with: - name: io.github.frequency403.openssh_gui.desktop - path: AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.asset_name }}${{ matrix.asset_extension }} - path: ./publish/${{ matrix.asset_name }}${{ matrix.asset_extension }} + # --- JOB 1: BUILD --- + build: + needs: prepare + uses: ./.github/workflows/build-and-package.yml + with: + version: ${{ needs.prepare.outputs.version }} + is_nightly: true + asset_name_prefix: OpenSSH-GUI-nightly - # --- JOB 2: NIGHTLY RELEASE --- - nightly-release: - name: Create Nightly Release + deploy-aur-nightly: + name: Update AUR Nightly Package runs-on: ubuntu-latest - needs: build - + needs: [ prepare, build ] + steps: - - name: Checkout + - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Resolve git metadata - run: | - HASH=$(git rev-parse --short HEAD) - DATE=$(date +%Y-%m-%d) - BASE_VERSION=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) - echo "GIT_HASH=$HASH" >> "$GITHUB_ENV" - echo "BUILD_DATE=$DATE" >> "$GITHUB_ENV" - echo "VERSION=${BASE_VERSION}+${HASH}" >> "$GITHUB_ENV" - - - name: Download all build artifacts + - name: Download Artifacts uses: actions/download-artifact@v4 with: path: artifacts/ - - name: Display structure of downloaded files - run: ls -R artifacts - - - name: Update nightly release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare for PKGBUILD update run: | - NOTES="**Branch:** \`development\` - **Commit:** \`${{ env.GIT_HASH }}\` - **Built:** ${{ env.BUILD_DATE }} - - > Automated nightly build — not intended for production use." + set -euo pipefail - if gh release view nightly &>/dev/null; then - gh release edit nightly \ - --title "Nightly (${{ env.BUILD_DATE }}) — ${{ env.VERSION }}" \ - --notes "$NOTES" \ - --prerelease + rm -rf aur-assets + mkdir -p aur-assets - gh release view nightly --json assets --jq '.assets[].name' \ - | xargs -r -I{} gh release delete-asset nightly {} --yes + find artifacts -type f -exec cp {} aur-assets/ \; - find artifacts -type f | xargs gh release upload nightly - else - find artifacts -type f | xargs gh release create nightly \ - --title "Nightly (${{ env.BUILD_DATE }}) — ${{ env.VERSION }}" \ - --notes "$NOTES" \ - --prerelease - fi - - # --- JOB 3: AUR NIGHTLY --- - deploy-aur-nightly: - name: Update AUR Nightly Package - runs-on: ubuntu-latest - needs: nightly-release - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download Linux Artifact - uses: actions/download-artifact@v4 - with: - name: OpenSSH-GUI-nightly-linux-x64 - path: ./ + echo "AUR asset files:" + find aur-assets -maxdepth 1 -type f -printf '%f\n' | sort + + test -f aur-assets/OpenSSH-GUI-nightly-linux-x64 + test -f aur-assets/appicon.png + test -f aur-assets/io.github.frequency403.openssh_gui.desktop + test -f LICENSE - - name: Download generated desktop file - uses: actions/download-artifact@v4 - with: - name: io.github.frequency403.openssh_gui.desktop - path: ./ - - - name: Update PKGBUILD for openssh-gui-nightly - run: | - HASH=$(git rev-parse --short HEAD) DATE=$(date +%Y%m%d) - BASE_VERSION=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) - VERSION="${BASE_VERSION}.${DATE}.${HASH}" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - SHA_BIN=$(sha256sum OpenSSH-GUI-nightly-linux-x64 | cut -d' ' -f1) - SHA_ICON=$(sha256sum OpenSSH_GUI/Assets/appicon.png | cut -d' ' -f1) - SHA_DESKTOP=$(sha256sum io.github.frequency403.openssh_gui.desktop | cut -d' ' -f1) + VERSION="${{ needs.prepare.outputs.base_version }}.${DATE}.${{ needs.prepare.outputs.git_hash }}" + echo "AUR_VERSION=$VERSION" >> "$GITHUB_ENV" + + SHA_BIN=$(sha256sum aur-assets/OpenSSH-GUI-nightly-linux-x64 | cut -d' ' -f1) + SHA_ICON=$(sha256sum aur-assets/appicon.png | cut -d' ' -f1) + SHA_DESKTOP=$(sha256sum aur-assets/io.github.frequency403.openssh_gui.desktop | cut -d' ' -f1) SHA_LICENSE=$(sha256sum LICENSE | cut -d' ' -f1) - + sed -i "s/^pkgver=.*/pkgver=$VERSION/" openssh-gui-nightly/PKGBUILD - sed -i "s/sha256sums=.*/sha256sums=('$SHA_BIN' '$SHA_ICON' '$SHA_DESKTOP' '$SHA_LICENSE')/" openssh-gui-nightly/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('$SHA_BIN' '$SHA_ICON' '$SHA_DESKTOP' '$SHA_LICENSE')/" openssh-gui-nightly/PKGBUILD + + echo "Updated openssh-gui-nightly PKGBUILD:" + grep -E '^(pkgver=|sha256sums=)' openssh-gui-nightly/PKGBUILD - name: Update AUR (openssh-gui-nightly) uses: KSXGitHub/github-actions-deploy-aur@v4.1.1 @@ -238,4 +98,4 @@ jobs: commit_username: ${{ github.repository_owner }} commit_email: ${{ github.repository_owner }}@users.noreply.github.com ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - commit_message: "Nightly update ${{ env.VERSION }}" \ No newline at end of file + commit_message: "Nightly update ${{ env.AUR_VERSION }}" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0d80e4a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Unit Tests + +on: + push: + branches: [ main, master, development ] + pull_request: + branches: [ main, master, development ] + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Determine .NET version from project + id: dotnet-version + run: | + TFM=$(grep -oPm1 '(?<=net)[0-9.]+' Directory.Build.props) + echo "version=${TFM}.x" >> "$GITHUB_OUTPUT" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ steps.dotnet-version.outputs.version }} + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-dotnet-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props', '**/Directory.Build.props') }} + restore-keys: | + ${{ runner.os }}-dotnet- + + - name: Restore dependencies + run: dotnet restore OpenSSH_GUI.slnx + + - name: Build + run: dotnet build OpenSSH_GUI.slnx --configuration Release --no-restore + + - name: Run Tests + run: dotnet test OpenSSH_GUI.slnx --configuration Release --no-build --verbosity normal diff --git a/Directory.Build.props b/Directory.Build.props index ca675a4..228496d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,8 @@ enable default https://github.com/frequency403/OpenSSH-GUI - 3.0.0 + 3.1.0 + true diff --git a/Directory.Build.targets b/Directory.Build.targets index c4676ec..03ab852 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -22,12 +22,16 @@ DependsOnTargets="ResolveGitHash"> 1.0.0 + false false - $(GitCommitHash) - $(BaseVersion) - $(BaseVersion)+$(GitCommitHash) + + $(BaseVersion) + $(BaseVersion) + + $(BaseVersion)-$(GitCommitHash) + $(BaseVersion)-$(GitCommitHash) diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..253ac32 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,52 @@ + + + true + true + $(NoWarn);NU1507 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Configuration/ApplicationConfiguration.cs b/OpenSSH_GUI.Core/Configuration/ApplicationConfiguration.cs new file mode 100644 index 0000000..d682586 --- /dev/null +++ b/OpenSSH_GUI.Core/Configuration/ApplicationConfiguration.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using OpenSSH_GUI.Core.Enums; +using OpenSSH_GUI.Core.Extensions; +using Serilog.Events; + +namespace OpenSSH_GUI.Core.Configuration; + +public class ApplicationConfiguration +{ + [JsonIgnore] + public static readonly ApplicationConfiguration Default = new() + { + LookupPaths = [SshConfigFilesExtension.GetBaseSshPath()], + PreferredTheme = ThemeVariant.Default, + LogLevel = LogEventLevel.Warning, + FontSize = 14, + LoggerConfiguration = LoggerConfiguration.Default + }; + + [JsonIgnore] + public static string ApplicationConfigurationPath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppDomain.CurrentDomain.FriendlyName); + + [JsonIgnore] + public static string ApplicationConfigurationName { get; } = Path.WithJsonExtension(AppDomain.CurrentDomain.FriendlyName.ToLower()); + + [JsonIgnore] + public static string DefaultApplicationConfigurationFileFullPath { get; } = Path.Combine(ApplicationConfigurationPath, ApplicationConfigurationName); + + [Required] + public required string[] LookupPaths { get; set; } + + [Required] + public required ThemeVariant PreferredTheme { get; set; } + + [Required] + public required LogEventLevel LogLevel { get; set; } + + [Required, Range(12, 48, ErrorMessage = "Font size must be between 12 and 48")] + public required double FontSize { get; set; } + + [Required, ValidateObjectMembers] + public required LoggerConfiguration LoggerConfiguration { get; set; } +} + +[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true), JsonSerializable(typeof(ApplicationConfiguration)), JsonSerializable(typeof(LoggerConfiguration))] +public partial class SourceGenerationContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Configuration/JsonFileConfigurationWriter.cs b/OpenSSH_GUI.Core/Configuration/JsonFileConfigurationWriter.cs new file mode 100644 index 0000000..93a8973 --- /dev/null +++ b/OpenSSH_GUI.Core/Configuration/JsonFileConfigurationWriter.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace OpenSSH_GUI.Core.Configuration; + +public sealed class JsonFileConfigurationWriter(string filePath, JsonTypeInfo typeInfo) +{ + private readonly SemaphoreSlim _lock = new(1, 1); + + /// + /// Reads and deserializes the configuration file into . + /// Returns a default instance if the file does not exist. + /// + public async Task ReadAsync(CancellationToken ct) + { + if (!File.Exists(filePath)) + return default; + + await using var stream = File.OpenRead(filePath); + return await JsonSerializer.DeserializeAsync(stream, typeInfo, ct); + } + + /// + /// Atomically writes to the configuration file via a temp-file swap. + /// + public async Task WriteAsync(T value, CancellationToken ct) + { + var tempFile = Path.GetTempFileName(); + await using (var stream = File.Open(tempFile, FileMode.Truncate)) + { + await JsonSerializer.SerializeAsync(stream, value, typeInfo, ct); + } + File.Move(tempFile, filePath, true); + } + + /// + /// Reads the current configuration, applies , then writes the result back atomically. + /// + public async Task UpdateAsync(Func> update, CancellationToken ct) + { + await _lock.WaitAsync(ct); + try + { + var current = await ReadAsync(ct); + var updated = await update(current); + await WriteAsync(updated, ct); + } + finally + { + _lock.Release(); + } + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Configuration/LoggerConfiguration.cs b/OpenSSH_GUI.Core/Configuration/LoggerConfiguration.cs index 864a017..72d17f6 100644 --- a/OpenSSH_GUI.Core/Configuration/LoggerConfiguration.cs +++ b/OpenSSH_GUI.Core/Configuration/LoggerConfiguration.cs @@ -1,9 +1,11 @@ -namespace OpenSSH_GUI.Core.Configuration; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using OpenSSH_GUI.Core.Extensions; + +namespace OpenSSH_GUI.Core.Configuration; public record LoggerConfiguration { - private const string LogFileFolderAndExtension = "log"; - #if DEBUG private const string LogTemplate = "[{Timestamp:yyyy/MM/dd HH:mm:ss}] [{Level:u3}] ({FileName}:{LineNumber}): {Message:lj}{NewLine}{Exception}"; @@ -12,16 +14,20 @@ public record LoggerConfiguration "[{Timestamp:yyyy/MM/dd HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; #endif - public string LogFileName { get; set; } = - Path.ChangeExtension(AppDomain.CurrentDomain.FriendlyName, LogFileFolderAndExtension); + [Required(AllowEmptyStrings = false)] + public string LogFileName { get; set; } = Path.WithLogExtension(AppDomain.CurrentDomain.FriendlyName); + [Required(AllowEmptyStrings = false)] public string LogFilePath { get; set; } = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - AppDomain.CurrentDomain.FriendlyName, LogFileFolderAndExtension); + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppDomain.CurrentDomain.FriendlyName, PathExtensions.LogExtension); - public string LogFileFullPath => Path.Combine(LogFilePath, LogFileName); + public string LogOutputTemplate { get; set; } = LogTemplate; - public string LogOutputTemplate => LogTemplate; + [JsonIgnore] + public string LogFileFullPath => Path.Combine(LogFilePath, LogFileName); + [JsonIgnore] public static LoggerConfiguration Default { get; } = new(); } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Configuration/MutableConfiguration.cs b/OpenSSH_GUI.Core/Configuration/MutableConfiguration.cs new file mode 100644 index 0000000..af7e0d2 --- /dev/null +++ b/OpenSSH_GUI.Core/Configuration/MutableConfiguration.cs @@ -0,0 +1,156 @@ +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenSSH_GUI.Core.Interfaces; + +namespace OpenSSH_GUI.Core.Configuration; + +/// +public sealed class MutableConfiguration : IMutableConfiguration + where T : class +{ + private readonly ILogger> _logger; + private readonly IOptionsMonitor _options; + private readonly IDisposable? _optionsMonitor; + private readonly JsonFileConfigurationWriter _writer; + + + public MutableConfiguration(ILogger> logger, + JsonFileConfigurationWriter writer, + IOptionsMonitor options) + { + _logger = logger; + _writer = writer; + _options = options; + _optionsMonitor = _options.OnChange(conf => + { + _logger.LogDebug("Configuration changed triggered"); + ConfigurationChanged?.Invoke(this, conf); + }); + } + + /// + public T Current => _options.CurrentValue; + + /// + public Task ExecuteConfigurationUpdateAsync(Action update, CancellationToken ct = default) => + _writer.UpdateAsync( + current => + { + try + { + var config = current ?? throw new InvalidOperationException("Configuration could not be read."); + update(config); + return Task.FromResult(config); + } + catch (Exception e) + { + _logger.LogError(e, "An error occurred while updating configuration."); + throw; + } + }, ct); + + /// + public Task SetPropertyValueAsync(Expression> property, TValue value, CancellationToken ct = default) => + _writer.UpdateAsync( + current => + { + try + { + var config = current ?? throw new InvalidOperationException("Configuration could not be read."); + + // Unwrap expression (handle Convert) + var memberExpression = property.Body switch + { + MemberExpression m => m, + UnaryExpression { NodeType: ExpressionType.Convert, Operand: MemberExpression m } => m, + _ => throw new InvalidOperationException( + $"Expression '{property}' does not refer to a property.") + }; + + if (memberExpression.Member is not PropertyInfo { CanWrite: true } propertyInfo) + throw new InvalidOperationException( + $"Expression '{property}' does not refer to a writable property."); + + // Convert value if necessary + var targetType = propertyInfo.PropertyType; + object? convertedValue = value; + + if (value is not null && !targetType.IsAssignableFrom(typeof(TValue))) + { + convertedValue = ConvertValue(value, targetType); + } + + SetPropertyValue(propertyInfo, config, convertedValue); + return Task.FromResult(config); + } + catch (Exception e) + { + _logger.LogError(e, "An error occurred while updating configuration."); + throw; + } + }, ct); + + /// + public Task SetPropertyValueAsync(string key, TValue value, CancellationToken ct = default) => + _writer.UpdateAsync( + current => + { + try + { + var config = current ?? throw new InvalidOperationException("Configuration could not be read."); + SetPropertyValue( + typeof(T).GetProperty( + key, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) + ?? throw new InvalidOperationException($"Property '{key}' was not found on type '{typeof(T).Name}'."), config, value); + return Task.FromResult(config); + } + catch (Exception e) + { + _logger.LogError(e, "An error occurred while updating configuration."); + throw; + } + }, ct); + + /// + public event EventHandler? ConfigurationChanged; + + /// + public void Dispose() { _optionsMonitor?.Dispose(); } + + + private void SetPropertyValue(PropertyInfo propertyInfo, T config, TValue value) + { + if (!propertyInfo.CanWrite) + throw new InvalidOperationException($"Property '{propertyInfo.Name}' on type '{typeof(T).Name}' is not writable."); + var initialValue = propertyInfo.GetValue(config); + propertyInfo.SetValue(config, value); + _logger.LogDebug( + "Updated property {PropertyName} of configuration object '{ConfigurationType}' from {InitialValue} to {CurrentValue}", propertyInfo.Name, typeof(T).Name, initialValue, + value); + } + + private static object ConvertValue(object value, Type targetType) + { + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + try + { + if (underlyingType.IsEnum) + return Enum.ToObject(underlyingType, value); + + return Convert.ChangeType(value, underlyingType); + } + catch + { + // fallback: try direct cast + if (targetType.IsInstanceOfType(value)) + return value; + + throw new InvalidCastException( + $"Cannot convert value '{value}' to type '{targetType}'."); + } + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Enums/AuthType.cs b/OpenSSH_GUI.Core/Enums/AuthType.cs deleted file mode 100644 index 50c875a..0000000 --- a/OpenSSH_GUI.Core/Enums/AuthType.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace OpenSSH_GUI.Core.Enums; - -/// -/// Represents the types of authentication supported for SSH connections. -/// -public enum AuthType -{ - /// - /// Represents connection credentials using password authentication. - /// - Password, - - /// - /// Represents the authentication type of connection credentials using SSH key. - /// - Key, - - /// - /// Represents a multi-key authentication type for SSH connections. - /// - MultiKey -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Enums/OperationResult.cs b/OpenSSH_GUI.Core/Enums/OperationResult.cs new file mode 100644 index 0000000..89b9993 --- /dev/null +++ b/OpenSSH_GUI.Core/Enums/OperationResult.cs @@ -0,0 +1,9 @@ +namespace OpenSSH_GUI.Core.Enums; + +public enum OperationResult +{ + Success, + Failure, + Conflict, + Cancelled +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Enums/ThemeVariant.cs b/OpenSSH_GUI.Core/Enums/ThemeVariant.cs new file mode 100644 index 0000000..63f294a --- /dev/null +++ b/OpenSSH_GUI.Core/Enums/ThemeVariant.cs @@ -0,0 +1,8 @@ +namespace OpenSSH_GUI.Core.Enums; + +public enum ThemeVariant +{ + Default, + Dark, + Light +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/ExceptionHandler.cs b/OpenSSH_GUI.Core/ExceptionHandler.cs index 217c2ff..748332e 100644 --- a/OpenSSH_GUI.Core/ExceptionHandler.cs +++ b/OpenSSH_GUI.Core/ExceptionHandler.cs @@ -7,12 +7,14 @@ namespace OpenSSH_GUI.Core; public class ExceptionHandler(ILogger logger) : IObserver { + /// public void OnCompleted() { if (Debugger.IsAttached) Debugger.Break(); } + /// public void OnError(Exception error) { if (Debugger.IsAttached) @@ -21,6 +23,7 @@ public void OnError(Exception error) AvaloniaScheduler.Instance.Schedule(error, HandleException); } + /// public void OnNext(Exception value) { if (Debugger.IsAttached) @@ -29,8 +32,5 @@ public void OnNext(Exception value) AvaloniaScheduler.Instance.Schedule(value, HandleException); } - private static IDisposable HandleException(IScheduler arg1, Exception arg2) - { - throw arg2; - } + private static IDisposable HandleException(IScheduler arg1, Exception arg2) => throw arg2; } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Extensions/DependencyInjectionExtensions.cs b/OpenSSH_GUI.Core/Extensions/DependencyInjectionExtensions.cs index 8213db9..002e7cd 100644 --- a/OpenSSH_GUI.Core/Extensions/DependencyInjectionExtensions.cs +++ b/OpenSSH_GUI.Core/Extensions/DependencyInjectionExtensions.cs @@ -1,5 +1,13 @@ -using Avalonia.Controls; -using DryIoc; +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization.Metadata; +using Avalonia.Controls; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenSSH_GUI.Core.Configuration; +using OpenSSH_GUI.Core.Interfaces; using OpenSSH_GUI.Core.MVVM; using OpenSSH_GUI.Core.Resources.Wrapper; @@ -7,60 +15,220 @@ namespace OpenSSH_GUI.Core.Extensions; public static class DependencyInjectionExtensions { + private static readonly ConcurrentDictionary RequiredPropertiesCache = new(); + + /// + /// Returns all publicly writable properties of that are + /// annotated with , using a per-type cache + /// to avoid repeated reflection overhead. + /// + /// The type to inspect. + /// An array of representing the required properties. + private static PropertyInfo[] GetRequiredProperties(Type type) + { + return RequiredPropertiesCache.GetOrAdd( + type, static t => + t.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanWrite && p.GetCustomAttribute() is not null) + .ToArray()); + } + + /// + /// Validates that and follow the + /// prescribed View/ViewModel naming convention. + /// + /// The View type. + /// The ViewModel type. + /// + /// if the naming convention is satisfied; otherwise . + /// private static bool ValidateNamingConvention() { var t1Name = typeof(T1).Name; var t2Name = typeof(T2).Name; if (t1Name == t2Name) return false; - var option1 = string.Equals(t1Name.Replace("Window", ""), t2Name.Replace("ViewModel", ""), + var option1 = string.Equals( + t1Name.Replace("Window", string.Empty), t2Name.Replace("ViewModel", string.Empty), StringComparison.Ordinal); var option2 = t2Name.StartsWith(t1Name) && t2Name.EndsWith("ViewModel"); return option1 || option2; } - extension(IResolver resolver) + extension(IServiceProvider serviceProvider) { + /// + /// Resolves a from a dedicated , + /// initializes it with the provided , and disposes + /// the scope automatically when the window closes. + /// + /// The window type to resolve. + /// The ViewModel type associated with the view. + /// The type of the initializer parameter passed to the ViewModel. + /// + /// The parameter passed to + /// . + /// + /// The startup location of the window. + /// A to observe during initialization. + /// The fully initialized instance. + /// + /// Thrown if the ViewModel is or was not properly initialized. + /// public async ValueTask ResolveViewAsync( TViewModelInitializerParameter initializerParameters, WindowStartupLocation windowStartupLocation = WindowStartupLocation.CenterScreen, CancellationToken token = default) where TView : WindowBase - where TViewModel : ViewModelBase - where TViewModelInitializerParameter : class, IInitializerParameters + where TViewModel : ViewModelBase { - var viewName = typeof(TView).Name; - var resolvedView = resolver.Resolve(serviceKey: viewName); - await resolvedView.InitializeAsync(initializerParameters, windowStartupLocation, token); - ArgumentNullException.ThrowIfNull(resolvedView.ViewModel); - return !resolvedView.ViewModel.IsInitialized ? throw new InvalidOperationException("ViewModel not properly initialized") : resolvedView; + var scope = serviceProvider.CreateScope(); + var scopeOwnership = scope; + try + { + var resolvedView = scope.ServiceProvider.GetRequiredKeyedService(typeof(TView).Name); + await resolvedView.InitializeAsync(initializerParameters, windowStartupLocation, token); + ArgumentNullException.ThrowIfNull(resolvedView.ViewModel); + + if (!resolvedView.ViewModel.IsInitialized) + throw new InvalidOperationException("ViewModel not properly initialized"); + + resolvedView.Closed += async (_, _) => + { + if (scope is IAsyncDisposable asyncScope) + await asyncScope.DisposeAsync(); + else + scope.Dispose(); + }; + + scopeOwnership = null; + return resolvedView; + } + finally + { + scopeOwnership?.Dispose(); + } } + /// + /// Resolves a from a dedicated , + /// initializes it, and disposes the scope automatically when the window closes. + /// + /// The window type to resolve. + /// The ViewModel type associated with the view. + /// The startup location of the window. + /// A to observe during initialization. + /// The fully initialized instance. + /// + /// Thrown if the ViewModel is or was not properly initialized. + /// public async ValueTask ResolveViewAsync( WindowStartupLocation windowStartupLocation = WindowStartupLocation.CenterScreen, CancellationToken token = default) where TView : WindowBase - where TViewModel : ViewModelBase + where TViewModel : ViewModelBase { - var viewName = typeof(TView).Name; - var resolvedView = resolver.Resolve(serviceKey: viewName); - await resolvedView.InitializeAsync(windowStartupLocation, token); - ArgumentNullException.ThrowIfNull(resolvedView.ViewModel); - return !resolvedView.ViewModel.IsInitialized ? throw new InvalidOperationException("ViewModel not properly initialized") : resolvedView; + var scope = serviceProvider.CreateScope(); + var scopeOwnership = scope; + try + { + var resolvedView = scope.ServiceProvider.GetRequiredKeyedService(typeof(TView).Name); + await resolvedView.InitializeAsync(windowStartupLocation, token); + ArgumentNullException.ThrowIfNull(resolvedView.ViewModel); + + if (!resolvedView.ViewModel.IsInitialized) + throw new InvalidOperationException("ViewModel not properly initialized"); + + resolvedView.Closed += async (_, _) => + { + if (scope is IAsyncDisposable asyncScope) + await asyncScope.DisposeAsync(); + else + scope.Dispose(); + }; + + scopeOwnership = null; + return resolvedView; + } + finally + { + scopeOwnership?.Dispose(); + } } } - extension(IContainer container) + extension(IHostBuilder builder) + { + /// + /// Adds mutable configuration support for the specified type using the provided JSON file path and type metadata. + /// + /// The type of the configuration object. + /// The path to the JSON file containing the configuration data. + /// The JSON type information used for deserialization of the configuration data. + /// The optional parameter, which indicates if the file is optional + /// + /// The optional section name within the JSON file to bind to the configuration object. If not + /// specified, the entire file is considered. + /// + /// The updated instance with the mutable configuration added. + public IHostBuilder AddMutableConfiguration(string filePath, JsonTypeInfo typeInfo, bool optionalFile = true, string? sectionName = null) where T : class + => builder.ConfigureServices((hostBuilderContext, serviceCollection) => + { + if (!Path.IsJson(filePath)) + throw new ArgumentException("File must be of the json file type", nameof(filePath)); + + serviceCollection.AddOptionsWithValidateOnStart() + .Bind(sectionName is null ? hostBuilderContext.Configuration : hostBuilderContext.Configuration.GetRequiredSection(sectionName)); + serviceCollection.AddSingleton(new JsonFileConfigurationWriter(filePath, typeInfo)); + serviceCollection.AddSingleton, MutableConfiguration>(); + }).ConfigureAppConfiguration(configurationBuilder => + { + configurationBuilder.AddJsonFile(filePath, optionalFile, true); + }); + } + + extension(IServiceCollection services) { - public void RegisterViewWithViewModel() - where TViewModel : ViewModelBase + /// + /// Registers and as a + /// keyed pair in the service collection, enforcing the View/ViewModel naming convention. + /// Required properties on the view are resolved and injected via + /// at activation time. + /// + /// The window type to register. + /// The ViewModel type to register. + /// The for both registrations. + /// + /// Thrown if the naming convention between and + /// is not satisfied. + /// + public void RegisterViewWithViewModel(ServiceLifetime lifetime = ServiceLifetime.Transient) + where TViewModel : ViewModelBase where TView : Window { + var viewType = typeof(TView); + var viewModelType = typeof(TViewModel); if (!ValidateNamingConvention()) throw new InvalidOperationException( - $"Viewmodels must follow the following convention: $NameOfView + $ViewModel -> in that case your Viewmodel must be renamed to \"{typeof(TView).Name + "ViewModel"}\""); + $"Viewmodels must follow the following convention: $NameOfView + $ViewModel -> in that case your Viewmodel must be renamed to \"{viewType.Name + "ViewModel"}\""); + + var serviceDescriptorView = ServiceDescriptor.DescribeKeyed( + viewType, viewType.Name, (provider, _) => + { + if (ActivatorUtilities.CreateInstance(provider, viewType) is not TView view) + throw new InvalidOperationException(); + foreach (var requiredProperty in GetRequiredProperties(viewType)) + { + if (provider.GetService(requiredProperty.PropertyType) is { } service) + requiredProperty.SetValue(view, service); + } + return view; + }, lifetime); + + var serviceDescriptorViewModel = + ServiceDescriptor.DescribeKeyed(viewModelType, viewModelType.Name, viewModelType, lifetime); - container.Register(serviceKey: typeof(TView).Name, reuse: Reuse.Transient, made: Made.Of(propertiesAndFields: PropertiesAndFields.Auto)); - container.Register(serviceKey: typeof(TViewModel).Name, reuse: Reuse.Transient); + services.Add(serviceDescriptorView); + services.Add(serviceDescriptorViewModel); } } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Extensions/IPrivateKeySourceExtensions.cs b/OpenSSH_GUI.Core/Extensions/IPrivateKeySourceExtensions.cs deleted file mode 100644 index 3913825..0000000 --- a/OpenSSH_GUI.Core/Extensions/IPrivateKeySourceExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Renci.SshNet; -using SshNet.Keygen.Extensions; - -namespace OpenSSH_GUI.Core.Extensions; - -/// -/// Provides extension methods for the IPrivateKeySource interface. -/// -public static class PrivateKeySourceExtensions -{ - /// - /// Retrieves the fingerprint hash of the private key source. - /// - /// The private key source. - /// The fingerprint hash of the private key source. - public static string FingerprintHash(this IPrivateKeySource privateKeySource) - { - return privateKeySource - .Fingerprint() - .Split(' ') - .First(e => e.StartsWith("SHA")) - .Split(':') - .Last(); - } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Extensions/PathExtensions.cs b/OpenSSH_GUI.Core/Extensions/PathExtensions.cs new file mode 100644 index 0000000..9ff9eec --- /dev/null +++ b/OpenSSH_GUI.Core/Extensions/PathExtensions.cs @@ -0,0 +1,162 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenSSH_GUI.Core.Extensions; + +public static class PathExtensions +{ + /// + /// Represents the file extension for JSON files. + /// + public const string JsonExtension = "json"; + + /// + /// Represents the file extension used for log files. + /// + public const string LogExtension = "log"; + + /// + /// Represents the file extension for OpenSSH Public Key format. + /// + public const string OpenSshPublicKeyFileExtension = "pub"; + + /// + /// Represents the file extension used for PuTTY private key files. + /// + public const string PuttyKeyFileExtension = "ppk"; + + extension(Path) + { + /// + /// Appends the ".json" extension to the specified base file name. + /// + /// The base file name to which the ".json" extension will be added. + /// A string representing the file name with the ".json" extension appended. + public static string WithJsonExtension(string baseName) + => Path.ChangeExtension(baseName, JsonExtension); + + /// + /// Appends the ".log" file extension to the specified base file name. + /// + /// The base file name for which the ".log" extension should be added. + /// A string representing the base file name combined with the ".log" extension. + public static string WithLogExtension(string baseName) + => Path.ChangeExtension(baseName, LogExtension); + + /// + /// Appends the OpenSSH Public Key file extension to the specified base file name. + /// + /// The base name of the file to which the extension will be appended. + /// The modified file name with the OpenSSH Public Key file extension. + public static string WithOpenSshPublicKeyExtension(string baseName) + => Path.ChangeExtension(baseName, OpenSshPublicKeyFileExtension); + + /// + /// Changes the file extension of the given base name to the PuTTY private key extension (.ppk). + /// + /// The base name of the file whose extension will be changed. + /// The file name with the PuTTY private key file extension (.ppk). + [SuppressMessage("ReSharper", "InconsistentNaming")] + public static string WithPuTTYKeyExtension(string baseName) + => Path.ChangeExtension(baseName, PuttyKeyFileExtension); + + /// + /// Determines whether the provided file path has a JSON file extension. + /// The comparison is performed in a case-insensitive manner. + /// + /// The file path to evaluate. + /// true if the file path ends with the ".json" extension; otherwise false. + public static bool IsJson(string path) + => path.EndsWith(JsonExtension, StringComparison.OrdinalIgnoreCase); + + /// + /// Determines whether the given path has the ".log" file extension. + /// Comparison is performed case-insensitively. + /// + /// The file path to check. + /// true if the path ends with ".log"; otherwise false. + public static bool IsLog(string path) + => path.EndsWith(LogExtension, StringComparison.OrdinalIgnoreCase); + /// + /// Determines whether the given path represents a PuTTY private key file (.ppk). + /// Comparison is performed case-insensitively. + /// + /// The file path to check. + /// true if the path ends with the PuTTY key file extension; otherwise false. + [SuppressMessage("ReSharper", "InconsistentNaming")] + public static bool IsPuTTYKey(string path) + => path.EndsWith(PuttyKeyFileExtension, StringComparison.OrdinalIgnoreCase); + /// + /// Determines whether the given path represents an OpenSSH public key file. + /// Comparison is performed case-insensitively. + /// + /// The file path to check. + /// true if the path ends with the OpenSSH public key file extension; otherwise false. + public static bool IsOpenSshPublicKey(string path) + => path.EndsWith(OpenSshPublicKeyFileExtension, StringComparison.OrdinalIgnoreCase); + + /// + /// Determines whether the given path has the specified file extension. + /// Comparison is performed case-insensitively. + /// + /// The file path to check. + /// The extension to compare (e.g. ".json"). + /// true if the path ends with the given extension; otherwise false. + public static bool HasExtension(string path, string extension) + => path.EndsWith(extension, StringComparison.OrdinalIgnoreCase); + + /// + /// Gets the file extension of the specified path in normalized form (always lower-case). + /// + /// The file path. + /// The normalized file extension including the leading dot, or an empty string. + public static string GetNormalizedExtension(string path) + => Path.GetExtension(path).ToLowerInvariant(); + } + + extension(Directory) + { + /// + /// Creates a directory at the specified path if it does not already exist. + /// Automatically ensures that the directory structure exists. + /// + /// The full path of the directory to create. + public static void CreateIfNotExists(string? path) + { + if (!Directory.Exists(path) && !string.IsNullOrWhiteSpace(path)) + Directory.CreateDirectory(path); + } + } + + extension(File) + { + /// + /// Creates a file at the specified path if it does not already exist. + /// Automatically ensures that the directory structure exists. + /// + /// The full path of the file to create. + /// The content to write to the file, defaults to + public static void CreateIfNotExists(string? path, string? content = null) + { + ArgumentNullException.ThrowIfNull(path); + if (File.Exists(path)) + return; + Directory.CreateIfNotExists(Path.GetDirectoryName(path)); + + using var createdFile = File.Create(path); + if (content is null) + return; + using var writer = new StreamWriter(createdFile); + writer.Write(content); + } + + /// + /// Deletes the file at the specified path if it exists. + /// + /// The full path of the file to delete. + public static void RemoveIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Extensions/PlatformIdExtensions.cs b/OpenSSH_GUI.Core/Extensions/PlatformIdExtensions.cs new file mode 100644 index 0000000..f6b4ba3 --- /dev/null +++ b/OpenSSH_GUI.Core/Extensions/PlatformIdExtensions.cs @@ -0,0 +1,27 @@ +namespace OpenSSH_GUI.Core.Extensions; + +/// +/// Provides extension methods for . +/// +public static class PlatformIdExtensions +{ + private const string UnixLineSeparator = "\n"; + private const string WindowsLineSeparator = "\r\n"; + + /// + /// Returns the line separator string used by the given platform. + /// + /// The target platform identifier. + /// \n for Unix-like platforms, \r\n for all Windows variants. + public static string GetLineSeparator(this PlatformID platformId) + { + return platformId switch + { + PlatformID.Win32NT or + PlatformID.Win32Windows or + PlatformID.Win32S or + PlatformID.WinCE => WindowsLineSeparator, + _ => UnixLineSeparator + }; + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Extensions/SshConfigExtensions.cs b/OpenSSH_GUI.Core/Extensions/SshConfigExtensions.cs index 0f406d6..9082b20 100644 --- a/OpenSSH_GUI.Core/Extensions/SshConfigExtensions.cs +++ b/OpenSSH_GUI.Core/Extensions/SshConfigExtensions.cs @@ -1,5 +1,4 @@ -using OpenSSH_GUI.Core.Interfaces.Credentials; -using OpenSSH_GUI.Core.Lib.Credentials; +using OpenSSH_GUI.Core.Lib.Misc; using OpenSSH_GUI.SshConfig.Models; namespace OpenSSH_GUI.Core.Extensions; @@ -15,10 +14,10 @@ public static class SshConfigExtensions /// from which the connection credentials will be extracted. /// /// - /// An enumerable collection of objects that + /// An enumerable collection of objects that /// represent the normalized connection details, such as hostname, username, and authentication method. /// - public static IEnumerable GetConnectionEntriesFromConfig(this SshConfigDocument document) + public static IEnumerable GetConnectionEntriesFromConfig(this SshConfigDocument document) { var globalUser = document.GetGlobalEntries("User").FirstOrDefault()?.Value; var globalPort = document.GetGlobalEntries("Port").FirstOrDefault()?.Value; diff --git a/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs b/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs index 96159b9..1e87942 100644 --- a/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs +++ b/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs @@ -91,14 +91,15 @@ public static string GetBaseSshPath(bool resolve = true, PlatformID? platformId /// The file path as a . public static string GetPathOfFile(this SshConfigFiles files, bool resolve = true, PlatformID? platform = null) { - var path = Path.Combine(files switch - { - SshConfigFiles.Authorized_Keys or - SshConfigFiles.Known_Hosts or - SshConfigFiles.Config => GetBaseSshPath(resolve, platform), - SshConfigFiles.Sshd_Config => GetRootSshPath(resolve, platform), - _ => throw new ArgumentException("Invalid value for \"files\"") - }, Enum.GetName(files)!.ToLower()); + var path = Path.Combine( + files switch + { + SshConfigFiles.Authorized_Keys or + SshConfigFiles.Known_Hosts or + SshConfigFiles.Config => GetBaseSshPath(resolve, platform), + SshConfigFiles.Sshd_Config => GetRootSshPath(resolve, platform), + _ => throw new ArgumentException("Invalid value for \"files\"") + }, Enum.GetName(files)!.ToLower()); platform ??= Environment.OSVersion.Platform; path = platform is PlatformID.Unix ? path.Replace('\\', '/') : path.Replace('/', '\\'); return path; diff --git a/OpenSSH_GUI.Core/Extensions/SshKeyFormatExtension.cs b/OpenSSH_GUI.Core/Extensions/SshKeyFormatExtension.cs index ae691a7..aab129b 100644 --- a/OpenSSH_GUI.Core/Extensions/SshKeyFormatExtension.cs +++ b/OpenSSH_GUI.Core/Extensions/SshKeyFormatExtension.cs @@ -7,16 +7,6 @@ namespace OpenSSH_GUI.Core.Extensions; /// public static class SshKeyFormatExtension { - /// - /// Represents the file extension for OpenSSH Public Key format. - /// - public const string OpenSshPublicKeyFileExtension = ".pub"; - - /// - /// Represents the file extension used for PuTTY private key files. - /// - public const string PuttyKeyFileExtension = ".ppk"; - /// The SSH key format. extension(SshKeyFormat format) { @@ -32,9 +22,9 @@ public static class SshKeyFormatExtension { return format switch { - SshKeyFormat.OpenSSH when usePublicFormat => OpenSshPublicKeyFileExtension, + SshKeyFormat.OpenSSH when usePublicFormat => PathExtensions.OpenSshPublicKeyFileExtension, SshKeyFormat.OpenSSH => null, - SshKeyFormat.PuTTYv2 or SshKeyFormat.PuTTYv3 => PuttyKeyFileExtension, + SshKeyFormat.PuTTYv2 or SshKeyFormat.PuTTYv3 => PathExtensions.PuttyKeyFileExtension, _ => null }; } @@ -45,9 +35,6 @@ public static class SshKeyFormatExtension /// The path to the file. /// Indicates whether the key is public. Default is true. /// The modified file path with the updated extension. - public string ChangeExtension(string path, bool usePublicFormat = true) - { - return Path.ChangeExtension(path, format.GetExtension(usePublicFormat)); - } + public string ChangeExtension(string path, bool usePublicFormat = true) => Path.ChangeExtension(path, format.GetExtension(usePublicFormat)); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Extensions/StringExtensions.cs b/OpenSSH_GUI.Core/Extensions/StringExtensions.cs index 76ad50d..28151f4 100644 --- a/OpenSSH_GUI.Core/Extensions/StringExtensions.cs +++ b/OpenSSH_GUI.Core/Extensions/StringExtensions.cs @@ -15,7 +15,8 @@ public static partial class StringExtensions extension(string input) { /// - /// Resolves a absolute path from a relative path which can contain ~ or ~user or %AppData% or %UserProfile% etc. + /// Resolves a absolute path from a relative path which can contain ~ or ~user or %AppData% or + /// %UserProfile% etc. /// public string ResolvePath() { @@ -26,7 +27,7 @@ public string ResolvePath() path = path.Length == 1 ? home : Path.Combine(home, path[2..]); return Path.GetFullPath(path); } - + /// /// Wraps the input string to the specified maximum length, optionally enclosing each chunk in a specified character. /// @@ -50,10 +51,7 @@ public string ResolvePath() /// // pping. /// /// - public string Wrap(int maxLength, char? wrapper = null) - { - return input.Wrap(maxLength, wrapper is null ? null : wrapper.ToString()); - } + public string Wrap(int maxLength, char? wrapper = null) => input.Wrap(maxLength, wrapper is null ? null : wrapper.ToString()); /// /// Wraps the input string to the specified maximum length, optionally enclosing each chunk in a specified string. @@ -71,11 +69,9 @@ public string Wrap(int maxLength, char? wrapper = null) /// // This is a | long stri | ng that n | eeds wra | pping. /// /// - public string Wrap(int maxLength, string? wrapper = null) - { - return string.Join(wrapper ?? Environment.NewLine, - EcapeRegex().Replace(input, "").SplitToChunks(maxLength)); - } + public string Wrap(int maxLength, string? wrapper = null) => string.Join( + wrapper ?? Environment.NewLine, + EcapeRegex().Replace(input, string.Empty).SplitToChunks(maxLength)); /// /// Splits the input string into chunks of the specified size. @@ -119,10 +115,7 @@ public IEnumerable SplitToChunks(int chunkSize) /// // pascal_case_string /// /// - public string ToSnakeCase() - { - return Regex.Replace(input, "(? Regex.Replace(input, "(? /// Converts the given string to camelCase. @@ -158,10 +151,7 @@ public string ToCamelCase() /// // pascal-case-string /// /// - public string ToKebabCase() - { - return Regex.Replace(input, "(? Regex.Replace(input, "(? /// Converts the given string to PascalCase. @@ -177,10 +167,7 @@ public string ToKebabCase() /// // SnakeCaseString /// /// - public string ToPascalCase() - { - return Regex.Replace(input, @"(^\w)|(\s\w)", m => m.Value.ToUpper()).Replace(" ", ""); - } + public string ToPascalCase() { return Regex.Replace(input, @"(^\w)|(\s\w)", m => m.Value.ToUpper()).Replace(" ", string.Empty); } /// /// Converts the given string to Title Case. @@ -196,10 +183,7 @@ public string ToPascalCase() /// // This Is A Title Case String /// /// - public string ToTitleCase() - { - return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLower()); - } + public string ToTitleCase() => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLower()); /// /// Converts the given string to Sentence case. @@ -238,7 +222,8 @@ public string ToSentenceCase() public string ToStudlyCaps() { var random = new Random(); - return input.Aggregate("", + return input.Aggregate( + string.Empty, (current, t) => current + (random.Next(2) == 0 ? char.ToUpper(t) : char.ToLower(t))); } @@ -256,9 +241,6 @@ public string ToStudlyCaps() /// // 133t Sp34k 15 c00l! /// /// - public string ToLeetSpeak() - { - return input.Replace('e', '3').Replace('a', '4').Replace('o', '0').Replace('i', '1').Replace('s', '5'); - } + public string ToLeetSpeak() => input.Replace('e', '3').Replace('a', '4').Replace('o', '0').Replace('i', '1').Replace('s', '5'); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/Credentials/IConnectionCredentials.cs b/OpenSSH_GUI.Core/Interfaces/Credentials/IConnectionCredentials.cs deleted file mode 100644 index 75c38f5..0000000 --- a/OpenSSH_GUI.Core/Interfaces/Credentials/IConnectionCredentials.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json.Serialization; -using OpenSSH_GUI.Core.Enums; -using Renci.SshNet; - -namespace OpenSSH_GUI.Core.Interfaces.Credentials; - -/// -/// Represents the interface for connection credentials. -/// -public interface IConnectionCredentials -{ - /// - /// Represents the host name for a connection. - /// - /// - /// The host name is an essential property for establishing a connection to a remote server. - /// It identifies the target server that the client wants to connect to. - /// - string Hostname { get; set; } - - /// - /// Represents the base class for connection credentials. - /// - int Port { get; } - - /// - /// Represents the username used for the connection credentials. - /// - string Username { get; set; } - - /// - /// Retrieves the connection information based on the provided credentials. - /// - /// The object representing the SSH connection information. - ConnectionInfo GetConnectionInfo(); -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/Credentials/IKeyConnectionCredentials.cs b/OpenSSH_GUI.Core/Interfaces/Credentials/IKeyConnectionCredentials.cs deleted file mode 100644 index 810976c..0000000 --- a/OpenSSH_GUI.Core/Interfaces/Credentials/IKeyConnectionCredentials.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; -using OpenSSH_GUI.Core.Lib.Keys; - -namespace OpenSSH_GUI.Core.Interfaces.Credentials; - -/// -/// Represents connection credentials for SSH using key-based authentication. -/// -public interface IKeyConnectionCredentials : IConnectionCredentials -{ - /// - /// Represents a connection credential that includes an SSH key. - /// - [JsonIgnore] - SshKeyFile? Key { get; set; } - - /// - /// Renews the SSH key used for authentication. - /// - /// The password for the key file (optional). - void RenewKey(string? password = null); -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/Credentials/IMultiKeyConnectionCredentials.cs b/OpenSSH_GUI.Core/Interfaces/Credentials/IMultiKeyConnectionCredentials.cs deleted file mode 100644 index 300156a..0000000 --- a/OpenSSH_GUI.Core/Interfaces/Credentials/IMultiKeyConnectionCredentials.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; -using OpenSSH_GUI.Core.Lib.Keys; - -namespace OpenSSH_GUI.Core.Interfaces.Credentials; - -/// -/// Represents the interface for multi-key connection credentials. -/// -public interface IMultiKeyConnectionCredentials : IConnectionCredentials -{ - /// - /// Represents the credentials for a multi-key connection. - /// - [JsonIgnore] - IEnumerable? Keys { get; set; } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/Credentials/IPasswordConnectionCredentials.cs b/OpenSSH_GUI.Core/Interfaces/Credentials/IPasswordConnectionCredentials.cs deleted file mode 100644 index e987cf3..0000000 --- a/OpenSSH_GUI.Core/Interfaces/Credentials/IPasswordConnectionCredentials.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace OpenSSH_GUI.Core.Interfaces.Credentials; - -/// -/// Represents the interface for password-based connection credentials. -/// -public interface IPasswordConnectionCredentials : IConnectionCredentials -{ - /// - /// Represents the password connection credentials. - /// - string Password { get; set; } - - /// - /// Gets or sets a value indicating whether the password is encrypted. - /// - bool EncryptedPassword { get; set; } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/Hosts/IDialogHost.cs b/OpenSSH_GUI.Core/Interfaces/Hosts/IDialogHost.cs index 2dbd3f5..604e559 100644 --- a/OpenSSH_GUI.Core/Interfaces/Hosts/IDialogHost.cs +++ b/OpenSSH_GUI.Core/Interfaces/Hosts/IDialogHost.cs @@ -1,12 +1,8 @@ using Avalonia.Controls; -using OpenSSH_GUI.Core.MVVM; namespace OpenSSH_GUI.Core.Interfaces.Hosts; public interface IDialogHost { public Task ShowDialog(TWindow dialogWindow) where TWindow : Window; - - public Task ShowDialog(TWindow dialogWindow) - where TWindow : Window where TResult : ViewModelBase; } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/IDirectoryCrawler.cs b/OpenSSH_GUI.Core/Interfaces/IDirectoryCrawler.cs new file mode 100644 index 0000000..b2c4714 --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IDirectoryCrawler.cs @@ -0,0 +1,23 @@ +using OpenSSH_GUI.Core.Lib.Keys; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Defines the contract for discovering SSH key file sources on disk. +/// +public interface IDirectoryCrawler +{ + /// + /// Gets a value indicating whether a key file search is currently in progress. + /// + bool IsSearching { get; } + + /// + /// Asynchronously enumerates possible SSH key file sources from both + /// the SSH configuration and the base SSH directory on disk. + /// + /// Token to cancel the enumeration. + /// An async stream of discovered instances. + IAsyncEnumerable GetPossibleKeyFilesOnDiskAsyncEnumerable( + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/IKeyFileBackupService.cs b/OpenSSH_GUI.Core/Interfaces/IKeyFileBackupService.cs new file mode 100644 index 0000000..f6b6f91 --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IKeyFileBackupService.cs @@ -0,0 +1,81 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Lib.Misc; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Provides file backup, restore, and deletion capabilities for SSH key operations, +/// as well as operation-scoped file logging to capture diagnostic output during +/// potentially destructive file system changes. +/// +public interface IKeyFileBackupService +{ + /// + /// Creates backup copies of the specified files in the backup directory. + /// Each backup is named after the original file with the backup extension appended. + /// + /// The files to back up. + /// + /// A sequence of instances representing the + /// original file and its corresponding backup location. + /// + IEnumerable BackupFiles(params FileInfo[] files); + + /// + /// Restores the specified backed-up files to their original locations, + /// overwriting any existing files at those paths. + /// + /// The backed-up files to restore. + void RestoreBackupFiles(params BackedUpFile[] files); + + /// + /// Deletes the backup copies of the specified files from the backup directory. + /// Should only be called after a successful operation. + /// + /// The backed-up files whose backup copies should be deleted. + void DeleteBackupFiles(params BackedUpFile[] files); + + /// + /// Begins an operation-scoped file log session. + /// Creates the backup directory if it does not exist and initializes a + /// Serilog file sink writing to operation_log.log within that directory. + /// Subsequent calls while a session is already active are no-ops. + /// + void BeginOperationLog(); + + /// + /// Ends the current operation-scoped file log session and releases all associated resources. + /// If is , the entire backup directory + /// is deleted on the assumption that no recovery artifacts need to be retained. + /// + /// + /// to retain the backup directory and log file for post-mortem inspection; + /// to delete the backup directory after the session ends. + /// + void EndOperationLog(bool errorsOccurred = false); + + /// + /// Writes a structured log message at the specified level exclusively to the + /// active operation-scoped file log. Does not write to the application logger — + /// the caller is responsible for that channel separately. + /// If no operation log session is currently active, the call is a no-op. + /// + /// The severity level of the log entry. + /// The structured message template. + /// Arguments to substitute into the message template. + void WriteToOperationLog(LogLevel level, [StructuredMessageTemplate] string? message, params object?[] args); + + /// + /// Writes a structured log message with an associated exception at the specified level + /// exclusively to the active operation-scoped file log. Does not write to the application + /// logger — the caller is responsible for that channel separately. + /// If no operation log session is currently active, the call is a no-op. + /// + /// The severity level of the log entry. + /// The exception to associate with the log entry. + /// The structured message template. + /// Arguments to substitute into the message template. + void WriteToOperationLog(LogLevel level, Exception? exception, [StructuredMessageTemplate] string? message, + params object?[] args); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/IKeyFileWriterService.cs b/OpenSSH_GUI.Core/Interfaces/IKeyFileWriterService.cs new file mode 100644 index 0000000..1163243 --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IKeyFileWriterService.cs @@ -0,0 +1,72 @@ +using System.Text; +using Renci.SshNet; +using SshNet.Keygen; +using SshNet.Keygen.SshKeyEncryption; + +namespace OpenSSH_GUI.Core.Interfaces; + +public interface IKeyFileWriterService +{ + /// + /// Writes the specified content to a file at the given file path, with optional encoding and overwrite behavior. + /// + /// The path of the file to write the content to. + /// The content to write to the file. + /// A flag indicating whether to overwrite the file if it already exists. Defaults to false. + /// The encoding to use when writing the file. Defaults to UTF-8 if not specified. + /// A task representing the asynchronous write operation. + /// Thrown if the file already exists and overwrite is set to false. + /// Thrown when an error occurs during the file writing operation. + ValueTask WriteToFile(string filePath, string content, + bool overwrite = false, Encoding? encoding = null); + /// + /// Writes a private key and its corresponding public key (if applicable) to files in a specific SSH key format. + /// + /// + /// The SSH key format to use for writing the files (e.g., OpenSSH, PuTTYv2, PuTTYv3). + /// + /// + /// The encryption strategy to apply to the private key. + /// + /// + /// The source of the private key to be written to the file. + /// + /// + /// The base file path where the SSH key files will be written. Extensions will be added based on the key format. + /// + /// + /// A boolean value indicating whether to overwrite existing files. Default value is false. + /// + /// + /// A task that represents the asynchronous operation. The task result contains an enumerable collection of file paths + /// to the written SSH key files. + /// + ValueTask> WriteToFileInSpecificFormat( + SshKeyFormat format, + ISshKeyEncryption encryption, + IPrivateKeySource privateKeySource, string filePath, bool overwrite = false); + + /// + /// Writes a private key and its corresponding public key (if applicable) to files in a specific SSH key format. + /// This overload extracts the format and encryption settings from the object. + /// + /// + /// The SSH key generation information containing the key format and encryption settings. + /// + /// + /// The generated private key to be written to the file. + /// + /// + /// The base file path where the SSH key files will be written. Extensions will be added based on the key format. + /// + /// + /// A boolean value indicating whether to overwrite existing files. Default value is false. + /// + /// + /// A task that represents the asynchronous operation. The task result contains an enumerable collection of file paths + /// to the written SSH key files. + /// + ValueTask> WriteToFileInSpecificFormat( + SshKeyGenerateInfo generateInfo, + GeneratedPrivateKey createdKey, string filePath, bool overwrite = false); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/IMutableConfiguration.cs b/OpenSSH_GUI.Core/Interfaces/IMutableConfiguration.cs new file mode 100644 index 0000000..dc00ead --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IMutableConfiguration.cs @@ -0,0 +1,65 @@ +using System.Linq.Expressions; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Represents a writable configuration that allows dynamic updates and overrides of configuration values at runtime. +/// +/// The type of the configuration class. +public interface IMutableConfiguration : IDisposable where T : class +{ + /// + /// Gets the current instance of the configuration object of type . + /// + /// + /// This property provides access to the current state of the configuration as managed by the underlying options + /// mechanism. + /// It reflects the current configuration values without the need to manually reload or retrieve them. + /// + T Current { get; } + + /// + /// Asynchronously updates the configuration object by applying the specified update action. + /// + /// + /// An action that performs updates on the configuration object. + /// The current configuration is passed to this action. + /// + /// + /// A that can be used to cancel the operation. Defaults to + /// . + /// + /// + /// A that represents the asynchronous operation. + /// + Task ExecuteConfigurationUpdateAsync(Action update, CancellationToken ct = default); + + /// + /// Sets the value of a specific property in the writable configuration using an expression to target the property. + /// + /// The type of the value being set. + /// An expression representing the property to set. + /// The new value to assign to the specified property. + /// A cancellation token to observe while waiting for the operation to complete. + /// A task that represents the asynchronous operation. + Task SetPropertyValueAsync(Expression> property, TValue value, CancellationToken ct = default); + + /// Asynchronously updates the configuration by setting a specific key to the provided value. + /// The key in the configuration to set the value for. + /// The value to assign to the specified key. + /// The optional cancellation token to cancel the operation. + /// The type of the value being set. + /// A task representing the asynchronous operation. + Task SetPropertyValueAsync(string key, TValue value, CancellationToken ct = default); + + + /// + /// Occurs when the configuration is updated, signaling that changes have been applied to the configuration values. + /// + /// + /// Subscribing to this event allows components to react to configuration changes dynamically at runtime. + /// This can be particularly useful for scenarios where live updates to settings or parameters require immediate + /// processing. + /// + event EventHandler ConfigurationChanged; +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/ISshKeyFactory.cs b/OpenSSH_GUI.Core/Interfaces/ISshKeyFactory.cs new file mode 100644 index 0000000..979031b --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/ISshKeyFactory.cs @@ -0,0 +1,14 @@ +using OpenSSH_GUI.Core.Lib.Keys; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Factory for creating new instances. +/// +public interface ISshKeyFactory +{ + /// + /// Creates a new, uninitialized instance. + /// + SshKeyFile Create(); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/ISshKeyGenerator.cs b/OpenSSH_GUI.Core/Interfaces/ISshKeyGenerator.cs new file mode 100644 index 0000000..1e861d4 --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/ISshKeyGenerator.cs @@ -0,0 +1,16 @@ +using OpenSSH_GUI.Core.Lib.Keys; +using SshNet.Keygen; + +namespace OpenSSH_GUI.Core.Interfaces; + +public interface ISshKeyGenerator +{ + /// + /// Generates a new SSH key. + /// + /// The full path where the new key should be stored. + /// Parameters for key generation. + /// Whether to overwrite existing file if it exists. + /// A value task representing the asynchronous operation. + ValueTask Generate(string fullFilePath, SshKeyGenerateInfo generateParamsInfo, bool overwrite = false); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHost.cs b/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHost.cs deleted file mode 100644 index 487dc4c..0000000 --- a/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHost.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ReactiveUI; - -namespace OpenSSH_GUI.Core.Interfaces.KnownHosts; - -/// -/// Represents a known host in the OpenSSH GUI. -/// -public interface IKnownHost : IReactiveObject -{ - /// - /// Represents a known host. - /// - string Host { get; } - - /// - /// Represents a known host in the OpenSSH GUI. - /// - bool DeleteWholeHost { get; } - - /// - /// Represents a known host in the OpenSSH GUI. - /// - List Keys { get; set; } - - /// - /// Toggles the marked for deletion flag of each within the list. - /// If the property is true, it sets the flag to false for all keys. Otherwise, it sets - /// the flag to true for all keys. - /// - void KeysDeletionSwitch(); - - /// - /// Retrieves all entries for a known host in the known hosts file. - /// - /// - /// Returns a string containing all the entries for the known host. - /// If the entire host is marked for deletion, returns the line ending character. - /// - string GetAllEntries(); -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHostKey.cs b/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHostKey.cs deleted file mode 100644 index f2e59d7..0000000 --- a/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHostKey.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ReactiveUI; -using SshNet.Keygen; - -namespace OpenSSH_GUI.Core.Interfaces.KnownHosts; - -/// Represents a known host key in the OpenSSH GUI. -/// / -public interface IKnownHostKey : IReactiveObject -{ - /// - /// Represents the type of a known host key. - /// - SshKeyType KeyType { get; } - - /// - /// Represents a known host key in the OpenSSH GUI. - /// - string Fingerprint { get; } - - /// - /// Represents a known host key in the OpenSSH GUI. - /// - string EntryWithoutHost { get; } - - /// - /// Gets or sets whether the KnownHostKey is marked for deletion. - /// - bool MarkedForDeletion { get; set; } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHostsFile.cs b/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHostsFile.cs deleted file mode 100644 index da5f332..0000000 --- a/OpenSSH_GUI.Core/Interfaces/KnownHosts/IKnownHostsFile.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Collections.ObjectModel; -using ReactiveUI; - -namespace OpenSSH_GUI.Core.Interfaces.KnownHosts; - -/// -/// Represents a known hosts file. -/// -public interface IKnownHostsFile : IReactiveObject -{ - /// - /// Represents the line ending character used in the known_hosts file. - /// - static string LineEnding { get; set; } = string.Empty; - - /// - /// Represents a file that contains known host entries. - /// - ObservableCollection KnownHosts { get; } - - /// - /// Asynchronously reads the contents of the known hosts file. - /// - /// - /// The file stream to read from. If null, the method reads from the file specified in the - /// constructor. - /// - /// A representing the asynchronous operation. - ValueTask ReadContentAsync(FileStream? stream = null); - - /// - /// Synchronizes the known hosts with the given list of new known hosts. - /// - /// The new known hosts to synchronize. - void SyncKnownHosts(IEnumerable newKnownHosts); - - /// - /// Updates the content of the known hosts file asynchronously. - /// - /// A representing the update operation. - ValueTask UpdateFileAsync(); - - /// - /// Initializes the known hosts file asynchronously. - /// - /// The path to the known hosts file or its content. - /// Indicates whether the content is from a server. - /// A cancellation token. - /// A representing the initialized object. - ValueTask InitializeAsync(string knownHostsPathOrContent, bool fromServer = false, - CancellationToken token = default); - - /// - /// Retrieves the updated contents of the known hosts file. - /// - /// The platform ID of the server. - /// The updated contents of the known hosts file as a string. - /// - /// This method retrieves the updated contents of the known hosts file. - /// It takes the platform ID of the server as a parameter and returns the - /// updated contents of the known hosts file as a string. The method - /// checks if the instance of the KnownHostsFile class is created from - /// the server or not. If it is not created from the server, it returns - /// an empty string. It sets the LineEnding property based on the platform - /// ID provided. It then aggregates the known hosts entries excluding those - /// which are flagged for deletion and returns the updated contents as a string. - /// - /// The platform ID of the server. - string GetUpdatedContents(PlatformID platformId); -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKey.cs b/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKey.cs index 16c2f4a..3f14166 100644 --- a/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKey.cs +++ b/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKey.cs @@ -19,7 +19,7 @@ private AuthorizedKey(string keyEntry) KeyTypeDeclarationInFile = split[0]; KeyType = Enum.Parse( KeyTypeDeclarationInFile.StartsWith("ssh-") - ? KeyTypeDeclarationInFile.Replace("ssh-", "") + ? KeyTypeDeclarationInFile.Replace("ssh-", string.Empty) : KeyTypeDeclarationInFile.Split('-')[0], true); Fingerprint = split[1]; Comment = split[2]; @@ -61,10 +61,7 @@ private AuthorizedKey(string keyEntry) /// The full key entry string consists of the key type, fingerprint, and comment separated by spaces. /// /// The full key entry string. - public string GetFullKeyEntry => $"{KeyTypeDeclarationInFile} {Fingerprint} {Comment}"; + public override string ToString() => $"{KeyTypeDeclarationInFile} {Fingerprint} {Comment}"; - internal static AuthorizedKey Parse(string keyEntry) - { - return new AuthorizedKey(keyEntry); - } + internal static AuthorizedKey Parse(string keyEntry) => new(keyEntry); } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKeysFile.cs b/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKeysFile.cs index 6168e2d..a545978 100644 --- a/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKeysFile.cs +++ b/OpenSSH_GUI.Core/Lib/AuthorizedKeys/AuthorizedKeysFile.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Text; using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Core.Lib.Keys; @@ -11,6 +12,8 @@ namespace OpenSSH_GUI.Core.Lib.AuthorizedKeys; /// public class AuthorizedKeysFile : ReactiveObject { + private AuthorizedKey[] _authorizedKeys = []; + /// /// The contents of the authorized keys file or the path to the file. /// @@ -19,10 +22,8 @@ public class AuthorizedKeysFile : ReactiveObject /// /// Represents an authorized keys file. /// - private AuthorizedKeysFile() - { - } - + private AuthorizedKeysFile() { } + /// /// Gets a value indicating whether the file is from a server. /// @@ -37,6 +38,10 @@ public ObservableCollection AuthorizedKeys set => this.RaiseAndSetIfChanged(ref field, value); } = []; + public bool ChangesMade => !_authorizedKeys.SequenceEqual(AuthorizedKeys); + + public static AuthorizedKeysFile Empty { get; } = new(); + public bool CanAddKey(SshKeyFile key) { try @@ -48,7 +53,7 @@ public bool CanAddKey(SshKeyFile key) return false; } } - + /// /// Adds an authorized key to the authorized keys file. /// @@ -61,28 +66,21 @@ public bool AddAuthorizedKey(SshKeyFile key) return true; } - /// - /// Applies the changes to the authorized keys file. - /// - /// The collection of keys to be applied as changes. - /// True if any changes were made to the authorized keys file; otherwise, false. - public bool ApplyChanges(IEnumerable keys) - { - var countBefore = AuthorizedKeys.Count; - AuthorizedKeys = new ObservableCollection(keys.Where(e => !e.MarkedForDeletion)); - return countBefore != AuthorizedKeys.Count; - } - /// /// Persists the changes made to the authorized keys file. /// - /// The modified object. + /// The modified object. public async ValueTask PersistChangesInFileAsync(CancellationToken token = default) { + if (!ChangesMade) return this; if (IsFileFromServer) return this; - await using var file = new FileStream(_fileContentsOrPath, FileMode.Truncate); - await using var streamWriter = new StreamWriter(file); - await streamWriter.WriteAsync(ExportFileContent()); + await using (var file = new FileStream(_fileContentsOrPath, FileMode.Truncate)) + await using (var streamWriter = new StreamWriter(file)) + { + ReadOnlyMemory content = ExportFileContent().ToCharArray(); + await streamWriter.WriteAsync(content, token); + } + await ReadAndLoadFileContents(_fileContentsOrPath, token); return this; } @@ -94,48 +92,24 @@ public async ValueTask PersistChangesInFileAsync(Cancellatio /// /// A indicating whether the key was added successfully. /// - public ValueTask AddAuthorizedKeyAsync(SshKeyFile key) - { - return ValueTask.FromResult(AddAuthorizedKey(key)); - } - - /// - /// Removes the specified SSH key from the authorized keys list. - /// - /// The SSH key to remove. - /// - /// Returns true if the key is successfully removed; - /// otherwise, false. - /// - public bool RemoveAuthorizedKey(SshKeyFile key) - { - if (AuthorizedKeys.All(e => e.Fingerprint != key.Fingerprint)) return false; - { - AuthorizedKeys.Remove(AuthorizedKeys.First(e => e.Fingerprint == key.Fingerprint)); - return true; - } - } + public ValueTask AddAuthorizedKeyAsync(SshKeyFile key) => ValueTask.FromResult(AddAuthorizedKey(key)); /// /// Exports the content of the authorized keys file. /// - /// Indicates whether to export for the local machine or remote server. Default is true (local). /// - /// The platform ID of the server. If null, the current OS platform will be used. Only applicable if - /// 'local' is set to false. + /// The platform ID of the server. If null, the current OS platform will be used /// /// The content of the authorized keys file as a string. - public string ExportFileContent(bool local = true, PlatformID? platform = null) + public string ExportFileContent(PlatformID? platform = null) { - return local - ? AuthorizedKeys.Where(e => !e.MarkedForDeletion) - .Aggregate("", (s, key) => s += $"{key.GetFullKeyEntry}\r\n") - : AuthorizedKeys.Where(e => !e.MarkedForDeletion).Aggregate("", - (s, key) => s += - $"{key.GetFullKeyEntry}{((platform ??= Environment.OSVersion.Platform) != PlatformID.Unix ? "`r`n" : "\r\n")}"); + var builder = new StringBuilder(); + foreach (var authorizedKey in AuthorizedKeys.Where(e => !e.MarkedForDeletion)) + { + builder.Append($"{authorizedKey}{(platform ?? Environment.OSVersion.Platform).GetLineSeparator()}"); + } + return builder.ToString(); } - - public static AuthorizedKeysFile Empty { get; } = new(); public static async ValueTask OpenAsync(string? filePath = null, CancellationToken cancellationToken = default) @@ -163,9 +137,15 @@ public static async ValueTask ParseAsync(Stream stream, /// A token to monitor for cancellation requests. private async ValueTask LoadFromStreamAsync(Stream stream, CancellationToken cancellationToken = default) { + AuthorizedKeys.Clear(); using var streamReader = new StreamReader(stream, detectEncodingFromByteOrderMarks: true, leaveOpen: true); - if (await streamReader.ReadToEndAsync(cancellationToken) is { } fileContents && - !string.IsNullOrWhiteSpace(fileContents)) LoadFileContents(fileContents); + while (await streamReader.ReadLineAsync(cancellationToken) is { } line) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed[0] == '#') + continue; + AuthorizedKeys.Add(AuthorizedKey.Parse(trimmed)); + } if (stream is FileStream fileStream) { @@ -176,29 +156,17 @@ private async ValueTask LoadFromStreamAsync(Stream stream, CancellationToken can { IsFileFromServer = true; } - } - - /// - /// Loads the contents of a file and parses them into a collection of authorized keys. - /// - /// The contents of the file. - private void LoadFileContents(string fileContents) - { - var splittedContents = fileContents - .Split("\r\n", StringSplitOptions.RemoveEmptyEntries) - .Where(e => !string.IsNullOrWhiteSpace(e.Trim())); - AuthorizedKeys = - new ObservableCollection(splittedContents.Select(e => AuthorizedKey.Parse(e.Trim()))); + _authorizedKeys = AuthorizedKeys.ToArray(); } /// /// Reads and loads the contents of a file. /// /// The path to the file to be read and loaded. + /// A cancellation token. private async ValueTask ReadAndLoadFileContents(string pathToFile, CancellationToken cancellationToken = default) { await using var fileStream = File.Open(pathToFile, FileMode.OpenOrCreate); - using var streamReader = new StreamReader(fileStream); - LoadFileContents(await streamReader.ReadToEndAsync(cancellationToken)); + await LoadFromStreamAsync(fileStream, cancellationToken); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Credentials/ConnectionCredentials.cs b/OpenSSH_GUI.Core/Lib/Credentials/ConnectionCredentials.cs deleted file mode 100644 index 1998230..0000000 --- a/OpenSSH_GUI.Core/Lib/Credentials/ConnectionCredentials.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; -using OpenSSH_GUI.Core.Enums; -using OpenSSH_GUI.Core.Interfaces.Credentials; -using Renci.SshNet; - -namespace OpenSSH_GUI.Core.Lib.Credentials; - -/// -/// Represents the base class for connection credentials. -/// -public class ConnectionCredentials(string hostname, string username) - : IConnectionCredentials -{ - /// - /// Represents the hostname of a server. - /// This property is used in classes related to connection credentials and server settings. - /// - public string Hostname { get; set; } = hostname; - - /// - /// Represents the port number used for establishing an SSH connection. - /// - public int Port => Hostname.Contains(':') ? int.Parse(Hostname.Split(':')[1]) : 22; - - /// - /// Represents the username property of a connection credentials. - /// - public string Username { get; set; } = username; - - /// - /// Retrieves the connection information based on the provided credentials. - /// - /// - /// The object representing the SSH connection information. - /// - public virtual ConnectionInfo GetConnectionInfo() - { - return new ConnectionInfo(Hostname, Username); - } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Credentials/KeyConnectionCredentials.cs b/OpenSSH_GUI.Core/Lib/Credentials/KeyConnectionCredentials.cs deleted file mode 100644 index cd58f14..0000000 --- a/OpenSSH_GUI.Core/Lib/Credentials/KeyConnectionCredentials.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Text.Json.Serialization; -using OpenSSH_GUI.Core.Enums; -using OpenSSH_GUI.Core.Interfaces.Credentials; -using OpenSSH_GUI.Core.Lib.Keys; -using Renci.SshNet; - -namespace OpenSSH_GUI.Core.Lib.Credentials; - -/// -/// Represents the credentials for a key-based connection to a server. -/// -public class KeyConnectionCredentials : ConnectionCredentials, IKeyConnectionCredentials -{ - /// - /// Represents connection credentials using SSH key authentication. - /// - public KeyConnectionCredentials(string hostname, string username, SshKeyFile? key) : base(hostname, username) - { - Key = key; - } - - /// - /// Represents connection credentials that include an SSH key for authentication. - /// - [JsonIgnore] - public SshKeyFile? Key { get; set; } - - - /// - /// Renews the SSH key used for authentication. - /// - /// The password for the key file (optional). - public void RenewKey(string? password = null) - { - } - - /// - /// Retrieves the connection information based on the provided credentials. - /// - /// - /// The object representing the SSH connection information. - /// - public override ConnectionInfo GetConnectionInfo() - { - return new PrivateKeyConnectionInfo(Hostname, Username, ProxyTypes.None, "", 0, Key?.PrivateKeySource); - } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Credentials/MultiKeyConnectionCredentials.cs b/OpenSSH_GUI.Core/Lib/Credentials/MultiKeyConnectionCredentials.cs deleted file mode 100644 index 1ed6ad7..0000000 --- a/OpenSSH_GUI.Core/Lib/Credentials/MultiKeyConnectionCredentials.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.Json.Serialization; -using OpenSSH_GUI.Core.Enums; -using OpenSSH_GUI.Core.Interfaces.Credentials; -using OpenSSH_GUI.Core.Lib.Keys; -using Renci.SshNet; - -namespace OpenSSH_GUI.Core.Lib.Credentials; - -/// *MultiKeyConnectionCredentials(string hostname, string username, -/// -/// ? keys)** -public class MultiKeyConnectionCredentials : ConnectionCredentials, IMultiKeyConnectionCredentials -{ - /// - /// Represents a set of connection credentials for a multi-key authentication. - /// - public MultiKeyConnectionCredentials(string hostname, string username, IEnumerable? keys) : base( - hostname, - username) - { - Keys = keys; - } - - /// - /// Represents the credentials for a multi-key SSH connection. - /// - [JsonIgnore] - public IEnumerable? Keys { get; set; } - - - /// - /// Retrieves the connection information for establishing an SSH connection. - /// - /// - /// The object representing the SSH connection information. - /// - public override ConnectionInfo GetConnectionInfo() - { - if (Keys is not { } keys) return new ConnectionInfo(Hostname, Port, Username); - var sources = keys.Select(e => e.PrivateKeySource).ToArray(); - return sources.All(s => s is not null) ? new PrivateKeyConnectionInfo(Hostname, Port, Username, sources) : new ConnectionInfo(Hostname, Port, Username); - } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Credentials/PasswordConnectionCredentials.cs b/OpenSSH_GUI.Core/Lib/Credentials/PasswordConnectionCredentials.cs deleted file mode 100644 index d26b6f2..0000000 --- a/OpenSSH_GUI.Core/Lib/Credentials/PasswordConnectionCredentials.cs +++ /dev/null @@ -1,35 +0,0 @@ -using OpenSSH_GUI.Core.Enums; -using OpenSSH_GUI.Core.Interfaces.Credentials; -using Renci.SshNet; - -namespace OpenSSH_GUI.Core.Lib.Credentials; - -public class PasswordConnectionCredentials( - string hostname, - string username, - string password, - bool encryptedPassword = false) - : ConnectionCredentials(hostname, username), IPasswordConnectionCredentials -{ - /// - /// Represents connection credentials using password authentication. - /// - public string Password { get; set; } = password; - - /// - /// Gets or sets a value indicating whether the password is encrypted. - /// - /// - /// true if the password is encrypted; otherwise, false. - /// - public bool EncryptedPassword { get; set; } = encryptedPassword; - - /// - /// Retrieves the connection information based on the provided credentials. - /// - /// The object representing the SSH connection information. - public override ConnectionInfo GetConnectionInfo() - { - return new PasswordConnectionInfo(Hostname, Username, Password); - } -} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs b/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs new file mode 100644 index 0000000..471d551 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/Keys/BasicSshKeyFileInformation.cs @@ -0,0 +1,385 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Text; +using OpenSSH_GUI.Core.Extensions; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using SshNet.Keygen; +using SshNet.Keygen.SshKeyEncryption; + +namespace OpenSSH_GUI.Core.Lib.Keys; + +[DebuggerDisplay("{ToString()}")] +public readonly record struct BasicSshKeyFileInformation() +{ + private const string OpensshPrivateHeader = "-----BEGIN OPENSSH PRIVATE KEY-----"; + private const string OpensshPrivateFooter = "-----END OPENSSH PRIVATE KEY-----"; + private const string PuttyFileStart = "PuTTY-User-Key-File-"; + private const string OutputFormat = "{0} {1}:{2} {3} ({4})"; + private static readonly ReadOnlyMemory OpensshMagic = "openssh-key-v1\0"u8.ToArray(); + internal ReadOnlyMemory KeyBlob { get; init; } + + + /// The hash algorithm used to compute the fingerprint. Always SHA256 for parsed keys. + public SshKeyHashAlgorithmName HashAlgorithmName { get; private init; } = SshKeyHashAlgorithmName.SHA256; + + /// Base64-encoded SHA256 fingerprint of the public key blob (without trailing padding). + public string FingerPrint { get; private init; } = string.Empty; + + /// Key comment as stored in the key file. + public string Comment { get; private init; } = string.Empty; + + /// Logical SSH key algorithm type. + public SshKeyType KeyType { get; private init; } = SshKeyType.RSA; + + /// Effective bit length of the key (e.g. 256, 384, 521, 2048, 4096). + public int BitLength { get; private init; } = 0; + + /// Storage format of the key, independent of whether it is split across one or two files. + public SshKeyFormat Format { get; private init; } = SshKeyFormat.OpenSSH; + + private bool IsEmpty => FingerPrint.Length == 0; + + private static BasicSshKeyFileInformation Empty { get; } = new(); + + /// + /// 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. + /// + /// Descriptor of the key file(s) on disk. + /// Parsed metadata, or an empty instance if the key cannot be read. + public static BasicSshKeyFileInformation FromKeyFileInfo(SshKeyFileInformation keyFileInformation) + { + if (keyFileInformation is { Exists: false }) + return Empty; + + // .pub file is always preferred — richest source, comment always present + if (keyFileInformation.PublicKeyFileName is { } pubPath) + return TryParseOrEmpty(() => ParseOpenSshPublicKey(File.ReadAllText(pubPath).Trim())); + + var files = keyFileInformation.Files.ToList(); + + // PPK — comment lives in the unencrypted plaintext header regardless of encryption + if (files.FirstOrDefault(f => + f.Extension.Equals(PathExtensions.PuttyKeyFileExtension, StringComparison.OrdinalIgnoreCase)) is + { } ppkFile) + return TryParseOrEmpty(() => ParsePpkFile(File.ReadAllText(ppkFile.FullName))); + + // OpenSSH private key — public key blob is always stored unencrypted + if (files.FirstOrDefault(LooksLikeOpensshPrivateKey) is { } privateFile) + return TryParseOrEmpty(() => ParseOpensshPrivateKey(File.ReadAllText(privateFile.FullName))); + + return Empty; + } + + /// + /// Parses a single-line OpenSSH public key in the format: + /// <keytype> <base64blob> [comment] + /// + private static BasicSshKeyFileInformation ParseOpenSshPublicKey(string content) + { + var parts = content.Split(' ', 3); + if (parts.Length < 2) + throw new FormatException("Not a valid OpenSSH public key line."); + + var keyTypeRaw = parts[0]; + var keyBlob = Convert.FromBase64String(parts[1]); + var comment = parts.Length == 3 ? parts[2] : string.Empty; + + return new BasicSshKeyFileInformation + { + Format = SshKeyFormat.OpenSSH, + HashAlgorithmName = SshKeyHashAlgorithmName.SHA256, + FingerPrint = ComputeFingerprint(keyBlob), + Comment = comment, + KeyType = MapKeyType(keyTypeRaw), + BitLength = ComputeBitLength(keyTypeRaw, keyBlob) + }; + } + + /// + /// Parses the unencrypted public-key section of an OpenSSH private key file. + /// The public key blob is stored in plaintext even when the private key is passphrase-protected. + /// The comment field will be empty because it resides in the encrypted section. + /// + private static BasicSshKeyFileInformation ParseOpensshPrivateKey(string pem) + { + var base64 = pem + .Replace(OpensshPrivateHeader, string.Empty) + .Replace(OpensshPrivateFooter, string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + + ReadOnlyMemory blob = Convert.FromBase64String(base64); + + if (!blob.Span[..OpensshMagic.Length].SequenceEqual(OpensshMagic.Span)) + throw new FormatException("Invalid OpenSSH private key magic bytes."); + + var reader = new BlobReader(blob, OpensshMagic.Length); + reader.ReadString(); // ciphername + reader.ReadString(); // kdfname + reader.ReadString(); // kdfoptions + + if (reader.ReadUInt32() == 0) + throw new FormatException("No keys found in OpenSSH private key file."); + + var pubKeyBlob = reader.ReadBlob(); + var inner = new BlobReader(pubKeyBlob, 0); + var keyTypeRaw = inner.ReadString(); + + return new BasicSshKeyFileInformation + { + KeyBlob = pubKeyBlob, + Format = SshKeyFormat.OpenSSH, + HashAlgorithmName = SshKeyHashAlgorithmName.SHA256, + FingerPrint = ComputeFingerprint(pubKeyBlob.Span), + Comment = string.Empty, + KeyType = MapKeyType(keyTypeRaw), + BitLength = ComputeBitLength(keyTypeRaw, pubKeyBlob.Span) + }; + } + + /// + /// Parses a PuTTY private key file (PPK v1, v2, or v3). + /// All versions store the public key blob and comment in unencrypted plaintext headers. + /// PPK v1 is mapped to as no dedicated enum value exists. + /// + private static BasicSshKeyFileInformation ParsePpkFile(string content) + { + var firstLine = content.Split('\n', 2)[0].Trim(); + + var format = int.TryParse(firstLine.Replace(PuttyFileStart, string.Empty)[0].ToString(), out var version) + ? version is 3 ? SshKeyFormat.PuTTYv3 : SshKeyFormat.PuTTYv2 + : SshKeyFormat.PuTTYv2; + + var keyTypeRaw = string.Empty; + var comment = string.Empty; + var publicBase64 = string.Empty; + + using var sr = new StringReader(content); + while (sr.ReadLine() is { } line) + if (line.StartsWith(PuttyFileStart)) + { + keyTypeRaw = SplitPpkField(line); + } + else if (line.StartsWith("Comment:")) + { + comment = SplitPpkField(line); + } + else if (line.StartsWith("Public-Lines:") && + int.TryParse(SplitPpkField(line), out var pubLineCount)) + { + for (var i = 0; i < pubLineCount; i++) + if (sr.ReadLine() is { } pubLine) + publicBase64 += pubLine.Trim(); + + break; // everything we need has been read + } + + if (publicBase64.Length == 0) + throw new FormatException("PPK file contains no public key data."); + + var keyBlob = Convert.FromBase64String(publicBase64); + + return new BasicSshKeyFileInformation + { + KeyBlob = keyBlob, + Format = format, + HashAlgorithmName = SshKeyHashAlgorithmName.SHA256, + FingerPrint = ComputeFingerprint(keyBlob), + Comment = comment, + KeyType = MapKeyType(keyTypeRaw), + BitLength = ComputeBitLength(keyTypeRaw, keyBlob) + }; + } + + /// + /// Maps an OpenSSH wire-format key type string to the enum. + /// DSA and unrecognized types fall back to . + /// + private static SshKeyType MapKeyType(string keyTypeRaw) + { + return keyTypeRaw switch + { + "ssh-ed25519" + or "ssh-ed448" + or "sk-ssh-ed25519@openssh.com" => SshKeyType.ED25519, + "ecdsa-sha2-nistp256" + or "ecdsa-sha2-nistp384" + or "ecdsa-sha2-nistp521" + or "sk-ecdsa-sha2-nistp256@openssh.com" => SshKeyType.ECDSA, + _ => SshKeyType.RSA + }; + } + + /// + /// Returns the effective bit length of the key. + /// For RSA and DSA the modulus size is read directly from the key blob. + /// + private static int ComputeBitLength(string keyTypeRaw, ReadOnlySpan keyBlob) + { + return keyTypeRaw switch + { + "ssh-ed25519" + or "sk-ssh-ed25519@openssh.com" => 256, + "ssh-ed448" => 448, + "ecdsa-sha2-nistp256" + or "sk-ecdsa-sha2-nistp256@openssh.com" => 256, + "ecdsa-sha2-nistp384" => 384, + "ecdsa-sha2-nistp521" => 521, + "ssh-rsa" => GetRsaBitLength(keyBlob), + "ssh-dss" => GetDsaBitLength(keyBlob), + _ => 0 + }; + } + + /// + /// Reads the RSA modulus from an SSH wire-format blob to determine the key's bit length. + /// Layout: [keytype][exponent e][modulus n] — all uint32-length-prefixed. + /// + private static int GetRsaBitLength(ReadOnlySpan span) + { + var typeLen = BinaryPrimitives.ReadInt32BigEndian(span); + span = span[(4 + typeLen)..]; + + var expLen = BinaryPrimitives.ReadInt32BigEndian(span); + span = span[(4 + expLen)..]; + + var modLen = BinaryPrimitives.ReadInt32BigEndian(span); + span = span[4..]; + + if (span[0] == 0x00) + { + span = span[1..]; + modLen--; + } + + return (modLen - 1) * 8 + (int)Math.Floor(Math.Log2(span[0]) + 1); + } + + /// + /// Reads the DSA prime p from an SSH wire-format blob to determine the key's bit length. + /// Layout: [keytype][p][q][g][y] — all uint32-length-prefixed. + /// + private static int GetDsaBitLength(ReadOnlySpan span) + { + var typeLen = BinaryPrimitives.ReadInt32BigEndian(span); + span = span[(4 + typeLen)..]; + + var pLen = BinaryPrimitives.ReadInt32BigEndian(span); + span = span[4..]; + + if (span[0] == 0x00) pLen--; + + return pLen * 8; + } + + /// Computes a SHA256 fingerprint and returns it as unpadded Base64. + private static string ComputeFingerprint(ReadOnlySpan keyBlob, + SshKeyHashAlgorithmName hashAlgorithmName = SshKeyHashAlgorithmName.SHA256) + { + IDigest digest = hashAlgorithmName switch + { + SshKeyHashAlgorithmName.SHA256 => new Sha256Digest(), + SshKeyHashAlgorithmName.SHA512 => new Sha512Digest(), + SshKeyHashAlgorithmName.SHA384 => new Sha384Digest(), + SshKeyHashAlgorithmName.SHA1 => new Sha1Digest(), + SshKeyHashAlgorithmName.MD5 => new MD5Digest(), + _ => new Sha256Digest() + }; + digest.BlockUpdate(keyBlob); + byte[]? rented = null; + var buffer = digest.GetDigestSize() <= 256 + ? stackalloc byte[digest.GetDigestSize()] + : rented = ArrayPool.Shared.Rent(digest.GetDigestSize()); + try + { + var digested = digest.DoFinal(buffer); + return Convert.ToBase64String(buffer[..digested]).TrimEnd('='); + } + finally + { + if (rented is not null) ArrayPool.Shared.Return(rented, true); + } + } + + /// Peeks at the first line of a file to check for the OpenSSH private key header. + private static bool LooksLikeOpensshPrivateKey(FileInfo file) + { + try + { + using var fs = file.OpenText(); + return fs.ReadLine()?.TrimStart().StartsWith(OpensshPrivateHeader) == true; + } + catch + { + return false; + } + } + + /// Splits a PPK header line of the form "Key: Value" and returns the trimmed value. + private static string SplitPpkField(string line) => line.Split(": ", 2) is { Length: 2 } parts ? parts[1].Trim() : string.Empty; + + /// + /// Wraps a parse call and returns on any exception, + /// so that a malformed or unsupported key file never crashes the caller. + /// + private static BasicSshKeyFileInformation TryParseOrEmpty(Func parse) + { + try + { + return parse(); + } + catch + { + return Empty; + } + } + + public string ToString(SshKeyHashAlgorithmName hashAlgorithmName, string outputFormat = OutputFormat) => IsEmpty + ? hashAlgorithmName == HashAlgorithmName + ? string.Format(outputFormat, BitLength, HashAlgorithmName, FingerPrint, Comment, KeyType) + : string.Format( + outputFormat, BitLength, hashAlgorithmName, ComputeFingerprint([], hashAlgorithmName), + Comment, KeyType) + : string.Empty; + + /// + /// Returns a human-readable string matching the output format of ssh-keygen -lf: + /// {bits} SHA256:{fingerprint} {comment} ({keyType}) + /// + public override string ToString() => ToString(HashAlgorithmName); +} + +/// +/// Reads SSH binary protocol fields encoded as big-endian uint32-length-prefixed byte arrays. +/// +file sealed class BlobReader(ReadOnlyMemory data, int offset) +{ + private int _position = offset; + + public uint ReadUInt32() + { + var value = (uint)( + data.Span[_position] << 24 | + data.Span[_position + 1] << 16 | + data.Span[_position + 2] << 8 | + data.Span[_position + 3]); + _position += 4; + return value; + } + + public ReadOnlyMemory ReadBlob() + { + var length = (int)ReadUInt32(); + var result = data[_position..(_position + length)]; + _position += length; + return result; + } + + public string ReadString(Encoding? encoding = null) => (encoding ?? Encoding.ASCII).GetString(ReadBlob().Span); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFactory.cs b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFactory.cs new file mode 100644 index 0000000..7a61eca --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFactory.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Interfaces; + +namespace OpenSSH_GUI.Core.Lib.Keys; + +/// +/// Default implementation of . +/// Creates instances with a shared logger, +/// eliminating the need for a service locator at the call site. +/// +public sealed class SshKeyFactory(ILogger logger, ILoggerFactory loggerFactory) : ISshKeyFactory +{ + /// + public SshKeyFile Create() + { + logger.LogDebug("Creating new SshKeyFile instance"); + return new SshKeyFile(loggerFactory.CreateLogger()); + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFile.cs b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFile.cs index 23b6bff..7dd15dd 100644 --- a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFile.cs +++ b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFile.cs @@ -1,12 +1,10 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using Microsoft.Extensions.Logging; -using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Core.Lib.AuthorizedKeys; -using OpenSSH_GUI.Core.Services; using ReactiveUI; +using ReactiveUI.Avalonia; using ReactiveUI.SourceGenerators; using Renci.SshNet; using Renci.SshNet.Common; @@ -21,168 +19,217 @@ namespace OpenSSH_GUI.Core.Lib.Keys; /// Represents an SSH key file used in the OpenSSH GUI application, encapsulating properties /// and functionality for managing SSH keys. /// -public sealed partial class SshKeyFile : ReactiveObject, IDisposable, IAsyncDisposable +public sealed partial record SshKeyFile : ReactiveRecord, IDisposable, IAsyncDisposable { + private readonly CompositeDisposable _disposables = new(); + /// /// A logger instance used for logging events and diagnostic information /// related to the operations and state within the class. /// private readonly ILogger _logger; - [ObservableAsProperty] private string _comment = string.Empty; + /// + /// Gets the absolute file path of the SSH key file, projected from . + /// + [ObservableAsProperty(ReadOnly = true)] + private string? _absoluteFilePath; /// - /// Stores the comment associated with the SSH key file. - /// This field is primarily used internally for extracting or storing - /// metadata related to the SSH key during file operations or processing. - /// Default value is an empty string. + /// The basic key file information extracted by ssh-keygen or the file itself in case of a PuTTy key. /// - private string _commentField = string.Empty; + [Reactive] private BasicSshKeyFileInformation _basicSshKeyFileInformation; - [ObservableAsProperty] private string _fingerprint = string.Empty; + /// + /// Holds the comment embedded in the SSH key, derived from either the loaded + /// or the . + /// + [ObservableAsProperty(ReadOnly = true)] + private string _comment = SshKeyGenerateInfo.DefaultSshKeyComment; + + [ObservableAsProperty(ReadOnly = true)] + private bool _fileChangesAllowed; /// - /// Stores the fingerprint information of the SSH key. + /// Gets the file name (without directory) of the SSH key file, projected from . /// - private string _fingerPrintField = string.Empty; + [ObservableAsProperty(ReadOnly = true)] + private string? _fileName; - [ObservableAsProperty] private string _fingerprintString = string.Empty; + /// + /// Holds the raw fingerprint hash of the SSH key, derived from either the loaded + /// or the . + /// + [ObservableAsProperty(ReadOnly = true)] + private string _fingerprint = string.Empty; - [ObservableAsProperty] private SshKeyHashAlgorithmName _hashAlgorithmName = SshKeyHashAlgorithmName.SHA256; + /// + /// Gets the current on-disk format of the SSH key file (e.g. OpenSSH or PuTTY), + /// projected from . + /// + [ObservableAsProperty(ReadOnly = true)] + private SshKeyFormat? _format; /// - /// Represents the default hash algorithm name used for calculating SSH key fingerprints. - /// This field is initialized to the SHA256 algorithm by default and can be updated - /// during key information extraction or other relevant operations. It is used as a fallback - /// in cases where the private key's host key algorithm name cannot be determined. + /// Indicates the hash algorithm (e.g. SHA256, MD5) used for the key's fingerprint, + /// resolved from the host key algorithms of the loaded + /// or from . /// - private SshKeyHashAlgorithmName _hashAlgorithmNameField = SshKeyHashAlgorithmName.SHA256; + [ObservableAsProperty(ReadOnly = true)] + private SshKeyHashAlgorithmName _hashAlgorithmName = SshKeyHashAlgorithmName.SHA256; - [ObservableAsProperty] private bool _isInitialized; + /// + /// Evaluates to when both a valid + /// is loaded and the associated exists on disk. + /// + [ObservableAsProperty(ReadOnly = true)] + private bool _isInitialized; - [ObservableAsProperty] private bool _isPuttyKey; + /// + /// Evaluates to when the current key file format is not + /// , indicating a PuTTY-compatible key format. + /// + [ObservableAsProperty(ReadOnly = true)] + private bool _isPuttyKey; /// /// Holds metadata information about the associated SSH key file, such as file path, name, /// format, and available formats for conversion. Provides access to details about the /// primary key file and related files, such as public key files. /// - /// - /// The source generator creates a public property named KeyFileInfo from this field. - /// The name avoids collision with . - /// [Reactive] private SshKeyFileInformation? _keyFileInfo; - [ObservableAsProperty] private SshKeyType _keyType = SshKeyType.RSA; + /// + /// Provides access to the collection of associated key files (e.g. private and public) + /// for the current SSH key, projected from . + /// Returns an empty array when no files are associated. + /// + [ObservableAsProperty(ReadOnly = true)] + private FileInfo[] _keyFiles = []; + + /// + /// Represents the cryptographic algorithm of the key (e.g. RSA, ECDSA, ED25519), + /// resolved from the loaded or . + /// + [ObservableAsProperty(ReadOnly = true)] + private SshKeyType _keyType = SshKeyType.RSA; + + /// + /// Indicates whether the associated SSH key file requires a password to access. + /// + [ObservableAsProperty(ReadOnly = true)] + private bool _needsPassword; /// - /// Represents the internal field used to store the key type in string representation. - /// This field is primarily utilized for parsing and determining the appropriate - /// when the associated SSH key metadata is loaded or updated. + /// Represents a password container for an SSH key file, encapsulating related + /// password properties and operations while supporting password validation. /// - private string _keyTypeField = string.Empty; + [Reactive(SetModifier = AccessModifier.Private)] + private SshKeyFilePassword _password = new(); /// /// Represents the underlying private key file used to interact with SSH-related /// operations, including authentication and cryptographic functions. /// - /// - /// The source generator creates a public property named PrivateKeyFile from this field. - /// [Reactive] private PrivateKeyFile? _privateKeyFile; - // --- ObservableAsProperty backing fields --- - // The source generator creates public read-only properties and - // corresponding _xxxHelper fields from each of these. - - [ObservableAsProperty] private PrivateKeyFile? _privateKeySource; - - internal void AttachChangeFormatHandler(Func handler) - { - changeFormatHandler = handler; - } - - private Func? changeFormatHandler; - - private Task ChangeFormatOnDisk(SshKeyFormat newFormat, CancellationToken token) - { - return changeFormatHandler is not null ? changeFormatHandler(this, newFormat, token) : Task.CompletedTask; - } - /// - /// Represents a file-based SSH key with fully encapsulated functionalities for managing, - /// manipulating, and interacting with the key. This class provides support for operations - /// such as key format conversion, password management, and key metadata retrieval. - /// Implements for reactive binding capabilities and - /// both and for lifecycle management. + /// Initializes a new instance of , wires up all reactive + /// observable property pipelines. /// + /// + /// An used for diagnostic output throughout the + /// lifetime of this instance. + /// public SshKeyFile(ILogger logger) { _logger = logger; - ChangeFormatOfKeyFile = ReactiveCommand.CreateFromTask(ChangeFormatOnDisk); - - // Wire up all computed ObservableAsPropertyHelper properties. - - _privateKeySourceHelper = this.WhenAnyValue(x => x.PrivateKeyFile) - .ToProperty(this, nameof(PrivateKeySource)); - - _fingerprintHelper = this.WhenAnyValue(x => x.PrivateKeyFile) - .Select(pk => pk?.FingerprintHash() ?? _fingerPrintField) - .ToProperty(this, nameof(Fingerprint)); - - _fingerprintStringHelper = this.WhenAnyValue(x => x.PrivateKeyFile) - .Select(pk => pk?.Fingerprint(SshKeyHashAlgorithmName.SHA256) - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Skip(1).FirstOrDefault() - ?.Split(':').Skip(1).FirstOrDefault() ?? _fingerPrintField) - .ToProperty(this, nameof(FingerprintString)); - - _commentHelper = this.WhenAnyValue(x => x.PrivateKeyFile) - .Select(pk => pk?.Key.Comment ?? _commentField) - .ToProperty(this, nameof(Comment)); - - _keyTypeHelper = this.WhenAnyValue(x => x.PrivateKeyFile) - .Select(pk => + + var privateKeyFileAndBasicFileInfoObservable = this + .WhenAnyValue(x => x.PrivateKeyFile, x => x.BasicSshKeyFileInformation) + .ObserveOn(AvaloniaScheduler.Instance); + + var privateKeyFileAndFileInfoObservable = this.WhenAnyValue(vm => vm.PrivateKeyFile, vm => vm.KeyFileInfo) + .ObserveOn(AvaloniaScheduler.Instance); + + _needsPasswordHelper = privateKeyFileAndBasicFileInfoObservable.Select(tuple => tuple.Item1 == null) + .ToProperty(this, x => x.NeedsPassword).DisposeWith(_disposables); + + _fingerprintHelper = privateKeyFileAndBasicFileInfoObservable.Select(tuple => tuple.Item2.FingerPrint) + .ToProperty(this, x => x.Fingerprint).DisposeWith(_disposables); + + _commentHelper = privateKeyFileAndBasicFileInfoObservable + .Select(tuple => tuple.Item1?.Key.Comment ?? tuple.Item2.Comment) + .ToProperty(this, x => x.Comment).DisposeWith(_disposables); + + _keyTypeHelper = privateKeyFileAndBasicFileInfoObservable.Select(tuple => + tuple.Item1?.Key switch { - if (pk is not null) - return pk.Key switch - { - EcdsaKey => SshKeyType.ECDSA, - ED25519Key => SshKeyType.ED25519, - _ => SshKeyType.RSA - }; - return Enum.TryParse(_keyTypeField, true, out var enumValue) - ? enumValue - : SshKeyType.RSA; + EcdsaKey => SshKeyType.ECDSA, + ED25519Key => SshKeyType.ED25519, + RsaKey => SshKeyType.RSA, + _ => tuple.Item2.KeyType + }).ToProperty(this, x => x.KeyType).DisposeWith(_disposables); + + _hashAlgorithmNameHelper = privateKeyFileAndBasicFileInfoObservable.Select(tuple => + Enum.TryParse( + tuple.Item1?.HostKeyAlgorithms.FirstOrDefault()?.Name ?? string.Empty, + out var enumValue) + ? enumValue + : tuple.Item2.HashAlgorithmName + ).ToProperty(this, x => x.HashAlgorithmName).DisposeWith(_disposables); + + _absoluteFilePathHelper = privateKeyFileAndFileInfoObservable.Select(tuple => tuple.Item2?.FullFileName) + .ToProperty(this, obj => obj.AbsoluteFilePath); + + _isInitializedHelper = privateKeyFileAndFileInfoObservable + .Select(tuple => tuple.Item1 is not null && tuple.Item2 is { Exists: true }) + .ToProperty(this, x => x.IsInitialized).DisposeWith(_disposables); + + _isPuttyKeyHelper = privateKeyFileAndFileInfoObservable + .Select(tuple => tuple.Item2?.CurrentFormat is not SshKeyFormat.OpenSSH) + .ToProperty(this, x => x.IsPuttyKey).DisposeWith(_disposables); + + _keyFilesHelper = privateKeyFileAndFileInfoObservable + .Select(tuple => tuple.Item2 is not null ? tuple.Item2.Files : []) + .ToProperty(this, x => x.KeyFiles).DisposeWith(_disposables); + + _fileNameHelper = privateKeyFileAndFileInfoObservable.Select(tuple => tuple.Item2?.FileName) + .ToProperty(this, x => x.FileName).DisposeWith(_disposables); + + _formatHelper = privateKeyFileAndFileInfoObservable.Select(tuple => tuple.Item2?.CurrentFormat) + .ToProperty(this, x => x.Format).DisposeWith(_disposables); + + _fileChangesAllowedHelper = this.WhenAnyValue( + vm => vm.NeedsPassword, + vm => vm.Password, + vm => vm.KeyFileInfo, + (needsPassword, password, keyFileInfo) => + keyFileInfo is { KeyFileSource.ProvidedByConfig: false } && + (!needsPassword || password.IsValid)) + .ObserveOn(AvaloniaScheduler.Instance) + .ToProperty(this, x => x.FileChangesAllowed).DisposeWith(_disposables); + + this.WhenAnyValue(vm => vm.KeyFileInfo) + .ObserveOn(AvaloniaScheduler.Instance) + .Subscribe(keyFileInfo => + { + try + { + if (keyFileInfo is not null) + BasicSshKeyFileInformation = BasicSshKeyFileInformation.FromKeyFileInfo(keyFileInfo); + } + catch (FileNotFoundException) + { + } + catch (Exception e) + { + logger.LogInformation(e, "Failed to extract key information"); + } }) - .ToProperty(this, nameof(KeyType)); - - _hashAlgorithmNameHelper = this.WhenAnyValue(x => x.PrivateKeyFile) - .Select(pk => - Enum.TryParse(pk?.HostKeyAlgorithms.FirstOrDefault()?.Name, out var enumValue) - ? enumValue - : _hashAlgorithmNameField) - .ToProperty(this, nameof(HashAlgorithmName)); - - _isInitializedHelper = this.WhenAnyValue(x => x.PrivateKeyFile, x => x.KeyFileInfo) - .Select(t => t.Item1 is not null && t.Item2 is { Exists: true }) - .ToProperty(this, nameof(IsInitialized)); - - _isPuttyKeyHelper = this.WhenAnyValue(x => x.KeyFileInfo) - .Select(fi => fi?.CurrentFormat is not SshKeyFormat.OpenSSH) - .ToProperty(this, nameof(IsPuttyKey)); + .DisposeWith(_disposables); } - /// - /// Provides access to the collection of associated key files for the current SSH key. - /// The key files typically include the private and public key files that are associated - /// with the key being managed. This property relies on the underlying - /// instance to determine and fetch the file information. - /// Returns an enumeration of objects, representing the files associated - /// with the SSH key. If no files are linked to the key, an empty enumeration is returned. - /// - internal IEnumerable KeyFiles => KeyFileInfo?.Files ?? []; - /// /// Represents the authorized key associated with an SSH key file. /// @@ -193,180 +240,88 @@ public AuthorizedKey AuthorizedKey { get { - if(PrivateKeyFile is { } privateKeyFile) + if (PrivateKeyFile is { } privateKeyFile) return AuthorizedKey.Parse(privateKeyFile.ToOpenSshPublicFormat()); throw new InvalidOperationException("SshKeyFile not initialized."); } } - /// - /// Indicates whether the associated SSH key file requires a password to access. - /// - public bool NeedsPassword - { - get; - set => this.RaiseAndSetIfChanged(ref field, value); - } - - /// - /// Represents a password container for an SSH key file, encapsulating related - /// password properties and operations while supporting password validation. - /// - public SshKeyFilePassword Password { get; } = new(); - - /// - /// Gets the absolute file path of the SSH key file. - /// - public string? AbsoluteFilePath => KeyFileInfo?.FullName; - - /// - /// Gets the name of the SSH key file. - /// - public string? FileName => KeyFileInfo?.Name; - - /// - /// Gets the current format of the SSH key file. - /// - public SshKeyFormat? Format => KeyFileInfo?.CurrentFormat; - - /// - /// Gets the list of available SSH key formats to which the current key can be converted. - /// - public IEnumerable? AvailableFormatsForConversion => KeyFileInfo?.AvailableFormatsForConversion; - - /// - /// Gets the default format to which the key file can be converted. - /// - public SshKeyFormat? DefaultConversionFormat => KeyFileInfo?.DefaultConversionFormat; - - /// - /// A reactive command that allows changing the format of an SSH key file on disk. - /// - public ReactiveCommand ChangeFormatOfKeyFile { get; } - - /// - /// Event triggered when the SSH key file is successfully deleted. - /// - public EventHandler? GotDeleted { get; set; } = delegate { }; - /// /// Asynchronously releases the unmanaged resources used by the SshKeyFile instance /// and optionally releases the managed resources. /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - if (PrivateKeyFile is IAsyncDisposable privateKeyFileAsyncDisposable) - await privateKeyFileAsyncDisposable.DisposeAsync(); - else - PrivateKeyFile?.Dispose(); + _disposables.Dispose(); + return ValueTask.CompletedTask; } /// /// Releases the unmanaged resources used by the instance /// and optionally releases the managed resources. /// - public void Dispose() - { - PrivateKeyFile?.Dispose(); - } + public void Dispose() { _disposables.Dispose(); } /// /// Implicit conversion to the underlying . /// - public static implicit operator PrivateKeyFile?(SshKeyFile sshKeyFile) - { - return sshKeyFile.PrivateKeyFile; - } + public static implicit operator PrivateKeyFile?(SshKeyFile sshKeyFile) => sshKeyFile.PrivateKeyFile; - private Process? BuildInformationProcess() - { - if (KeyFileInfo is not { Exists: true }) - throw new FileNotFoundException(); - var processInformation = new ProcessStartInfo - { - FileName = "ssh-keygen", - Arguments = $"-lf {KeyFileInfo.FullName}", - CreateNoWindow = true, - WorkingDirectory = KeyFileInfo.DirectoryName, - UseShellExecute = false, - RedirectStandardOutput = true - }; - return Process.Start(processInformation); - } - - private string? GetPublicKeyInfo() - { - string? publicKeyInfo = null; - if (BuildInformationProcess() is not { } process) return publicKeyInfo; - publicKeyInfo = process.StandardOutput.ReadToEnd(); - return publicKeyInfo; - } - - private async ValueTask GetPublicKeyInfoAsync() - { - string? publicKeyInfo = null; - if (BuildInformationProcess() is not { } process) return publicKeyInfo; - publicKeyInfo = await process.StandardOutput.ReadToEndAsync(); - return publicKeyInfo; - } - /// - /// Extracts detailed information about the SSH key file, such as its fingerprint, - /// hash algorithm, comment, and key type, using the ssh-keygen command-line tool. + /// Resets the state of the current SSH key file instance, clearing any previously set password, + /// and reinitializing the associated private key file to its initial state. + /// If the associated key file requires a password to decrypt but no password is set, + /// the method updates the state to indicate that a password is needed and attempts to + /// extract key metadata for further operations. Logs errors and rethrows exceptions + /// in case of unexpected failures during the reset process. /// - /// - /// Thrown if the SSH key file does not exist. - /// - private async ValueTask ExtractKeyInformation() + public void Reset() { - if (KeyFileInfo is not { Exists: true }) - throw new FileNotFoundException(); - - if (await GetPublicKeyInfoAsync() is { } publicKeyInfo) + try { - var splitted = publicKeyInfo.TrimEnd('\r', '\n').Split(' ', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var pingerprintSplit = splitted[1].Split(':'); - - _hashAlgorithmNameField = Enum.Parse(pingerprintSplit[0]); - _fingerPrintField = pingerprintSplit[1]; - _commentField = splitted[2]; - _keyTypeField = splitted[3]; - - _logger.LogInformation("Extracted Key Information from {filePath}: \"{joinedString}\"", - KeyFileInfo.FullName, string.Join(" ", splitted)); + PrivateKeyFile?.Dispose(); + PrivateKeyFile = null; + Password.Clear(); + PrivateKeyFile = new PrivateKeyFile(KeyFileInfo!.FullFileName); } + catch (SshPassPhraseNullOrEmptyException) + { + if (KeyFileInfo is not null) + BasicSshKeyFileInformation = BasicSshKeyFileInformation.FromKeyFileInfo(KeyFileInfo); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to Initialize {className}", nameof(SshKeyFile)); + throw; + } + + _logger.LogInformation("Reset {className} successfully", KeyFileInfo?.FileName ?? string.Empty); } - /// - /// Resets the state of the current SSH key file instance, clearing any previously set password, - /// and reinitializing the associated private key file to its initial state. - /// If the associated key file requires a password to decrypt but no password is set, - /// the method updates the state to indicate that a password is needed and attempts to - /// extract key metadata for further operations. Logs errors and rethrows exceptions - /// in case of unexpected failures during the reset process. - /// - /// A task representing the asynchronous operation of resetting the SSH key file. - public async ValueTask Reset() + public void Load(SshKeyFileSource source) { try { - Password.Clear(); - PrivateKeyFile = new PrivateKeyFile(KeyFileInfo!.FullName); + KeyFileInfo = new SshKeyFileInformation(source); + PrivateKeyFile = Password.IsValid + ? new PrivateKeyFile(KeyFileInfo.FullFileName, Password.GetPasswordString()) + : new PrivateKeyFile(KeyFileInfo.FullFileName); } - catch (SshPassPhraseNullOrEmptyException) + catch (SshPassPhraseNullOrEmptyException passPhraseNullOrEmptyException) { - NeedsPassword = true; - await ExtractKeyInformation(); + _logger.LogInformation( + passPhraseNullOrEmptyException, "Missing Password for keyfile {filePath}", + source.AbsolutePath); + if (KeyFileInfo is not null) + BasicSshKeyFileInformation = BasicSshKeyFileInformation.FromKeyFileInfo(KeyFileInfo); } catch (Exception e) { _logger.LogError(e, "Failed to Initialize {className}", nameof(SshKeyFile)); throw; } - _logger.LogInformation("Reset {className} successfully", KeyFileInfo?.Name ?? string.Empty); } - + /// /// Loads an SSH key file from the specified file path and initializes it, /// optionally using the provided passphrase for decryption. @@ -375,22 +330,12 @@ public async ValueTask Reset() /// /// An optional passphrase for the key file, used to unlock encrypted private keys. /// - public async ValueTask Load(SshKeyFileSource keyFileSource, ReadOnlyMemory? passPhrase = null) + public void Load(SshKeyFileSource keyFileSource, ReadOnlySpan passPhrase) { try { - KeyFileInfo = new SshKeyFileInformation(keyFileSource); - if (passPhrase is { Length: > 0 } pass) - Password.Set(pass); - PrivateKeyFile = Password.IsValid - ? new PrivateKeyFile(KeyFileInfo.FullName, Password.GetPasswordString()) - : new PrivateKeyFile(KeyFileInfo.FullName); - } - catch (SshPassPhraseNullOrEmptyException passPhraseNullOrEmptyException) - { - _logger.LogInformation(passPhraseNullOrEmptyException, "Missing Password for keyfile {filePath}", keyFileSource.AbsolutePath); - NeedsPassword = true; - await ExtractKeyInformation(); + Password.Set(passPhrase); + Load(keyFileSource); } catch (Exception e) { @@ -406,20 +351,18 @@ public async ValueTask Load(SshKeyFileSource keyFileSource, ReadOnlyMemory /// /// A boolean value indicating whether the password was successfully set. /// - public async ValueTask SetPassword(ReadOnlyMemory password) + public bool SetPassword(ReadOnlySpan password) { try { if (KeyFileInfo is not { Exists: true }) - throw new FileNotFoundException("SshKeyFile not found", KeyFileInfo?.Name); - await Load(KeyFileInfo.KeyFileSource, password); - NeedsPassword = false; + throw new FileNotFoundException("SshKeyFile not found", KeyFileInfo?.FileName); + Load(KeyFileInfo.KeyFileSource, password); return true; } catch (SshPassPhraseNullOrEmptyException) { - NeedsPassword = true; - _logger.LogWarning("Missing Password for keyfile {filePath}", KeyFileInfo?.FullName); + _logger.LogWarning("Missing Password for keyfile {filePath}", KeyFileInfo?.FullFileName); } catch (Exception e) { @@ -428,65 +371,4 @@ public async ValueTask SetPassword(ReadOnlyMemory password) return false; } - - /// - /// Deletes all files associated with this SSH key. If all deletions complete successfully, - /// the event will be triggered. - /// - /// - /// A boolean indicating whether all files were successfully deleted. - /// - /// - /// Thrown if the SSH key file is not initialized before calling this method. - /// - public bool Delete([NotNullWhen(false)] out Exception? error) - { - error = null; - if (!IsInitialized) - throw new InvalidOperationException("Not initialized."); - - var allSucceeded = true; - foreach (var file in KeyFileInfo!.Files) - try - { - file.Delete(); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to delete {FilePath}", file.FullName); - error = e; - allSucceeded = false; - } - - if (allSucceeded && GotDeleted is not null) - GotDeleted(this, EventArgs.Empty); - return allSucceeded; - } - - /// - /// Changes the filename of the SSH key file on disk to the specified new filename. - /// - /// The new filename to assign to the SSH key file. - public void ChangeFilenameOnDisk(string newFilename) - { - try - { - foreach (var file in KeyFileInfo?.Files ?? []) - { - var newFileNameWithMatchingExtension = Path.ChangeExtension(newFilename, - string.IsNullOrEmpty(file.Extension) ? null : file.Extension); - var destination = Path.Combine( - file.DirectoryName ?? SshConfigFilesExtension.GetBaseSshPath(), - newFileNameWithMatchingExtension); - if (File.Exists(destination)) - throw new InvalidOperationException($"File {destination} already exists"); - file.MoveTo(destination); - } - } - catch (Exception e) - { - _logger.LogError(e, "Failed to change filename of {className}", nameof(SshKeyFile)); - throw; - } - } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileInformation.cs b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileInformation.cs index 29b5a5a..6463ea4 100644 --- a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileInformation.cs +++ b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileInformation.cs @@ -1,138 +1,113 @@ -using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using OpenSSH_GUI.Core.Extensions; using SshNet.Keygen; namespace OpenSSH_GUI.Core.Lib.Keys; /// /// Represents metadata and operations related to a specific SSH key file. -/// Provides access to the associated private and potential public key file information, -/// as well as details about key format and available conversion options. +/// All properties are computed eagerly at construction time and are immutable thereafter. /// -public class SshKeyFileInformation(SshKeyFileSource keyFileSource) +public sealed record SshKeyFileInformation { + private static readonly SshKeyFormat[] AvailableFormats = Enum.GetValues(); + /// - /// Represents the internal object associated with the SSH key file. - /// This variable is used to perform various file operations and retrieve metadata of the specified SSH key file path. + /// Initializes a new instance of + /// and eagerly computes all metadata from the provided . /// - private readonly FileInfo _fileInfo = new(keyFileSource.AbsolutePath); + /// The source descriptor for the SSH key file. + public SshKeyFileInformation(SshKeyFileSource keyFileSource) + { + KeyFileSource = keyFileSource; + CanChangeFileName = keyFileSource is { ProvidedByConfig: false }; - public SshKeyFileSource KeyFileSource => keyFileSource; - public bool CanChangeFileName => !keyFileSource.ProvidedByConfig; + FileInfo = !string.IsNullOrWhiteSpace(keyFileSource.AbsolutePath) + ? new FileInfo(keyFileSource.AbsolutePath) + : new FileInfo(Assembly.GetExecutingAssembly().Location); - /// - /// Indicates whether the SSH key file associated with the instance conforms to the OpenSSH format. - /// - /// - /// This property evaluates the format of the SSH key file based on its current extension or metadata. - /// If the key format matches , the property returns true; - /// otherwise, it returns false. - /// - [MemberNotNullWhen(true, nameof(PublicKeyFileName))] - private bool IsOpenSshKey => CurrentFormat is SshKeyFormat.OpenSSH; + FileName = FileInfo.Name; + FullFileName = FileInfo.FullName; + DirectoryName = FileInfo.DirectoryName; + Exists = FileInfo.Exists; - /// - /// Gets the name of the file represented by the current instance of - /// . - /// - /// - /// This property provides the file name, including its extension, as a string. - /// It is derived from the FileInfo instance initialized with the file path. - /// - public string Name => _fileInfo.Name; + CurrentFormat = FileInfo.Extension.EndsWith(PathExtensions.PuttyKeyFileExtension) + ? SshKeyFormat.PuTTYv3 + : SshKeyFormat.OpenSSH; - /// - /// Gets the file name of the public key associated with the current SSH key file, - /// if the key format is OpenSSH. If the current key format is not OpenSSH, this property returns null. - /// - /// - /// The public key file name is constructed by changing the extension of the current file name - /// to ".pub" if the key format is OpenSSH. For other formats, there is no associated public key file. - /// - /// - /// A string representing the file name of the public key for OpenSSH keys, or null - /// if the key is in a format other than OpenSSH. - /// - public string? PublicKeyFileName => IsOpenSshKey ? Path.ChangeExtension(_fileInfo.FullName, "pub") : null; + IsOpenSshKey = CurrentFormat == SshKeyFormat.OpenSSH; - /// - /// Gets the full path of the SSH key file, including the file name and extension. - /// - /// - /// This property provides the complete path to the file as a string, based on the - /// property. It represents the file location on - /// the filesystem. - /// - public string FullName => _fileInfo.FullName; + PublicKeyFileName = IsOpenSshKey + ? Path.ChangeExtension(FullFileName, PathExtensions.OpenSshPublicKeyFileExtension) + : null; - /// - /// Indicates whether the associated SSH key file exists in the file system. - /// - /// - /// This property checks the existence of the file represented by this instance - /// by verifying its status in the file system. It returns true if the file - /// is found, and false otherwise. The property is useful for validation - /// and ensures that operations on the file are only performed when it is available. - /// - public bool Exists => _fileInfo.Exists; + AvailableFormatsForConversion = AvailableFormats + .Where(f => f != CurrentFormat) + .ToArray(); - /// - /// Gets the name of the directory where the SSH key file is located. - /// - /// - /// This property retrieves the full path of the directory containing the SSH key - /// file associated with this instance. If the file is not associated with a valid directory, - /// the property may return null. - /// - public string? DirectoryName => _fileInfo.DirectoryName; + DefaultConversionFormat = AvailableFormatsForConversion.Contains(SshKeyFormat.OpenSSH) + ? SshKeyFormat.OpenSSH + : AvailableFormatsForConversion.FirstOrDefault(); - /// - /// Represents a collection of key-related files associated with an SSH key. - /// - public IEnumerable Files => new[] { FullName, PublicKeyFileName }.Where(e => !string.IsNullOrEmpty(e)) - .Select(e => new FileInfo(e!)); + Files = new[] + { + FullFileName, PublicKeyFileName + } + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => new FileInfo(p!)) + .ToArray(); + } + + /// + public SshKeyFileSource KeyFileSource { get; } + + /// Gets the for the private key file. + public FileInfo FileInfo { get; } + + /// Gets the file name including extension. + public string FileName { get; } + + /// Gets the full absolute file path. + public string FullFileName { get; } + + /// Gets the directory containing the key file, or null if unavailable. + public string? DirectoryName { get; } + + /// Gets whether the key file exists on disk. + public bool Exists { get; } + + /// Gets the detected format of the key file. + public SshKeyFormat CurrentFormat { get; } + + /// Gets whether the key is in OpenSSH format. + public bool IsOpenSshKey { get; } /// - /// Gets the current format of the SSH key file. + /// Gets the absolute path of the associated public key file, + /// or null if the key is not in OpenSSH format. /// - /// - /// The CurrentFormat property determines the format of the SSH key file - /// based on its file extension. It returns SshKeyFormat.PuTTYv3 if the - /// file extension is ".ppk", otherwise it defaults to SshKeyFormat.OpenSSH. - /// This property is used to identify the key format for further operations. - /// - public SshKeyFormat CurrentFormat => _fileInfo.Extension switch - { - ".ppk" => SshKeyFormat.PuTTYv3, - _ => SshKeyFormat.OpenSSH - }; + public string? PublicKeyFileName { get; } + + /// Gets all formats this key can be converted to, excluding its current format. + public SshKeyFormat[] AvailableFormatsForConversion { get; } /// - /// Gets the default format to which the current SSH key can be converted. + /// Gets the recommended default conversion target. + /// Prefers OpenSSH; falls back to the first available format. /// - /// - /// This property evaluates the list of available formats for conversion, and selects the default based on the - /// following criteria: - /// If the OpenSSH format is available for conversion, it will be chosen as the default. - /// Otherwise, the highest-ranking format in the list of available formats (in descending order) is selected. - /// - /// - /// The representing the default conversion format for the SSH key. - /// - /// - public SshKeyFormat DefaultConversionFormat => AvailableFormatsForConversion.Contains(SshKeyFormat.OpenSSH) - ? SshKeyFormat.OpenSSH - : AvailableFormatsForConversion.OrderDescending().First(); + public SshKeyFormat DefaultConversionFormat { get; } /// - /// Gets the collection of SSH key formats that the current key can be converted to, - /// excluding its current format. + /// Gets all files associated with this key (private + public if applicable). /// - /// - /// This property provides a dynamic list of possible target formats for conversion - /// based on the current format of the key. It ensures that the current format is - /// excluded from the list of available options. Examples of SSH key formats include - /// OpenSSH and PuTTYv3. - /// - public IEnumerable AvailableFormatsForConversion => - Enum.GetValues().Where(e => e != CurrentFormat); + public FileInfo[] Files { get; } + + /// Gets whether the file name can be changed by the user. + public bool CanChangeFileName { get; } + + /// + public bool Equals(SshKeyFileInformation? other) => other is not null && KeyFileSource == other.KeyFileSource; + + /// + public override int GetHashCode() => KeyFileSource.GetHashCode(); } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFilePassword.cs b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFilePassword.cs index 85d5e4f..56a5cc5 100644 --- a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFilePassword.cs +++ b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFilePassword.cs @@ -1,225 +1,127 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; +using System.Buffers; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; using System.Text; +using OpenSSH_GUI.Core.Lib.Misc; +using ReactiveUI; +using ReactiveUI.Avalonia; +using ReactiveUI.SourceGenerators; +using SshNet.Keygen; +using SshNet.Keygen.SshKeyEncryption; namespace OpenSSH_GUI.Core.Lib.Keys; /// -/// Provides secure, pinned-memory storage for an SSH key file passphrase. -/// All sensitive data is kept in a single pinned buffer and wiped on or . +/// A reactive, disposable container for an SSH key passphrase stored as raw bytes. +/// Acts as a two-state machine: EmptyHasPassword. +/// All observable properties ( fire +/// +/// on every state transition triggered by or . /// -/// -/// -/// Security contract: This class never allocates the passphrase on the managed heap -/// (beyond what callers pass in as ). Callers who need the passphrase as -/// text should use with a stack-allocated Span<char> and -/// wipe it immediately after use. -/// -/// This class is not thread-safe. -/// -public sealed class SshKeyFilePassword : INotifyPropertyChanged, IDisposable +public sealed partial record SshKeyFilePassword : ReactiveRecord, IDisposable { - /// - /// Maximum passphrase size in bytes. Sufficient for any reasonable SSH passphrase. - /// - public const int MaxPasswordBytes = 1024; - - /// - /// Pinned buffer that holds the passphrase bytes. Pinning prevents the GC from - /// relocating the data, so - /// can reliably wipe the only copy. - /// - private readonly byte[] _buffer = GC.AllocateArray(MaxPasswordBytes, true); - - private bool _disposed; + private readonly ReactiveBufferWriter _bufferWriter = new(ushort.MaxValue); + private readonly CompositeDisposable _disposables = new(); private Encoding _encoding = Encoding.UTF8; - private int _writtenCount; - /// - /// Creates an empty instance. Use to populate. + /// Gets a value indicating whether the buffer contains at least one byte. /// - internal SshKeyFilePassword() - { - } - - // ── Public API ────────────────────────────────────────────────────── + [Reactive(SetModifier = AccessModifier.Private)] + private bool _isValid; /// - /// Gets a value indicating whether the buffer contains a passphrase. + /// Initialises the state machine and wires all derived properties + /// to the internal mutation subject. /// - [MemberNotNullWhen(true, nameof(WrittenSpan))] - public bool IsValid + public SshKeyFilePassword() { - get - { - ThrowIfDisposed(); - return _writtenCount > 0; - } + _bufferWriter.WhenAnyValue(vm => vm.WrittenCount) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(e => e != 0) + .Subscribe(eval => + { + this.RaisePropertyChanging(nameof(WrittenSpan)); + IsValid = eval; + this.RaisePropertyChanged(nameof(WrittenSpan)); + }) + .DisposeWith(_disposables); } - /// - /// Gets the number of passphrase bytes currently stored. - /// - public int Length - { - get - { - ThrowIfDisposed(); - return _writtenCount; - } - } + // ── Span accessor ──────────────────────────────────────────────────── /// /// Gets a read-only span over the stored passphrase bytes. - /// This is the primary way to consume the passphrase without heap allocation. + /// This is the primary way to consume the passphrase without heap allocation. + /// + /// + /// is raised for this member on every or call. + /// /// - /// - public ReadOnlySpan WrittenSpan - { - get - { - ThrowIfDisposed(); - return _buffer.AsSpan(0, _writtenCount); - } - } + public ReadOnlySpan WrittenSpan => _bufferWriter.WrittenSpan; - // ── IDisposable ───────────────────────────────────────────────────── + // ── IDisposable ────────────────────────────────────────────────────── /// - /// Securely wipes the internal buffer and marks the instance as disposed. + /// Securely wipes the internal buffer, completes the mutation subject, + /// and disposes all reactive subscriptions. /// Subsequent calls are no-ops. /// public void Dispose() { - if (_disposed) return; - _disposed = true; - SecureClearBuffer(); + _bufferWriter.Clear(); + _disposables.Dispose(); // completes bufferMutated and all ToProperty helpers } - // ── INotifyPropertyChanged ────────────────────────────────────────── - - /// - public event PropertyChangedEventHandler? PropertyChanged; + // ── State transitions ──────────────────────────────────────────────── /// - /// Decodes the stored passphrase into the caller-provided character buffer. - /// The caller should wipe after use. - /// - /// Target buffer (ideally stackalloc). - /// The number of characters written. - /// - /// Thrown when is too small. - public int GetChars(Span destination) - { - ThrowIfDisposed(); - if (_writtenCount == 0) return 0; - return _encoding.GetChars(_buffer.AsSpan(0, _writtenCount), destination); - } - - /// - /// Returns the maximum number of characters that could write - /// for the currently stored passphrase. Useful for sizing a stackalloc buffer. - /// - public int GetMaxCharCount() - { - ThrowIfDisposed(); - return _encoding.GetMaxCharCount(_writtenCount); - } - - /// - /// Replaces the stored passphrase with the UTF-8 encoding of . - /// - /// - /// - public void Set(string password, Encoding? encoding = null) - { - ThrowIfDisposed(); - var enc = encoding ?? _encoding; - var byteCount = enc.GetByteCount(password); - ArgumentOutOfRangeException.ThrowIfGreaterThan(byteCount, MaxPasswordBytes, nameof(password)); - - Span temp = stackalloc byte[byteCount]; - enc.GetBytes(password, temp); - Set(temp, enc); - CryptographicOperations.ZeroMemory(temp); - } - - /// - /// Replaces the stored passphrase with the given raw bytes. + /// Replaces the stored passphrase with the given raw bytes and transitions + /// the instance to the HasPassword state (or stays there on overwrite). + /// Notifies all reactive observers after the write is complete. /// + /// Raw passphrase bytes to store. + /// + /// Optional encoding override used by . + /// Defaults to the previously configured encoding. + /// /// /// public void Set(ReadOnlySpan password, Encoding? encoding = null) { - ThrowIfDisposed(); - SecureClearBuffer(); + _bufferWriter.Clear(); _encoding = encoding ?? _encoding; - WriteToBuffer(password); - } - - /// - public void Set(ReadOnlyMemory password, Encoding? encoding = null) - { - Set(password.Span, encoding); + _bufferWriter.Write(password); } /// - /// Securely wipes the passphrase buffer without disposing the instance, - /// allowing it to be reused with . + /// Securely wipes the passphrase buffer and transitions the instance + /// to the Empty state without disposing it, allowing reuse. + /// Notifies all reactive observers after the wipe. /// /// - public void Clear() - { - ThrowIfDisposed(); - SecureClearBuffer(); - OnPropertyChanged(nameof(IsValid)); - OnPropertyChanged(nameof(Length)); - } - - // ── Private helpers ───────────────────────────────────────────────── - - private void WriteToBuffer(ReadOnlySpan data) - { - ThrowIfDisposed(); - ArgumentOutOfRangeException.ThrowIfGreaterThan( - _writtenCount + data.Length, MaxPasswordBytes, nameof(data)); - - data.CopyTo(_buffer.AsSpan(_writtenCount)); - _writtenCount += data.Length; - - OnPropertyChanged(nameof(IsValid)); - OnPropertyChanged(nameof(Length)); - } - - private void SecureClearBuffer() - { - CryptographicOperations.ZeroMemory(_buffer); - _writtenCount = 0; - } - - private void ThrowIfDisposed() - { - ObjectDisposedException.ThrowIf(_disposed, this); - } - - private void OnPropertyChanged([CallerMemberName] string? propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } + public void Clear() { _bufferWriter.Clear(); } /// - /// Converts the stored password bytes to a string on the heap. - /// The resulting string cannot be wiped and will live until GC collection. + /// Decodes the stored passphrase to a managed on the heap. + /// The resulting string cannot be securely wiped and will persist until GC collection. /// public string GetPasswordString() { - Span chars = stackalloc char[GetMaxCharCount()]; - var written = GetChars(chars); + if (!IsValid) + return string.Empty; + + Span chars = stackalloc char[_encoding.GetMaxCharCount(_bufferWriter.WrittenCount)]; + var written = _encoding.GetChars(_bufferWriter.WrittenSpan, chars); var result = new string(chars[..written]); chars.Clear(); return result; } + + public ISshKeyEncryption ToSshKeyEncryption(SshKeyFormat? format = null) => this is { IsValid: true } keyPassword + ? new SshKeyEncryptionAes256( + keyPassword.GetPasswordString(), + format is SshKeyFormat.PuTTYv3 ? new PuttyV3Encryption() : null) + : SshKeyGenerateInfo.DefaultSshKeyEncryption; } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileSource.cs b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileSource.cs index fa4b70e..b95bb10 100644 --- a/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileSource.cs +++ b/OpenSSH_GUI.Core/Lib/Keys/SshKeyFileSource.cs @@ -3,11 +3,18 @@ namespace OpenSSH_GUI.Core.Lib.Keys; public record SshKeyFileSource { public string AbsolutePath { get; init; } = string.Empty; - public bool ProvidedByConfig { get; init; } = false; + public bool ProvidedByConfig { get; init; } - public static SshKeyFileSource FromDisk(string absolutePath) => - new() { AbsolutePath = absolutePath }; + public static SshKeyFileSource FromDisk(string absolutePath) => new() + { + AbsolutePath = absolutePath + }; - public static SshKeyFileSource FromConfig(string absolutePath) => - new() { AbsolutePath = absolutePath, ProvidedByConfig = true }; + public static SshKeyFileSource FromConfig(string absolutePath) => new() + { + AbsolutePath = absolutePath, + ProvidedByConfig = true + }; + + public override string ToString() => $"{AbsolutePath} | Referenced by Config: {ProvidedByConfig}"; } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHost.cs b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHost.cs index 1f11b47..340a734 100644 --- a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHost.cs +++ b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHost.cs @@ -1,21 +1,23 @@ -using OpenSSH_GUI.Core.Interfaces.KnownHosts; +using System.Collections.ObjectModel; +using System.Text; +using DynamicData; +using OpenSSH_GUI.Core.Extensions; using ReactiveUI; +using ReactiveUI.SourceGenerators; namespace OpenSSH_GUI.Core.Lib.KnownHosts; /// /// Represents a known host in the OpenSSH GUI. /// -public class KnownHost : ReactiveObject, IKnownHost +public partial record KnownHost : ReactiveRecord { + private readonly KnownHostKey[] _keysCopy; + /// - /// Represents a known host entry in the known_hosts file. + /// Represents a known host in the OpenSSH_GUI. /// - public KnownHost(IGrouping knownHosts) - { - Host = knownHosts.Key; - Keys = knownHosts.Select(e => new KnownHostKey(e.Replace($"{Host}", "").Trim()) as IKnownHostKey).ToList(); - } + [ReactiveCollection] private ObservableCollection _keys = []; /// /// Gets or sets the toggled state of the switch. @@ -25,12 +27,23 @@ public KnownHost(IGrouping knownHosts) /// - If it was previously off, all known host keys are marked for deletion. /// - If it was previously on, all known host keys are unmarked for deletion. /// - private bool SwitchToggled { get; set; } + [Reactive] private bool _switchToggled; + + public KnownHost(KeyValuePair knownHosts) + { + HostUri = knownHosts.Key; + _keysCopy = knownHosts.Value; + Keys.AddRange(_keysCopy); + } + + public KnownHostHost HostUri { get; } + + public bool ChangesMade => !_keysCopy.SequenceEqual(Keys); /// /// Represents a known host in the SSH known hosts file. /// - public string Host { get; } + public string Host => HostUri.ToString(); /// /// Represents a known host that can be deleted in its entirety. @@ -38,16 +51,7 @@ public KnownHost(IGrouping knownHosts) public bool DeleteWholeHost => Keys.All(e => e.MarkedForDeletion); /// - /// Represents a known host in the OpenSSH_GUI. - /// - public List Keys - { - get; - set => this.RaiseAndSetIfChanged(ref field, value); - } = []; - - /// - /// Toggles the marked for deletion flag of each within the list. + /// Toggles the marked for deletion flag of each within the list. /// If the property is true, it sets the flag to false for all keys. Otherwise, it sets /// the flag to true for all keys. /// @@ -74,14 +78,15 @@ public void KeysDeletionSwitch() /// Returns a string containing all the entries for the known host. /// If the entire host is marked for deletion, returns the line ending character. /// - public string GetAllEntries() + public string Export(PlatformID? platformId = null) { - return DeleteWholeHost - ? IKnownHostsFile.LineEnding - : Keys - .Where(e => !e.MarkedForDeletion) - .Aggregate("", - (current, knownHostsKey) => - current + $"{Host} {knownHostsKey.EntryWithoutHost}{IKnownHostsFile.LineEnding}"); + platformId ??= Environment.OSVersion.Platform; + if (DeleteWholeHost) return platformId.Value.GetLineSeparator(); + var stringBuilder = new StringBuilder(); + foreach (var knownHostKey in Keys.Where(e => !e.MarkedForDeletion)) + { + stringBuilder.Append($"{Host} {knownHostKey}{platformId.Value.GetLineSeparator()}"); + } + return stringBuilder.ToString(); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostHost.cs b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostHost.cs new file mode 100644 index 0000000..f95a14b --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostHost.cs @@ -0,0 +1,26 @@ +namespace OpenSSH_GUI.Core.Lib.KnownHosts; + +public readonly record struct KnownHostHost +{ + private readonly string _originalHostEntry; + + public KnownHostHost(string host) + { + _originalHostEntry = host; + if (host.Split(':') is not { Length: 2 } split) + { + Host = host; + } + else + { + Port = int.Parse(split[1]); + Host = split[0]; + } + Host = Host.Trim('[', ']'); + } + + public int Port { get; } = 22; + public string Host { get; } = string.Empty; + + public override string ToString() => _originalHostEntry; +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostKey.cs b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostKey.cs index 36f9317..6e61b8e 100644 --- a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostKey.cs +++ b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostKey.cs @@ -1,5 +1,5 @@ -using OpenSSH_GUI.Core.Interfaces.KnownHosts; -using ReactiveUI; +using ReactiveUI; +using ReactiveUI.SourceGenerators; using SshNet.Keygen; namespace OpenSSH_GUI.Core.Lib.KnownHosts; @@ -7,21 +7,27 @@ namespace OpenSSH_GUI.Core.Lib.KnownHosts; /// /// Represents a known host key in the OpenSSH GUI. /// -public class KnownHostKey : ReactiveObject, IKnownHostKey +public partial record KnownHostKey : ReactiveRecord { + private readonly string _entryWithoutHost; + + /// + /// Gets or sets a value indicating whether the known host key is marked for deletion. + /// + [Reactive] private bool _markedForDeletion; + /// /// Represents a known host key in the OpenSSH GUI. /// - public KnownHostKey(string entry) + public KnownHostKey(string[] keyParts) { - EntryWithoutHost = entry; - var splitted = EntryWithoutHost.Split(' '); - TypeDeclarationInFile = splitted[0]; + _entryWithoutHost = string.Join(" ", keyParts); + TypeDeclarationInFile = keyParts[0]; KeyType = Enum.Parse( TypeDeclarationInFile.StartsWith("ssh-") - ? TypeDeclarationInFile.Replace("ssh-", "") + ? TypeDeclarationInFile.Replace("ssh-", string.Empty) : TypeDeclarationInFile.Split('-')[0], true); - Fingerprint = splitted[1].Replace("\n", "").Replace("\r", ""); + Fingerprint = keyParts[1]; } /// @@ -39,17 +45,5 @@ public KnownHostKey(string entry) /// public string Fingerprint { get; } - /// - /// Represents a known host key without the host entry in the OpenSSH GUI. - /// - public string EntryWithoutHost { get; } - - /// - /// Gets or sets a value indicating whether the known host key is marked for deletion. - /// - public bool MarkedForDeletion - { - get; - set => this.RaiseAndSetIfChanged(ref field, value); - } + public override string ToString() => _entryWithoutHost; } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostsFile.cs b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostsFile.cs index eb58b44..b8156d0 100644 --- a/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostsFile.cs +++ b/OpenSSH_GUI.Core/Lib/KnownHosts/KnownHostsFile.cs @@ -1,81 +1,60 @@ using System.Collections.ObjectModel; -using OpenSSH_GUI.Core.Interfaces.KnownHosts; +using System.Text; +using OpenSSH_GUI.Core.Enums; +using OpenSSH_GUI.Core.Extensions; using ReactiveUI; +using ReactiveUI.SourceGenerators; namespace OpenSSH_GUI.Core.Lib.KnownHosts; /// Represents a known hosts file. -/// / -public class KnownHostsFile : ReactiveObject, IKnownHostsFile +public sealed partial record KnownHostsFile : ReactiveRecord { - /// Represents the path to the known hosts file. - /// / - private string _fileKnownHostsPath = ""; - /// - /// Gets or sets a boolean value indicating whether the `KnownHostsFile` object is created from a server or not. + /// Represents a known hosts file. /// - private bool _isFromServer; + [ReactiveCollection] private ObservableCollection _knownHosts = []; - /// - /// Initializes a new instance of the class. - /// - public KnownHostsFile() - { - } + /// Represents a known hosts file. + public KnownHostsFile(bool IsFromServer = false) => this.IsFromServer = IsFromServer; - /// - /// Represents a known hosts file that stores information about trusted hosts. - /// - /// The path to the file or its content. - /// Indicates whether the content is from a server. - public KnownHostsFile(string knownHostsPathOrContent, bool fromServer = false) - { - _isFromServer = fromServer; - if (_isFromServer) - SetKnownHosts(knownHostsPathOrContent); - else - _fileKnownHostsPath = knownHostsPathOrContent; - // Synchronous reading is deprecated. Use InitializeAsync. - } + public static KnownHostsFile Empty { get; } = new(); + public bool IsFromServer { get; init; } - /// - /// Initializes the known hosts file asynchronously. - /// - /// The path to the file or its content. - /// Indicates whether the content is from a server. - /// A cancellation token. - /// A representing the initialized object. - public async ValueTask InitializeAsync(string knownHostsPathOrContent, bool fromServer = false, - CancellationToken token = default) + private static FileStreamOptions CreateOptions() { - _isFromServer = fromServer; - if (_isFromServer) + var options = new FileStreamOptions { - SetKnownHosts(knownHostsPathOrContent); - } - else + BufferSize = 0, + Access = FileAccess.ReadWrite, + Mode = FileMode.OpenOrCreate, + Share = FileShare.ReadWrite + }; + + if (!OperatingSystem.IsWindows()) { - _fileKnownHostsPath = knownHostsPathOrContent; - await ReadContentAsync(); + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; } - return this; + return options; } - /// - /// Represents a file that contains known SSH hosts and their keys. - /// - public static string LineEnding { get; set; } = "\r\n"; + public static ValueTask InitializeAsync(FileInfo fileInfo, bool fromServer = false, + CancellationToken token = default) => fileInfo is null + ? throw new ArgumentNullException(nameof(fileInfo)) + : InitializeAsync(new FileStream(fileInfo.FullName, CreateOptions()), fromServer, true, token); - /// - /// Represents a known hosts file. - /// - public ObservableCollection KnownHosts + public static async ValueTask InitializeAsync(Stream knownHostsContent, bool fromServer = false, + bool disposeStream = true, CancellationToken token = default) { - get; - private set => this.RaiseAndSetIfChanged(ref field, value); - } = []; + var knownHostsFile = new KnownHostsFile(fromServer); + if (fromServer) + await knownHostsFile.SetKnownHostsAsync(knownHostsContent, disposeStream, token); + else + await knownHostsFile.ReadContentAsync(token: token); + + return knownHostsFile; + } /// /// Asynchronously reads the contents of the known hosts file. @@ -84,48 +63,38 @@ public ObservableCollection KnownHosts /// The file stream to read from. If null, the method reads from the file specified in the /// constructor. /// + /// A cancellation token. /// A representing the asynchronous operation. - public async ValueTask ReadContentAsync(FileStream? stream = null) + public async ValueTask ReadContentAsync(FileStream? stream = null, CancellationToken token = default) { - if (_isFromServer) return; + if (IsFromServer) return; if (stream is null) { - if (string.IsNullOrEmpty(_fileKnownHostsPath)) return; - await using var file = new FileStream(_fileKnownHostsPath, FileMode.OpenOrCreate); + await using var file = new FileStream(SshConfigFiles.Known_Hosts.GetPathOfFile(), CreateOptions()); using var streamReader = new StreamReader(file, leaveOpen: true); - SetKnownHosts(await streamReader.ReadToEndAsync()); + await SetKnownHostsAsync(file, false, token); } else { using var streamReader = new StreamReader(stream); - SetKnownHosts(await streamReader.ReadToEndAsync()); + await SetKnownHostsAsync(stream, token: token); } } - /// - /// Synchronizes the known hosts with the given list of new known hosts. - /// - /// The new known hosts to synchronize. - public void SyncKnownHosts(IEnumerable newKnownHosts) - { - KnownHosts = new ObservableCollection(newKnownHosts); - } - /// /// Updates the content of the known hosts file asynchronously. /// /// A representing the update operation. public async ValueTask UpdateFileAsync() { - if (_isFromServer) return; - if (string.IsNullOrEmpty(_fileKnownHostsPath)) return; - await using var file = new FileStream(_fileKnownHostsPath, FileMode.Truncate); + if (!KnownHosts.Any(e => e.ChangesMade)) return; + if (IsFromServer) return; + await using var file = new FileStream(SshConfigFiles.Known_Hosts.GetPathOfFile(), FileMode.Truncate); await using var streamWriter = new StreamWriter(file); - var newContent = KnownHosts - .Where(e => !e.DeleteWholeHost) - .Aggregate("", (current, host) => current + host.GetAllEntries()); + var newContent = Export(); await streamWriter.WriteAsync(newContent); - SetKnownHosts(newContent); + file.Seek(0, SeekOrigin.Begin); + await SetKnownHostsAsync(file, false); } /// @@ -133,27 +102,50 @@ public async ValueTask UpdateFileAsync() /// /// The platform ID of the server. /// The updated contents of the known hosts file as a string. - public string GetUpdatedContents(PlatformID platformId) + public async ValueTask GetUpdatedContentsAsync(PlatformID platformId) { - if (!_isFromServer) return ""; - LineEnding = platformId == PlatformID.Unix ? LineEnding : "`r`n"; - var newContent = KnownHosts - .Where(e => !e.DeleteWholeHost) - .Aggregate("", (current, host) => current + host.GetAllEntries()); - SetKnownHosts(newContent); - return newContent; + if (!IsFromServer) return string.Empty; + var content = Export(platformId); + + using var memoryStream = new MemoryStream(); + Memory newContent = Encoding.UTF8.GetBytes(content); + await memoryStream.WriteAsync(newContent); + memoryStream.Seek(0, SeekOrigin.Begin); + await SetKnownHostsAsync(memoryStream, false); + return content; } - /// - /// Sets the known hosts for the file. - /// - /// The contents of the known hosts file. - private void SetKnownHosts(string fileContent) + private async ValueTask SetKnownHostsAsync(Stream contentStream, bool disposeStream = true, + CancellationToken token = default) { - KnownHosts = new ObservableCollection(fileContent - .Split(LineEnding) - .Where(e => !string.IsNullOrEmpty(e)) - .GroupBy(e => e.Split(' ')[0]) - .Select(e => new KnownHost(e))); + KnownHosts.Clear(); + using var streamReader = new StreamReader(contentStream, leaveOpen: !disposeStream); + var dicc = new Dictionary(); + while (await streamReader.ReadLineAsync(token) is { } line) + { + if (line.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) is not { Length: >= 2 } splitted) + continue; + foreach (var host in splitted[0].Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var uri = new KnownHostHost(host); + var key = new KnownHostKey(splitted[1..]); + dicc.Add(uri, dicc.Remove(uri, out var keys) ? keys.Append(key).ToArray() : [key]); + } + } + foreach (var dictionaryEntry in dicc) + { + KnownHosts.Add(new KnownHost(dictionaryEntry)); + } + } + + private string Export(PlatformID? platformId = null) + { + platformId ??= Environment.OSVersion.Platform; + var stringBuilder = new StringBuilder(); + foreach (var knownHost in KnownHosts) + { + stringBuilder.Append(knownHost.Export(platformId)); + } + return stringBuilder.ToString(); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Misc/BackedUpFile.cs b/OpenSSH_GUI.Core/Lib/Misc/BackedUpFile.cs new file mode 100644 index 0000000..26c5959 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/Misc/BackedUpFile.cs @@ -0,0 +1,15 @@ +namespace OpenSSH_GUI.Core.Lib.Misc; + +public record BackedUpFile +{ + public required FileInfo InitialFile { get; init; } + public required FileInfo BackupFile { get; init; } + + public void Backup() { InitialFile.CopyTo(BackupFile.FullName); } + + public void Restore() { BackupFile.MoveTo(InitialFile.FullName, true); } + + public void Delete() { BackupFile.Delete(); } + + public override string ToString() => $"{InitialFile.FullName} -> {BackupFile.FullName}"; +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Misc/ConnectionCredentials.cs b/OpenSSH_GUI.Core/Lib/Misc/ConnectionCredentials.cs new file mode 100644 index 0000000..1f568f0 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/Misc/ConnectionCredentials.cs @@ -0,0 +1,108 @@ +using OpenSSH_GUI.Core.Lib.Keys; +using Renci.SshNet; + +namespace OpenSSH_GUI.Core.Lib.Misc; + +/// +/// Represents the base class for connection credentials. +/// +public abstract class ConnectionCredentials +{ + private const string Placeholder = "123"; + + /// + /// Represents the base class for connection credentials. + /// + protected ConnectionCredentials(string hostname, string username) + { + if (hostname.Contains(':')) + { + var split = hostname.Split(':'); + Hostname = split[0]; + Port = int.Parse(split[1]); + } + else + { + Hostname = hostname; + Port = 22; + } + + Username = username; + } + + internal static ConnectionCredentials Empty { get; } = + new PasswordConnectionCredentials(Placeholder, Placeholder, Placeholder); + + /// + /// Represents the hostname of a server. + /// This property is used in classes related to connection credentials and server settings. + /// + public string Hostname { get; } + + /// + /// Represents the port number used for establishing an SSH connection. + /// + public int Port { get; } + + /// + /// Represents the username property of a connection credentials. + /// + public string Username { get; } + + /// + /// Retrieves the connection information based on the provided credentials. + /// + /// + /// The object representing the SSH connection information. + /// + public virtual ConnectionInfo GetConnectionInfo() => AddAuthenticationMethods(new NoneAuthenticationMethod(Username)); + + protected ConnectionInfo AddAuthenticationMethods(params AuthenticationMethod[] methods) => new(Hostname, Port, Username, methods); +} + +/// +/// Represents the credentials for a key-based connection to a server. +/// +public class KeyConnectionCredentials(string hostname, string username, SshKeyFile? key) + : ConnectionCredentials(hostname, username) +{ + /// + /// Retrieves the connection information based on the provided credentials. + /// + /// + /// The object representing the SSH connection information. + /// + public override ConnectionInfo GetConnectionInfo() => AddAuthenticationMethods(new PrivateKeyAuthenticationMethod(Username, key?.PrivateKeyFile)); +} + +public class MultiKeyConnectionCredentials(string hostname, string username, IEnumerable? keys) + : ConnectionCredentials(hostname, username) +{ + /// + /// Retrieves the connection information for establishing an SSH connection. + /// + /// + /// The object representing the SSH connection information. + /// + public override ConnectionInfo GetConnectionInfo() + { + return keys is null + ? base.GetConnectionInfo() + : AddAuthenticationMethods( + keys.Select(e => new PrivateKeyAuthenticationMethod(Username, e.PrivateKeyFile) + ).ToArray()); + } +} + +public class PasswordConnectionCredentials( + string hostname, + string username, + string password) + : ConnectionCredentials(hostname, username) +{ + /// + /// Retrieves the connection information based on the provided credentials. + /// + /// The object representing the SSH connection information. + public override ConnectionInfo GetConnectionInfo() => AddAuthenticationMethods(new PasswordAuthenticationMethod(Username, password)); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Misc/DirectoryCrawler.cs b/OpenSSH_GUI.Core/Lib/Misc/DirectoryCrawler.cs index a95e170..e6a7b46 100644 --- a/OpenSSH_GUI.Core/Lib/Misc/DirectoryCrawler.cs +++ b/OpenSSH_GUI.Core/Lib/Misc/DirectoryCrawler.cs @@ -1,7 +1,10 @@ -using Microsoft.Extensions.Configuration; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Configuration; using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; using OpenSSH_GUI.Core.Lib.Keys; using OpenSSH_GUI.SshConfig.Models; @@ -10,61 +13,98 @@ namespace OpenSSH_GUI.Core.Lib.Misc; /// /// Represents a directory crawler for searching and managing SSH keys. /// -public class DirectoryCrawler( - ILogger logger, - IConfiguration configuration) +public sealed class DirectoryCrawler(ILogger logger, IConfiguration configuration, IMutableConfiguration mutableConfiguration) + : IDirectoryCrawler { private static readonly string[] ImportantFileNames = Enum.GetNames(); - + private readonly List _keyFileSources = []; + + public bool IsSearching { get; private set; } + /// - /// Asynchronously retrieves a collection of new SSH keys from the disk. + /// Asynchronously enumerates possible SSH key file sources from both + /// the SSH configuration and the base SSH directory on disk. /// - /// A cancellation token that can be used to cancel the asynchronous operation. - /// An asynchronous enumerable containing the file paths of the discovered SSH keys. - public ValueTask> GetPossibleKeyFilesOnDisk(CancellationToken token = default) + /// Token to cancel the enumeration. + /// An async stream of discovered instances. + public async IAsyncEnumerable GetPossibleKeyFilesOnDiskAsyncEnumerable( + [EnumeratorCancellation] CancellationToken cancellationToken = default) { + IsSearching = true; + try { - var possibleKeyFiles = new List(); - - try - { - if (configuration.GetSection("SshConfig").Get() is { } sshConfig) + if (configuration.GetSection("SshConfig").Get() is { } sshConfig) + foreach (var hostSetting in sshConfig.Hosts.Concat(sshConfig.Blocks).Append(sshConfig.Global)) { - foreach (var hostSetting in sshConfig.Hosts.Concat(sshConfig.Blocks).Append(sshConfig.Global)) + cancellationToken.ThrowIfCancellationRequested(); + + if (hostSetting.IdentityFiles is not { Length: > 0 } hostIdentityFiles) + continue; + + foreach (var resolvedPath in hostIdentityFiles.Select(p => p.ResolvePath())) { - if (hostSetting.IdentityFiles is not { Length: > 0 } hostIdentityFiles) continue; - foreach (var hostIdentityFile in hostIdentityFiles.Select(path => path.ResolvePath())) - { - if(!possibleKeyFiles.Any(e => e.AbsolutePath.Equals(hostIdentityFile, StringComparison.OrdinalIgnoreCase)) && File.Exists(hostIdentityFile)) - possibleKeyFiles.Add(SshKeyFileSource.FromConfig(hostIdentityFile)); - } + cancellationToken.ThrowIfCancellationRequested(); + + var alreadyTracked = _keyFileSources.Any(e => + e.AbsolutePath.Equals(resolvedPath, StringComparison.OrdinalIgnoreCase)); + + var exists = await Task.Run(() => File.Exists(resolvedPath), cancellationToken); + + if (alreadyTracked || !exists) + continue; + + var source = SshKeyFileSource.FromConfig(resolvedPath); + logger.LogDebug("Adding key file source {Source}", source); + _keyFileSources.Add(source); + yield return source; } } - } - catch (Exception e) + + await foreach (var keyFileSource in EnumerateDiskSources(cancellationToken)) { - logger.LogDebug(e, "Config not readable"); + cancellationToken.ThrowIfCancellationRequested(); + logger.LogDebug("Adding keyfile {KeyFile}", keyFileSource); + _keyFileSources.Add(keyFileSource); + yield return keyFileSource; } - - possibleKeyFiles = possibleKeyFiles.Concat( - Directory.EnumerateFiles(SshConfigFilesExtension.GetBaseSshPath(), "*", new EnumerationOptions - { - IgnoreInaccessible = true, - RecurseSubdirectories = false - }).Select(e => new FileInfo(e)) - .Where(e => !ImportantFileNames.Any(ifn => ifn.Equals(e.Name, StringComparison.OrdinalIgnoreCase))) - .Where(e => !possibleKeyFiles.Any(k => k.AbsolutePath.Equals(e.FullName, StringComparison.OrdinalIgnoreCase))) - .Where(e => string.IsNullOrWhiteSpace(e.Extension) || e.Extension.Equals(".ppk", StringComparison.OrdinalIgnoreCase)) - .DistinctBy(e => e.FullName, StringComparer.OrdinalIgnoreCase).Select(e => SshKeyFileSource.FromDisk(e.FullName)) - ).ToList(); - - logger.LogInformation("Found {count} keys", possibleKeyFiles.Count); - return ValueTask.FromResult>(possibleKeyFiles); } - catch (Exception exception) + finally + { + _keyFileSources.Clear(); + IsSearching = false; + } + } + + /// + /// Enumerates SSH key file sources from the base SSH directory, + /// excluding already tracked and config-reserved files. + /// + /// A list of found on disk. + private async IAsyncEnumerable EnumerateDiskSources([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var directoryInfo in mutableConfiguration.Current.LookupPaths.Select(e => new DirectoryInfo(e)) ?? []) { - return ValueTask.FromException>(exception); + logger.LogDebug("Processing directory {Directory}", directoryInfo); + if (cancellationToken.IsCancellationRequested) + yield break; + foreach (var keyFile in directoryInfo.EnumerateFiles( + "*", new EnumerationOptions + { + IgnoreInaccessible = true, + RecurseSubdirectories = false + }).Where(e => !ImportantFileNames.Any(ifn => + ifn.Equals(e.Name, StringComparison.OrdinalIgnoreCase))) + .Where(e => !_keyFileSources.Any(k => + k.AbsolutePath.Equals(e.FullName, StringComparison.OrdinalIgnoreCase))) + .Where(e => string.IsNullOrWhiteSpace(e.Extension) || Path.IsPuTTYKey(e.Name)) + .DistinctBy(e => e.FullName, StringComparer.OrdinalIgnoreCase)) + { + logger.LogDebug("Found key file {KeyFile}", keyFile); + yield return SshKeyFileSource.FromDisk(keyFile.FullName); + if (cancellationToken.IsCancellationRequested) + yield break; + } } } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Misc/KeyManagerOperationResult.cs b/OpenSSH_GUI.Core/Lib/Misc/KeyManagerOperationResult.cs new file mode 100644 index 0000000..dfb2f2c --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/Misc/KeyManagerOperationResult.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.CodeAnalysis; +using OpenSSH_GUI.Core.Enums; + +namespace OpenSSH_GUI.Core.Lib.Misc; + +public record KeyManagerOperationResult +{ + [MemberNotNullWhen(false, nameof(Exception))] + public virtual bool IsSuccess => Result == OperationResult.Success; + + [MemberNotNullWhen(true, nameof(Exception))] + public bool IsConflict => Result == OperationResult.Conflict; + + [MemberNotNullWhen(true, nameof(Exception))] + public bool IsCancelled => Result == OperationResult.Cancelled; + + [MemberNotNullWhen(true, nameof(Exception))] + public bool IsFailure => Result == OperationResult.Failure; + + public OperationResult Result { get; protected init; } + public Exception? Exception { get; protected init; } + + public static KeyManagerOperationResult Success() => new() + { + Result = OperationResult.Success + }; + + public static KeyManagerOperationResult Success(T value) => KeyManagerOperationResult.Success(value); + + public static KeyManagerOperationResult FromException(Exception exception) => exception is OperationCanceledException ? Cancelled(exception) : Failure(exception); + + public static KeyManagerOperationResult Failure(Exception exception) => new() + { + Result = OperationResult.Failure, + Exception = exception + }; + + public static KeyManagerOperationResult Conflict(Exception exception) => new() + { + Result = OperationResult.Conflict, + Exception = exception + }; + + internal static KeyManagerOperationResult Cancelled(Exception exception) => new() + { + Result = OperationResult.Cancelled, + Exception = exception + }; + + /// Throws the associated exception if the result represents a failure. + /// Whether to also throw if the result was cancelled. + public void ThrowIfFailure(bool throwOnCancelled = true) + { + if (IsFailure || throwOnCancelled && IsCancelled) + throw Exception; + } + + public KeyManagerOperationResult WithValue(T value) => KeyManagerOperationResult.SetValue(value, this); +} + +public sealed record KeyManagerOperationResult : KeyManagerOperationResult +{ +#pragma warning disable CS8776 + [MemberNotNullWhen(true, nameof(ResultValue)), MemberNotNullWhen(false, nameof(Exception))] + public override bool IsSuccess => Result == OperationResult.Success && ResultValue is not null; +#pragma warning restore CS8776 + + public T? ResultValue { get; private init; } + + internal static KeyManagerOperationResult SetValue(T value, KeyManagerOperationResult operationResult) => new() + { + Exception = operationResult.Exception, + Result = operationResult.Result, + ResultValue = value + }; + + public static KeyManagerOperationResult Success(T value) => new() + { + Result = OperationResult.Success, + ResultValue = value + }; +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Misc/ReactiveBufferWriter.cs b/OpenSSH_GUI.Core/Lib/Misc/ReactiveBufferWriter.cs new file mode 100644 index 0000000..3d6b5e9 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/Misc/ReactiveBufferWriter.cs @@ -0,0 +1,118 @@ +using System.Buffers; +using System.ComponentModel; +using ReactiveUI; + +namespace OpenSSH_GUI.Core.Lib.Misc; + +/// +/// A thread-safe, reactive wrapper around +/// implementing for use with ReactiveUI bindings. +/// +/// The element type of the buffer. +public sealed class ReactiveBufferWriter : IReactiveObject, IBufferWriter +{ + private readonly ArrayBufferWriter _inner; + private readonly Lock _lockObject = new(); + private readonly string[] _propertyNames = [nameof(WrittenCount), nameof(WrittenMemory)]; + + /// + /// Initializes a new instance with an optional initial capacity. + /// + /// Initial buffer capacity. Defaults to 256. + public ReactiveBufferWriter(int initialCapacity = 256) => _inner = new ArrayBufferWriter(initialCapacity); + + /// Gets the portion of the buffer that has been written to. + public ReadOnlyMemory WrittenMemory + { + get + { + lock (_lockObject) + { + return _inner.WrittenMemory; + } + } + } + + /// Gets the written data as a span. + public ReadOnlySpan WrittenSpan + { + get + { + lock (_lockObject) + { + return _inner.WrittenSpan; + } + } + } + + /// Gets the number of committed elements. + public int WrittenCount + { + get + { + lock (_lockObject) + { + return _inner.WrittenCount; + } + } + } + + /// + public void Advance(int count) + { + RaiseAllChanging(); + lock (_lockObject) + { + _inner.Advance(count); + } + + RaiseAllChanged(); + } + + /// + public Memory GetMemory(int sizeHint = 0) + { + lock (_lockObject) + { + return _inner.GetMemory(sizeHint); + } + } + + /// + public Span GetSpan(int sizeHint = 0) + { + lock (_lockObject) + { + return _inner.GetSpan(sizeHint); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + public event PropertyChangingEventHandler? PropertyChanging; + + void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) { PropertyChanging?.Invoke(this, args); } + + void IReactiveObject.RaisePropertyChanged(PropertyChangedEventArgs args) { PropertyChanged?.Invoke(this, args); } + + /// Resets the writer and notifies subscribers. + public void Clear() + { + RaiseAllChanging(); + lock (_lockObject) + { + _inner.Clear(); + } + + RaiseAllChanged(); + } + + private void RaiseAllChanging() + { + foreach (var publicPropertyName in _propertyNames) this.RaisePropertyChanging(publicPropertyName); + } + + private void RaiseAllChanged() + { + foreach (var publicPropertyName in _propertyNames) this.RaisePropertyChanged(publicPropertyName); + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs b/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs index 641f333..3af05d0 100644 --- a/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs +++ b/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs @@ -1,64 +1,91 @@ -using OpenSSH_GUI.Core.Enums; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; -using OpenSSH_GUI.Core.Interfaces.Credentials; -using OpenSSH_GUI.Core.Interfaces.KnownHosts; using OpenSSH_GUI.Core.Lib.AuthorizedKeys; -using OpenSSH_GUI.Core.Lib.Credentials; using OpenSSH_GUI.Core.Lib.KnownHosts; using ReactiveUI; +using ReactiveUI.SourceGenerators; using Renci.SshNet; namespace OpenSSH_GUI.Core.Lib.Misc; -public class ServerConnection : ReactiveObject, IDisposable +public sealed partial class ServerConnection : ReactiveObject, IDisposable { - private SshClient _sshClient; + private readonly CompositeDisposable _disposables = new(); - public ServerConnection(IConnectionCredentials? credentials = null) - { - credentials ??= new PasswordConnectionCredentials("123", "123", "123"); - ConnectionCredentials = credentials; - _sshClient = new SshClient(credentials.GetConnectionInfo()) { KeepAliveInterval = TimeSpan.FromSeconds(10) }; - ConnectionTime = DateTime.Now; - } + [ObservableAsProperty(ReadOnly = true)] + private string _connectionString = string.Empty; - private SshClient ClientConnection + [Reactive(SetModifier = AccessModifier.Private)] + private DateTime _connectionTime = DateTime.Now; + + [ObservableAsProperty(ReadOnly = true)] + private string _createEmptyFileCommand = string.Empty; + + [Reactive(SetModifier = AccessModifier.Private)] + private bool _isConnected; + + [ObservableAsProperty(ReadOnly = true)] + private string _lineSeparator = string.Empty; + + [ObservableAsProperty(ReadOnly = true)] + private string _readContentsCommand = string.Empty; + + [Reactive(SetModifier = AccessModifier.Private)] + private PlatformID _serverOs = PlatformID.Other; + + private ServerConnection(ConnectionCredentials? credentials = null) { - get => _sshClient; - set => this.RaiseAndSetIfChanged(ref _sshClient, value); + ConnectionCredentials = credentials ?? ConnectionCredentials.Empty; + ClientConnection = new SshClient(ConnectionCredentials.GetConnectionInfo()) + { + KeepAliveInterval = TimeSpan.FromSeconds(10) + }; + + _connectionStringHelper = this.WhenAnyValue(obj => obj.IsConnected) + .Select(c => c ? $"{ConnectionCredentials.Username}@{ConnectionCredentials.Hostname}" : string.Empty) + .ToProperty(this, obj => obj.ConnectionString) + .DisposeWith(_disposables); + + _readContentsCommandHelper = this.WhenAnyValue(obj => obj.ServerOs) + .Select(c => c == PlatformID.Win32NT ? "type" : "cat") + .ToProperty(this, obj => obj.ReadContentsCommand) + .DisposeWith(_disposables); + + _createEmptyFileCommandHelper = this.WhenAnyValue(obj => obj.ServerOs) + .Select(c => c == PlatformID.Win32NT ? "echo. >" : "touch") + .ToProperty(this, obj => obj.CreateEmptyFileCommand) + .DisposeWith(_disposables); + + _lineSeparatorHelper = this.WhenAnyValue(obj => obj.ServerOs) + .Select(e => e.GetLineSeparator()) + .ToProperty(this, obj => obj.LineSeparator) + .DisposeWith(_disposables); } - private string ReadContentsCommand => ServerOs == PlatformID.Win32NT ? "type" : "cat"; - private string CreateEmptyFileCommand => ServerOs == PlatformID.Win32NT ? "echo. >" : "touch"; - public IConnectionCredentials ConnectionCredentials { get; } - + public static ServerConnection Empty { get; } = new(); - public DateTime ConnectionTime + private ConnectionCredentials ConnectionCredentials { get; - set => this.RaiseAndSetIfChanged(ref field, value); - } = DateTime.Now; + init => this.RaiseAndSetIfChanged(ref field, value); + } - public bool IsConnected + private SshClient ClientConnection { get; - set => this.RaiseAndSetIfChanged(ref field, value); + init => this.RaiseAndSetIfChanged(ref field, value); } - public string ConnectionString => - IsConnected ? $"{ConnectionCredentials.Username}@{ConnectionCredentials.Hostname}" : ""; + /// + public void Dispose() { _disposables.Dispose(); } - public PlatformID ServerOs { get; set; } = PlatformID.Other; - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - void IDisposable.Dispose() - { - GC.SuppressFinalize(this); - } + public static ServerConnection WithCredentials(ConnectionCredentials credentials) => new(credentials); public async ValueTask ConnectToServerAsync(CancellationToken token = default) { - if (ConnectionCredentials is IMultiKeyConnectionCredentials mkcc) return await TestMultiAsync(mkcc, token); await ClientConnection.ConnectAsync(token); IsConnected = ClientConnection.IsConnected; if (!IsConnected) return ServerOs != PlatformID.Other && IsConnected; @@ -68,57 +95,43 @@ public async ValueTask ConnectToServerAsync(CancellationToken token = defa return ServerOs != PlatformID.Other && IsConnected; } - public async ValueTask DisconnectFromServerAsync(CancellationToken token = default) - { - try - { - await Task.Run(() => ClientConnection.Disconnect(), token); - IsConnected = false; - return true; - } - catch (Exception) - { - return false; - } - } - - public async ValueTask CloseConnectionAsync(CancellationToken token = default) + public ValueTask DisconnectFromServerAsync(CancellationToken token = default) { try { - await Task.Run(() => ClientConnection.Disconnect(), token); - IsConnected = false; - return true; + ClientConnection.Disconnect(); + IsConnected = ClientConnection.IsConnected; + return ValueTask.FromResult(true); } catch (Exception) { - return false; + return ValueTask.FromResult(false); } } - public async ValueTask GetKnownHostsFromServerAsync(CancellationToken token = default) + public async ValueTask GetKnownHostsFromServerAsync(CancellationToken token = default) { - if (!IsConnected) return new KnownHostsFile("", true); + if (!IsConnected) throw new InvalidOperationException("No connection to get known hosts from"); - var path = await ResolveRemoteEnvVariablesAsync(SshConfigFiles.Known_Hosts.GetPathOfFile(false, ServerOs), + var path = await ResolveRemoteEnvVariablesAsync( + SshConfigFiles.Known_Hosts.GetPathOfFile(false, ServerOs), token); - var command = ClientConnection.CreateCommand($"{ReadContentsCommand} {path}"); - var result = await Task.Run(() => command.Execute(), token); - - return new KnownHostsFile(result, true); + using var command = ClientConnection.CreateCommand($"{ReadContentsCommand} {path}"); + await command.ExecuteAsync(token); + return await KnownHostsFile.InitializeAsync(command.OutputStream, true, false, token); } - public async ValueTask WriteKnownHostsToServerAsync(IKnownHostsFile knownHostsFile, + public async ValueTask WriteKnownHostsToServerAsync(KnownHostsFile knownHostsFile, CancellationToken token = default) { + if (!knownHostsFile.KnownHosts.Any(e => e.ChangesMade)) return false; if (!IsConnected) return false; - var path = await ResolveRemoteEnvVariablesAsync(SshConfigFiles.Known_Hosts.GetPathOfFile(false, ServerOs), - token); - var command = - ClientConnection.CreateCommand($"echo \"{knownHostsFile.GetUpdatedContents(ServerOs)}\" > {path}"); - var result = await Task.Run(() => command.Execute(), token); - + var path = await ResolveRemoteEnvVariablesAsync( + SshConfigFiles.Known_Hosts.GetPathOfFile(false, ServerOs), token); + var content = await knownHostsFile.GetUpdatedContentsAsync(ServerOs); + using var command = ClientConnection.CreateCommand(BuildRemoteWriteCommand(ServerOs, content, path)); + await command.ExecuteAsync(token); return command.ExitStatus == 0; } @@ -127,9 +140,9 @@ public async ValueTask GetAuthorizedKeysFromServerAsync(Canc if (!IsConnected) throw new InvalidOperationException("No connection to get authorized keys from"); - var path = await ResolveRemoteEnvVariablesAsync(SshConfigFiles.Authorized_Keys.GetPathOfFile(false, ServerOs), - token); - var command = ClientConnection.CreateCommand($"{ReadContentsCommand} {path}"); + var path = await ResolveRemoteEnvVariablesAsync( + SshConfigFiles.Authorized_Keys.GetPathOfFile(false, ServerOs), token); + using var command = ClientConnection.CreateCommand($"{ReadContentsCommand} {path}"); await command.ExecuteAsync(token); return await AuthorizedKeysFile.ParseAsync(command.OutputStream, token); } @@ -137,76 +150,23 @@ public async ValueTask GetAuthorizedKeysFromServerAsync(Canc public async ValueTask WriteAuthorizedKeysChangesToServerAsync(AuthorizedKeysFile authorizedKeysFile, CancellationToken token = default) { + if (!authorizedKeysFile.ChangesMade) return false; if (!IsConnected) return false; - var path = await ResolveRemoteEnvVariablesAsync(SshConfigFiles.Authorized_Keys.GetPathOfFile(false, ServerOs), - token); - var command = - ClientConnection.CreateCommand( - $"echo \"{authorizedKeysFile.ExportFileContent(false, ServerOs)}\" > {path}"); - await Task.Run(() => command.Execute(), token); - + var path = await ResolveRemoteEnvVariablesAsync( + SshConfigFiles.Authorized_Keys.GetPathOfFile(false, ServerOs), token); + var content = authorizedKeysFile.ExportFileContent(ServerOs); + using var command = ClientConnection.CreateCommand(BuildRemoteWriteCommand(ServerOs, content, path)); + await command.ExecuteAsync(token); return command.ExitStatus == 0; } - public async ValueTask TestAndOpenConnectionAsync(CancellationToken token = default) - { - if (ConnectionCredentials is IMultiKeyConnectionCredentials mkcc) return await TestMultiAsync(mkcc, token); - try - { - await ClientConnection.ConnectAsync(token); - IsConnected = ClientConnection.IsConnected; - if (IsConnected) - { - ServerOs = await GetServerOsAsync(token); - await CheckForFilesAndCreateThemIfTheyNotExistAsync(token); - ConnectionTime = DateTime.Now; - } - - if (ServerOs != PlatformID.Other) return IsConnected; - return false; - } - catch (Exception) - { - return false; - } - } - - private async ValueTask TestMultiAsync(IMultiKeyConnectionCredentials mkcc, CancellationToken token = default) - { - //var workingKeys = new List(); - foreach (var key in mkcc.Keys!) - try - { - // using var connection = new SshClient(mkcc.Hostname, mkcc.Username, key.GetSshNetKeyType()); - // await connection.ConnectAsync(token); - // if (connection.IsConnected) workingKeys.Add(key); - } - catch (Exception) - { - // - } - - //mkcc.Keys = workingKeys; - if (mkcc.Keys.Any()) - { - await ClientConnection.ConnectAsync(token); - IsConnected = ClientConnection.IsConnected; - } - - if (!IsConnected) return IsConnected; - ServerOs = await GetServerOsAsync(token); - await CheckForFilesAndCreateThemIfTheyNotExistAsync(token); - ConnectionTime = DateTime.Now; - return ServerOs != PlatformID.Other && IsConnected; - } - private async ValueTask ResolveRemoteEnvVariablesAsync(string originalPath, CancellationToken token = default) { if (!IsConnected) return originalPath; var parts = originalPath.Split('%', StringSplitOptions.RemoveEmptyEntries); - var result = ""; + var result = string.Empty; foreach (var part in parts) if (part.Contains('\\') || part.Contains('/')) { @@ -214,10 +174,12 @@ private async ValueTask ResolveRemoteEnvVariablesAsync(string originalPa } else { - var cmdText = ServerOs is PlatformID.Unix or PlatformID.MacOSX ? $"echo ${part}" : $"echo %{part}%"; - var command = ClientConnection.CreateCommand(cmdText); - var output = await Task.Run(() => command.Execute(), token); - result += output.Trim(); + var cmdText = ServerOs is PlatformID.Unix or PlatformID.MacOSX + ? $"echo ${part}" + : $"echo %{part}%"; + using var command = ClientConnection.CreateCommand(cmdText); + await command.ExecuteAsync(token); + result += command.Result.Trim(); } return result; @@ -230,38 +192,89 @@ private async ValueTask CheckForFilesAndCreateThemIfTheyNotExistAsync(Cancellati var authKeyPath = SshConfigFiles.Authorized_Keys.GetPathOfFile(false); var knownHostPath = SshConfigFiles.Known_Hosts.GetPathOfFile(false); - var authorizedKeysFileCheck = ClientConnection.CreateCommand($"{ReadContentsCommand} {authKeyPath}"); - await Task.Run(() => authorizedKeysFileCheck.Execute(), token); + using var authorizedKeysFileCheck = ClientConnection.CreateCommand($"{ReadContentsCommand} {authKeyPath}"); + await authorizedKeysFileCheck.ExecuteAsync(token); - var knownHostsFileCheck = ClientConnection.CreateCommand($"{ReadContentsCommand} {knownHostPath}"); - await Task.Run(() => knownHostsFileCheck.Execute(), token); + using var knownHostsFileCheck = ClientConnection.CreateCommand($"{ReadContentsCommand} {knownHostPath}"); + await knownHostsFileCheck.ExecuteAsync(token); if (authorizedKeysFileCheck.ExitStatus != 0) { - var createAuthCmd = ClientConnection.CreateCommand($"{CreateEmptyFileCommand} {authKeyPath}"); - await Task.Run(() => createAuthCmd.Execute(), token); + using var createAuthCmd = ClientConnection.CreateCommand($"{CreateEmptyFileCommand} {authKeyPath}"); + await createAuthCmd.ExecuteAsync(token); } if (knownHostsFileCheck.ExitStatus != 0) { - var createKnownCmd = ClientConnection.CreateCommand($"{CreateEmptyFileCommand} {knownHostPath}"); - await Task.Run(() => createKnownCmd.Execute(), token); + using var createKnownCmd = ClientConnection.CreateCommand($"{CreateEmptyFileCommand} {knownHostPath}"); + await createKnownCmd.ExecuteAsync(token); } } private async ValueTask GetServerOsAsync(CancellationToken token = default) { - var linuxCommand = ClientConnection.CreateCommand("uname -s"); - var windowsCommand = ClientConnection.CreateCommand("ver"); + using var unixCommand = ClientConnection.CreateCommand("uname -s"); + await unixCommand.ExecuteAsync(token); - await Task.Run(() => linuxCommand.Execute(), token); - await Task.Run(() => windowsCommand.Execute(), token); + if (unixCommand.ExitStatus == 0) + return PlatformID.Unix; - var isWindows = windowsCommand.ExitStatus == 0; - var isLinux = linuxCommand.ExitStatus == 0; + using var windowsCommand = ClientConnection.CreateCommand("ver"); + await windowsCommand.ExecuteAsync(token); + + if (windowsCommand.ExitStatus == 0 && + windowsCommand.Result.Contains("Windows", StringComparison.OrdinalIgnoreCase)) + return PlatformID.Win32NT; - if (isWindows && !isLinux) return PlatformID.Win32NT; - if (isLinux && !isWindows) return PlatformID.Unix; return PlatformID.Other; } + + /// + /// Builds a platform-appropriate shell command to write the given content to a file on the remote host. + /// + /// The of the remote host. + /// The content to write into the file. + /// The full remote path of the target file. + /// If true, appends to the file instead of overwriting it. + /// A shell command string ready to be executed on the remote host. + /// + /// Thrown when no write command can be constructed for the given . + /// + private static string BuildRemoteWriteCommand(PlatformID platformId, string content, string filePath, + bool append = false) + { + var redirectOperator = append ? ">>" : ">"; + + return platformId is PlatformID.Unix or PlatformID.MacOSX + ? BuildUnixCommand(content, filePath, redirectOperator) + : BuildWindowsCommand(content, filePath, redirectOperator); + } + + /// + /// Builds a Unix shell write command using printf for reliable, escape-safe output. + /// + /// The content to write. + /// The target file path on the remote host. + /// Shell redirect operator (> or >>). + /// A Unix shell command string. + private static string BuildUnixCommand(string content, string filePath, string redirectOperator) + { + var escaped = content.Replace("'", "'\\''"); + return $"printf '%s' '{escaped}' {redirectOperator} '{filePath}'"; + } + + /// + /// Builds a Windows shell write command using PowerShell's Set-Content or Add-Content + /// for reliable Unicode-safe file writing. + /// + /// The content to write. + /// The target file path on the remote host. + /// Shell redirect operator (> or >>), used to determine append mode. + /// A PowerShell command string. + private static string BuildWindowsCommand(string content, string filePath, string redirectOperator) + { + var escaped = content.Replace("'", "''"); + var cmdlet = redirectOperator == ">>" ? "Add-Content" : "Set-Content"; + return $"powershell -Command \"{cmdlet} -Path '{filePath}' -Value '{escaped}' -NoNewline -Encoding UTF8\""; + } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/MVVM/IInitializableViewModel.cs b/OpenSSH_GUI.Core/MVVM/IInitializableViewModel.cs new file mode 100644 index 0000000..a04676f --- /dev/null +++ b/OpenSSH_GUI.Core/MVVM/IInitializableViewModel.cs @@ -0,0 +1,29 @@ +namespace OpenSSH_GUI.Core.MVVM; + +public interface IInitializableViewModel +{ + /// + /// Asynchronously initializes the view model, performing necessary setup operations. + /// + /// A token to monitor for cancellation requests. + /// A representing the asynchronous initialization operation. + public ValueTask InitializeAsync(CancellationToken cancellationToken = default); +} + +public interface IInitializableViewModel +{ + /// Asynchronously initializes the ViewModel with the specified parameters and optional cancellation token. + /// Sets the state of the ViewModel as initialized upon completion. + /// + /// The parameters used to initialize the ViewModel. + /// + /// + /// An optional token for observing cancellation requests. + /// + /// + /// A ValueTask representing the asynchronous initialization operation. + /// + public ValueTask InitializeAsync( + TParam parameters, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs b/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs index 2931d9c..120d3c7 100644 --- a/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs +++ b/OpenSSH_GUI.Core/MVVM/ViewModelBase.cs @@ -1,191 +1,140 @@ -using System.Reactive; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; using ReactiveUI; using ReactiveUI.SourceGenerators; namespace OpenSSH_GUI.Core.MVVM; /// -/// Serves as a base class for all view models in the MVVM pattern within the application. -/// Provides core properties, methods, and initialization logic. +/// Serves as a base class for all view models in the MVVM pattern within the application. +/// Provides core properties, methods, and initialization logic. /// -public abstract class ViewModelBase(ILogger? logger = null) - : ViewModelBase(logger) - where TViewModel : ViewModelBase - where TParameters : class, IInitializerParameters +public abstract class ViewModelBase : ViewModelBase, IInitializableViewModel { - /// Asynchronously initializes the ViewModel with the specified parameters and optional cancellation token. - /// Sets the state of the ViewModel as initialized upon completion. - /// - /// The parameters used to initialize the ViewModel. - /// - /// - /// An optional token for observing cancellation requests. - /// - /// - /// A ValueTask representing the asynchronous initialization operation. - /// + /// public virtual ValueTask InitializeAsync( - TParameters parameters, + TParameters? parameters, CancellationToken cancellationToken = default) { IsInitialized = true; + Activator.Activate().DisposeWith(Disposables); return ValueTask.CompletedTask; } -} -/// -/// Represents a base class for all ViewModel implementations in the MVVM architecture. -/// This class provides core functionality such as exception handling, initialization, -/// and common command execution logic required by derived ViewModels. -/// -/// -/// Inherits from ReactiveUI.ReactiveObject to facilitate reactive programming. -/// Integrates with ILogger for logging purposes and supports exception handling via a reactive subscription. -/// Defines commands and methods that assist in the management of ViewModel-specific operations. -/// -public abstract class ViewModelBase(ILogger? logger = null) : ViewModelBase(logger) - where TViewModel : ViewModelBase -{ - /// - /// Asynchronously initializes the view model, performing necessary setup operations. - /// - /// A token to monitor for cancellation requests. - /// A representing the asynchronous initialization operation. - public virtual ValueTask InitializeAsync(CancellationToken cancellationToken = default) - { - IsInitialized = true; - return ValueTask.CompletedTask; - } + /// + public sealed override ValueTask InitializeAsync(CancellationToken cancellationToken = default) => InitializeAsync(default, cancellationToken); } /// -/// Serves as an abstract base class for view models, providing shared properties, commands, -/// and behaviors for managing the interaction between the view and the application logic. +/// Serves as an abstract base class for view models, providing shared properties, commands, +/// and behaviors for managing the interaction between the view and the application logic. /// -public abstract partial class ViewModelBase : ReactiveObject, IDisposable, IAsyncDisposable +public abstract partial class ViewModelBase : ReactiveObject, IDisposable, IAsyncDisposable, IActivatableViewModel, + IInitializableViewModel { - private readonly IDisposable _booleanSubmitSubscription; - private readonly IDisposable _thrownExceptionsSubscription; - + protected readonly CompositeDisposable Disposables; + /// - /// Represents an event handler used to signal close requests for the view model. - /// This private field can be invoked internally to notify subscribers of the close event. + /// Represents an event handler used to signal close requests for the view model. + /// This private field can be invoked internally to notify subscribers of the close event. /// - [Reactive] - private EventHandler _close = delegate { }; + [Reactive] private EventHandler _close = delegate { }; /// - /// Indicates whether the ViewModel has been initialized successfully. - /// This flag is used to track the internal state of the ViewModel and ensure that - /// initialization processes are not repeated or called prematurely. + /// Indicates whether the ViewModel has been initialized successfully. + /// This flag is used to track the internal state of the ViewModel and ensure that + /// initialization processes are not repeated or called prematurely. /// - [Reactive] - private bool _isInitialized; + [Reactive] private bool _isInitialized; /// - /// Serves as a base class for view models, providing initialization - /// support, exception logging, and reactive command functionality. + /// Serves as a base class for view models, providing initialization + /// support, exception logging, and reactive command functionality. /// - protected ViewModelBase(ILogger? logger) + protected ViewModelBase() { - Logger = logger ?? NullLogger.Instance; - _thrownExceptionsSubscription = ThrownExceptions.Subscribe(exception => Logger.LogError(exception, "Viewmodel threw an exception")); - BooleanSubmit = ReactiveCommand.CreateFromTask(OnBooleanSubmitAsync); - _booleanSubmitSubscription = BooleanSubmit.Subscribe(_ => + Disposables = new CompositeDisposable(); + BooleanSubmitCommand.Subscribe(_ => { if (CloseOnBooleanSubmit) RequestClose(); - }); + }).DisposeWith(Disposables); } /// - /// Provides an instance of the logger. + /// Gets or sets a value indicating whether the view model should automatically + /// request to close when the command is executed. /// /// - /// The property gives access to the logging functionality - /// provided by the Microsoft.Extensions.Logging framework. It is used to log - /// messages, exceptions, and other runtime information throughout the lifecycle - /// of the ViewModel. This property is primarily intended for internal use by - /// the ViewModel to handle various events and errors gracefully. + /// When set to true, the view model invokes the method + /// after the execution of the command. This behavior + /// enables automatic closure of the view upon certain operations. /// - protected ILogger Logger { get; } + protected bool CloseOnBooleanSubmit { get; set; } = true; /// - /// Gets or sets a value indicating whether the view model should automatically - /// request to close when the command is executed. + /// Provides the activator for the view model, enabling activation and deactivation + /// of reactive components tied to the lifecycle of the view model. This property + /// supports managing subscriptions and other reactive resources. /// - /// - /// When set to true, the view model invokes the method - /// after the execution of the command. This behavior - /// enables automatic closure of the view upon certain operations. - /// - private protected bool CloseOnBooleanSubmit { get; set; } = true; + public ViewModelActivator Activator { get; } = new(); - /// - /// Gets a reactive command that represents an asynchronous operation - /// triggered with a boolean parameter. Typically used to handle - /// user interactions requiring confirmation, such as "Ok" or "Cancel" actions. - /// - /// - /// When executed, the command invokes the OnBooleanSubmitAsync method - /// with the provided boolean parameter. By default, this may also trigger a - /// window close action if CloseOnBooleanSubmit is set to true. - /// - public ReactiveCommand BooleanSubmit { get; } + /// + public ValueTask DisposeAsync() + { + Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + /// + public void Dispose() + { + Disposables.Dispose(); + GC.SuppressFinalize(this); + } + + /// + public virtual ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + IsInitialized = true; + Activator.Activate().DisposeWith(Disposables); + return ValueTask.CompletedTask; + } /// - /// Triggers a request to close the associated view or component. + /// Triggers a request to close the associated view or component. /// /// - /// This method raises the internal Close event, signaling that - /// the ViewModel intends to close. It is primarily used in scenarios - /// where the ViewModel is responsible for managing its own lifecycle transitions. + /// This method raises the internal Close event, signaling that + /// the ViewModel intends to close. It is primarily used in scenarios + /// where the ViewModel is responsible for managing its own lifecycle transitions. /// - protected void RequestClose() - { - Close.Invoke(this, EventArgs.Empty); - } + protected void RequestClose() { Close.Invoke(this, EventArgs.Empty); } /// - /// Handles the submission of a boolean input asynchronously. - /// This method can contain custom logic to process the submitted boolean - /// and perform required asynchronous operations. + /// Handles the submission of a boolean input asynchronously. + /// This method can contain custom logic to process the submitted boolean + /// and perform required asynchronous operations. /// /// The boolean input parameter supplied during submission. /// A token to observe while waiting for the task to complete. /// A task representing the asynchronous operation. - protected virtual Task OnBooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) - { - return Task.CompletedTask; - } - - /// - public virtual void Dispose() - { - _booleanSubmitSubscription.Dispose(); - _thrownExceptionsSubscription.Dispose(); - GC.SuppressFinalize(this); - } + [ReactiveCommand] + protected virtual Task BooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) => Task.CompletedTask; - /// - public virtual ValueTask DisposeAsync() + /// + /// Displays the attached for a specified control. + /// + /// + /// The control for which the flyout will be displayed. This parameter must be of type . + /// + [ReactiveCommand] + private void OpenFlyout(object? parameter) { - Dispose(); - GC.SuppressFinalize(this); - return ValueTask.CompletedTask; + if (parameter is Control control) + FlyoutBase.ShowAttachedFlyout(control); } -} - -/// -/// Represents a set of initialization parameters for a specific ViewModel type. -/// This interface is used to define the structure of the data required to initialize a ViewModel. -/// -/// -/// The type of the ViewModel that utilizes this initializer parameters implementation. -/// Must inherit from . -/// -public interface IInitializerParameters where TViewModel : ViewModelBase -{ } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/OpenSSH_GUI.Core.csproj b/OpenSSH_GUI.Core/OpenSSH_GUI.Core.csproj index 1a0a604..c667015 100644 --- a/OpenSSH_GUI.Core/OpenSSH_GUI.Core.csproj +++ b/OpenSSH_GUI.Core/OpenSSH_GUI.Core.csproj @@ -1,31 +1,31 @@  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Resources/AppIconStore.cs b/OpenSSH_GUI.Core/Resources/AppIconStore.cs new file mode 100644 index 0000000..b5bb543 --- /dev/null +++ b/OpenSSH_GUI.Core/Resources/AppIconStore.cs @@ -0,0 +1,26 @@ +using Avalonia.Controls; +using Avalonia.Media.Imaging; + +namespace OpenSSH_GUI.Core.Resources; + +/// +/// Holds pre-rendered app icons and window icons, keyed by a canonical string key. +/// Populated during Avalonia framework initialization before the main window is shown. +/// +public sealed class AppIconStore +{ + private readonly Dictionary _bitmaps = new(); + private readonly Dictionary _windowIcons = new(); + + /// Stores a rendered under the given key. + public void AddBitmap(string key, Bitmap bitmap) { _bitmaps[key] = bitmap; } + + /// Stores a under the given key. + public void AddWindowIcon(string key, WindowIcon icon) { _windowIcons[key] = icon; } + + /// Retrieves a by key, or if not found. + public Bitmap? GetBitmap(string key) => _bitmaps.GetValueOrDefault(key); + + /// Retrieves a by key, or if not found. + public WindowIcon? GetWindowIcon(string key) => _windowIcons.GetValueOrDefault(key); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Resources/Converter/CollectionIndexConverter.cs b/OpenSSH_GUI.Core/Resources/Converter/CollectionIndexConverter.cs new file mode 100644 index 0000000..f0083b5 --- /dev/null +++ b/OpenSSH_GUI.Core/Resources/Converter/CollectionIndexConverter.cs @@ -0,0 +1,21 @@ +using System.Collections; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace OpenSSH_GUI.Core.Resources.Converter; + +/// +/// Converts an item and its parent collection into a 1-based index string. +/// +public class CollectionIndexConverter : IMultiValueConverter +{ + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count < 2 || values[0] is null || values[1] is not IList collection) + return string.Empty; + + var index = collection.IndexOf(values[0]); + return index >= 0 ? (index + 1).ToString() : string.Empty; + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Resources/Converter/PathDeletableConverter.cs b/OpenSSH_GUI.Core/Resources/Converter/PathDeletableConverter.cs new file mode 100644 index 0000000..84cf395 --- /dev/null +++ b/OpenSSH_GUI.Core/Resources/Converter/PathDeletableConverter.cs @@ -0,0 +1,21 @@ +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using OpenSSH_GUI.Core.Extensions; + +namespace OpenSSH_GUI.Core.Resources.Converter; + +/// +/// Converts a path string to a boolean indicating whether it can be deleted. +/// Returns if the path equals the protected default path. +/// +public sealed class PathDeletableConverter : IValueConverter +{ + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string path && path != SshConfigFilesExtension.GetBaseSshPath(); + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => new BindingNotification(new NotSupportedException(), BindingErrorType.Error); +} \ 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 428c06f..7b62e46 100644 --- a/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs +++ b/OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs @@ -1,36 +1,50 @@ +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; using Avalonia.Controls; -using Avalonia.Media.Imaging; -using DryIoc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.MVVM; using ReactiveUI.Avalonia; -using Serilog; namespace OpenSSH_GUI.Core.Resources.Wrapper; public abstract class WindowBase : WindowBase - where TViewModel : ViewModelBase - where TViewModelInitializer : class, IInitializerParameters + where TViewModel : ViewModelBase { - public ValueTask InitializeAsync(TViewModelInitializer initializer, WindowStartupLocation startupLocation = WindowStartupLocation.CenterScreen, CancellationToken cancellationToken = default) + public async ValueTask InitializeAsync(TViewModelInitializer initializer, + WindowStartupLocation startupLocation = WindowStartupLocation.CenterScreen, + CancellationToken cancellationToken = default) { WindowInitialize(startupLocation); ArgumentNullException.ThrowIfNull(ViewModel); - return ViewModel.InitializeAsync(initializer,cancellationToken); + await ViewModel.InitializeAsync(initializer, cancellationToken); } } -public abstract class WindowBase : ReactiveWindow where TViewModel : ViewModelBase +public abstract class WindowBase : ReactiveWindow, IDisposable + where TViewModel : ViewModelBase { + private CompositeDisposable Disposables { get; } = new(); public required ILogger> Logger { get; set; } - public required IResolver Resolver { get; set; } + public required IServiceProvider Services { get; set; } + public required AppIconStore AppIconStore { get; set; } + + public void Dispose() { Disposables.Dispose(); } protected void WindowInitialize(WindowStartupLocation startupLocation = WindowStartupLocation.CenterScreen) { EnsureInitialized(); + Observable.FromEventPattern( + h => ActualThemeVariantChanged += h, + h => ActualThemeVariantChanged -= h) + .ObserveOn(AvaloniaScheduler.Instance) + .Subscribe(_ => SetIcon()) + .DisposeWith(Disposables); try { - ViewModel = Resolver.Resolve(serviceKey: typeof(TViewModel).Name); + ViewModel = Services.GetRequiredKeyedService(typeof(TViewModel).Name); } catch (Exception e) { @@ -38,24 +52,33 @@ protected void WindowInitialize(WindowStartupLocation startupLocation = WindowSt throw; } + SetIcon(); + WindowStartupLocation = startupLocation; + ViewModel.Close += RequestClose; + } + + private void SetIcon() + { try { - Icon = new WindowIcon(Resolver.Resolve(serviceKey: "AppIcon")); + if (Enum.TryParse(ActualThemeVariant.Key.ToString(), true, out var themeVariant)) + Icon = AppIconStore.GetWindowIcon(string.Join("_", nameof(WindowIcon), 32, themeVariant).ToLower()); + else + Logger.LogWarning("Could not resolve theme variant {themeVariant}", ActualThemeVariant); } catch (Exception e) { Logger.LogError(e, "Failed to resolve AppIcon"); throw; } - WindowStartupLocation = startupLocation; - ViewModel.Close += RequestClose; } - - public ValueTask InitializeAsync(WindowStartupLocation startupLocation = WindowStartupLocation.CenterScreen, CancellationToken cancellationToken = default) + + public async ValueTask InitializeAsync(WindowStartupLocation startupLocation = WindowStartupLocation.CenterScreen, + CancellationToken cancellationToken = default) { WindowInitialize(startupLocation); ArgumentNullException.ThrowIfNull(ViewModel); - return ViewModel!.InitializeAsync(cancellationToken); + await ViewModel!.InitializeAsync(cancellationToken); } private void RequestClose(object? sender, EventArgs e) diff --git a/OpenSSH_GUI.Core/Services/Hosted/FileSystemAnalyzer.cs b/OpenSSH_GUI.Core/Services/Hosted/FileSystemAnalyzer.cs index bcb9a2a..16b3346 100644 --- a/OpenSSH_GUI.Core/Services/Hosted/FileSystemAnalyzer.cs +++ b/OpenSSH_GUI.Core/Services/Hosted/FileSystemAnalyzer.cs @@ -41,7 +41,8 @@ private async Task DoWork(CancellationToken cancellationToken) #pragma warning disable CA1416 if (!Directory.Exists(rootSshPath)) if (unixPlatform) - Directory.CreateDirectory(rootSshPath, + Directory.CreateDirectory( + rootSshPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | @@ -52,7 +53,8 @@ private async Task DoWork(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); if (!Directory.Exists(baseSshPath)) if (unixPlatform) - Directory.CreateDirectory(baseSshPath, + Directory.CreateDirectory( + baseSshPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); // 700 diff --git a/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs b/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs new file mode 100644 index 0000000..d1485af --- /dev/null +++ b/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs @@ -0,0 +1,126 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.Misc; +using Serilog; +using Serilog.Extensions.Logging; + +namespace OpenSSH_GUI.Core.Services; + +/// +/// Default implementation of . +/// Manages per-operation backup directories and a scoped Serilog file logger +/// that captures diagnostic output for potentially destructive SSH key file operations. +/// The application logger is intentionally not held by this service — callers are +/// responsible for their own application-level logging channel. +/// +public sealed class KeyFileBackupService : IKeyFileBackupService, IDisposable +{ + private const string BackupFileExtension = "bak"; + + private static readonly string BackupDirectory = + Path.Combine(SshConfigFilesExtension.GetBaseSshPath(), AppDomain.CurrentDomain.FriendlyName); + + private SerilogLoggerFactory? _loggerFactory; + + private ILogger? _operationLogger; + + /// + public void Dispose() { _loggerFactory?.Dispose(); } + + /// + public IEnumerable BackupFiles(params FileInfo[] files) + { + foreach (var file in files) + { + var destination = Path.Combine(BackupDirectory, string.Join(".", file.Name, BackupFileExtension)); + WriteToOperationLog(LogLevel.Debug, "Backing up file {file} to {destination}", file.FullName, destination); + var backup = new BackedUpFile + { + InitialFile = file, + BackupFile = new FileInfo(destination) + }; + backup.Backup(); + WriteToOperationLog(LogLevel.Debug, "Successfully backed up file {file}", file.FullName); + yield return backup; + } + } + + /// + public void RestoreBackupFiles(params BackedUpFile[] files) + { + foreach (var file in files) + { + WriteToOperationLog( + LogLevel.Debug, "Restoring backup file {file} to {destination}", + file.BackupFile.FullName, file.InitialFile.FullName); + file.Restore(); + WriteToOperationLog(LogLevel.Debug, "Successfully restored backup file {file}", file.BackupFile.FullName); + } + } + + /// + public void DeleteBackupFiles(params BackedUpFile[] files) + { + foreach (var file in files) + { + WriteToOperationLog(LogLevel.Debug, "Deleting backup file {file}", file.BackupFile.FullName); + file.Delete(); + WriteToOperationLog(LogLevel.Debug, "Successfully deleted backup file {file}", file.BackupFile.FullName); + } + } + + /// + public void BeginOperationLog() + { + if (_operationLogger is not null) return; + + if (!Directory.Exists(BackupDirectory)) + Directory.CreateDirectory(BackupDirectory); + + var operationLogFile = Path.Combine(BackupDirectory, Path.ChangeExtension("operation_log", "log")); + _loggerFactory = new SerilogLoggerFactory( + new LoggerConfiguration() + .WriteTo.File(operationLogFile) + .MinimumLevel.Verbose() + .CreateLogger(), true); + _operationLogger = _loggerFactory.CreateLogger(); + } + + /// + public void EndOperationLog(bool errorsOccurred = false) + { + if (_operationLogger is null) return; + _operationLogger = null; + _loggerFactory?.Dispose(); + _loggerFactory = null; + if (errorsOccurred) return; + try + { + Directory.Delete(BackupDirectory, true); + } + catch (Exception e) + { + // Intentionally swallowed — backup directory cleanup is best-effort. + // The caller's application logger should have already captured context. + _ = e; + } + } + +#pragma warning disable CA2254 + /// + public void WriteToOperationLog(LogLevel level, [StructuredMessageTemplate] string? message, + params object?[] args) + { + _operationLogger?.Log(level, message, args); + } + + /// + public void WriteToOperationLog(LogLevel level, Exception? exception, + [StructuredMessageTemplate] string? message, params object?[] args) + { + _operationLogger?.Log(level, exception, message, args); + } +#pragma warning restore CA2254 +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs b/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs new file mode 100644 index 0000000..cd088e4 --- /dev/null +++ b/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs @@ -0,0 +1,139 @@ +using System.Buffers; +using System.Text; +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; +using Renci.SshNet; +using SshNet.Keygen; +using SshNet.Keygen.Extensions; +using SshNet.Keygen.SshKeyEncryption; + +namespace OpenSSH_GUI.Core.Services; + +/// +/// Provides functionality to write content or SSH key files to the filesystem. +/// +public class KeyFileWriterService(ILogger logger) : IKeyFileWriterService +{ + /// + public async ValueTask WriteToFile(string filePath, string content, + bool overwrite = false, Encoding? encoding = null) + { + if (encoding is null) + { + encoding ??= Encoding.UTF8; + logger.LogDebug("Using default encoding: {encoding}", encoding.EncodingName); + } + else + { + logger.LogDebug("Using encoding: {encoding}", encoding.EncodingName); + } + + var fileInfo = new FileInfo(filePath); + if (fileInfo.Exists && !overwrite) + { + logger.LogWarning("File {filePath} already exists. Skipping write operation.", filePath); + throw new IOException("File already exists"); + } + + var options = new FileStreamOptions + { + BufferSize = 0, + Access = FileAccess.ReadWrite, + Mode = FileMode.OpenOrCreate, + Share = FileShare.ReadWrite + }; + + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + await using var fileStream = fileInfo.Open(options); + logger.LogDebug("Opened file {filePath}", filePath); + + byte[]? rented = null; + var maxByteCount = encoding.GetMaxByteCount(content.Length); + var buffer = maxByteCount <= 256 + ? stackalloc byte[256] + : rented = ArrayPool.Shared.Rent(maxByteCount); + logger.LogDebug("Allocated {byteCount} bytes", buffer.Length); + try + { + var writtenBytes = encoding.GetBytes(content, buffer); + logger.LogDebug("Writing {byteCount} bytes into file {filePath}", writtenBytes, filePath); + fileStream.Write(buffer[..writtenBytes]); + logger.LogDebug("Successfully wrote file {filePath}", filePath); + } + catch (Exception e) + { + logger.LogError(e, "Error while writing file {filePath}", filePath); + throw; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented, true); + logger.LogDebug("Freeing memory"); + } + } + } + + + /// + public async ValueTask> WriteToFileInSpecificFormat( + SshKeyFormat format, + ISshKeyEncryption encryption, + IPrivateKeySource privateKeySource, string filePath, bool overwrite = false) + { + var privateKeyFileContent = format is SshKeyFormat.OpenSSH + ? privateKeySource.ToOpenSshFormat(encryption) + : privateKeySource.ToPuttyFormat(encryption, format); + var writtenFiles = new List(); + switch (format) + { + case SshKeyFormat.PuTTYv2: + case SshKeyFormat.PuTTYv3: + break; + case SshKeyFormat.OpenSSH: + default: + { + var pubKeyFormat = format.ChangeExtension(filePath); + try + { + await WriteToFile(pubKeyFormat, privateKeySource.ToOpenSshPublicFormat(), overwrite); + } + catch (Exception e) + { + logger.LogError(e, "Failed to write public key file {filePath}", pubKeyFormat); + throw; + } + + writtenFiles.Add(pubKeyFormat); + break; + } + } + + var privateFilePath = format.ChangeExtension(filePath, false); + try + { + await WriteToFile(privateFilePath, privateKeyFileContent, overwrite); + } + catch (Exception e) + { + logger.LogError(e, "Failed to write private key file {filePath}", privateFilePath); + throw; + } + writtenFiles.Add(privateFilePath); + return writtenFiles; + } + + + /// + public ValueTask> WriteToFileInSpecificFormat( + SshKeyGenerateInfo generateInfo, + GeneratedPrivateKey createdKey, string filePath, bool overwrite = false) => WriteToFileInSpecificFormat( + generateInfo.KeyFormat, generateInfo.Encryption, createdKey, filePath, + overwrite); +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Services/ServerConnectionService.cs b/OpenSSH_GUI.Core/Services/ServerConnectionService.cs index 4d98eef..ad73a81 100644 --- a/OpenSSH_GUI.Core/Services/ServerConnectionService.cs +++ b/OpenSSH_GUI.Core/Services/ServerConnectionService.cs @@ -1,13 +1,19 @@ -using System.Diagnostics.CodeAnalysis; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; using Microsoft.Extensions.Logging; -using OpenSSH_GUI.Core.Interfaces.Credentials; using OpenSSH_GUI.Core.Lib.Misc; using ReactiveUI; +using ReactiveUI.SourceGenerators; namespace OpenSSH_GUI.Core.Services; -public class ServerConnectionService(ILogger logger) : ReactiveObject +public sealed partial class ServerConnectionService : ReactiveObject, IDisposable { + private readonly CompositeDisposable _disposables = new(); + + private readonly ILogger _logger; + /// /// Indicates whether the current server connection is active. /// @@ -16,12 +22,8 @@ public class ServerConnectionService(ILogger logger) : /// and is currently active. If the connection is not established or has /// been terminated, it returns false. /// - [MemberNotNullWhen(true, nameof(ServerConnection))] - public bool IsConnected - { - get; - set => this.RaiseAndSetIfChanged(ref field, value); - } + [ObservableAsProperty(ReadOnly = true)] + private bool _isConnected; /// /// Gets or sets the server connection instance associated with the service. @@ -31,14 +33,24 @@ public bool IsConnected /// to retrieve or update the instance of the server connection. Setting this property /// raises an internal change notification. /// - public ServerConnection? ServerConnection + [Reactive(SetModifier = AccessModifier.Private)] + private ServerConnection _serverConnection = ServerConnection.Empty; + + public ServerConnectionService(ILogger logger) { - get; - set - { - this.RaiseAndSetIfChanged(ref field, value); - IsConnected = value != null; - } + _logger = logger; + + _isConnectedHelper = this.WhenAnyValue(vm => vm.ServerConnection) + .Select(e => e.WhenAnyValue(sc => sc.IsConnected)) + .Switch() + .ToProperty(this, obj => obj.IsConnected) + .DisposeWith(_disposables); + } + + public void Dispose() + { + _disposables.Dispose(); + _serverConnection.Dispose(); } /// @@ -56,23 +68,23 @@ public ServerConnection? ServerConnection /// A representing the result of the connection attempt. /// Returns true if the connection is successfully established; otherwise, false. /// - public async ValueTask EstablishConnection(IConnectionCredentials connectionCredentials, + public async ValueTask EstablishConnection(ConnectionCredentials connectionCredentials, CancellationToken token = default) { try { - ServerConnection = new ServerConnection(connectionCredentials); + ServerConnection = ServerConnection.WithCredentials(connectionCredentials); return await ServerConnection.ConnectToServerAsync(token); } catch (Exception e) { - logger.LogError(e, "Error connecting to server"); + _logger.LogError(e, "Error connecting to server"); throw; } } /// - /// Closes the current connection to the server, if a connection exists. + /// Closes the current connection to the server if a connection exists. /// /// Indicates whether to throw an exception if no connection exists. /// @@ -86,11 +98,12 @@ public async ValueTask EstablishConnection(IConnectionCredentials connecti /// public async ValueTask CloseConnection(bool throwOnNoConnection = true, CancellationToken token = default) { - if (!IsConnected) + if (!IsConnected) return throwOnNoConnection ? throw new InvalidOperationException("No connection to disconnect from") : true; var disconnectResult = await ServerConnection.DisconnectFromServerAsync(token); + ServerConnection.Dispose(); if (disconnectResult) - ServerConnection = null; + ServerConnection = ServerConnection.Empty; return disconnectResult; } } \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Services/SshKeyGenerator.cs b/OpenSSH_GUI.Core/Services/SshKeyGenerator.cs new file mode 100644 index 0000000..d63ecf5 --- /dev/null +++ b/OpenSSH_GUI.Core/Services/SshKeyGenerator.cs @@ -0,0 +1,42 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.Keys; +using SshNet.Keygen; + +namespace OpenSSH_GUI.Core.Services; + +public class SshKeyGenerator(ILogger logger, ISshKeyFactory keyFactory, IKeyFileWriterService keyFileWriterService) : ISshKeyGenerator +{ + /// + public async ValueTask Generate(string fullFilePath, SshKeyGenerateInfo generateParamsInfo, bool overwrite = false) + { + GeneratedPrivateKey? createdKey; + try + { + await using var privateStream = new MemoryStream(); + createdKey = SshKey.Generate(privateStream, generateParamsInfo); + if (createdKey is null) + throw new InvalidOperationException("Could not generate new key"); + } + catch (Exception e) + { + logger.LogError(e, "Error while generating key file {filePath}", fullFilePath); + throw; + } + + var filePath = generateParamsInfo.KeyFormat.ChangeExtension(fullFilePath, false); + + await keyFileWriterService.WriteToFileInSpecificFormat(generateParamsInfo, createdKey, filePath, overwrite); + + var keyFileSource = SshKeyFileSource.FromDisk(filePath); + var keyFile = keyFactory.Create(); + if (string.IsNullOrWhiteSpace(generateParamsInfo.Encryption.Passphrase)) + keyFile.Load(keyFileSource); + else + keyFile.Load(keyFileSource, Encoding.UTF8.GetBytes(generateParamsInfo.Encryption.Passphrase)); + + return keyFile; + } +} \ No newline at end of file diff --git a/OpenSSH_GUI.Core/Services/SshKeyManager.cs b/OpenSSH_GUI.Core/Services/SshKeyManager.cs index 4fe146e..abbcfd2 100644 --- a/OpenSSH_GUI.Core/Services/SshKeyManager.cs +++ b/OpenSSH_GUI.Core/Services/SshKeyManager.cs @@ -1,17 +1,16 @@ using System.Collections.ObjectModel; -using System.Collections.Specialized; +using System.Diagnostics; using System.Text; -using DryIoc; -using Microsoft.Extensions.DependencyInjection; +using JetBrains.Annotations; using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; using OpenSSH_GUI.Core.Lib.Keys; using OpenSSH_GUI.Core.Lib.Misc; using ReactiveUI; +using ReactiveUI.SourceGenerators; using Renci.SshNet; using SshNet.Keygen; -using SshNet.Keygen.Extensions; -using SshKey = SshNet.Keygen.SshKey; namespace OpenSSH_GUI.Core.Services; @@ -19,483 +18,576 @@ namespace OpenSSH_GUI.Core.Services; /// Manager for SSH keys on the local machine. /// Provides functionality for searching, generating, and changing formats of SSH keys. /// -public class SshKeyManager : ReactiveObject, IDisposable +public sealed partial class SshKeyManager : ReactiveObject, IDisposable { - private const string BackupFileExtension = ".bak"; - - private static readonly FileStreamOptions FileStreamOptions = new() - { - BufferSize = 0, - Access = FileAccess.ReadWrite, - Mode = FileMode.OpenOrCreate, - Share = FileShare.ReadWrite - }; - - private readonly DirectoryCrawler _directoryCrawler; + private readonly IKeyFileBackupService _backupService; + private readonly IDirectoryCrawler _directoryCrawler; + private readonly ISshKeyFactory _keyFactory; + private readonly IKeyFileWriterService _keyFileWriterService; + private readonly ISshKeyGenerator _keyGenerator; private readonly ILogger _logger; private readonly SemaphoreSlim _semaphoreSlim = new(1, 1); - private readonly IResolver _resolver; - private readonly FileSystemWatcher _watcher; + private readonly ObservableCollection _sshKeysInternal = []; - private volatile bool _searching; + [Reactive] private bool _processing; public SshKeyManager( ILogger logger, - DirectoryCrawler directoryCrawler, - IResolver resolver) + IDirectoryCrawler directoryCrawler, + ISshKeyFactory keyFactory, + ISshKeyGenerator keyGenerator, + IKeyFileWriterService keyFileWriterService, + IKeyFileBackupService backupService) { _logger = logger; _directoryCrawler = directoryCrawler; - _resolver = resolver; - - if (!OperatingSystem.IsWindows()) - FileStreamOptions.UnixCreateMode = (UnixFileMode)Convert.ToInt32("600", 8); - - _watcher = new FileSystemWatcher - { - Path = SshConfigFilesExtension.GetBaseSshPath(), - EnableRaisingEvents = true - }; - _watcher.Filters.Add("*.pub"); - _watcher.Filters.Add("*.ppk"); - _watcher.Created += async (_, eventArgs) => await WatcherOnCreated(eventArgs); - _watcher.Deleted += WatcherOnDeleted; - _watcher.Renamed += async (_, eventArgs) => await WatcherOnRenamed(eventArgs); - - SshKeysInternal = []; - SshKeysInternal.CollectionChanged += SshKeysOnCollectionChanged; + _keyFactory = keyFactory; + _keyGenerator = keyGenerator; + _keyFileWriterService = keyFileWriterService; + _backupService = backupService; + SshKeys = new ReadOnlyObservableCollection(_sshKeysInternal); } - - /// - /// Performs the initial SSH key search on disk. - /// Must be called after the DI container is fully built. - /// - public Task InitialSearchAsync(CancellationToken token = default) - => SearchForKeysAndUpdateCollection(); - - private ObservableCollection SshKeysInternal { get; } /// /// Gets the collection of detected SSH keys. /// - public IReadOnlyCollection SshKeys => SshKeysInternal; + public ReadOnlyObservableCollection SshKeys { get; } - public int SshKeysCount + /// + public void Dispose() { - get; - set => this.RaiseAndSetIfChanged(ref field, value); + _semaphoreSlim.Dispose(); + foreach (var sshKeyFile in SshKeys) sshKeyFile.Dispose(); } /// - /// Changes the format of an existing SSH key. + /// Performs the initial SSH key search on disk. + /// Must be called after the DI container is fully built. /// - /// The SSH key file to change. - /// The target SSH key format. - /// A cancellation token. - /// A task representing the asynchronous operation. - public async Task ChangeFormatOfKeyAsync( - SshKeyFile key, - SshKeyFormat newFormat, - CancellationToken token = default) + public async ValueTask InitialSearchAsync(CancellationToken token = default) { - if (!key.IsInitialized) - throw new InvalidOperationException("Key file not initialized"); - - PrivateKeyFile? privateKeyFile = key; - ArgumentNullException.ThrowIfNull(privateKeyFile); - ArgumentException.ThrowIfNullOrWhiteSpace(key.AbsoluteFilePath); - - var filePath = newFormat.ChangeExtension(Path.GetFullPath(key.AbsoluteFilePath), false); - - var password = key.Password.IsValid - ? key.Password.GetPasswordString() - : null; + Processing = true; + await SearchForKeysAndUpdateCollectionAsync(token); + Processing = false; + } - var backedUpFiles = new List<(string backup, string original)>(); - var semaphoreAquired = false; + /// + /// Changes the password of an SSH key file, handling both OpenSSH and PuTTY formats transparently. + /// If the key is in PuTTY format, it will be temporarily converted to OpenSSH, the password changed, + /// and then converted back to the original format. + /// + /// The SSH key file whose password should be changed. + /// The new password to set, encoded using . + /// + /// The encoding used to interpret . Defaults to if + /// null. + /// + /// A cancellation token to observe while waiting for the operation to complete. + /// Thrown if the private key file of is null. + /// Thrown if the resolved key file path is null or whitespace. + /// Thrown if the internal semaphore could not be acquired within 5 seconds. + /// + /// Thrown if ssh-keygen exits with a non-zero code, or if intermediate key file operations fail. + /// On failure, all modified files are restored from backup. + /// + public async ValueTask ChangePasswordOfKeyAsync(SshKeyFile key, + ReadOnlyMemory newPassword, + Encoding? encoding = null, CancellationToken token = default) + { + _backupService.BeginOperationLog(); + encoding ??= Encoding.UTF8; + var semaphoreAcquired = false; + var errorsOccured = false; + BackedUpFile[] backupFiles = []; + string[] additionalDeleteFiles = []; + var keyFilePath = string.Empty; try { - foreach (var existingFile in key.KeyFiles.Select(e => e.FullName)) + Processing = true; + keyFilePath = key.AbsoluteFilePath; + var privateKeyFile = key.PrivateKeyFile; + ArgumentNullException.ThrowIfNull(privateKeyFile); + ArgumentException.ThrowIfNullOrWhiteSpace(keyFilePath); + if (!await _semaphoreSlim.WaitAsync(TimeSpan.FromSeconds(5), token)) { - var backup = existingFile + BackupFileExtension; - File.Copy(existingFile, backup, true); - backedUpFiles.Add((backup, existingFile)); + Log(LogLevel.Error, "Failed to acquire semaphore within 5 seconds"); + throw new TimeoutException("Failed to acquire semaphore within 5 seconds"); } - if(!key.Delete(out var exception)) - throw exception; - semaphoreAquired = await _semaphoreSlim.WaitAsync(TimeSpan.FromSeconds(2), token); - if (!semaphoreAquired) - throw new InvalidOperationException("Another key operation is in progress"); + semaphoreAcquired = true; + backupFiles = _backupService.BackupFiles(key.KeyFiles).ToArray(); - switch (newFormat) + if (key.Format is { } and not SshKeyFormat.OpenSSH) { - case SshKeyFormat.OpenSSH: - await using (var privateFileStream = new FileStream(filePath, FileStreamOptions)) - await using (var streamWriter = new StreamWriter(privateFileStream, Encoding.UTF8)) - { - await streamWriter.WriteAsync(key.Password.IsValid - ? privateKeyFile.ToOpenSshFormat(key.Password.GetPasswordString()) - : privateKeyFile.ToOpenSshFormat()); - } - - await using (var publicFileStream = - new FileStream(newFormat.ChangeExtension(filePath), FileStreamOptions)) - await using (var streamWriter = new StreamWriter(publicFileStream, Encoding.UTF8)) - { - await streamWriter.WriteAsync(privateKeyFile.ToOpenSshPublicFormat()); - } + Log(LogLevel.Debug, "Detected PuTTY key {key} - need to change format first", keyFilePath); + additionalDeleteFiles = (await _keyFileWriterService.WriteToFileInSpecificFormat( + SshKeyFormat.OpenSSH, + key.Password.ToSshKeyEncryption(), privateKeyFile, keyFilePath, true)).ToArray(); - break; + keyFilePath = additionalDeleteFiles.First(e => string.IsNullOrWhiteSpace(Path.GetExtension(e))); + Log(LogLevel.Debug, "New file path: {newFilePath}", keyFilePath); + } - case SshKeyFormat.PuTTYv2: - case SshKeyFormat.PuTTYv3: - default: - await using (var privateFileStream = new FileStream(filePath, FileStreamOptions)) - await using (var streamWriter = new StreamWriter(privateFileStream, Encoding.UTF8)) - { - await streamWriter.WriteAsync(password is not null - ? privateKeyFile.ToPuttyFormat(password, newFormat) - : privateKeyFile.ToPuttyFormat(newFormat)); - } + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "ssh-keygen", + Arguments = + $"-p -f {keyFilePath} -P \"{key.Password.GetPasswordString()}\" -N \"{encoding.GetString(newPassword.Span)}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + if (process.Start()) + { + await process.WaitForExitAsync(token); + if (process.ExitCode != 0) + { + var message = await process.StandardError.ReadToEndAsync(token); + Log( + LogLevel.Error, "ssh-keygen exited with code {exitCode} and message: {message}", + process.ExitCode, message); + throw new Exception($"ssh-keygen exited with code {process.ExitCode}"); + } - break; + var output = await process.StandardOutput.ReadToEndAsync(token); + Log(LogLevel.Debug, "ssh-keygen exited without errors and output: {message}", output); } - foreach (var (backup, _) in backedUpFiles) - TryDeleteFile(backup); + if (key.Format is { } format and not SshKeyFormat.OpenSSH) + { + var keyFile = _keyFactory.Create(); + keyFile.Load(SshKeyFileSource.FromDisk(keyFilePath), newPassword.Span); + Log( + LogLevel.Debug, + "Changes to the password were made in OpenSSH Format - need to change format to Putty again"); + keyFilePath = (await _keyFileWriterService.WriteToFileInSpecificFormat( + format, keyFile.Password.ToSshKeyEncryption(), + keyFile.PrivateKeyFile ?? throw new Exception("Private key file not found"), keyFilePath, + true)).First(); + + Log(LogLevel.Debug, "New file path: {newFilePath}", keyFilePath); + foreach (var deleteFile in additionalDeleteFiles) File.Delete(deleteFile); + } - await AddKeyAsync(SshKeyFileSource.FromDisk(filePath)); + key.Load(SshKeyFileSource.FromDisk(keyFilePath), newPassword.Span); + Log(LogLevel.Debug, "Successfully changed password of key {key}", keyFilePath); + _backupService.DeleteBackupFiles(backupFiles); + return KeyManagerOperationResult.Success(); } catch (Exception e) { - _logger.LogError(e, "Error changing format of key – attempting rollback"); - foreach (var (backup, original) in backedUpFiles) - try - { - File.Copy(backup, original, true); - TryDeleteFile(backup); - } - catch (Exception rollbackEx) - { - _logger.LogError(rollbackEx, - "Rollback failed for '{original}' – manual recovery may be required", - original); - } - - throw; + errorsOccured = true; + Log(LogLevel.Error, e, "Error changing password of key {key}", keyFilePath); + _backupService.RestoreBackupFiles(backupFiles); + return KeyManagerOperationResult.FromException(e); } finally { - if (semaphoreAquired) + if (semaphoreAcquired) _semaphoreSlim.Release(); + _backupService.EndOperationLog(errorsOccured); + Processing = false; } } /// - /// Changes the order of the SSH keys in the collection. + /// Attempts to delete all files associated with the given SSH key. + /// Unlike , this method does not throw on failure — + /// instead, all encountered exceptions are aggregated and returned alongside a success flag. /// - /// Function to reorder the keys. - public void ChangeOrder(Func, IEnumerable> orderFunc) + /// The SSH key file to delete, including all associated key files. + /// A cancellation token to observe while waiting for the semaphore. + /// + /// A indicating success, or containing an + /// if one or more files could not be deleted. + /// + public async ValueTask TryDeleteKeyAsync(SshKeyFile key, + CancellationToken token = default) { - var reordered = orderFunc(SshKeys).ToList(); - for (var i = 0; i < reordered.Count; i++) + _backupService.BeginOperationLog(); + var errorsOccured = false; + Exception? exception = null; + var semaphoreAcquired = false; + try + { + Processing = true; + semaphoreAcquired = await _semaphoreSlim.WaitAsync(TimeSpan.FromSeconds(5), token); + foreach (var keyFile in key.KeyFiles) + try + { + keyFile.Delete(); + } + catch (Exception ex) + { + Log(LogLevel.Debug, ex, "Error while deleting key {key}", key.AbsoluteFilePath); + exception = exception is null ? ex : new AggregateException(exception, ex); + } + + if (exception is not null) + throw exception; + Log(LogLevel.Debug, "Successfully deleted key {key}", key.AbsoluteFilePath); + _sshKeysInternal.Remove(key); + return KeyManagerOperationResult.Success(); + } + catch (Exception e) + { + Log(LogLevel.Error, e, "Error deleting key"); + errorsOccured = true; + exception = exception is null ? e : new AggregateException(exception, e); + return KeyManagerOperationResult.FromException(exception); + } + finally { - var oldIndex = SshKeysInternal.IndexOf(reordered[i]); - if (oldIndex != i) - SshKeysInternal.Move(oldIndex, i); + if (semaphoreAcquired) + _semaphoreSlim.Release(); + _backupService.EndOperationLog(errorsOccured); + Processing = false; } } /// - /// Generates a new SSH key. + /// Renames all files associated with the given to a new base file name, + /// preserving each file's original extension. If any target file already exists and + /// is , a conflict result is returned. + /// On failure, all files are restored from backup. /// - /// The full path where the new key should be stored. - /// Parameters for key generation. - /// A value task representing the asynchronous operation. - public async ValueTask GenerateNewKey(string fullFilePath, SshKeyGenerateInfo generateParamsInfo) + /// + /// The whose associated files are to be renamed. + /// After a successful rename, the key is reloaded from the new primary file. + /// + /// + /// The new base file name (without extension) to assign to all files of the key. + /// Each file retains its original extension. + /// + /// A flag to indicate forceful overwrite of any existent files. + /// + /// A to observe while waiting for the semaphore + /// and during file move operations. + /// + /// + /// Thrown when another key operation is already in progress and the semaphore + /// could not be acquired within the timeout. + /// + /// + /// File moves are performed via wrapped in + /// , + /// since no native async move API exists in .NET. On same-volume moves, this is an atomic + /// metadata operation. Backups are created before any file is moved and deleted only on full success; + /// on any failure the backup is restored. + /// + public async ValueTask RenameKeyAsync(SshKeyFile key, string newFileName, + bool overwrite = false, CancellationToken token = default) { - if (File.Exists(fullFilePath)) - throw new InvalidOperationException("File already exists"); - if (GenerateKeyFile() is not { } keyFile) - throw new InvalidOperationException("Key file not generated"); - if (!await _semaphoreSlim.WaitAsync(100)) - throw new InvalidOperationException("Another key operation is in progress"); + var semaphoreAcquired = false; + _backupService.BeginOperationLog(); + var errorsOccurred = false; + BackedUpFile[] backupFiles = []; try { - await using var privateStream = new MemoryStream(); - var createdKey = SshKey.Generate(privateStream, generateParamsInfo); + Processing = true; + semaphoreAcquired = await _semaphoreSlim.WaitAsync(TimeSpan.FromSeconds(5), token); + if (!semaphoreAcquired) + throw new InvalidOperationException("Another key operation is in progress"); - switch (generateParamsInfo.KeyFormat) + backupFiles = _backupService.BackupFiles(key.KeyFiles).ToArray(); + var filePairs = (key.KeyFileInfo?.Files ?? []).Select(file => { - case SshKeyFormat.PuTTYv2: - case SshKeyFormat.PuTTYv3: - var puttyPath = generateParamsInfo.KeyFormat.ChangeExtension(fullFilePath); - await using (var fs = new FileStream(puttyPath, FileStreamOptions)) - await using (var sw = new StreamWriter(fs)) - { - await sw.WriteAsync(createdKey.ToPuttyFormat( - generateParamsInfo.Encryption, generateParamsInfo.KeyFormat)); - } - - await keyFile.Load(SshKeyFileSource.FromDisk(puttyPath), - Encoding.UTF8.GetBytes(generateParamsInfo.Encryption.Passphrase)); - break; - - case SshKeyFormat.OpenSSH: - default: - var pubPath = generateParamsInfo.KeyFormat.ChangeExtension(fullFilePath); - var privatePath = generateParamsInfo.KeyFormat.ChangeExtension(fullFilePath, false); - await using (var fs = new FileStream(privatePath, FileStreamOptions)) - await using (var sw = new StreamWriter(fs)) - { - await sw.WriteAsync( - createdKey.ToOpenSshFormat(generateParamsInfo.Encryption)); - } + ArgumentNullException.ThrowIfNull(file.Directory); + var newFileNameForFile = Path.ChangeExtension( + newFileName, + string.IsNullOrEmpty(file.Extension) ? null : file.Extension); + var destinationForFile = Path.Combine(file.Directory.FullName, newFileNameForFile); + Log(LogLevel.Debug, "Renaming file {file} to {newFileName}", file.FullName, newFileNameForFile); + Log(LogLevel.Debug, "Destination: {destination}", destinationForFile); + return (Source: file, Target: destinationForFile); + }).ToArray(); + + if (!overwrite && filePairs.Any(p => File.Exists(p.Target))) + { + Log(LogLevel.Debug, "Destination files already exist"); + return KeyManagerOperationResult.Conflict(new Exception("Destination files already exist")); + } - await using (var fs = new FileStream(pubPath, FileStreamOptions)) - await using (var sw = new StreamWriter(fs)) - { - await sw.WriteAsync(createdKey.ToOpenSshPublicFormat()); - } + foreach (var (source, target) in filePairs) + { + await Task.Run(() => source.MoveTo(target, true), token); + Log(LogLevel.Debug, "Successfully renamed file {file} to {newFileName}", source.FullName, source.Name); + } - await keyFile.Load(SshKeyFileSource.FromDisk(privatePath), - Encoding.UTF8.GetBytes(generateParamsInfo.Encryption.Passphrase)); - break; + var expectedExtension = key.Format?.GetExtension(false); + if (filePairs.Select(p => p.Source).FirstOrDefault(file => + string.Equals( + string.IsNullOrEmpty(file.Extension) ? null : file.Extension, + expectedExtension, + StringComparison.OrdinalIgnoreCase) && + file.Exists) is { } keyFileToLoad) + { + Log(LogLevel.Debug, "Loading key file {keyFile}", keyFileToLoad.FullName); + key.Load(SshKeyFileSource.FromDisk(keyFileToLoad.FullName)); + Log(LogLevel.Debug, "Successfully loaded key file {keyFile}", keyFileToLoad.FullName); + _backupService.DeleteBackupFiles(backupFiles); + return KeyManagerOperationResult.Success(); } - SshKeysInternal.Add(keyFile); + Log(LogLevel.Warning, "No valid key file found for key format {format}", key.Format); + throw new Exception("No valid key file found for key format"); } catch (Exception e) { - _logger.LogError(e, "Error generating key"); - throw; + errorsOccurred = true; + Log(LogLevel.Error, e, "Failed to change filename of {className}", nameof(SshKeyFile)); + _backupService.RestoreBackupFiles(backupFiles); + return KeyManagerOperationResult.FromException(e); } finally { - _semaphoreSlim.Release(); + if (semaphoreAcquired) + _semaphoreSlim.Release(); + _backupService.EndOperationLog(errorsOccurred); + Processing = false; } } /// - /// Triggers a re-search for SSH keys on the disk. + /// Changes the format of an existing SSH key. /// + /// The SSH key file to change. + /// The target SSH key format. + /// A cancellation token. /// A task representing the asynchronous operation. - public async Task RerunSearchAsync() + public async ValueTask ChangeFormatOfKeyAsync( + SshKeyFile key, + SshKeyFormat newFormat, + CancellationToken token = default) { - if (!await _semaphoreSlim.WaitAsync(100)) - throw new InvalidOperationException("Another key operation is in progress"); - if (_searching) - throw new InvalidOperationException("Can't rerun search while searching"); + if (!key.IsInitialized) + return KeyManagerOperationResult.Failure(new InvalidOperationException("Key file not initialized")); + + PrivateKeyFile? privateKeyFile = key; try { - SshKeysInternal.Clear(); - await SearchForKeysAndUpdateCollection(); + ArgumentNullException.ThrowIfNull(privateKeyFile); + ArgumentException.ThrowIfNullOrWhiteSpace(key.AbsoluteFilePath); } catch (Exception e) { - _logger.LogError(e, "Unhandled error during key re-search"); + return KeyManagerOperationResult.Failure(e); } - finally - { - _semaphoreSlim.Release(); - } - } - private async Task WatcherOnRenamed(RenamedEventArgs e) - { - if (!await _semaphoreSlim.WaitAsync(100)) - return; + _backupService.BeginOperationLog(); + var filePath = newFormat.ChangeExtension(Path.GetFullPath(key.AbsoluteFilePath), false); + var writtenFiles = new List(); + BackedUpFile[] backupFiles = []; + var semaphoreAcquired = false; + var errorsOccured = false; try { - if (SshKeysInternal.SingleOrDefault(k => k.AbsoluteFilePath == Path.ChangeExtension(e.OldFullPath, null)) is - { } oldKey) - SshKeyGotDeleted(oldKey, EventArgs.Empty); - await AddKeyAsync(SshKeyFileSource.FromDisk(Path.ChangeExtension(e.FullPath, null))); - } - catch (Exception exception) - { - _logger.LogError(exception, "Error handling renamed key"); - } - finally - { - _semaphoreSlim.Release(); - } - } + Processing = true; + backupFiles = _backupService.BackupFiles(key.KeyFiles).ToArray(); - private void WatcherOnDeleted(object? sender, FileSystemEventArgs eventArgs) - { - if (!_semaphoreSlim.Wait(100)) - return; - try - { - var normalizedPath = Path.ChangeExtension( - Path.GetFullPath(eventArgs.FullPath), null); + semaphoreAcquired = await _semaphoreSlim.WaitAsync(TimeSpan.FromSeconds(2), token); + if (!semaphoreAcquired) + throw new InvalidOperationException("Another key operation is in progress"); - var key = SshKeys.SingleOrDefault(k => - string.Equals(k.AbsoluteFilePath, normalizedPath, - StringComparison.OrdinalIgnoreCase)); + writtenFiles.AddRange( + await _keyFileWriterService.WriteToFileInSpecificFormat( + newFormat, + key.Password.ToSshKeyEncryption(), + privateKeyFile, + filePath, true)); - if (key is null) - return; + key.Load(SshKeyFileSource.FromDisk(filePath)); + Log( + LogLevel.Debug, "Successfully changed format of key {key} to {format}", + key.AbsoluteFilePath, newFormat); - _logger.LogDebug("Key {key} deleted", key.AbsoluteFilePath); - SshKeyGotDeleted(key, EventArgs.Empty); + foreach (var backupFile in backupFiles) + { + if (!writtenFiles.Contains(backupFile.InitialFile.FullName, StringComparer.OrdinalIgnoreCase)) + { + backupFile.InitialFile.Delete(); + Log(LogLevel.Debug, "Deleted source key file {file}", backupFile.InitialFile.FullName); + } + } + + _backupService.DeleteBackupFiles(backupFiles); + return KeyManagerOperationResult.Success(); } catch (Exception e) { - _logger.LogError(e, "Error handling deleted key"); + var exc = e; + errorsOccured = true; + Log(LogLevel.Error, e, "Error changing format of key – attempting rollback"); + foreach (var writtenFile in writtenFiles) + try + { + File.Delete(writtenFile); + } + catch (Exception ex) + { + exc = exc switch + { + AggregateException agg => new AggregateException(agg.InnerExceptions.Append(ex)), + not null => new AggregateException(exc, ex), + _ => ex + }; + Log(LogLevel.Warning, ex, "Could not delete created file '{path}'", writtenFile); + } + + try + { + _backupService.RestoreBackupFiles(backupFiles); + } + catch (Exception exception) + { + exc = exc switch + { + AggregateException agg => new AggregateException(agg.InnerExceptions.Append(exception)), + not null => new AggregateException(exc, exception), + _ => exception + }; + Log(LogLevel.Warning, exception, "Could not restore backup files"); + } + + return KeyManagerOperationResult.FromException(exc); } finally { - _semaphoreSlim.Release(); + if (semaphoreAcquired) + _semaphoreSlim.Release(); + _backupService.EndOperationLog(errorsOccured); + Processing = false; } } - private async Task WatcherOnCreated(FileSystemEventArgs e) + /// + /// Generates a new SSH key and adds it to the managed collection. + /// + /// The full path where the new key should be stored. + /// Parameters for key generation. + /// Whether to overwrite an existing file at the target path. + /// A indicating the outcome of the operation. + public async ValueTask GenerateNewKey(string fullFilePath, + SshKeyGenerateInfo generateParamsInfo, bool overwrite = false) { + if (File.Exists(fullFilePath) && !overwrite) + return KeyManagerOperationResult.Failure(new InvalidOperationException("File already exists")); if (!await _semaphoreSlim.WaitAsync(100)) - return; + return KeyManagerOperationResult.FromException( + new InvalidOperationException("Another key operation is in progress")); try { - var keyFilePath = string.Equals( - Path.GetExtension(e.FullPath), - SshKeyFormatExtension.PuttyKeyFileExtension, - StringComparison.OrdinalIgnoreCase) - ? e.FullPath - : Path.ChangeExtension(e.FullPath, null); - - if (SshKeys.Any(key => - string.Equals(key.AbsoluteFilePath, keyFilePath, - StringComparison.OrdinalIgnoreCase))) - return; - - await AddKeyAsync(SshKeyFileSource.FromDisk(keyFilePath)); + Processing = true; + var filePath = generateParamsInfo.KeyFormat.ChangeExtension(fullFilePath, false); + var keyFile = await _keyGenerator.Generate(filePath, generateParamsInfo, overwrite); + _sshKeysInternal.Add(keyFile); + return KeyManagerOperationResult.Success(); } - catch (Exception exception) + catch (Exception e) { - _logger.LogError(exception, "Error adding key"); + Log(LogLevel.Error, e, "Error generating key"); + return KeyManagerOperationResult.FromException(e); } finally { _semaphoreSlim.Release(); + Processing = false; } } - private void SshKeysOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - if (e.NewItems is { } newItems) - foreach (var key in newItems.OfType()) - try - { - key.GotDeleted += SshKeyGotDeleted; - } - catch (Exception exception) - { - _logger.LogError(exception, "Error adding GotDeleted event handler"); - } - - break; - - case NotifyCollectionChangedAction.Remove: - if (e.OldItems is { } oldItems) - foreach (var key in oldItems.OfType()) - try - { - key.GotDeleted -= SshKeyGotDeleted; - key.Dispose(); - } - catch (Exception exception) - { - _logger.LogError(exception, "Error removing GotDeleted event handler"); - } - - break; - } - - SshKeysCount = SshKeysInternal.Count; - } - - private SshKeyFile? GenerateKeyFile() + /// + /// Triggers a re-search for SSH keys on disk and rebuilds the managed collection. + /// + /// A cancellation token. + /// A indicating the outcome of the operation. + public async ValueTask RerunSearchAsync(CancellationToken token = default) { + if (!await _semaphoreSlim.WaitAsync(100, token)) + return KeyManagerOperationResult.FromException( + new InvalidOperationException("Another key operation is in progress")); try { - if (_resolver.GetService() is { } keyFile) - { - keyFile.AttachChangeFormatHandler(ChangeFormatOfKeyAsync); - return keyFile; - } + Processing = true; + _sshKeysInternal.Clear(); + await SearchForKeysAndUpdateCollectionAsync(token); } catch (Exception e) { - _logger.LogError(e, "Error resolving generic SshKeyFile"); + Log(LogLevel.Error, e, "Unhandled error during key re-search"); + return KeyManagerOperationResult.FromException(e); } - return null; + finally + { + _semaphoreSlim.Release(); + Processing = false; + } + + return KeyManagerOperationResult.Success(); } - private async Task AddKeyAsync(SshKeyFileSource keyFileSource) + private void AddKey(SshKeyFileSource keyFileSource) { - if (SshKeysInternal.Any(k => - string.Equals(k.AbsoluteFilePath, keyFileSource.AbsolutePath, + if (_sshKeysInternal.Any(k => + string.Equals( + k.AbsoluteFilePath, keyFileSource.AbsolutePath, StringComparison.OrdinalIgnoreCase))) return; try { - if (GenerateKeyFile() is not { } keyFileGenerated) - throw new InvalidOperationException("Key file not generated"); - - await keyFileGenerated.Load(keyFileSource); - SshKeysInternal.Add(keyFileGenerated); + var keyFileGenerated = _keyFactory.Create(); + keyFileGenerated.Load(keyFileSource); + _sshKeysInternal.Add(keyFileGenerated); } catch (Exception e) { - _logger.LogError(e, "Error loading keyfile {filePath}", keyFileSource.AbsolutePath); + Log(LogLevel.Error, e, "Error loading keyfile {filePath}", keyFileSource.AbsolutePath); } } - private async Task SearchForKeysAndUpdateCollection() + private async ValueTask SearchForKeysAndUpdateCollectionAsync( + CancellationToken token = default) { - Interlocked.Exchange(ref _searching, true); + if (_directoryCrawler.IsSearching) + return KeyManagerOperationResult.Conflict(new InvalidOperationException("Key search already in progress")); + var semaphoreAcquired = false; + var errorsOccured = false; + _backupService.BeginOperationLog(); try { - foreach (var key in await _directoryCrawler.GetPossibleKeyFilesOnDisk()) - await AddKeyAsync(key); + semaphoreAcquired = await _semaphoreSlim.WaitAsync(TimeSpan.FromSeconds(5), token); + await foreach (var sshKey in _directoryCrawler.GetPossibleKeyFilesOnDiskAsyncEnumerable(token)) + AddKey(sshKey); + return KeyManagerOperationResult.Success(); + } + catch (Exception e) + { + errorsOccured = true; + Log(LogLevel.Error, e, "Error searching for keys"); + return KeyManagerOperationResult.Failure(e); } finally { - Interlocked.Exchange(ref _searching, false); + if (semaphoreAcquired) + _semaphoreSlim.Release(); + _backupService.EndOperationLog(errorsOccured); } } - private void SshKeyGotDeleted(object? sender, EventArgs e) +#pragma warning disable CA2254 + private void Log(LogLevel level, [StructuredMessageTemplate] string? message, params object?[] args) { - if (sender is not SshKeyFile key) return; - SshKeysInternal.Remove(key); + _logger.Log(level, message, args); + _backupService.WriteToOperationLog(level, message, args); } - private void TryDeleteFile(string path) + private void Log(LogLevel level, Exception? exception, [StructuredMessageTemplate] string? message, + params object?[] args) { - try - { - File.Delete(path); - } - catch (Exception e) - { - _logger.LogWarning(e, "Could not delete temporary file '{path}'", path); - } - } - - public void Dispose() - { - _watcher.Dispose(); - _semaphoreSlim.Dispose(); - foreach (var sshKeyFile in SshKeys) - { - sshKeyFile.Dispose(); - } - GC.SuppressFinalize(this); + _logger.Log(level, exception, message, args); + _backupService.WriteToOperationLog(level, exception, message, args); } +#pragma warning restore CA2254 } \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Enums/MessageBoxButtons.cs b/OpenSSH_GUI.Dialogs/Enums/MessageBoxButtons.cs index e16b3a2..4d274b6 100644 --- a/OpenSSH_GUI.Dialogs/Enums/MessageBoxButtons.cs +++ b/OpenSSH_GUI.Dialogs/Enums/MessageBoxButtons.cs @@ -1,3 +1,5 @@ +using OpenSSH_GUI.Dialogs.Views; + namespace OpenSSH_GUI.Dialogs.Enums; /// diff --git a/OpenSSH_GUI.Dialogs/Enums/MessageBoxIcon.cs b/OpenSSH_GUI.Dialogs/Enums/MessageBoxIcon.cs index 9231bc5..aca0424 100644 --- a/OpenSSH_GUI.Dialogs/Enums/MessageBoxIcon.cs +++ b/OpenSSH_GUI.Dialogs/Enums/MessageBoxIcon.cs @@ -1,3 +1,5 @@ +using OpenSSH_GUI.Dialogs.Views; + namespace OpenSSH_GUI.Dialogs.Enums; /// diff --git a/OpenSSH_GUI.Dialogs/Enums/MessageBoxResult.cs b/OpenSSH_GUI.Dialogs/Enums/MessageBoxResult.cs index 603d908..747efab 100644 --- a/OpenSSH_GUI.Dialogs/Enums/MessageBoxResult.cs +++ b/OpenSSH_GUI.Dialogs/Enums/MessageBoxResult.cs @@ -1,3 +1,5 @@ +using OpenSSH_GUI.Dialogs.Views; + namespace OpenSSH_GUI.Dialogs.Enums; /// diff --git a/OpenSSH_GUI.Dialogs/Interfaces/IMessageBoxProvider.cs b/OpenSSH_GUI.Dialogs/Interfaces/IMessageBoxProvider.cs index 98f9ab7..9cd18dc 100644 --- a/OpenSSH_GUI.Dialogs/Interfaces/IMessageBoxProvider.cs +++ b/OpenSSH_GUI.Dialogs/Interfaces/IMessageBoxProvider.cs @@ -1,3 +1,4 @@ +using Material.Icons; using OpenSSH_GUI.Dialogs.Enums; using OpenSSH_GUI.Dialogs.Models; @@ -21,7 +22,7 @@ Task ShowMessageBoxAsync( string title, string message, MessageBoxButtons buttons = MessageBoxButtons.Ok, - MessageBoxIcon icon = MessageBoxIcon.None); + MaterialIconKind icon = MaterialIconKind.ErrorOutline); /// /// Shows a modal message box and returns the button the user clicked. @@ -34,6 +35,11 @@ Task ShowMessageBoxAsync( /// Task ShowMessageBoxAsync(MessageBoxParams @params); + public Task ShowErrorMessageBoxAsync(Exception? e = null, string? customMessage = null); + + public Task ShowRetryMessageBoxAsync(Func> tryActionAsync, string title, string message, + MaterialIconKind icon = MaterialIconKind.ErrorOutline, int retries = 3, bool showTryCountInTitle = true); + /// /// Shows a modal secure-input (password) prompt and returns the result. /// @@ -63,7 +69,8 @@ Task ShowMessageBoxAsync( /// The parameters for the secure-input prompt. /// /// A whose - /// contains the UTF-8 encoded password, or null when the user cancels. + /// contains the by the encoded password, or null when the user + /// cancels. /// The caller is responsible for disposing the result to zero the buffer. /// Task ShowSecureInputAsync(SecureInputParams @params); diff --git a/OpenSSH_GUI.Dialogs/Models/MessageBoxParams.cs b/OpenSSH_GUI.Dialogs/Models/MessageBoxParams.cs index 396162b..df95c3b 100644 --- a/OpenSSH_GUI.Dialogs/Models/MessageBoxParams.cs +++ b/OpenSSH_GUI.Dialogs/Models/MessageBoxParams.cs @@ -27,9 +27,4 @@ public class MessageBoxParams /// Gets or sets the icon shown beside the message. /// public MaterialIconKind? Icon { get; set; } - - /// - /// Gets or sets the legacy icon shown beside the message. - /// - public MessageBoxIcon LegacyIcon { get; set; } = MessageBoxIcon.None; } \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Models/SecureInputParams.cs b/OpenSSH_GUI.Dialogs/Models/SecureInputParams.cs index 828a2e9..18c16d5 100644 --- a/OpenSSH_GUI.Dialogs/Models/SecureInputParams.cs +++ b/OpenSSH_GUI.Dialogs/Models/SecureInputParams.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace OpenSSH_GUI.Dialogs.Models; /// @@ -21,4 +23,9 @@ public class SecureInputParams : MessageBoxParams /// Defaults to 0 (unlimited). /// public int MaxLength { get; set; } = 0; + + /// + /// Gets or sets the character encoding used for input. + /// + public Encoding Encoding { get; set; } = Encoding.UTF8; } \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Models/SecureInputResult.cs b/OpenSSH_GUI.Dialogs/Models/SecureInputResult.cs index 56839b8..63ea54e 100644 --- a/OpenSSH_GUI.Dialogs/Models/SecureInputResult.cs +++ b/OpenSSH_GUI.Dialogs/Models/SecureInputResult.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using OpenSSH_GUI.Dialogs.Views; namespace OpenSSH_GUI.Dialogs.Models; @@ -20,10 +21,7 @@ public sealed class SecureInputResult : IDisposable /// Ownership of is transferred to this instance. /// /// The UTF-8 encoded password bytes. Must not be null. - internal SecureInputResult(byte[] buffer) - { - _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); - } + internal SecureInputResult(byte[] buffer) => _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); /// /// Gets the UTF-8 encoded password bytes. diff --git a/OpenSSH_GUI.Dialogs/OpenSSH_GUI.Dialogs.csproj b/OpenSSH_GUI.Dialogs/OpenSSH_GUI.Dialogs.csproj index ab6394a..be14827 100644 --- a/OpenSSH_GUI.Dialogs/OpenSSH_GUI.Dialogs.csproj +++ b/OpenSSH_GUI.Dialogs/OpenSSH_GUI.Dialogs.csproj @@ -1,14 +1,21 @@  - - - net10.0 - enable - enable - - - - - - - - + + enable + enable + + + + + + + + + + + ..\..\..\home\olli\.nuget\packages\reactiveui\23.2.1\lib\net10.0\ReactiveUI.dll + + + ..\..\..\home\olli\.nuget\packages\reactiveui.avalonia\11.4.12\lib\net10.0\ReactiveUI.Avalonia.dll + + + \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Services/MessageBoxProvider.cs b/OpenSSH_GUI.Dialogs/Services/MessageBoxProvider.cs index 7c8c8ca..ac15b89 100644 --- a/OpenSSH_GUI.Dialogs/Services/MessageBoxProvider.cs +++ b/OpenSSH_GUI.Dialogs/Services/MessageBoxProvider.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Material.Icons; using OpenSSH_GUI.Dialogs.Enums; using OpenSSH_GUI.Dialogs.Interfaces; using OpenSSH_GUI.Dialogs.Models; @@ -13,60 +14,92 @@ namespace OpenSSH_GUI.Dialogs.Services; public class MessageBoxProvider(Window owner) : IMessageBoxProvider { /// - public async Task ShowMessageBoxAsync( + public Task ShowMessageBoxAsync( string title, string message, MessageBoxButtons buttons = MessageBoxButtons.Ok, - MessageBoxIcon icon = MessageBoxIcon.None) - { - return await ShowMessageBoxAsync(new MessageBoxParams + MaterialIconKind icon = MaterialIconKind.ErrorOutline) => ShowMessageBoxAsync( + new MessageBoxParams { Title = title, Message = message, Buttons = buttons, - LegacyIcon = icon + Icon = icon }); - } /// - public async Task ShowMessageBoxAsync(MessageBoxParams @params) + public Task ShowMessageBoxAsync(MessageBoxParams @params) { var dialog = new MessageBoxDialog(@params); - return await dialog.ShowDialog(owner); + return dialog.ShowDialog(owner); + } + + public Task ShowErrorMessageBoxAsync(Exception? e = null, string? customMessage = null) + { + return ShowMessageBoxAsync( + new MessageBoxParams + { + Title = e?.GetType().Name ?? "Error", + Message = e switch + { + not null when !string.IsNullOrWhiteSpace(customMessage) => string.Join(" ", customMessage, e.Message), + null when !string.IsNullOrWhiteSpace(customMessage) => customMessage, + not null => e.ToString(), + _ => string.Empty + }, + Buttons = MessageBoxButtons.Ok, + Icon = MaterialIconKind.ErrorOutline + }); + } + + public async Task ShowRetryMessageBoxAsync(Func> tryActionAsync, string title, string message, + MaterialIconKind icon = MaterialIconKind.ErrorOutline, int retries = 3, bool showTryCountInTitle = true) + { + var tryCount = 1; + + while (tryCount <= retries) + { + if (showTryCountInTitle) + title = string.Join(" ", title, string.Join("/", tryCount, retries)); + if (await tryActionAsync() is null or true) + return true; + if (await ShowMessageBoxAsync(title, message, MessageBoxButtons.OkCancel, icon) is MessageBoxResult.Cancel) + return true; + tryCount++; + } + + return tryCount <= retries; } /// - public async Task ShowSecureInputAsync( + public Task ShowSecureInputAsync( string title, string prompt, int minLength = 1, - int maxLength = 0) - { - return await ShowSecureInputAsync(new SecureInputParams + int maxLength = 0) => ShowSecureInputAsync( + new SecureInputParams { Title = title, Prompt = prompt, MinLength = minLength, MaxLength = maxLength }); - } /// - public async Task ShowSecureInputAsync(SecureInputParams @params) + public Task ShowSecureInputAsync(SecureInputParams @params) { var dialog = new SecureInputDialog(@params); - return await dialog.ShowDialog(owner); + return dialog.ShowDialog(owner); } /// - public async Task ShowValidatedInputAsync( + public Task ShowValidatedInputAsync( string title, string prompt, Func validator, string initialValue = "", - string watermark = "Enter value…") - { - return await ShowValidatedInputAsync(new ValidatedInputParams + string watermark = "Enter value…") => ShowValidatedInputAsync( + new ValidatedInputParams { Title = title, Prompt = prompt, @@ -74,12 +107,11 @@ public async Task ShowMessageBoxAsync(MessageBoxParams @params InitialValue = initialValue, Watermark = watermark }); - } /// - public async Task ShowValidatedInputAsync(ValidatedInputParams @params) + public Task ShowValidatedInputAsync(ValidatedInputParams @params) { var dialog = new ValidatedInputDialog(@params); - return await dialog.ShowDialog(owner); + return dialog.ShowDialog(owner); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Views/MessageBoxDialog.axaml b/OpenSSH_GUI.Dialogs/Views/MessageBoxDialog.axaml index 79438b8..6a5c6b7 100644 --- a/OpenSSH_GUI.Dialogs/Views/MessageBoxDialog.axaml +++ b/OpenSSH_GUI.Dialogs/Views/MessageBoxDialog.axaml @@ -3,15 +3,16 @@ xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" x:Class="OpenSSH_GUI.Dialogs.Views.MessageBoxDialog" Title="Message" - Width="440" - MinWidth="300" - SizeToContent="Height" + MinWidth="440" + SizeToContent="WidthAndHeight" CanResize="False" + CanMaximize="False" + CanMinimize="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False" - ExtendClientAreaChromeHints="NoChrome" - ExtendClientAreaToDecorationsHint="False"> - + ExtendClientAreaToDecorationsHint="False" + Closing="Window_OnClosing"> + @@ -22,6 +23,7 @@ VerticalAlignment="Top"> + public partial class MessageBoxDialog : Window { - /// - /// Initialises a new with the provided content and configuration. - /// - /// The window title bar text. - /// The message body shown to the user. - /// Which button set to display. Defaults to . - /// Optional icon shown to the left of the message. Defaults to . - public MessageBoxDialog( - string title, - string message, - MessageBoxButtons buttons = MessageBoxButtons.Ok, - MessageBoxIcon icon = MessageBoxIcon.None) - { - InitializeComponent(); - - Title = title; - PART_Message.Text = message; - - ApplyButtons(buttons); - ApplyIcon(icon); - } - + private bool _isInternalClose; /// /// Initialises a new with the provided . /// @@ -49,10 +28,7 @@ public MessageBoxDialog(MessageBoxParams @params) ApplyButtons(@params.Buttons); - if (@params.Icon.HasValue) - ApplyIcon(@params.Icon); - else - ApplyIcon(@params.LegacyIcon); + ApplyIcon(@params.Icon); } // ------------------------------------------------------------------------- @@ -88,26 +64,6 @@ private void ApplyButtons(MessageBoxButtons buttons) } } - /// - /// Applies the icon glyph and colour that correspond to the requested type. - /// Uses Unicode symbols so no external icon library is required. - /// - private void ApplyIcon(MessageBoxIcon icon) - { - if (icon == MessageBoxIcon.None) return; - - PART_Icon.IsVisible = true; - - (PART_Icon.Text, PART_Icon.Foreground) = icon switch - { - MessageBoxIcon.Information => ("ℹ", Brushes.DodgerBlue), - MessageBoxIcon.Warning => ("⚠", Brushes.Orange), - MessageBoxIcon.Error => ("✖", Brushes.Crimson), - MessageBoxIcon.Question => ("?", Brushes.MediumSlateBlue), - _ => (string.Empty, Brushes.Transparent) - }; - } - /// /// Applies the to the dialog. /// @@ -126,21 +82,35 @@ private void ApplyIcon(MaterialIconKind? icon) private void OnYesClick(object? sender, RoutedEventArgs e) { + _isInternalClose = true; Close(MessageBoxResult.Yes); } private void OnNoClick(object? sender, RoutedEventArgs e) { + _isInternalClose = true; Close(MessageBoxResult.No); } private void OnOkClick(object? sender, RoutedEventArgs e) { + _isInternalClose = true; Close(MessageBoxResult.Ok); } private void OnCancelClick(object? sender, RoutedEventArgs e) { + _isInternalClose = true; + Close(MessageBoxResult.Cancel); + } + private void Window_OnClosing(object? sender, WindowClosingEventArgs e) + { + if (_isInternalClose) + return; + + e.Cancel = true; + + _isInternalClose = true; Close(MessageBoxResult.Cancel); } } \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml b/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml index d48cb0f..a640091 100644 --- a/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml +++ b/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml @@ -3,15 +3,17 @@ xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" x:Class="OpenSSH_GUI.Dialogs.Views.SecureInputDialog" Title="Secure Input" - Width="380" - SizeToContent="Height" + MinWidth="380" + SizeToContent="WidthAndHeight" CanResize="False" + CanMaximize="False" + CanMinimize="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False" - ExtendClientAreaChromeHints="NoChrome" ExtendClientAreaToDecorationsHint="False" - Opened="OnOpened"> - + Opened="OnOpened" + Closing="Window_OnClosing"> + @@ -35,13 +37,13 @@ diff --git a/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml.cs b/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml.cs index 7841943..cf602d0 100644 --- a/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml.cs +++ b/OpenSSH_GUI.Dialogs/Views/SecureInputDialog.axaml.cs @@ -42,8 +42,8 @@ namespace OpenSSH_GUI.Dialogs.Views; /// public partial class SecureInputDialog : Window { + private readonly Encoding _encoding = Encoding.UTF8; private readonly int _maxLength; - private readonly int _minLength; // Each entry represents the UTF-8 encoding of one logical character typed @@ -51,6 +51,8 @@ public partial class SecureInputDialog : Window // character correctly even for multi-byte code points. private readonly List _segments = new(); + private bool _isInternalClose; + /// /// Initialises a new . /// @@ -93,6 +95,7 @@ public SecureInputDialog(SecureInputParams @params) { InitializeComponent(); + _encoding = @params.Encoding; Title = @params.Title; PART_Prompt.Text = @params.Prompt; PART_Prompt.IsVisible = !string.IsNullOrWhiteSpace(@params.Prompt); @@ -121,10 +124,7 @@ public SecureInputDialog(SecureInputParams @params) /// /// Moves keyboard focus to the password field once the window is shown. /// - private void OnOpened(object? sender, EventArgs e) - { - PART_Input.Focus(); - } + private void OnOpened(object? sender, EventArgs e) { PART_Input.Focus(); } // ------------------------------------------------------------------------- // Secure input interception @@ -144,11 +144,7 @@ private void OnInputTextInput(object? sender, TextInputEventArgs e) // Encode each character individually so Backspace can remove exactly // one logical character at a time. - foreach (var ch in e.Text) - { - var encoded = Encoding.UTF8.GetBytes(new[] { ch }); - _segments.Add(encoded); - } + foreach (var encoded in e.Text.Select(ch => _encoding.GetBytes([ch]))) _segments.Add(encoded); SyncDisplay(); HideError(); @@ -181,14 +177,12 @@ private void OnInputKeyDown(object? sender, KeyEventArgs e) // Button handlers // ------------------------------------------------------------------------- - private void OnOkClick(object? sender, RoutedEventArgs e) - { - TryConfirm(); - } + private void OnOkClick(object? sender, RoutedEventArgs e) { TryConfirm(); } private void OnCancelClick(object? sender, RoutedEventArgs e) { ZeroSegments(); + _isInternalClose = true; Close(null); } @@ -211,7 +205,7 @@ private void TryConfirm() var buffer = ConsolidateBuffer(); ZeroSegments(); - + _isInternalClose = true; Close(new SecureInputResult(buffer)); } @@ -274,4 +268,14 @@ private void HideError() PART_Error.IsVisible = false; PART_Error.Text = string.Empty; } + private void Window_OnClosing(object? sender, WindowClosingEventArgs e) + { + if (_isInternalClose) + return; + + e.Cancel = true; + _isInternalClose = true; + Closing -= Window_OnClosing; + Close(null); + } } \ No newline at end of file diff --git a/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml b/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml index 35c2dae..65b2a7c 100644 --- a/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml +++ b/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml @@ -3,15 +3,16 @@ xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" x:Class="OpenSSH_GUI.Dialogs.Views.ValidatedInputDialog" Title="Input" - Width="420" - SizeToContent="Height" + MinWidth="420" + SizeToContent="WidthAndHeight" CanResize="False" + CanMaximize="False" + CanMinimize="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False" - ExtendClientAreaChromeHints="NoChrome" ExtendClientAreaToDecorationsHint="False" - Opened="OnOpened"> - + Opened="OnOpened" Closing="Window_OnClosing"> + @@ -31,12 +32,12 @@ diff --git a/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml.cs b/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml.cs index ffc0e56..3542cd6 100644 --- a/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml.cs +++ b/OpenSSH_GUI.Dialogs/Views/ValidatedInputDialog.axaml.cs @@ -25,6 +25,8 @@ public partial class ValidatedInputDialog : Window { private readonly Func _validator; + private bool _isInternalClose; + /// /// Initialises a new . /// @@ -54,7 +56,7 @@ public ValidatedInputDialog( Title = title; PART_Prompt.Text = prompt; PART_Prompt.IsVisible = !string.IsNullOrWhiteSpace(prompt); - PART_Input.Watermark = watermark; + PART_Input.PlaceholderText = watermark; PART_Input.Text = initialValue; // Subscribe to live text changes for real-time validation. @@ -84,7 +86,7 @@ public ValidatedInputDialog(ValidatedInputParams @params) PART_MaterialIcon.Kind = @params.Icon.Value; } - PART_Input.Watermark = @params.Watermark; + PART_Input.PlaceholderText = @params.Watermark; PART_Input.Text = @params.InitialValue; // Subscribe to live text changes for real-time validation. @@ -108,10 +110,7 @@ private void OnOpened(object? sender, EventArgs e) // Validation // ------------------------------------------------------------------------- - private void OnInputTextChanged(object? sender, TextChangedEventArgs e) - { - Validate(); - } + private void OnInputTextChanged(object? sender, TextChangedEventArgs e) { Validate(); } /// /// Runs the external validator against the current input and updates the @@ -155,13 +154,11 @@ private void OnInputKeyDown(object? sender, KeyEventArgs e) } } - private void OnOkClick(object? sender, RoutedEventArgs e) - { - TryConfirm(); - } + private void OnOkClick(object? sender, RoutedEventArgs e) { TryConfirm(); } private void OnCancelClick(object? sender, RoutedEventArgs e) { + _isInternalClose = true; Close(new ValidatedInputResult(null)); } @@ -180,6 +177,7 @@ private void TryConfirm() return; } + _isInternalClose = true; Close(new ValidatedInputResult(text)); } @@ -194,4 +192,15 @@ private void HideError() PART_Error.IsVisible = false; PART_Error.Text = string.Empty; } + private void Window_OnClosing(object? sender, WindowClosingEventArgs e) + { + if (_isInternalClose) + return; + + e.Cancel = true; + + _isInternalClose = true; + Closing -= Window_OnClosing; + Close(new ValidatedInputResult(null)); + } } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Extensions/SshConfigurationExtensions.cs b/OpenSSH_GUI.SshConfig/Extensions/SshConfigurationExtensions.cs index ca8d72e..b704e5c 100644 --- a/OpenSSH_GUI.SshConfig/Extensions/SshConfigurationExtensions.cs +++ b/OpenSSH_GUI.SshConfig/Extensions/SshConfigurationExtensions.cs @@ -19,11 +19,9 @@ public static class SshConfigurationExtensions /// Path relative to the base path stored in of /// . /// + /// /// The . - public IConfigurationBuilder AddSshConfig(string path, Action? loggingAction = null) - { - return builder.AddSshConfig(null, path, false, false, loggingAction); - } + public IConfigurationBuilder AddSshConfig(string path, Action? loggingAction = null) => builder.AddSshConfig(null, path, false, false, loggingAction); /// /// Adds the SSH configuration file at to the . @@ -33,11 +31,10 @@ public IConfigurationBuilder AddSshConfig(string path, Action /// . /// /// Whether the file is optional. + /// /// The . - public IConfigurationBuilder AddSshConfig(string path, bool optional, Action? loggingAction = null) - { - return builder.AddSshConfig(null, path, optional, false, loggingAction); - } + public IConfigurationBuilder AddSshConfig(string path, bool optional, + Action? loggingAction = null) => builder.AddSshConfig(null, path, optional, false, loggingAction); /// /// Adds the SSH configuration file at to the . @@ -48,12 +45,10 @@ public IConfigurationBuilder AddSshConfig(string path, bool optional, Action /// Whether the file is optional. /// Whether the configuration should be reloaded if the file changes. + /// /// The . public IConfigurationBuilder AddSshConfig(string path, bool optional, - bool reloadOnChange, Action? loggingAction = null) - { - return builder.AddSshConfig(null, path, optional, reloadOnChange, loggingAction); - } + bool reloadOnChange, Action? loggingAction = null) => builder.AddSshConfig(null, path, optional, reloadOnChange, loggingAction); /// /// Adds the SSH configuration file at to the . @@ -65,6 +60,7 @@ public IConfigurationBuilder AddSshConfig(string path, bool optional, /// /// Whether the file is optional. /// Whether the configuration should be reloaded if the file changes. + /// /// The . public IConfigurationBuilder AddSshConfig(IFileProvider? fileProvider, string path, bool optional, bool reloadOnChange, Action? loggingAction = null) @@ -72,22 +68,25 @@ public IConfigurationBuilder AddSshConfig(IFileProvider? fileProvider, ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(path); - return builder.AddSshConfig(s => - { - s.FileProvider = fileProvider; - s.Path = path; - s.Optional = optional; - s.ReloadOnChange = reloadOnChange; - s.ResolveFileProvider(); - }, loggingAction); + return builder.AddSshConfig( + s => + { + s.FileProvider = fileProvider; + s.Path = path; + s.Optional = optional; + s.ReloadOnChange = reloadOnChange; + s.ResolveFileProvider(); + }, loggingAction); } /// /// Adds an SSH configuration source to the . /// /// Configures the source. + /// /// The . - public IConfigurationBuilder AddSshConfig(Action? configureSource, Action? loggingAction) + public IConfigurationBuilder AddSshConfig(Action? configureSource, + Action? loggingAction) { var source = new SshConfigurationSource { diff --git a/OpenSSH_GUI.SshConfig/Extensions/SshHostBlockExtensions.cs b/OpenSSH_GUI.SshConfig/Extensions/SshHostBlockExtensions.cs index 4c69d15..05c4bbe 100644 --- a/OpenSSH_GUI.SshConfig/Extensions/SshHostBlockExtensions.cs +++ b/OpenSSH_GUI.SshConfig/Extensions/SshHostBlockExtensions.cs @@ -13,11 +13,9 @@ public static class SshHostBlockExtensions /// /// The block to convert. /// A type-safe representation of the block. - public static SshHostSettings GetSettings(this SshBlock block) - { - return GetSettingsFromEntries(block.GetEntries(), - block is SshHostBlock hostBlock ? hostBlock.Patterns.ToArray() : null); - } + public static SshHostSettings GetSettings(this SshBlock block) => GetSettingsFromEntries( + block.GetEntries(), + block is SshHostBlock hostBlock ? hostBlock.Patterns.ToArray() : null); /// /// Extracts from a collection of . @@ -94,7 +92,12 @@ public static SshHostBlock WithSettings(this SshHostBlock block, SshHostSettings var handledKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { - "HostName", "User", "Port", "IdentityFile", "ProxyJump", "LocalForward" + "HostName", + "User", + "Port", + "IdentityFile", + "ProxyJump", + "LocalForward" }; var addedHostName = settings.HostName == null; @@ -159,8 +162,8 @@ public static SshHostBlock WithSettings(this SshHostBlock block, SshHostSettings break; } - else if ((item is SshConfigEntry otherEntry && settings.OtherEntries is { Length: > 0 } && - settings.OtherEntries.Contains(otherEntry)) || item is not SshConfigEntry) + else if (item is SshConfigEntry otherEntry && settings.OtherEntries is { Length: > 0 } && + settings.OtherEntries.Contains(otherEntry) || item is not SshConfigEntry) newItems.Add(item); // Add any settings that weren't in the original block @@ -177,11 +180,19 @@ public static SshHostBlock WithSettings(this SshHostBlock block, SshHostSettings // Add new other entries that weren't there if (settings.OtherEntries is not { Length: > 0 }) - return block with { Items = newItems.ToImmutable(), RawHeaderText = string.Empty }; + return block with + { + Items = newItems.ToImmutable(), + RawHeaderText = string.Empty + }; foreach (var oe in settings.OtherEntries) if (!newItems.Contains(oe)) newItems.Add(oe); - return block with { Items = newItems.ToImmutable(), RawHeaderText = string.Empty }; + return block with + { + Items = newItems.ToImmutable(), + RawHeaderText = string.Empty + }; } } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Models/SshBlock.cs b/OpenSSH_GUI.SshConfig/Models/SshBlock.cs index fd16024..52c55dc 100644 --- a/OpenSSH_GUI.SshConfig/Models/SshBlock.cs +++ b/OpenSSH_GUI.SshConfig/Models/SshBlock.cs @@ -93,10 +93,7 @@ public SshHostBlock( int lineNumber, string rawHeaderText, string? headerComment) - : base(items, lineNumber, rawHeaderText, headerComment) - { - Patterns = patterns; - } + : base(items, lineNumber, rawHeaderText, headerComment) => Patterns = patterns; /// /// Gets the hostname patterns declared on the Host header line. @@ -114,16 +111,10 @@ public SshHostBlock( /// /// One or more hostname patterns. /// Optional initial block contents. - public static SshHostBlock Create(IEnumerable patterns, IEnumerable? items = null) - { - return new SshHostBlock([..patterns], [..items ?? []], 0, string.Empty, null); - } + public static SshHostBlock Create(IEnumerable patterns, IEnumerable? items = null) => new([..patterns], [..items ?? []], 0, string.Empty, null); /// - public override string ToString() - { - return $"Host {string.Join(' ', Patterns)}"; - } + public override string ToString() => $"Host {string.Join(' ', Patterns)}"; } /// @@ -142,10 +133,7 @@ public SshMatchBlock( int lineNumber, string rawHeaderText, string? headerComment) - : base(items, lineNumber, rawHeaderText, headerComment) - { - Criteria = criteria; - } + : base(items, lineNumber, rawHeaderText, headerComment) => Criteria = criteria; /// /// Gets the criteria that must all be satisfied simultaneously for this block to apply. @@ -159,14 +147,8 @@ public SshMatchBlock( /// /// One or more match criteria. /// Optional initial block contents. - public static SshMatchBlock Create(IEnumerable criteria, IEnumerable? items = null) - { - return new SshMatchBlock([..criteria], [..items ?? []], 0, string.Empty, null); - } + public static SshMatchBlock Create(IEnumerable criteria, IEnumerable? items = null) => new([..criteria], [..items ?? []], 0, string.Empty, null); /// - public override string ToString() - { - return $"Match {string.Join(' ', Criteria.Select(static c => c.ToString()))}"; - } + public override string ToString() { return $"Match {string.Join(' ', Criteria.Select(static c => c.ToString()))}"; } } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Models/SshConfigDocument.cs b/OpenSSH_GUI.SshConfig/Models/SshConfigDocument.cs index d118be9..2e43bb4 100644 --- a/OpenSSH_GUI.SshConfig/Models/SshConfigDocument.cs +++ b/OpenSSH_GUI.SshConfig/Models/SshConfigDocument.cs @@ -47,12 +47,10 @@ public SshConfigDocument(ImmutableArray globalItems, ImmutableArray public static SshConfigDocument Empty { get; } = new([], []); /// Returns all instances in document order. - public IEnumerable HostBlocks => - Blocks.OfType(); + public IEnumerable HostBlocks => Blocks.OfType(); /// Returns all instances in document order. - public IEnumerable MatchBlocks => - Blocks.OfType(); + public IEnumerable MatchBlocks => Blocks.OfType(); /// /// Returns all items at global scope, @@ -72,8 +70,5 @@ public IEnumerable GetGlobalEntries(string? key = null) /// . /// /// The target hostname to test. - public IEnumerable GetMatchingHostBlocks(string hostname) - { - return HostBlocks.Where(b => SshWildcardMatcher.Matches(hostname.AsSpan(), b.Patterns)); - } + public IEnumerable GetMatchingHostBlocks(string hostname) { return HostBlocks.Where(b => SshWildcardMatcher.Matches(hostname.AsSpan(), b.Patterns)); } } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Models/SshHostSettings.cs b/OpenSSH_GUI.SshConfig/Models/SshHostSettings.cs index 3468b08..d25fc78 100644 --- a/OpenSSH_GUI.SshConfig/Models/SshHostSettings.cs +++ b/OpenSSH_GUI.SshConfig/Models/SshHostSettings.cs @@ -26,9 +26,7 @@ public sealed record SshHostSettings( /// Initializes a new instance of the class. /// Required for the configuration binder. /// - public SshHostSettings() : this([]) - { - } + public SshHostSettings() : this([]) { } /// /// Gets an empty instance. diff --git a/OpenSSH_GUI.SshConfig/Models/SshKnownKeys.cs b/OpenSSH_GUI.SshConfig/Models/SshKnownKeys.cs index 793fd0b..659496f 100644 --- a/OpenSSH_GUI.SshConfig/Models/SshKnownKeys.cs +++ b/OpenSSH_GUI.SshConfig/Models/SshKnownKeys.cs @@ -157,7 +157,8 @@ public static class SshKnownKeys /// (i.e. later occurrences accumulate rather than override earlier ones). /// private static readonly FrozenSet MultiOccurrenceKeys = - FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + FrozenSet.Create( + StringComparer.OrdinalIgnoreCase, "CertificateFile", "DynamicForward", "IdentityFile", @@ -168,7 +169,8 @@ public static class SshKnownKeys /// Keywords that accept multiple space-separated value tokens on a single directive line. /// private static readonly FrozenSet MultiTokenKeys = - FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + FrozenSet.Create( + StringComparer.OrdinalIgnoreCase, "SendEnv", "SetEnv", "Host", @@ -179,35 +181,23 @@ public static class SshKnownKeys /// or returns unchanged if it is not a recognised keyword. /// /// A configuration keyword in any casing. - public static string Normalize(string key) - { - return CanonicalKeys.GetValueOrDefault(key, key); - } + public static string Normalize(string key) => CanonicalKeys.GetValueOrDefault(key, key); /// /// Returns when supports multiple occurrences /// within the same block with additive (accumulative) semantics. /// - public static bool IsMultiOccurrenceKey(string key) - { - return MultiOccurrenceKeys.Contains(key); - } + public static bool IsMultiOccurrenceKey(string key) => MultiOccurrenceKeys.Contains(key); /// /// Returns when accepts multiple /// space-separated value tokens on a single directive line. /// - public static bool IsMultiTokenKey(string key) - { - return MultiTokenKeys.Contains(key); - } + public static bool IsMultiTokenKey(string key) => MultiTokenKeys.Contains(key); /// /// Returns when is a recognised /// ssh_config(5) client keyword. /// - public static bool IsKnownKey(string key) - { - return CanonicalKeys.ContainsKey(key); - } + public static bool IsKnownKey(string key) => CanonicalKeys.ContainsKey(key); } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Models/SshLineItem.cs b/OpenSSH_GUI.SshConfig/Models/SshLineItem.cs index fc29289..d476cb0 100644 --- a/OpenSSH_GUI.SshConfig/Models/SshLineItem.cs +++ b/OpenSSH_GUI.SshConfig/Models/SshLineItem.cs @@ -36,15 +36,10 @@ private protected SshLineItem(int lineNumber, string rawText) public sealed record SshBlankLine : SshLineItem { /// 1-based source line number. - public SshBlankLine(int lineNumber) : base(lineNumber, string.Empty) - { - } + public SshBlankLine(int lineNumber) : base(lineNumber, string.Empty) { } /// Creates a blank line not associated with any source position. - public static SshBlankLine Create() - { - return new SshBlankLine(0); - } + public static SshBlankLine Create() => new(0); } /// @@ -56,10 +51,7 @@ public sealed record SshCommentLine : SshLineItem /// 1-based source line number. /// Original line text. public SshCommentLine(string comment, int lineNumber, string rawText) - : base(lineNumber, rawText) - { - Comment = comment; - } + : base(lineNumber, rawText) => Comment = comment; /// /// Gets the full comment text, including the leading # character @@ -144,8 +136,5 @@ public SshConfigEntry( /// /// Configuration keyword (case-insensitive). /// One or more value tokens. - public static SshConfigEntry Create(string key, params string[] values) - { - return new SshConfigEntry(SshKnownKeys.Normalize(key), [..values], null, 0, string.Empty); - } + public static SshConfigEntry Create(string key, params string[] values) => new(SshKnownKeys.Normalize(key), [..values], null, 0, string.Empty); } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Models/SshMatchCriterion.cs b/OpenSSH_GUI.SshConfig/Models/SshMatchCriterion.cs index bd1ec0a..e2c7a63 100644 --- a/OpenSSH_GUI.SshConfig/Models/SshMatchCriterion.cs +++ b/OpenSSH_GUI.SshConfig/Models/SshMatchCriterion.cs @@ -80,22 +80,13 @@ public sealed record SshMatchCriterion(SshMatchCriterionKind Kind, string? Patte public static SshMatchCriterion Final { get; } = new(SshMatchCriterionKind.Final, null); /// Creates a host criterion with the given pattern. - public static SshMatchCriterion ForHost(string pattern) - { - return new SshMatchCriterion(SshMatchCriterionKind.Host, pattern); - } + public static SshMatchCriterion ForHost(string pattern) => new(SshMatchCriterionKind.Host, pattern); /// Creates a user criterion with the given pattern. - public static SshMatchCriterion ForUser(string pattern) - { - return new SshMatchCriterion(SshMatchCriterionKind.User, pattern); - } + public static SshMatchCriterion ForUser(string pattern) => new(SshMatchCriterionKind.User, pattern); /// Creates an exec criterion with the given shell command. - public static SshMatchCriterion ForExec(string command) - { - return new SshMatchCriterion(SshMatchCriterionKind.Exec, command); - } + public static SshMatchCriterion ForExec(string command) => new(SshMatchCriterionKind.Exec, command); /// public override string ToString() diff --git a/OpenSSH_GUI.SshConfig/OpenSSH_GUI.SshConfig.csproj b/OpenSSH_GUI.SshConfig/OpenSSH_GUI.SshConfig.csproj index ed5af58..e8fd359 100644 --- a/OpenSSH_GUI.SshConfig/OpenSSH_GUI.SshConfig.csproj +++ b/OpenSSH_GUI.SshConfig/OpenSSH_GUI.SshConfig.csproj @@ -1,15 +1,15 @@ - - - false - true - true - OpenSSH_GUI.SshConfig - - - - - - - - + + + false + true + true + OpenSSH_GUI.SshConfig + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Options/SshConfigParserOptions.cs b/OpenSSH_GUI.SshConfig/Options/SshConfigParserOptions.cs index 0f739db..d9af89e 100644 --- a/OpenSSH_GUI.SshConfig/Options/SshConfigParserOptions.cs +++ b/OpenSSH_GUI.SshConfig/Options/SshConfigParserOptions.cs @@ -40,12 +40,12 @@ public sealed record SshConfigParserOptions /// Defaults to . /// public bool ThrowOnUnknownKey { get; init; } - + /// - /// Optional callback invoked when an included file cannot be read due to - /// insufficient permissions or an I/O error. Receives the file path and the - /// causing exception. When , inaccessible files are - /// silently skipped. + /// Optional callback invoked when an included file cannot be read due to + /// insufficient permissions or an I/O error. Receives the file path and the + /// causing exception. When , inaccessible files are + /// silently skipped. /// public Action? OnSkippedIncludeFile { get; init; } @@ -55,5 +55,8 @@ public sealed record SshConfigParserOptions /// /// Gets a strict options instance that throws on any unrecognised keyword. /// - public static SshConfigParserOptions Strict { get; } = new() { ThrowOnUnknownKey = true }; + public static SshConfigParserOptions Strict { get; } = new() + { + ThrowOnUnknownKey = true + }; } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Options/SshSerializerOptions.cs b/OpenSSH_GUI.SshConfig/Options/SshSerializerOptions.cs index b123bb8..34da542 100644 --- a/OpenSSH_GUI.SshConfig/Options/SshSerializerOptions.cs +++ b/OpenSSH_GUI.SshConfig/Options/SshSerializerOptions.cs @@ -56,5 +56,8 @@ public sealed record SshSerializerOptions public static SshSerializerOptions Default { get; } = new(); /// Gets a round-trip options instance that preserves original formatting verbatim. - public static SshSerializerOptions RoundTripMode { get; } = new() { RoundTrip = true }; + public static SshSerializerOptions RoundTripMode { get; } = new() + { + RoundTrip = true + }; } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Parsers/SshConfigParser.cs b/OpenSSH_GUI.SshConfig/Parsers/SshConfigParser.cs index 0b5fa79..a5ccd59 100644 --- a/OpenSSH_GUI.SshConfig/Parsers/SshConfigParser.cs +++ b/OpenSSH_GUI.SshConfig/Parsers/SshConfigParser.cs @@ -50,14 +50,16 @@ public static class SshConfigParser // ───────────────────────────────────────────────────────────────────────── private static readonly FrozenSet MatchKeywords = - FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + FrozenSet.Create( + StringComparer.OrdinalIgnoreCase, "all", "canonical", "final", "exec", "host", "originalhost", "user", "localuser", "tagged", "localnetwork", "address", "group", "localaddress", "localport", "port", "rdomain"); private static readonly FrozenSet NoArgMatchKeywords = - FrozenSet.Create(StringComparer.OrdinalIgnoreCase, + FrozenSet.Create( + StringComparer.OrdinalIgnoreCase, "all", "canonical", "final"); // ───────────────────────────────────────────────────────────────────────── // Public API @@ -101,20 +103,15 @@ public static async Task LoadAsync( /// /// Raw configuration text. /// Parser options, or to use . - public static SshConfigDocument Parse(string content, SshConfigParserOptions? options = null) - { - return ParseDocument(content, null, options ?? SshConfigParserOptions.Default, 0); - } + public static SshConfigDocument Parse(string content, SshConfigParserOptions? options = null) => ParseDocument(content, null, options ?? SshConfigParserOptions.Default, 0); /// /// Parses SSH configuration content from a of characters. /// /// Raw configuration characters. /// Parser options, or to use . - public static SshConfigDocument Parse(ReadOnlySpan content, SshConfigParserOptions? options = null) - { - return ParseDocument(content.ToString(), null, options ?? SshConfigParserOptions.Default, 0); - } + public static SshConfigDocument Parse(ReadOnlySpan content, SshConfigParserOptions? options = null) => + ParseDocument(content.ToString(), null, options ?? SshConfigParserOptions.Default, 0); // ───────────────────────────────────────────────────────────────────────── // Core parse loop @@ -324,13 +321,14 @@ private static ImmutableArray ParseMatchCriteria( if (NoArgMatchKeywords.Contains(keyword)) { - criteria.Add(keyword.ToLowerInvariant() switch - { - "all" => SshMatchCriterion.All, - "canonical" => SshMatchCriterion.Canonical, - "final" => SshMatchCriterion.Final, - _ => throw new UnreachableException() - }); + criteria.Add( + keyword.ToLowerInvariant() switch + { + "all" => SshMatchCriterion.All, + "canonical" => SshMatchCriterion.Canonical, + "final" => SshMatchCriterion.Final, + _ => throw new UnreachableException() + }); i++; } else @@ -499,11 +497,8 @@ private sealed class BlockBuilder public string? HeaderComment { get; init; } public ImmutableArray.Builder Items { get; } = ImmutableArray.CreateBuilder(); - public SshBlock Build() - { - return IsHost - ? new SshHostBlock(HostPatterns, Items.ToImmutable(), LineNumber, RawHeaderText, HeaderComment) - : new SshMatchBlock(MatchCriteria, Items.ToImmutable(), LineNumber, RawHeaderText, HeaderComment); - } + public SshBlock Build() => IsHost + ? new SshHostBlock(HostPatterns, Items.ToImmutable(), LineNumber, RawHeaderText, HeaderComment) + : new SshMatchBlock(MatchCriteria, Items.ToImmutable(), LineNumber, RawHeaderText, HeaderComment); } } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Serializers/SshConfigSerializer.cs b/OpenSSH_GUI.SshConfig/Serializers/SshConfigSerializer.cs index 188cc4b..a0736a0 100644 --- a/OpenSSH_GUI.SshConfig/Serializers/SshConfigSerializer.cs +++ b/OpenSSH_GUI.SshConfig/Serializers/SshConfigSerializer.cs @@ -104,7 +104,7 @@ private static void WriteBlock(StringBuilder sb, SshBlock block, SshSerializerOp if (opts.RoundTrip && block.RawHeaderText.Length > 0) sb.Append(block.RawHeaderText); else - sb.Append(BuildBlockHeader(block, opts)); + sb.Append(BuildBlockHeader(block)); sb.Append(opts.NewLine); @@ -112,7 +112,7 @@ private static void WriteBlock(StringBuilder sb, SshBlock block, SshSerializerOp WriteItem(sb, item, opts.Indent, opts); } - private static string BuildBlockHeader(SshBlock block, SshSerializerOptions opts) + private static string BuildBlockHeader(SshBlock block) { var header = block switch { @@ -172,8 +172,5 @@ private static string BuildEntryLine(SshConfigEntry entry, string indent, SshSer /// Wraps in double quotes when it contains whitespace, /// preserving unquoted values that are already safe. /// - private static string QuoteIfNeeded(string value) - { - return value.AsSpan().ContainsAny(' ', '\t') ? $"\"{value}\"" : value; - } + private static string QuoteIfNeeded(string value) => value.AsSpan().ContainsAny(' ', '\t') ? $"\"{value}\"" : value; } \ No newline at end of file diff --git a/OpenSSH_GUI.SshConfig/Services/SshConfigFileService.cs b/OpenSSH_GUI.SshConfig/Services/SshConfigFileService.cs index 58c3cd8..1ab00e9 100644 --- a/OpenSSH_GUI.SshConfig/Services/SshConfigFileService.cs +++ b/OpenSSH_GUI.SshConfig/Services/SshConfigFileService.cs @@ -22,10 +22,11 @@ public static SshConfiguration LoadFromFile(string filePath) return new SshConfiguration(); var content = File.ReadAllText(filePath); - var document = SshConfigParser.Parse(content, new SshConfigParserOptions - { - IncludeBasePath = Path.GetDirectoryName(filePath) - }); + var document = SshConfigParser.Parse( + content, new SshConfigParserOptions + { + IncludeBasePath = Path.GetDirectoryName(filePath) + }); return MapDocumentToConfiguration(document); } diff --git a/OpenSSH_GUI.SshConfig/Services/SshConfigurationProvider.cs b/OpenSSH_GUI.SshConfig/Services/SshConfigurationProvider.cs index f2b78db..360d111 100644 --- a/OpenSSH_GUI.SshConfig/Services/SshConfigurationProvider.cs +++ b/OpenSSH_GUI.SshConfig/Services/SshConfigurationProvider.cs @@ -15,9 +15,7 @@ public sealed class SshConfigurationProvider : FileConfigurationProvider /// Initializes a new instance of . /// /// The source settings. - public SshConfigurationProvider(SshConfigurationSource source) : base(source) - { - } + public SshConfigurationProvider(SshConfigurationSource source) : base(source) { } /// /// Loads the SSH configuration data from the stream. @@ -31,8 +29,13 @@ public override void Load(Stream stream) // Use the existing parser to parse the content. // We use the file path from the source if available for better error messages. var filePath = Source.Path; - var document = SshConfigParser.Parse(content, - new SshConfigParserOptions { IncludeBasePath = filePath is null ? null : Path.GetDirectoryName(filePath), OnSkippedIncludeFile = Source is SshConfigurationSource source ? source.OnSkippedIncludeFile : null }); + var document = SshConfigParser.Parse( + content, + new SshConfigParserOptions + { + IncludeBasePath = filePath is null ? null : Path.GetDirectoryName(filePath), + OnSkippedIncludeFile = Source is SshConfigurationSource source ? source.OnSkippedIncludeFile : null + }); var data = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/OpenSSH_GUI.SshConfig/Services/SshConfigurationSource.cs b/OpenSSH_GUI.SshConfig/Services/SshConfigurationSource.cs index 78a92b2..5f62349 100644 --- a/OpenSSH_GUI.SshConfig/Services/SshConfigurationSource.cs +++ b/OpenSSH_GUI.SshConfig/Services/SshConfigurationSource.cs @@ -8,13 +8,13 @@ namespace OpenSSH_GUI.SshConfig.Services; public sealed class SshConfigurationSource : FileConfigurationSource { /// - /// Optional callback invoked when an included file cannot be read due to - /// insufficient permissions or an I/O error. Receives the file path and the - /// causing exception. When , inaccessible files are - /// silently skipped. + /// Optional callback invoked when an included file cannot be read due to + /// insufficient permissions or an I/O error. Receives the file path and the + /// causing exception. When , inaccessible files are + /// silently skipped. /// public Action? OnSkippedIncludeFile { get; init; } - + /// /// Builds the for this source. /// diff --git a/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs index 8a01f05..9afac9c 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/ServiceCollectionExtensionsTests.cs @@ -1,9 +1,6 @@ using Avalonia.Controls; using Avalonia.Threading; -using DryIoc; -using DryIoc.Microsoft.DependencyInjection; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Core.MVVM; using Xunit; @@ -16,17 +13,18 @@ public class DependencyInjectionExtensionsTests public void RegisterViewWithViewModel_ValidNaming_ShouldRegister() { // Arrange - var services = new Container(); + var serviceCollection = new ServiceCollection(); // Act - services.RegisterViewWithViewModel(); - var provider = services.BuildServiceProvider(); + serviceCollection.RegisterViewWithViewModel(); + + var services = serviceCollection.BuildServiceProvider(); // Assert Dispatcher.UIThread.Invoke(() => { - Assert.NotNull(provider.GetKeyedService("MockWindow")); - Assert.NotNull(provider.GetKeyedService("MockWindowViewModel")); + Assert.NotNull(services.GetKeyedService(nameof(MockWindow))); + Assert.NotNull(services.GetKeyedService(nameof(MockWindowViewModel))); }); } @@ -34,22 +32,15 @@ public void RegisterViewWithViewModel_ValidNaming_ShouldRegister() public void RegisterViewWithViewModel_InvalidNaming_ShouldThrow() { // Arrange - var services = new Container(); + var services = new ServiceCollection(); // Act & Assert - Assert.Throws(() => - services.RegisterViewWithViewModel()); + Assert.Throws(() => services.RegisterViewWithViewModel()); } - private class MockWindow : Window - { - } + private class MockWindow : Window; - private class MockWindowViewModel() : ViewModelBase(NullLogger.Instance) - { - } + private class MockWindowViewModel : ViewModelBase; - private class InvalidVm() : ViewModelBase(NullLogger.Instance) - { - } + private class InvalidVm : ViewModelBase; } \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs index 6aea5b5..f0d8f8b 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshConfigFilesExtensionTests.cs @@ -7,28 +7,13 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class SshConfigFilesExtensionTests { - [Theory] - [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, "%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")] - public void GetBaseSshPath_Tests(PlatformID platform, bool resolve, string expected) - { - SshConfigFilesExtension.GetBaseSshPath(resolve, platform).ShouldBe(expected); - } + [Theory, 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")] - [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); - } + [Theory, 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 3f9986c..8960a62 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyFormatExtensionTests.cs @@ -7,21 +7,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")] - [InlineData(SshKeyFormat.PuTTYv3, true, ".ppk")] - public void GetExtension_Tests(SshKeyFormat format, bool isPublic, string? expected) - { - format.GetExtension(isPublic).ShouldBe(expected); - } + [Theory, 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")] - public void ChangeExtension_Tests(SshKeyFormat format, string path, bool isPublic, string expected) - { - format.ChangeExtension(path, isPublic).ShouldBe(expected); - } + [Theory, 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 c154ecf..2e762d7 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/SshKeyTypeExtensionTests.cs @@ -6,10 +6,7 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class SshKeyTypeExtensionTests { - [Theory] - [InlineData(SshKeyType.RSA)] - [InlineData(SshKeyType.ECDSA)] - [InlineData(SshKeyType.ED25519)] + [Theory, 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 31e6834..a99e794 100644 --- a/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs +++ b/OpenSSH_GUI.Tests/Core/Extensions/StringExtensionsTests.cs @@ -6,33 +6,17 @@ namespace OpenSSH_GUI.Tests.Core.Extensions; public class StringExtensionsTests { - [Theory] - [InlineData("HelloWorld", "hello_world")] - public void ToSnakeCase_Tests(string input, string expected) - { - input.ToSnakeCase().ShouldBe(expected); - } + [Theory, InlineData("HelloWorld", "hello_world")] + public void ToSnakeCase_Tests(string input, string expected) { input.ToSnakeCase().ShouldBe(expected); } - [Theory] - [InlineData("HelloWorld", "helloWorld")] - public void ToCamelCase_Tests(string input, string expected) - { - input.ToCamelCase().ShouldBe(expected); - } + [Theory, InlineData("HelloWorld", "helloWorld")] + public void ToCamelCase_Tests(string input, string expected) { input.ToCamelCase().ShouldBe(expected); } - [Theory] - [InlineData("HelloWorld", "hello-world")] - public void ToKebabCase_Tests(string input, string expected) - { - input.ToKebabCase().ShouldBe(expected); - } + [Theory, InlineData("HelloWorld", "hello-world")] + public void ToKebabCase_Tests(string input, string expected) { input.ToKebabCase().ShouldBe(expected); } - [Theory] - [InlineData("hello world", "HelloWorld")] - public void ToPascalCase_Tests(string input, string expected) - { - input.ToPascalCase().ShouldBe(expected); - } + [Theory, InlineData("hello world", "HelloWorld")] + public void ToPascalCase_Tests(string input, string expected) { input.ToPascalCase().ShouldBe(expected); } [Fact] public void SplitToChunks_Tests() @@ -49,22 +33,13 @@ public void Wrap_Tests() } [Fact] - public void ToTitleCase_Tests() - { - "this is a title".ToTitleCase().ShouldBe("This Is A Title"); - } + public void ToTitleCase_Tests() { "this is a title".ToTitleCase().ShouldBe("This Is A Title"); } [Fact] - public void ToSentenceCase_Tests() - { - "THIS IS A SENTENCE.".ToSentenceCase().ShouldBe("This is a sentence."); - } + public void ToSentenceCase_Tests() { "THIS IS A SENTENCE.".ToSentenceCase().ShouldBe("This is a sentence."); } [Fact] - public void ToLeetSpeak_Tests() - { - "leetspeak".ToLeetSpeak().ShouldBe("l33t5p34k"); - } + public void ToLeetSpeak_Tests() { "leetspeak".ToLeetSpeak().ShouldBe("l33t5p34k"); } [Fact] public void ToStudlyCaps_Tests() diff --git a/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs b/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs index ff08f95..273217a 100644 --- a/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs +++ b/OpenSSH_GUI.Tests/Core/MVVM/ViewModelBaseTests.cs @@ -1,5 +1,4 @@ using System.Reactive.Linq; -using Microsoft.Extensions.Logging.Abstractions; using OpenSSH_GUI.Core.MVVM; using Xunit; @@ -14,7 +13,7 @@ public async Task InitializeAsync_ShouldSetIsInitialized() var vm = new TestViewModel(); // Act - await vm.InitializeAsync(cancellationToken: TestContext.Current.CancellationToken); + await vm.InitializeAsync(TestContext.Current.CancellationToken); // Assert Assert.True(vm.IsInitialized); @@ -27,7 +26,7 @@ public async Task BooleanSubmit_ShouldCallOnBooleanSubmitAsync() var vm = new TestViewModel(); // Act - await vm.BooleanSubmit.Execute(true).FirstAsync(); + await vm.BooleanSubmitCommand.Execute(true).FirstAsync(); // Assert Assert.True(vm.OnBooleanSubmitCalled); @@ -41,7 +40,7 @@ public void RequestClose_ShouldInvokeCloseEvent() // Arrange var vm = new TestViewModel(); var closeInvoked = false; - vm.Close += (s, e) => closeInvoked = true; + vm.Close += (_, _) => closeInvoked = true; // Act vm.TriggerClose(); @@ -51,21 +50,18 @@ public void RequestClose_ShouldInvokeCloseEvent() Assert.False(vm.IsInitialized); } - private class TestViewModel() : ViewModelBase(NullLogger.Instance) + private class TestViewModel : ViewModelBase { public bool OnBooleanSubmitCalled { get; private set; } public bool InputParam { get; private set; } - protected override Task OnBooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) + protected override Task BooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) { OnBooleanSubmitCalled = true; InputParam = inputParameter; return Task.CompletedTask; } - public void TriggerClose() - { - RequestClose(); - } + public void TriggerClose() { RequestClose(); } } } \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj b/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj index 85c4d73..746a06f 100644 --- a/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj +++ b/OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj @@ -1,38 +1,34 @@ - - false - true - Exe - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - + + false + true + Exe + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs b/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs index cee200e..87cc1d2 100644 --- a/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs +++ b/OpenSSH_GUI.Tests/ReactiveUiInitFixture.cs @@ -6,20 +6,20 @@ 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 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, and deadlocks -/// without a running message loop. +/// 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(); + private readonly CancellationTokenSource _cts = new(); + private readonly ManualResetEventSlim _initialized = new(); public ReactiveUiInitFixture() { @@ -33,17 +33,17 @@ public ReactiveUiInitFixture() .WithCoreServices() .BuildApp(); - initialized.Set(); + _initialized.Set(); - Dispatcher.UIThread.MainLoop(cts.Token); + Dispatcher.UIThread.MainLoop(_cts.Token); }); uiThread.IsBackground = true; uiThread.Start(); - initialized.Wait(); + _initialized.Wait(); } /// - public void Dispose() => cts.Cancel(); + 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 3a5f516..99a716f 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigParserTests.cs @@ -1,8 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; -using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; -using OpenSSH_GUI.Core.Lib.Credentials; +using OpenSSH_GUI.Core.Lib.Misc; using OpenSSH_GUI.SshConfig.Exceptions; using OpenSSH_GUI.SshConfig.Extensions; using OpenSSH_GUI.SshConfig.Models; @@ -15,11 +14,8 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshConfigParserTests { - private IFileProvider GetEmbeddedFileProvider() - { - return new EmbeddedFileProvider(typeof(SshConfigParserTests).Assembly, "OpenSSH_GUI.Tests.Assets.Testfiles"); - } - + private IFileProvider GetEmbeddedFileProvider() => new EmbeddedFileProvider(typeof(SshConfigParserTests).Assembly, "OpenSSH_GUI.Tests.Assets.Testfiles"); + private string GetEmbeddedResource(string fileName) { var assembly = typeof(SshConfigParserTests).Assembly; @@ -39,12 +35,10 @@ public void Parse_GlobalConfig_ShouldParseEmbeddedFile() var doc = SshConfigParser.Parse(content); doc.Blocks.Length.ShouldBeGreaterThan(0); - // "Host *" sollte vorhanden sein var allHosts = doc.Blocks.OfType().ToList(); allHosts.ShouldContain(b => b.Patterns.Contains("*")); - // Suche nach "ConnectTimeout 20" im globalen Kontext oder im Host * Block - var globalEntries = doc.GetGlobalEntries().ToArray(); + _ = doc.GetGlobalEntries().ToArray(); var hostStar = allHosts.FirstOrDefault(b => b.Patterns.Contains("*")); hostStar.ShouldNotBeNull(); hostStar.GetEntries().ShouldContain(e => e.Key == "ConnectTimeout" && e.Value == "20"); @@ -60,10 +54,10 @@ public void Parse_PersonalConfig_Into_Config_DependencyInjection() var ss = configurationRoot.GetSection("SshConfig").Get(); Assert.NotNull(ss); - + var ifsCount = ss.Hosts.Where(host => host.IdentityFiles is not null).Sum(host => host.IdentityFiles?.Length); ifsCount.ShouldNotBe(null); - if(ifsCount is { } count) + if (ifsCount is { } count) count.ShouldBeGreaterThan(0); } @@ -105,7 +99,7 @@ public void Parse_SshdServerConfig_ShouldParseEmbeddedFile() [Fact] public void Parse_EmptyContent_ShouldReturnEmptyDocument() { - var doc = SshConfigParser.Parse(""); + var doc = SshConfigParser.Parse(string.Empty); doc.GlobalItems.ShouldAllBe(i => i is SshBlankLine); doc.Blocks.ShouldBeEmpty(); } @@ -243,7 +237,10 @@ public void Parse_IncludeRecursion_ShouldThrow() // Arrange var content = "Include recursive.conf"; var options = new SshConfigParserOptions - { MaxIncludeDepth = 1, IncludeBasePath = Directory.GetCurrentDirectory() }; + { + MaxIncludeDepth = 1, + IncludeBasePath = Directory.GetCurrentDirectory() + }; var recursiveFile = Path.Combine(Directory.GetCurrentDirectory(), "recursive.conf"); File.WriteAllText(recursiveFile, "Include recursive.conf"); @@ -283,6 +280,7 @@ public void Parse_InvalidPort_ShouldBeHandledInSettings() // Assert Assert.Null(settings.Port); // Note: In SshHostBlockExtensions.GetSettings, unparseable "Port" is added to otherEntries + Assert.NotNull(settings.OtherEntries); Assert.Single(settings.OtherEntries); Assert.Equal("Port", settings.OtherEntries[0].Key); } @@ -301,6 +299,7 @@ public void Parse_QuotedValues_ShouldStripDoubleQuotes() // Assert Assert.Equal("quoted server", block.Patterns[0]); Assert.Equal("alice", settings.User); + Assert.NotNull(settings.IdentityFiles); Assert.Contains("~/.ssh/id rsa", settings.IdentityFiles); } diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs index 5bcaaec..2aa1a0d 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigSerializerTests.cs @@ -17,7 +17,11 @@ public void Serialize_SimpleDocument_ShouldProduceCorrectOutput() [SshHostBlock.Create(["example"], [SshConfigEntry.Create("User", "alice")])] ); - var output = SshConfigSerializer.Serialize(doc, new SshSerializerOptions { Indent = " " }); + var output = SshConfigSerializer.Serialize( + doc, new SshSerializerOptions + { + Indent = " " + }); output.ShouldContain("VisualHostKey yes"); output.ShouldContain("Host example"); @@ -38,7 +42,10 @@ public void Serialize_RoundTrip_ShouldPreserveFormatting() [Fact] public void Serialize_MatchBlock_ShouldProduceCorrectOutput() { - var criteria = new[] { SshMatchCriterion.ForHost("example.com"), SshMatchCriterion.ForUser("root") }; + var criteria = new[] + { + SshMatchCriterion.ForHost("example.com"), SshMatchCriterion.ForUser("root") + }; var doc = new SshConfigDocument( [], [SshMatchBlock.Create(criteria, [SshConfigEntry.Create("Port", "22")])] @@ -81,11 +88,25 @@ public void Serialize_RoundTrip_WithModifications_ShouldRegenerate() var entry = block.GetEntries("User").First(); // Modify entry and clear RawText to force regeneration - var modifiedEntry = entry with { Values = ["bob"], RawText = string.Empty }; - var modifiedBlock = block with { Items = [modifiedEntry], RawHeaderText = string.Empty }; - var modifiedDoc = doc with { Blocks = [modifiedBlock] }; + var modifiedEntry = entry with + { + Values = ["bob"], + RawText = string.Empty + }; + var modifiedBlock = block with + { + Items = [modifiedEntry], + RawHeaderText = string.Empty + }; + var modifiedDoc = doc with + { + Blocks = [modifiedBlock] + }; - var options = new SshSerializerOptions { RoundTrip = true }; + var options = new SshSerializerOptions + { + RoundTrip = true + }; // Act var output = SshConfigSerializer.Serialize(modifiedDoc, options); diff --git a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs index 33c4f3c..2c22f27 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshConfigurationBindingTests.cs @@ -9,10 +9,7 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshConfigurationBindingTests { - private static IFileProvider GetEmbeddedFileProvider() - { - return new EmbeddedFileProvider(typeof(SshConfigParserTests).Assembly, "OpenSSH_GUI.Tests.Assets.Testfiles"); - } + private static IFileProvider GetEmbeddedFileProvider() => new EmbeddedFileProvider(typeof(SshConfigParserTests).Assembly, "OpenSSH_GUI.Tests.Assets.Testfiles"); [Fact] public void AddSshConfig_ShouldBeBindableToObjects() @@ -40,10 +37,8 @@ public void AddSshConfig_ShouldBeBindableToObjects() path = path.Length == 1 ? home : Path.Combine(home, path[2..]); return Path.GetFullPath(path); })) - { if (!possibleKeyFiles.Any(e => e.Equals(hostIdentityFile, StringComparison.OrdinalIgnoreCase))) possibleKeyFiles.Add(hostIdentityFile); - } } possibleKeyFiles.ShouldNotBeEmpty(); diff --git a/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs index d46e911..79f946a 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshHostSettingsTests.cs @@ -62,8 +62,10 @@ public void WithSettings_ShouldUpdateBlock() Assert.Equal("new.example.com", reserializedSettings.HostName); Assert.Equal("bob", reserializedSettings.User); Assert.Equal(22, reserializedSettings.Port); + Assert.NotNull(reserializedSettings.IdentityFiles); Assert.Single(reserializedSettings.IdentityFiles); Assert.Equal("~/.ssh/id_new", reserializedSettings.IdentityFiles[0]); + Assert.NotNull(reserializedSettings.LocalForwards); Assert.Single(reserializedSettings.LocalForwards); Assert.Equal("9000 localhost:90", reserializedSettings.LocalForwards[0]); } diff --git a/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs index 9baab90..86fb65f 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshKnownKeysTests.cs @@ -6,37 +6,15 @@ namespace OpenSSH_GUI.Tests.SshConfig; public class SshKnownKeysTests { - [Theory] - [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("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)] - public void IsMultiOccurrenceKey_Tests(string key, bool expected) - { - SshKnownKeys.IsMultiOccurrenceKey(key).ShouldBe(expected); - } + [Theory, 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)] - public void IsMultiTokenKey_Tests(string key, bool expected) - { - SshKnownKeys.IsMultiTokenKey(key).ShouldBe(expected); - } + [Theory, 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)] - public void IsKnownKey_Tests(string key, bool expected) - { - SshKnownKeys.IsKnownKey(key).ShouldBe(expected); - } + [Theory, 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/SshWildcardMatcherTests.cs b/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs index 3d6df75..b52c5e0 100644 --- a/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs +++ b/OpenSSH_GUI.Tests/SshConfig/SshWildcardMatcherTests.cs @@ -6,41 +6,45 @@ 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)] - [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("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("host1", new[] { "host1", "host2" }, true)] - [InlineData("host2", new[] { "host1", "host2" }, true)] - [InlineData("host3", new[] { "host1", "host2" }, false)] - [InlineData("host1", new[] { "!host1", "host*" }, false)] - [InlineData("host2", new[] { "!host1", "host*" }, true)] - [InlineData("host1", new[] { "host*", "!host1" }, false)] - [InlineData("host1", new string[] { }, false)] - [InlineData("host1", new[] { "" }, false)] - public void Matches_Tests(string hostname, string[] patterns, bool expected) - { - SshWildcardMatcher.Matches(hostname.AsSpan(), patterns).ShouldBe(expected); - } + [Theory, InlineData( + "host1", new[] + { + "host1", "host2" + }, true), InlineData( + "host2", new[] + { + "host1", "host2" + }, true), InlineData( + "host3", new[] + { + "host1", "host2" + }, false), + InlineData( + "host1", new[] + { + "!host1", "host*" + }, false), InlineData( + "host2", new[] + { + "!host1", "host*" + }, true), InlineData( + "host1", new[] + { + "host*", "!host1" + }, false), + InlineData( + "host1", new string[] + { + }, false), InlineData( + "host1", new[] + { + "" + }, false)] + public void Matches_Tests(string hostname, string[] patterns, bool expected) { SshWildcardMatcher.Matches(hostname.AsSpan(), patterns).ShouldBe(expected); } } \ No newline at end of file diff --git a/OpenSSH_GUI.sln.DotSettings b/OpenSSH_GUI.sln.DotSettings new file mode 100644 index 0000000..e5e3410 --- /dev/null +++ b/OpenSSH_GUI.sln.DotSettings @@ -0,0 +1,115 @@ + + HINT + SUGGESTION + <?xml version="1.0" encoding="utf-16"?><Profile name="Full Cleanup Custom"><CppReformatCode>True</CppReformatCode><FSharpReformatCode>True</FSharpReformatCode><ShaderLabReformatCode>True</ShaderLabReformatCode><XMLReformatCode>True</XMLReformatCode><VBReformatCode>True</VBReformatCode><CSReformatCode>True</CSReformatCode><CSharpReformatComments>True</CSharpReformatComments><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeAccessors="True" ArrangeArgumentsStyle="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeEmptyString="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CppCodeStyleCleanupDescriptor ArrangeBraces="True" ArrangeAuto="True" ArrangeFunctionDeclarations="True" ArrangeNestedNamespaces="True" ArrangeTypeAliases="True" ArrangeCVQualifiers="True" ArrangeSlashesInIncludeDirectives="True" ArrangeOverridingFunctions="True" SortDefinitions="True" SortIncludeDirectives="True" SortMemberInitializers="True" /><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CSReformatInactiveBranches>True</CSReformatInactiveBranches><CSharpFormatDocComments>True</CSharpFormatDocComments><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSReorderTypeMembers>True</CSReorderTypeMembers><CSShortenReferences>True</CSShortenReferences><VBOptimizeImports>True</VBOptimizeImports><VBShortenReferences>True</VBShortenReferences><Xaml.RemoveRedundantNamespaceAlias>True</Xaml.RemoveRedundantNamespaceAlias><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><IDEA_SETTINGS>&lt;profile version="1.0"&gt; + &lt;option name="myName" value="Full Cleanup Custom" /&gt; + &lt;inspection_tool class="ConditionalExpressionWithIdenticalBranchesJS" enabled="true" level="WARNING" enabled_by_default="true" /&gt; + &lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="true" level="WARNING" enabled_by_default="true" /&gt; + &lt;inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="true" level="WARNING" enabled_by_default="true" /&gt; + &lt;inspection_tool class="JSRemoveUnnecessaryParentheses" enabled="true" level="WARNING" enabled_by_default="true" /&gt; + &lt;inspection_tool class="UnterminatedStatementJS" enabled="true" level="WARNING" enabled_by_default="true" /&gt; + &lt;inspection_tool class="WrongPropertyKeyValueDelimiter" enabled="true" level="WARNING" enabled_by_default="true" /&gt; +&lt;/profile&gt;</IDEA_SETTINGS><RIDER_SETTINGS>&lt;profile&gt; + &lt;Language id=""&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;/Language&gt; + &lt;Language id="CMake"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="CSS"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;/Language&gt; + &lt;Language id="EditorConfig"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="HTML"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;/Language&gt; + &lt;Language id="HTTP Request"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Handlebars"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Ini"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="JSON"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Jade"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="JavaScript"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;/Language&gt; + &lt;Language id="Markdown"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Properties"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="RELAX-NG"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="Razor"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="SQL"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="VueExpr"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; + &lt;Language id="XML"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; + &lt;Rearrange&gt;true&lt;/Rearrange&gt; + &lt;/Language&gt; + &lt;Language id="yaml"&gt; + &lt;Reformat&gt;true&lt;/Reformat&gt; + &lt;/Language&gt; +&lt;/profile&gt;</RIDER_SETTINGS><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CppAddTypenameTemplateKeywords>True</CppAddTypenameTemplateKeywords><CppCStyleToStaticCastDescriptor>True</CppCStyleToStaticCastDescriptor><CppRedundantDereferences>True</CppRedundantDereferences><CppDeleteRedundantAccessSpecifier>True</CppDeleteRedundantAccessSpecifier><CppRemoveCastDescriptor>True</CppRemoveCastDescriptor><CppRemoveElseKeyword>True</CppRemoveElseKeyword><CppShortenQualifiedName>True</CppShortenQualifiedName><CppDeleteRedundantSpecifier>True</CppDeleteRedundantSpecifier><CppRemoveStatement>True</CppRemoveStatement><CppDeleteRedundantTypenameTemplateKeywords>True</CppDeleteRedundantTypenameTemplateKeywords><CppReplaceExpressionWithBooleanConst>True</CppReplaceExpressionWithBooleanConst><CppMakeIfConstexpr>True</CppMakeIfConstexpr><CppMakePostfixOperatorPrefix>True</CppMakePostfixOperatorPrefix><CppMakeVariableConstexpr>True</CppMakeVariableConstexpr><CppChangeSmartPointerToMakeFunction>True</CppChangeSmartPointerToMakeFunction><CppReplaceThrowWithRethrowFix>True</CppReplaceThrowWithRethrowFix><CppTypeTraitAliasDescriptor>True</CppTypeTraitAliasDescriptor><CppRemoveRedundantConditionalExpressionDescriptor>True</CppRemoveRedundantConditionalExpressionDescriptor><CppSimplifyConditionalExpressionDescriptor>True</CppSimplifyConditionalExpressionDescriptor><CppReplaceExpressionWithNullptr>True</CppReplaceExpressionWithNullptr><CppReplaceTieWithStructuredBindingDescriptor>True</CppReplaceTieWithStructuredBindingDescriptor><CppUseAssociativeContainsDescriptor>True</CppUseAssociativeContainsDescriptor><CppUseEraseAlgorithmDescriptor>True</CppUseEraseAlgorithmDescriptor><CppJoinDeclarationAndAssignmentDescriptor>True</CppJoinDeclarationAndAssignmentDescriptor><CppMakeClassFinal>True</CppMakeClassFinal><CppMakeLocalVarConstDescriptor>True</CppMakeLocalVarConstDescriptor><CppMakeMethodConst>True</CppMakeMethodConst><CppMakeMethodStatic>True</CppMakeMethodStatic><CppMakePtrOrRefParameterConst>True</CppMakePtrOrRefParameterConst><CppMakeParameterConst>True</CppMakeParameterConst><CppPassValueParameterByConstReference>True</CppPassValueParameterByConstReference><CppRemoveElaboratedTypeSpecifierDescriptor>True</CppRemoveElaboratedTypeSpecifierDescriptor><CppRemoveRedundantLambdaParameterListDescriptor>True</CppRemoveRedundantLambdaParameterListDescriptor><CppRemoveRedundantMemberInitializerDescriptor>True</CppRemoveRedundantMemberInitializerDescriptor><CppRemoveRedundantParentheses>True</CppRemoveRedundantParentheses><CppRemoveTemplateArgumentsDescriptor>True</CppRemoveTemplateArgumentsDescriptor><CppRemoveUnreachableCode>True</CppRemoveUnreachableCode><CppRemoveUnusedIncludes>True</CppRemoveUnusedIncludes><CppRemoveUnusedLambdaCaptures>True</CppRemoveUnusedLambdaCaptures><CppReplaceIfWithIfConsteval>True</CppReplaceIfWithIfConsteval><RemoveCodeRedundanciesVB>True</RemoveCodeRedundanciesVB><VBMakeFieldReadonly>True</VBMakeFieldReadonly><Xaml.RedundantFreezeAttribute>True</Xaml.RedundantFreezeAttribute><Xaml.RemoveRedundantModifiersAttribute>True</Xaml.RemoveRedundantModifiersAttribute><Xaml.RemoveRedundantNameAttribute>True</Xaml.RemoveRedundantNameAttribute><Xaml.RemoveRedundantResource>True</Xaml.RemoveRedundantResource><Xaml.RemoveRedundantCollectionProperty>True</Xaml.RemoveRedundantCollectionProperty><Xaml.RemoveRedundantAttachedPropertySetter>True</Xaml.RemoveRedundantAttachedPropertySetter><Xaml.RemoveRedundantStyledValue>True</Xaml.RemoveRedundantStyledValue><Xaml.RemoveForbiddenResourceName>True</Xaml.RemoveForbiddenResourceName><Xaml.RemoveRedundantGridDefinitionsAttribute>True</Xaml.RemoveRedundantGridDefinitionsAttribute><Xaml.RemoveRedundantUpdateSourceTriggerAttribute>True</Xaml.RemoveRedundantUpdateSourceTriggerAttribute><Xaml.RemoveRedundantBindingModeAttribute>True</Xaml.RemoveRedundantBindingModeAttribute><Xaml.RemoveRedundantGridSpanAttribut>True</Xaml.RemoveRedundantGridSpanAttribut></Profile> + ExpressionBody + NotRequired + False + ExpressionBody + StringEmpty + Join + ExpressionBody + ExpressionBody + public file private required internal new protected static abstract sealed override async extern unsafe volatile virtual readonly + Remove + 0 + 0 + 0 + 0 + 1 + True + True + False + NEVER + ALWAYS + ALWAYS + NEVER + False + False + True + False + False + True + True + True + 182 + CHOP_IF_LONG + CHOP_ALWAYS + + True + True + True + True \ No newline at end of file diff --git a/OpenSSH_GUI.slnx b/OpenSSH_GUI.slnx index 17e92e4..08d6113 100644 --- a/OpenSSH_GUI.slnx +++ b/OpenSSH_GUI.slnx @@ -14,31 +14,13 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/OpenSSH_GUI/App.axaml b/OpenSSH_GUI/App.axaml index 1e39dfa..f58e492 100644 --- a/OpenSSH_GUI/App.axaml +++ b/OpenSSH_GUI/App.axaml @@ -3,17 +3,108 @@ x:Class="OpenSSH_GUI.App" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" RequestedThemeVariant="Default"> - + + + + + + + + + + + + + + + 14 + 14 + 18 - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/App.axaml.cs b/OpenSSH_GUI/App.axaml.cs index b93c6c6..2fc7291 100644 --- a/OpenSSH_GUI/App.axaml.cs +++ b/OpenSSH_GUI/App.axaml.cs @@ -1,21 +1,71 @@ +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using DryIoc; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Configuration; +using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Resources; using OpenSSH_GUI.Core.Services; using OpenSSH_GUI.ViewModels; using OpenSSH_GUI.Views; +using Renci.SshNet; +using Serilog.Core; +using SkiaSharp; +using Svg.Skia; namespace OpenSSH_GUI; -public class App(ILogger logger, IResolver resolver) : Application +internal class DoubleToleranceComparer(double epsilon) : IEqualityComparer { + public bool Equals(double x, double y) => Math.Abs(x - y) < epsilon; + + public int GetHashCode(double obj) => 0; +} + +[UsedImplicitly] +public class App( + ILogger logger, + IServiceProvider serviceProvider, + AppIconStore iconStore, + IHostApplicationLifetime hostApplicationLifetime) : Application +{ + private const string RessourceUri = "avares://OpenSSH_GUI/Assets/openssh-gui{0}.svg"; + private const string Underline = "_"; + internal const string SystemFontSize = "SystemFontSize"; + private const string BaseFontSize = "BaseFontSize"; + private const string MaterialIconSize = "MaterialIconSize"; + private static readonly CompositeDisposable Disposables = new(); + + private static readonly Dictionary IconSizes = new() + { + { 16, 16 }, + { 32, 32 }, + { 48, 48 }, + { 64, 64 }, + { 128, 128 }, + { 256, 256 }, + { 512, 512 } + }; + public override void Initialize() { AvaloniaXamlLoader.Load(this); + +#if DEBUG + this.AttachDeveloperTools(); +#endif } public override async void OnFrameworkInitializationCompleted() @@ -23,31 +73,148 @@ public override async void OnFrameworkInitializationCompleted() try { base.OnFrameworkInitializationCompleted(); - if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; - desktop.MainWindow = await resolver.ResolveViewAsync(); - logger.LogInformation("MainWindow created"); - desktop.MainWindow.Opened += OnMainWindowOpened; + SshNetLoggingConfiguration.InitializeLogging(serviceProvider.GetRequiredService()); + try + { + foreach (var variant in new[] + { + ThemeVariant.Light, ThemeVariant.Dark + }) + { + foreach (var (width, height) in IconSizes) + { + await using var svgStream = AssetLoader.Open( + new Uri( + string.Format(RessourceUri, variant is ThemeVariant.Light ? "-light" : string.Empty))); + var memoryStream = new MemoryStream(); + using var svg = new SKSvg(); + svg.Load(svgStream); + var bitmap = new SKBitmap(width, height, true); + using (var canvas = new SKCanvas(bitmap)) + { + if (Math.Min( + width / (svg.Picture?.CullRect.Width ?? width), + height / (svg.Picture?.CullRect.Height ?? height)) is var scale and > 0) + canvas.Scale(scale); + canvas.Clear(SKColors.Transparent); + canvas.DrawPicture(svg.Picture); + } + + using (var data = SKImage.FromBitmap(bitmap)) + { + if (data != null) + { + using var dataToEncode = data.Encode(SKEncodedImageFormat.Png, 100); + if (dataToEncode is null) continue; + memoryStream.Write(dataToEncode.AsSpan()); + memoryStream.Seek(0, SeekOrigin.Begin); + } + } + + var bm = new Bitmap(memoryStream); + var bitmapKey = string.Join(Underline, nameof(Bitmap), width, variant).ToLower(); + iconStore.AddBitmap(bitmapKey, bm); + } + + var iconKey = string.Join(Underline, nameof(WindowIcon), 32, variant).ToLower(); + var bitmapRef = iconStore.GetBitmap(string.Join(Underline, nameof(Bitmap), 32, variant).ToLower()); + if (bitmapRef is not null) + iconStore.AddWindowIcon(iconKey, new WindowIcon(bitmapRef)); + } + } + catch (Exception e) + { + logger.LogError(e, "Error creating app icons"); + throw; + } + + try + { + ApplyConfiguration(); + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; + desktop.MainWindow = await serviceProvider.ResolveViewAsync(); + + if (Current is not null) + { + Current.Resources + .GetResourceObservable(SystemFontSize) + .Select(fs => fs as double?) + .Where(fs => fs.HasValue) + .Select(fs => fs!.Value) + .DistinctUntilChanged(new DoubleToleranceComparer(0.1)) + .Subscribe(FontSizeChanged) + .DisposeWith(Disposables); + if (Current.TryFindResource(BaseFontSize, out var fontSize) && + fontSize is double fontSizeValueDouble) + { + var fontSizeValue = fontSizeValueDouble * desktop.MainWindow.RenderScaling; + Current.Resources[SystemFontSize] = fontSizeValue; + } + } + + logger.LogInformation("MainWindow created"); + desktop.MainWindow.Opened += OnMainWindowOpened; + } + catch (Exception e) + { + logger.LogError(e, "Error during application initialization"); + } } catch (Exception e) { - logger.LogError(e, "Error during application initialization"); + logger.LogError(e, "Unhandled error during application initialization"); } } - + + private void ApplyConfiguration() + { + var configuration = serviceProvider.GetRequiredService>().Current; + Resources[SystemFontSize] = configuration.FontSize; + RequestedThemeVariant = configuration.PreferredTheme switch + { + ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light, + ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark, + _ => Avalonia.Styling.ThemeVariant.Default + }; + + var levelSwitch = serviceProvider.GetRequiredService(); + levelSwitch.MinimumLevel = configuration.LogLevel; + } + + private static void FontSizeChanged(double fontSize) + { + if (Current is null) return; + var materialIconSize = fontSize + 4; + Current.Resources[MaterialIconSize] = materialIconSize; + } + /// - /// Triggers the initial SSH key search after the main window has been presented, - /// ensuring the UI is fully ready before background work begins. + /// Triggers the initial SSH key search after the main window has been presented, + /// ensuring the UI is fully ready before background work begins. /// private async void OnMainWindowOpened(object? sender, EventArgs e) { try { if (sender is Window window) + { window.Opened -= OnMainWindowOpened; + window.Topmost = true; + logger.LogDebug("Trying to bring {WindowName} to front", sender.GetType().Name ?? "null"); + window.Activate(); + + Dispatcher.Post( + () => + { + logger.LogDebug("Window is not set as topmost anymore"); + window.Topmost = false; + }, DispatcherPriority.Background); + } try { - await resolver.Resolve().InitialSearchAsync(); + await serviceProvider.GetRequiredService().InitialSearchAsync(hostApplicationLifetime.ApplicationStopping); + logger.LogInformation("Initial key search completed"); } catch (Exception ex) { diff --git a/OpenSSH_GUI/Assets/appicon.ico b/OpenSSH_GUI/Assets/appicon.ico deleted file mode 100644 index d7a2067..0000000 Binary files a/OpenSSH_GUI/Assets/appicon.ico and /dev/null differ diff --git a/OpenSSH_GUI/Assets/appicon.png b/OpenSSH_GUI/Assets/appicon.png deleted file mode 100644 index a799995..0000000 Binary files a/OpenSSH_GUI/Assets/appicon.png and /dev/null differ diff --git a/OpenSSH_GUI/Assets/avalonia-logo.ico b/OpenSSH_GUI/Assets/avalonia-logo.ico deleted file mode 100644 index da8d49f..0000000 Binary files a/OpenSSH_GUI/Assets/avalonia-logo.ico and /dev/null differ diff --git a/OpenSSH_GUI/Converters/Converter.cs b/OpenSSH_GUI/Converters/Converter.cs index 23eba18..4fc3455 100644 --- a/OpenSSH_GUI/Converters/Converter.cs +++ b/OpenSSH_GUI/Converters/Converter.cs @@ -1,6 +1,6 @@ using Avalonia.Data.Converters; +using OpenSSH_GUI.Resources; using SshNet.Keygen; -using SshNet.Keygen.SshKeyEncryption; namespace OpenSSH_GUI.Converters; @@ -10,16 +10,18 @@ public static class Converter private const string WindowsShort = "Win"; public static FuncValueConverter FormatToStringConverter { get; } = new(EnumToString); - public static FuncValueConverter KeyTypeToStringConverter { get; } = new(EnumToString); + + public static FuncValueConverter FormatChangeTooltipConverter { get; } = + new(format => string.Format(StringsAndTexts.FileInfoWindowChangeFormatTo, EnumToString(format))); + public static FuncValueConverter PlatformIdToStringConverter { get; } = new(ConvertPlatformId); + public static FuncValueConverter NullToColumnSpanConverter { get; } = new(o => o is null ? 2 : 1); - private static string? EnumToString(TEnum value) where TEnum : struct, Enum - => Enum.GetName(value); + private static string? EnumToString(TEnum value) where TEnum : struct, Enum => Enum.GetName(value); - private static string? ConvertPlatformId(PlatformID arg) => - EnumToString(arg) is { } platformId - ? platformId.StartsWith(WindowsShort, StringComparison.CurrentCultureIgnoreCase) - ? Windows - : platformId - : null; + private static string? ConvertPlatformId(PlatformID arg) => EnumToString(arg) is { } platformId + ? platformId.StartsWith(WindowsShort, StringComparison.CurrentCultureIgnoreCase) + ? Windows + : platformId + : null; } \ No newline at end of file diff --git a/OpenSSH_GUI/Extensions/CallerEnricherExtensions.cs b/OpenSSH_GUI/Extensions/CallerEnricherExtensions.cs index a26abf5..094ecf3 100644 --- a/OpenSSH_GUI/Extensions/CallerEnricherExtensions.cs +++ b/OpenSSH_GUI/Extensions/CallerEnricherExtensions.cs @@ -5,15 +5,14 @@ namespace OpenSSH_GUI.Extensions; /// -/// Provides extension methods for enriching Serilog loggers with caller information. +/// Provides extension methods for enriching Serilog loggers with caller information. /// public static class CallerEnricherExtensions { /// - /// Enriches log events with the caller's class name, method name, and line number. + /// Enriches log events with the caller's class name, method name, and line number. /// /// The Serilog enrichment configuration. - /// The updated . - public static LoggerConfiguration WithCaller(this LoggerEnrichmentConfiguration enrichmentConfiguration) - => enrichmentConfiguration.With(); + /// The updated . + public static LoggerConfiguration WithCaller(this LoggerEnrichmentConfiguration enrichmentConfiguration) => enrichmentConfiguration.With(); } \ No newline at end of file diff --git a/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs b/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs index 3d8c335..61e462d 100644 --- a/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs +++ b/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs @@ -1,15 +1,16 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Input.Platform; -using Avalonia.Media.Imaging; -using Avalonia.Platform; using Avalonia.Platform.Storage; -using DryIoc; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using OpenSSH_GUI.Core; using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; using OpenSSH_GUI.Core.Interfaces.Hosts; using OpenSSH_GUI.Core.Lib.Keys; using OpenSSH_GUI.Core.Lib.Misc; +using OpenSSH_GUI.Core.Resources; using OpenSSH_GUI.Core.Services; using OpenSSH_GUI.Core.Services.Hosted; using OpenSSH_GUI.Dialogs.Interfaces; @@ -17,67 +18,51 @@ using OpenSSH_GUI.ViewModels; using OpenSSH_GUI.Views; using Serilog.Core; -#if DEBUG -using Serilog.Events; -#endif namespace OpenSSH_GUI.Extensions; public static class DependencyInjectionExtensions { - private const string IconUri = "avares://OpenSSH_GUI/Assets/appicon.ico"; - - extension(IContainer container) + extension(IHostBuilder builder) { - internal void ConfigureServicesInternal() + internal IHostBuilder RegisterOpenSshGuiServices() { - container.Register(); - container.Register(); - container.RegisterInstance( - new LoggingLevelSwitch( -#if DEBUG - LogEventLevel.Verbose -#endif - )); - - container.Register(); - container.Register(); - container.Register(); - container.Register(serviceKey: nameof(MainWindow), made: Made.Of(propertiesAndFields: PropertiesAndFields.Auto)); - container.Register(serviceKey: nameof(MainWindowViewModel)); + builder.ConfigureServices((_, services) => + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); - container.RegisterDelegate(resolver => - resolver.Resolve(serviceKey: nameof(MainWindow))); - container.RegisterDelegate(resolver => - resolver.Resolve(serviceKey: nameof(MainWindow))); - container.RegisterDelegate(resolver => - resolver.Resolve(serviceKey: nameof(MainWindow))!.Clipboard!); - container.RegisterDelegate(resolver => - resolver.Resolve(serviceKey: nameof(MainWindow)).StorageProvider); - container.RegisterDelegate(resolver => - resolver.Resolve(serviceKey: nameof(MainWindow)).Launcher); - container.RegisterDelegate(_ => new Bitmap(AssetLoader.Open(new Uri(IconUri))), - serviceKey: Program.IconServiceKey); + services.AddSingleton(sp => sp.GetRequiredKeyedService(nameof(MainWindow))); + services.AddSingleton(sp => sp.GetRequiredKeyedService(nameof(MainWindow))); + services.AddSingleton(sp => sp.GetRequiredKeyedService(nameof(MainWindow)).Clipboard!); + services.AddSingleton(sp => sp.GetRequiredKeyedService(nameof(MainWindow)).StorageProvider); + services.AddSingleton(sp => sp.GetRequiredKeyedService(nameof(MainWindow)).Launcher); - container.RegisterViewWithViewModel(); - container.RegisterViewWithViewModel(); - container.RegisterViewWithViewModel(); - container.RegisterViewWithViewModel(); - container.RegisterViewWithViewModel(); - container.RegisterViewWithViewModel(); - container.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(ServiceLifetime.Singleton); + services.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(); + services.RegisterViewWithViewModel(); - container.Register(Reuse.Transient); - container.Register(Reuse.Transient); - } - } - - extension(IServiceCollection collection) - { - internal IServiceCollection RegisterOpenSshGuiServices() - { - collection.AddHostedService(); - return collection; + services.AddTransient(); + services.AddTransient(); + services.AddHostedService(); + }); + return builder; } } } \ No newline at end of file diff --git a/OpenSSH_GUI/Logging/Enricher/CallerEnricher.cs b/OpenSSH_GUI/Logging/Enricher/CallerEnricher.cs index 1df30c8..d90386f 100644 --- a/OpenSSH_GUI/Logging/Enricher/CallerEnricher.cs +++ b/OpenSSH_GUI/Logging/Enricher/CallerEnricher.cs @@ -5,17 +5,17 @@ namespace OpenSSH_GUI.Logging.Enricher; /// -/// Enriches log events with caller information: -/// the class name, method name, and line number -/// derived from the current stack frame. +/// Enriches log events with caller information: +/// the class name, method name, and line number +/// derived from the current stack frame. /// public sealed class CallerEnricher : ILogEventEnricher { private const string LineNumberProperty = "LineNumber"; - private const string FileNameProperty = "FileName"; + private const string FileNameProperty = "FileName"; // Serilog-internal namespaces to skip when walking the stack - private static readonly string[] serilogNamespaces = + private static readonly string[] SerilogNamespaces = [ "Serilog.", "System.", @@ -23,37 +23,37 @@ public sealed class CallerEnricher : ILogEventEnricher ]; /// - /// Enriches the given log event with caller class, method, file name, and line number. + /// Enriches the given log event with caller class, method, file name, and line number. /// public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { var frame = FindCallerFrame(); - var lineNumber = frame?.GetFileLineNumber() ?? 0; - var fileName = Path.GetFileName(frame?.GetFileName()) ?? ""; + var lineNumber = frame?.GetFileLineNumber() ?? 0; + var fileName = Path.GetFileName(frame?.GetFileName()) ?? ""; logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(LineNumberProperty, lineNumber)); - logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(FileNameProperty, fileName)); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(FileNameProperty, fileName)); } /// - /// Walks the stack to find the first frame outside of Serilog, system namespaces, - /// and the enricher itself. + /// Walks the stack to find the first frame outside of Serilog, system namespaces, + /// and the enricher itself. /// - /// The first relevant , or null if not found. + /// The first relevant , or null if not found. private static StackFrame? FindCallerFrame() { - var stack = new StackTrace(fNeedFileInfo: true); + var stack = new StackTrace(true); foreach (var frame in stack.GetFrames()) { var declaringType = frame.GetMethod()?.DeclaringType; - if (declaringType == null) continue; - if (declaringType == typeof(CallerEnricher)) continue; - if (typeof(ILogEventEnricher).IsAssignableFrom(declaringType)) continue; - + if (declaringType == null) continue; + if (declaringType == typeof(CallerEnricher)) continue; + if (typeof(ILogEventEnricher).IsAssignableFrom(declaringType)) continue; + var ns = declaringType.Namespace ?? string.Empty; - if (Array.Exists(serilogNamespaces, ns.StartsWith)) continue; + if (Array.Exists(SerilogNamespaces, ns.StartsWith)) continue; return frame; } diff --git a/OpenSSH_GUI/OpenSSH_GUI.csproj b/OpenSSH_GUI/OpenSSH_GUI.csproj index a21773e..3c0d78e 100644 --- a/OpenSSH_GUI/OpenSSH_GUI.csproj +++ b/OpenSSH_GUI/OpenSSH_GUI.csproj @@ -1,68 +1,76 @@  - - - - - Exe - true - app.manifest - true - true - true - true - Assets\appicon.ico - false - frequency403 - en - OpenSSH-GUI.snk - false - - - - - - - - - - - - - - - - - - - - - - - - - - PublicResXFileCodeGenerator - StringsAndTexts.Designer.cs - - - - - - True - True - StringsAndTexts.resx - - - - - OpenSSH GUI - OpenSSH GUI - frequency403.opensshgui - 1.0.0 - APPL - OpenSSHGui - OpenSSHGui.icns - NSApplication - true - - + + + Exe + 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 + + \ No newline at end of file diff --git a/OpenSSH_GUI/Program.cs b/OpenSSH_GUI/Program.cs index 76b4bc7..8beaaee 100644 --- a/OpenSSH_GUI/Program.cs +++ b/OpenSSH_GUI/Program.cs @@ -1,12 +1,13 @@ using System.Reflection; +using System.Text.Json; using Avalonia; -using DryIoc; -using DryIoc.Microsoft.DependencyInjection; using JetBrains.Annotations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using OpenSSH_GUI.Core; +using OpenSSH_GUI.Core.Configuration; using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Extensions; @@ -14,100 +15,125 @@ using ReactiveUI.Avalonia; using Serilog; using Serilog.Core; +using Serilog.Sinks.SystemConsole.Themes; +using LoggerConfiguration = Serilog.LoggerConfiguration; namespace OpenSSH_GUI; +// REFACTOR: Change Readme.MD accordingly to new Project functionality; [UsedImplicitly] internal sealed class Program { - public const SshConfigFiles ConfigFile = SshConfigFiles.Config; - public const SshConfigFiles SshdConfig = SshConfigFiles.Sshd_Config; + private const SshConfigFiles ConfigFile = SshConfigFiles.Config; + private const SshConfigFiles SshdConfig = SshConfigFiles.Sshd_Config; public const string AppName = "OpenSSH GUI"; public const string VersionEnvVar = "RUNNING_VERSION"; - public const string IconServiceKey = "AppIcon"; - private static string GetHostVersion() - { - return Assembly.GetEntryAssembly() - ?.GetCustomAttribute() - ?.InformationalVersion - ?? Assembly.GetEntryAssembly()?.GetName().Version?.ToString() - ?? "0.0.0"; - } + private static string GetHostVersion() => Assembly.GetEntryAssembly() + ?.GetCustomAttribute() + ?.InformationalVersion + ?? Assembly.GetEntryAssembly()?.GetName().Version?.ToString() + ?? "0.0.0"; - private static Logger? CreateLogger(IContainer container) + private static void ConfigureOpenSshGuiLogger( + ApplicationConfiguration bootstrapConfig, + LoggingLevelSwitch levelSwitch, + LoggerConfiguration loggerConfiguration) { - var logConfiguration = Core.Configuration.LoggerConfiguration.Default; - if (!Directory.Exists(logConfiguration.LogFilePath)) - Directory.CreateDirectory(logConfiguration.LogFilePath); + var loggerConfig = bootstrapConfig.LoggerConfiguration; + Directory.CreateIfNotExists(loggerConfig.LogFilePath); - return new LoggerConfiguration() + loggerConfiguration .Enrich.FromLogContext() .Enrich.WithCaller() - .MinimumLevel.ControlledBy(container.Resolve()) + .MinimumLevel.ControlledBy(levelSwitch) #if DEBUG - .WriteTo.ColoredConsole(outputTemplate: logConfiguration.LogOutputTemplate) + .WriteTo.Console( + outputTemplate: loggerConfig.LogOutputTemplate, + theme: AnsiConsoleTheme.Code) #endif .WriteTo.File( - logConfiguration.LogFileFullPath, - outputTemplate: logConfiguration.LogOutputTemplate, - rollingInterval: RollingInterval.Day) - .CreateLogger(); + loggerConfig.LogFileFullPath, + outputTemplate: loggerConfig.LogOutputTemplate, + rollingInterval: RollingInterval.Day); } #pragma warning disable CA1416 [STAThread] public static async Task Main(string[] args) { - using var container = new Container(rules => - { - var newRules = rules; - if (!rules.HasMicrosoftDependencyInjectionRules()) - newRules = rules.WithMicrosoftDependencyInjectionRules(); - - return newRules.WithDefaultReuse(Reuse.Singleton) - .WithTrackingDisposableTransients() - .WithoutThrowOnRegisteringDisposableTransient() - .WithDefaultIfAlreadyRegistered(IfAlreadyRegistered.Replace); - }); - container.ConfigureServicesInternal(); - var factory = new DryIocServiceProviderFactory(container); using var mainCancellationTokenSource = new CancellationTokenSource(); + + File.CreateIfNotExists( + ApplicationConfiguration.DefaultApplicationConfigurationFileFullPath, + JsonSerializer.Serialize(ApplicationConfiguration.Default, SourceGenerationContext.Default.ApplicationConfiguration)); + + var bootstrapConfig = ReadBootstrapConfiguration(); + var levelSwitch = new LoggingLevelSwitch(bootstrapConfig.LogLevel); + var host = Host.CreateDefaultBuilder(args) - .UseServiceProviderFactory(factory) - .ConfigureServices(services => services.RegisterOpenSshGuiServices()) - .UseSerilog(logger: CreateLogger(container), dispose: true) + .AddMutableConfiguration(ApplicationConfiguration.DefaultApplicationConfigurationFileFullPath, SourceGenerationContext.Default.ApplicationConfiguration, false) .ConfigureAppConfiguration(ConfigureAppConfiguration) + .ConfigureServices(services => services.AddSingleton(levelSwitch)) + .RegisterOpenSshGuiServices() + .UseSerilog((_, _, loggerConfig) => + ConfigureOpenSshGuiLogger(bootstrapConfig, levelSwitch, loggerConfig)) .Build(); var appBuilder = AppBuilder.Configure(() => host.Services.GetRequiredService()) + .UseSkia() .UsePlatformDetect() .WithInterFont() .UseReactiveUI(configure => { - configure.WithPlatformServices(); - configure.WithAvalonia(); - configure.WithExceptionHandler(host.Services.GetRequiredService()); + configure + .WithPlatformServices() + .WithAvalonia() + .WithExceptionHandler(host.Services.GetRequiredService()); }); - + await host.StartAsync(mainCancellationTokenSource.Token); appBuilder.StartWithClassicDesktopLifetime(args); await host.StopAsync(mainCancellationTokenSource.Token); } #pragma warning restore CA1416 - private static void ConfigureAppConfiguration(HostBuilderContext builderContext, + private static void ConfigureAppConfiguration(HostBuilderContext hostBuilderContext, IConfigurationBuilder configurationBuilder) { configurationBuilder.AddSshConfig(ConfigFile.GetPathOfFile(), true, true, LoggingAction); configurationBuilder.AddSshConfig(SshdConfig.GetPathOfFile(), true, true, LoggingAction); - configurationBuilder.AddInMemoryCollection([ + + configurationBuilder.AddJsonFile( + new PhysicalFileProvider(ApplicationConfiguration.ApplicationConfigurationPath), ApplicationConfiguration.ApplicationConfigurationName, false, true); + + configurationBuilder.AddInMemoryCollection( + [ new KeyValuePair(VersionEnvVar, GetHostVersion()) ]); } - private static void LoggingAction(string arg1, Exception arg2) + private static void LoggingAction(string arg1, Exception arg2) { Log.Logger.Error(arg2, "Failed to load SSH config file: {Path}", arg1); } + + /// + /// Reads the application configuration directly from disk without using DI. + /// Used during host bootstrap to avoid circular dependency with Serilog setup. + /// Returns on any failure. + /// + private static ApplicationConfiguration ReadBootstrapConfiguration() { - Log.Logger.Error(arg2, "Failed to load SSH config file: {Path}", arg1); + try + { + var json = File.ReadAllText( + ApplicationConfiguration.DefaultApplicationConfigurationFileFullPath); + return JsonSerializer.Deserialize( + json, + SourceGenerationContext.Default.ApplicationConfiguration) + ?? ApplicationConfiguration.Default; + } + catch + { + return ApplicationConfiguration.Default; + } } } \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/FlyoutButton.cs b/OpenSSH_GUI/Resources/Controls/FlyoutButton.cs new file mode 100644 index 0000000..751b478 --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/FlyoutButton.cs @@ -0,0 +1,45 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace OpenSSH_GUI.Resources.Controls; + +public class FlyoutButton : ContentControl +{ + public static readonly StyledProperty FlyoutProperty = + AvaloniaProperty.Register(nameof(Flyout)); + + public FlyoutButton() + { + AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel); + AddHandler(PointerEnteredEvent, (_, _) => PseudoClasses.Set(":pointerover", true)); + AddHandler( + PointerExitedEvent, (_, _) => + { + PseudoClasses.Set(":pointerover", false); + PseudoClasses.Set(":pressed", false); + }); + } + + public FlyoutBase? Flyout + { + get => GetValue(FlyoutProperty); + set => SetValue(FlyoutProperty, value); + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + PseudoClasses.Set(":pressed", true); + + if (Flyout is { } flyout) + { + flyout.ShowAt(this); + e.Handled = true; + } + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) { PseudoClasses.Set(":pressed", false); } +} \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/HeaderedItem.axaml b/OpenSSH_GUI/Resources/Controls/HeaderedItem.axaml new file mode 100644 index 0000000..60b3fd2 --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/HeaderedItem.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/HeaderedItem.axaml.cs b/OpenSSH_GUI/Resources/Controls/HeaderedItem.axaml.cs new file mode 100644 index 0000000..c018493 --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/HeaderedItem.axaml.cs @@ -0,0 +1,70 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace OpenSSH_GUI.Resources.Controls; + +public partial class HeaderedItem : ContentControl +{ + public static readonly StyledProperty HeaderProperty = + AvaloniaProperty.Register(nameof(Header)); + + public static readonly StyledProperty SideHeaderProperty = + AvaloniaProperty.Register(nameof(SideHeader)); + + // Header alignment + public static readonly StyledProperty HeaderHorizontalAlignmentProperty = + AvaloniaProperty.Register( + nameof(HeaderHorizontalAlignment), HorizontalAlignment.Left); + + public static readonly StyledProperty HeaderVerticalAlignmentProperty = + AvaloniaProperty.Register( + nameof(HeaderVerticalAlignment), VerticalAlignment.Center); + + // SideHeader alignment + public static readonly StyledProperty SideHeaderHorizontalAlignmentProperty = + AvaloniaProperty.Register( + nameof(SideHeaderHorizontalAlignment), HorizontalAlignment.Right); + + public static readonly StyledProperty SideHeaderVerticalAlignmentProperty = + AvaloniaProperty.Register( + nameof(SideHeaderVerticalAlignment), VerticalAlignment.Center); + + public HeaderedItem() { InitializeComponent(); } + + public object? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public object? SideHeader + { + get => GetValue(SideHeaderProperty); + set => SetValue(SideHeaderProperty, value); + } + + public HorizontalAlignment HeaderHorizontalAlignment + { + get => GetValue(HeaderHorizontalAlignmentProperty); + set => SetValue(HeaderHorizontalAlignmentProperty, value); + } + + public VerticalAlignment HeaderVerticalAlignment + { + get => GetValue(HeaderVerticalAlignmentProperty); + set => SetValue(HeaderVerticalAlignmentProperty, value); + } + + public HorizontalAlignment SideHeaderHorizontalAlignment + { + get => GetValue(SideHeaderHorizontalAlignmentProperty); + set => SetValue(SideHeaderHorizontalAlignmentProperty, value); + } + + public VerticalAlignment SideHeaderVerticalAlignment + { + get => GetValue(SideHeaderVerticalAlignmentProperty); + set => SetValue(SideHeaderVerticalAlignmentProperty, value); + } +} \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/PasswordDisplay.axaml b/OpenSSH_GUI/Resources/Controls/PasswordDisplay.axaml new file mode 100644 index 0000000..2d45cd4 --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/PasswordDisplay.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/PasswordDisplay.axaml.cs b/OpenSSH_GUI/Resources/Controls/PasswordDisplay.axaml.cs new file mode 100644 index 0000000..6c0b6e0 --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/PasswordDisplay.axaml.cs @@ -0,0 +1,88 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Markup.Xaml; +using OpenSSH_GUI.Core.Lib.Keys; + +namespace OpenSSH_GUI.Resources.Controls; + +/// +/// A password input control with a toggleable visibility button (eye icon). +/// When hidden, input is masked with the configured ; +/// when revealed, plain text is shown. Supports read-only mode and external +/// observation of the current visibility state via . +/// +public partial class PasswordDisplay : UserControl +{ + private const char DefaultMaskChar = '●'; + private const string DefaultWatermark = "Password"; + + // --- Avalonia Styled Properties --- + + /// Bindable property for the placeholder text. + public static readonly StyledProperty WatermarkProperty = + AvaloniaProperty.Register(nameof(Watermark), DefaultWatermark); + + /// Bindable property for the character used to mask the password input. + public static readonly StyledProperty MaskCharacterProperty = + AvaloniaProperty.Register(nameof(MaskCharacter), DefaultMaskChar); + + /// + /// Bindable property indicating whether the password is currently visible. + /// Can be observed or driven externally (e.g., from a ViewModel) to react + /// to visibility changes — for instance, to show or enable a copy button. + /// + public static readonly StyledProperty PasswordVisibleProperty = + AvaloniaProperty.Register(nameof(PasswordVisible)); + + + public static readonly StyledProperty SecurePasswordProperty = + AvaloniaProperty.Register(nameof(SecurePassword)); + + // --- Parts --- + private TextBox _textBox = new(); + private ToggleButton _toggle = new(); + + public PasswordDisplay() { InitializeComponent(); } + + public SshKeyFilePassword? SecurePassword + { + get => GetValue(SecurePasswordProperty); + set => SetValue(SecurePasswordProperty, value); + } + + public string? Watermark + { + get => GetValue(WatermarkProperty); + set => SetValue(WatermarkProperty, value); + } + + public char MaskCharacter + { + get => GetValue(MaskCharacterProperty); + set => SetValue(MaskCharacterProperty, value); + } + + public bool PasswordVisible + { + get => GetValue(PasswordVisibleProperty); + set => SetValue(PasswordVisibleProperty, value); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SecurePasswordProperty && GetValue(SecurePasswordProperty) is { } property) + _textBox.Text = property.GetPasswordString(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + + _textBox = this.FindControl("PART_Password")!; + _toggle = this.FindControl("PART_Toggle")!; + _textBox.TextChanged += (_, _) => { _toggle.IsEnabled = _textBox.Text?.Length > 0; }; + } +} \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/SubmitButtons.axaml b/OpenSSH_GUI/Resources/Controls/SubmitButtons.axaml new file mode 100644 index 0000000..61665ce --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/SubmitButtons.axaml @@ -0,0 +1,49 @@ + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/SubmitButtons.axaml.cs b/OpenSSH_GUI/Resources/Controls/SubmitButtons.axaml.cs new file mode 100644 index 0000000..857b75a --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/SubmitButtons.axaml.cs @@ -0,0 +1,162 @@ +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using Avalonia; +using Avalonia.Controls; +using Material.Icons; +using ReactiveUI; + +namespace OpenSSH_GUI.Resources.Controls; + +public partial class SubmitButtons : UserControl +{ + public static readonly DirectProperty> BooleanSubmitProperty = + AvaloniaProperty.RegisterDirect>( + nameof(BooleanSubmit), + c => c.BooleanSubmit, + (c, v) => c.BooleanSubmit = v); + + public static readonly DirectProperty AbortButtonEnabledProperty = + AvaloniaProperty.RegisterDirect( + nameof(AbortButtonEnabled), + c => c.AbortButtonEnabled, (c, v) => c.AbortButtonEnabled = v); + + public static readonly DirectProperty SubmitButtonEnabledProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubmitButtonEnabled), + c => c.SubmitButtonEnabled, (c, v) => c.SubmitButtonEnabled = v); + + public static readonly DirectProperty AbortButtonTooltipProperty = + AvaloniaProperty.RegisterDirect( + nameof(AbortButtonTooltip), + c => c.AbortButtonTooltip, (c, v) => c.AbortButtonTooltip = v); + + public static readonly DirectProperty SubmitButtonTooltipProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubmitButtonTooltip), + c => c.SubmitButtonTooltip, (c, v) => c.SubmitButtonTooltip = v); + + public static readonly DirectProperty AbortButtonIconKindProperty = + AvaloniaProperty.RegisterDirect( + nameof(AbortButtonIconKind), + c => c.AbortButtonIconKind, (c, v) => c.AbortButtonIconKind = v); + + public static readonly DirectProperty SubmitButtonIconKindProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubmitButtonIconKind), + c => c.SubmitButtonIconKind, (c, v) => c.SubmitButtonIconKind = v); + + public static readonly DirectProperty AbortButtonContentProperty = + AvaloniaProperty.RegisterDirect( + nameof(AbortButtonContent), + c => c.AbortButtonContent, (c, v) => c.AbortButtonContent = v); + + public static readonly DirectProperty AbortButtonContentEnabledProperty = + AvaloniaProperty.RegisterDirect( + nameof(AbortButtonContentEnabled), + c => c.AbortButtonContentEnabled, (c, v) => c.AbortButtonContentEnabled = v); + + public static readonly DirectProperty SubmitButtonContentProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubmitButtonContent), + c => c.SubmitButtonContent, (c, v) => c.SubmitButtonContent = v); + + public static readonly DirectProperty SubmitButtonContentEnabledProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubmitButtonContentEnabled), + c => c.SubmitButtonContentEnabled, (c, v) => c.SubmitButtonContentEnabled = v); + + private readonly CompositeDisposable _disposables = new(); + + public SubmitButtons() + { + this.WhenAnyValue(x => x.AbortButtonContent) + .Subscribe(x => AbortButtonContentEnabled = x is not null) + .DisposeWith(_disposables); + + this.WhenAnyValue(x => x.SubmitButtonContent) + .Subscribe(x => SubmitButtonContentEnabled = x is not null) + .DisposeWith(_disposables); + + InitializeComponent(); + } + + public bool AbortButtonContentEnabled + { + get; + set => SetAndRaise(AbortButtonContentEnabledProperty, ref field, value); + } = false; + + public Control? AbortButtonContent + { + get; + set => SetAndRaise(AbortButtonContentProperty, ref field, value); + } = null; + + public bool SubmitButtonContentEnabled + { + get; + set => SetAndRaise(SubmitButtonContentEnabledProperty, ref field, value); + } = false; + + public Control? SubmitButtonContent + { + get; + set => SetAndRaise(SubmitButtonContentProperty, ref field, value); + } = null; + + + public ReactiveCommand BooleanSubmit + { + get; + set => SetAndRaise(BooleanSubmitProperty, ref field, value); + } = ReactiveCommand.Create(_ => new Unit()); + + + public bool AbortButtonEnabled + { + get; + set => SetAndRaise(AbortButtonEnabledProperty, ref field, value); + } = true; + + + public bool SubmitButtonEnabled + { + get; + set => SetAndRaise(SubmitButtonEnabledProperty, ref field, value); + } = true; + + + public string AbortButtonTooltip + { + get; + set => SetAndRaise(AbortButtonTooltipProperty, ref field, value); + } = StringsAndTexts.CancelAndClose; + + + public string SubmitButtonTooltip + { + get; + set => SetAndRaise(SubmitButtonTooltipProperty, ref field, value); + } = StringsAndTexts.SaveAndClose; + + + public MaterialIconKind AbortButtonIconKind + { + get; + set => SetAndRaise(AbortButtonIconKindProperty, ref field, value); + } = MaterialIconKind.CancelOutline; + + + public MaterialIconKind SubmitButtonIconKind + { + get; + set => SetAndRaise(SubmitButtonIconKindProperty, ref field, value); + } = MaterialIconKind.CheckOutline; + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _disposables.Dispose(); + } +} \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/TooltippedIcon.axaml b/OpenSSH_GUI/Resources/Controls/TooltippedIcon.axaml new file mode 100644 index 0000000..8c16cd0 --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/TooltippedIcon.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Controls/TooltippedIcon.axaml.cs b/OpenSSH_GUI/Resources/Controls/TooltippedIcon.axaml.cs new file mode 100644 index 0000000..83263ab --- /dev/null +++ b/OpenSSH_GUI/Resources/Controls/TooltippedIcon.axaml.cs @@ -0,0 +1,119 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using Material.Icons; + +namespace OpenSSH_GUI.Resources.Controls; + +public partial class TooltippedIcon : UserControl +{ + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), MaterialIconKind.Info); + + public static readonly StyledProperty ToolTipContentProperty = + AvaloniaProperty.Register(nameof(ToolTipContent)); + + public static readonly StyledProperty ToolTipPlacementProperty = + AvaloniaProperty.Register(nameof(ToolTipPlacement), PlacementMode.Bottom); + + private DispatcherTimer? _hoverTimer; + private bool _isPinned; + + public TooltippedIcon() + { + InitializeComponent(); + InitHoverTimer(); + } + + public MaterialIconKind Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public object? ToolTipContent + { + get => GetValue(ToolTipContentProperty); + set => SetValue(ToolTipContentProperty, value); + } + + public PlacementMode ToolTipPlacement + { + get => GetValue(ToolTipPlacementProperty); + set => SetValue(ToolTipPlacementProperty, value); + } + + /// + /// Initializes the hover delay timer used to open the popup on prolonged pointer hover. + /// + private void InitHoverTimer() + { + _hoverTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(600) + }; + _hoverTimer.Tick += OnHoverTimerTick; + } + + /// + /// Opens the popup transiently when the hover delay elapses, unless already pinned. + /// + private void OnHoverTimerTick(object? sender, EventArgs e) + { + _hoverTimer?.Stop(); + Popup.IsOpen = true; + } + + /// + /// Starts the hover timer when the pointer enters the hit area. + /// + private void OnPointerEntered(object? sender, PointerEventArgs e) + { + if (!_isPinned) + _hoverTimer?.Start(); + } + + /// + /// Closes the popup on pointer exit, unless it has been pinned via click. + /// + private void OnPointerExited(object? sender, PointerEventArgs e) + { + _hoverTimer?.Stop(); + if (_isPinned) return; + + var pos = e.GetPosition(Popup.Child); + if (Popup.IsOpen && Popup.Child is not null) + { + var bounds = Popup.Child.Bounds; + if (bounds.Contains(pos)) return; + } + + Popup.IsOpen = false; + } + + /// + /// Pins the popup open on click, or unpins and closes it if already pinned. + /// Light dismiss will also unpin via . + /// + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + _hoverTimer?.Stop(); + _isPinned = !_isPinned; + Popup.IsOpen = _isPinned; + } + + /// + /// Resets the pinned state when the popup is closed externally via light dismiss. + /// + private void OnPopupClosed(object? sender, EventArgs e) { _isPinned = false; } + + /// + /// Closes the popup when the pointer leaves the popup content area, unless pinned. + /// + private void OnPopupContentExited(object? sender, PointerEventArgs e) + { + if (!_isPinned) + Popup.IsOpen = false; + } +} \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs b/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs index 0800c85..42a90b8 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs +++ b/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs @@ -201,15 +201,9 @@ public static string MainWindowEditKnownHostsFileToolTip { } } - public static string MainWindowFoundKeyPairsCountLabelPart1 { + public static string MainWindowFoundKeyPairsCountLabel { get { - return ResourceManager.GetString("MainWindowFoundKeyPairsCountLabelPart1", resourceCulture); - } - } - - public static string MainWindowFoundKeyPairsCountLabelPart2 { - get { - return ResourceManager.GetString("MainWindowFoundKeyPairsCountLabelPart2", resourceCulture); + return ResourceManager.GetString("MainWindowFoundKeyPairsCountLabel", resourceCulture); } } @@ -573,18 +567,6 @@ public static string Days { } } - public static string ApplicationSettingsCleanup { - get { - return ResourceManager.GetString("ApplicationSettingsCleanup", resourceCulture); - } - } - - public static string ApplicationSettingsFiles { - get { - return ResourceManager.GetString("ApplicationSettingsFiles", resourceCulture); - } - } - public static string ApplicationSettingsClearWholeCache { get { return ResourceManager.GetString("ApplicationSettingsClearWholeCache", resourceCulture); @@ -620,5 +602,209 @@ public static string ApplicationSettingsViewModelConfirmationError { return ResourceManager.GetString("ApplicationSettingsViewModelConfirmationError", resourceCulture); } } + + public static string MainWindowReloadingKeys { + get { + return ResourceManager.GetString("MainWindowReloadingKeys", resourceCulture); + } + } + + public static string MainWindowKeyNotPasswordProtected { + get { + return ResourceManager.GetString("MainWindowKeyNotPasswordProtected", resourceCulture); + } + } + + public static string MainWindowKeyPasswordProtectedLocked { + get { + return ResourceManager.GetString("MainWindowKeyPasswordProtectedLocked", resourceCulture); + } + } + + public static string MainWindowKeyPasswordProtectedUnlocked { + get { + return ResourceManager.GetString("MainWindowKeyPasswordProtectedUnlocked", resourceCulture); + } + } + + public static string MainWindowProvidePassword { + get { + return ResourceManager.GetString("MainWindowProvidePassword", resourceCulture); + } + } + + public static string MainWindowOpenFileInfoWindow { + get { + return ResourceManager.GetString("MainWindowOpenFileInfoWindow", resourceCulture); + } + } + + public static string FileInfoWindowFoundAssociatedFiles { + get { + return ResourceManager.GetString("FileInfoWindowFoundAssociatedFiles", resourceCulture); + } + } + + public static string FileInfoWindowChangePasswordTooltip { + get { + return ResourceManager.GetString("FileInfoWindowChangePasswordTooltip", resourceCulture); + } + } + + public static string FileInfoWindowKeyFormat { + get { + return ResourceManager.GetString("FileInfoWindowKeyFormat", resourceCulture); + } + } + + public static string FileInfoWindowCurrent { + get { + return ResourceManager.GetString("FileInfoWindowCurrent", resourceCulture); + } + } + + public static string FileInfoWindowChangeFormatTo { + get { + return ResourceManager.GetString("FileInfoWindowChangeFormatTo", resourceCulture); + } + } + + public static string FileInfoWindowPassword { + get { + return ResourceManager.GetString("FileInfoWindowPassword", resourceCulture); + } + } + + public static string FileInfoWindowChangePassword { + get { + return ResourceManager.GetString("FileInfoWindowChangePassword", resourceCulture); + } + } + + public static string FileInfoWindowEnterNewPassword { + get { + return ResourceManager.GetString("FileInfoWindowEnterNewPassword", resourceCulture); + } + } + + public static string FileInfoWindowConfirmFileOverwrite { + get { + return ResourceManager.GetString("FileInfoWindowConfirmFileOverwrite", resourceCulture); + } + } + + public static string FileInfoWindowFileAlreadyExists { + get { + return ResourceManager.GetString("FileInfoWindowFileAlreadyExists", resourceCulture); + } + } + + public static string FileInfoWindowChangeMessage { + get { + return ResourceManager.GetString("FileInfoWindowChangeMessage", resourceCulture); + } + } + + public static string FileInfoWindowEnterNewFilename { + get { + return ResourceManager.GetString("FileInfoWindowEnterNewFilename", resourceCulture); + } + } + + public static string FileInfoWindowFilenameCannotBeEmpty { + get { + return ResourceManager.GetString("FileInfoWindowFilenameCannotBeEmpty", resourceCulture); + } + } + + public static string FileInfoWindowPasswordCopied { + get { + return ResourceManager.GetString("FileInfoWindowPasswordCopied", resourceCulture); + } + } + + public static string ApplicationSettingsLogLevel { + get { + return ResourceManager.GetString("ApplicationSettingsLogLevel", resourceCulture); + } + } + + public static string ApplicationSettingsTheme { + get { + return ResourceManager.GetString("ApplicationSettingsTheme", resourceCulture); + } + } + + public static string ApplicationSettingsFontSize { + get { + return ResourceManager.GetString("ApplicationSettingsFontSize", resourceCulture); + } + } + + public static string ApplicationSettingsCleanupFiles { + get { + return ResourceManager.GetString("ApplicationSettingsCleanupFiles", resourceCulture); + } + } + + public static string ConnectToServerPreconfiguredConnections { + get { + return ResourceManager.GetString("ConnectToServerPreconfiguredConnections", resourceCulture); + } + } + + public static string ConnectToServerHostname { + get { + return ResourceManager.GetString("ConnectToServerHostname", resourceCulture); + } + } + + public static string ConnectToServerUsername { + get { + return ResourceManager.GetString("ConnectToServerUsername", resourceCulture); + } + } + + public static string ConnectToServerPassword { + get { + return ResourceManager.GetString("ConnectToServerPassword", resourceCulture); + } + } + + public static string ConnectToServerConnectionFailed { + get { + return ResourceManager.GetString("ConnectToServerConnectionFailed", resourceCulture); + } + } + + public static string EditKnownHostsLocal { + get { + return ResourceManager.GetString("EditKnownHostsLocal", resourceCulture); + } + } + + public static string EditKnownHostsRemote { + get { + return ResourceManager.GetString("EditKnownHostsRemote", resourceCulture); + } + } + + public static string EditAuthorizedKeysLocal { + get { + return ResourceManager.GetString("EditAuthorizedKeysLocal", resourceCulture); + } + } + + public static string EditAuthorizedKeysServer { + get { + return ResourceManager.GetString("EditAuthorizedKeysServer", resourceCulture); + } + } + + public static string ApplicationSettingsLookupPaths { + get { + return ResourceManager.GetString("ApplicationSettingsLookupPaths", resourceCulture); + } + } } } diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.de.resx b/OpenSSH_GUI/Resources/StringsAndTexts.de.resx index a99b372..239021b 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.de.resx +++ b/OpenSSH_GUI/Resources/StringsAndTexts.de.resx @@ -138,8 +138,8 @@ "known_hosts"-Datei bearbeiten - - Schlüsselpaare im SSH-Verzeichnis gefunden + + {0} Schlüsselpaare im SSH-Verzeichnis gefunden Mit einem Server verbinden, um die Datei auf dem Server zu bearbeiten! @@ -165,9 +165,6 @@ Änderungen speichern und schließen - - Es wurden - Keine Verbindung zum schließen! @@ -253,13 +250,13 @@ Passwort eingeben: - Passwürt für {0} Schlüsseldatei bereitstellen + Passwort für Schlüsseldatei eingeben Falsches Passwort! - Sie haben ein falsches passwort eingegeben. Dies ist Versuch {0} von {1} möglichen Versuchen. Möchten sie es erneut versuchen? + Sie haben ein falsches passwort eingegeben. Möchten sie es erneut versuchen? Dateinamen ändern @@ -276,12 +273,6 @@ Tage - - Räume - - - Dateien auf - Gesamtes cache aufräumen @@ -300,4 +291,106 @@ Bitte den richtigen Bestätigungswert eingeben + + Schlüssel werden neu geladen... + + + Schlüssel ist nicht passwortgeschützt + + + Schlüssel ist passwortgeschützt und kann erst nach Eingabe des Passworts verwendet werden + + + Schlüssel ist passwortgeschützt und mit korrektem Passwort geöffnet + + + Passwort eingeben + + + Dateiinfo-Fenster öffnen + + + {0} zugehörige Dateien gefunden + + + Passwort dieses Schlüssels ändern + + + Schlüsselformat + + + Aktuell: + + + Format zu {0} ändern + + + Passwort + + + Passwort ändern + + + Neues Passwort für Schlüssel {0} eingeben + + + Dateiüberschreibung bestätigen + + + Die Schlüsseldatei {0} existiert bereits. Möchten Sie sie überschreiben? + + + Ändern + + + Neuen Dateinamen eingeben + + + Dateiname darf nicht leer sein + + + Passwort in Zwischenablage kopiert + + + Log-Stufe + + + Design + + + Schriftgröße + + + {0} Dateien aufräumen + + + Vorkonfigurierte Verbindungen + + + Hostname + + + Benutzername + + + Passwort + + + Verbindung fehlgeschlagen + + + Lokal + + + Entfernt + + + Lokal + + + Server + + + Suchpfade + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.resx b/OpenSSH_GUI/Resources/StringsAndTexts.resx index 94e236e..75f14d8 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.resx +++ b/OpenSSH_GUI/Resources/StringsAndTexts.resx @@ -102,11 +102,8 @@ Edit "known_hosts" file - - Found - - - keypairs in SSH directory + + Found {0} keypairs in SSH directory Delete {0}? @@ -198,6 +195,7 @@ Filename does already exist! + Status: {0} @@ -265,13 +263,13 @@ Enter the password: - Provide password for key {0} + Provide password for key Password incorrect! - You provided a wrong password. Try {0} of {1}. Do you want to try again? + You provided a wrong password. Do you want to try again? Change Filename @@ -288,12 +286,6 @@ Days - - Cleanup - - - files - Clear whole cache @@ -312,4 +304,106 @@ Provide the correct phrase to continue + + Reloading keys... + + + Key is not password protected + + + Key is password protected and cannot be used until a password is provided + + + Key is password protected and opened with correct password + + + Provide Password + + + Open Fileinfo Window + + + Found {0} associated Files + + + Change password of this key + + + KeyFormat + + + Current: + + + Change format to the {0} format + + + Password + + + Change password + + + Enter a new password for key {0} + + + Confirm File Overwrite + + + The keyfile {0} already exist. Do you want to overwrite it? + + + ChangeMe + + + Enter new filename + + + Filename cannot be empty + + + Password copied to clipboard + + + LogLevel + + + Theme + + + Font Size + + + Cleanup {0} files + + + Preconfigured Connections + + + Hostname + + + Username + + + Password + + + Connection failed + + + Local + + + Remote + + + Local + + + Server + + + Lookup Paths + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Styles/ColorsDark.axaml b/OpenSSH_GUI/Resources/Styles/ColorsDark.axaml new file mode 100644 index 0000000..a4dba5d --- /dev/null +++ b/OpenSSH_GUI/Resources/Styles/ColorsDark.axaml @@ -0,0 +1,146 @@ + + + + #FF0D1117 + + + #FF161B22 + + + #FF1C2128 + + + + #800D1117 + + + + #FF21262D + + + + #FF30363D + + + + #264EC9B0 + + + + #334EC9B0 + + + + #FFE6EDF3 + + + #FF8B949E + + + + #FFC8A87E + + + + #FF4EC9B0 + + + #FF6DD4BE + + + #FF0D1117 + + + + #FFF0A500 + + + #FFFFB929 + + + #FF0D1117 + + + + + + #FF3FB950 + + + + #FFDB8C00 + + + + #FF6E7681 + + + + #FF2EA472 + + + + #FFCF4949 + + + + #FFFF7A1A + + + + + + #FF79B8FF + + + + #FFFF6B4A + + + + #FFBC8CDF + + + + #FF8BC34A + + + + #FF45D0D0 + + + + #FFB5C77A + + + + #FF8B7EC8 + + + + #FFFF5F57 + + + #FFFFBD2E + + + #FF28C840 + + + #FF1A7A7A + #FF1D8E8E + #FF155F5F + #FF0F4040 + #FF155F5F + #FFE6EDF3 + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Styles/ColorsLight.axaml b/OpenSSH_GUI/Resources/Styles/ColorsLight.axaml new file mode 100644 index 0000000..f744fd9 --- /dev/null +++ b/OpenSSH_GUI/Resources/Styles/ColorsLight.axaml @@ -0,0 +1,127 @@ + + + + #FFF0F4F8 + + + #FFE2E8EF + + + #FFFFFFFF + + + #80F0F4F8 + + + + #FF2D333B + + + + #FFB8C5D0 + + + #401A8A75 + + + #401A8A75 + + + + #FF1A2332 + + + #FF5A6A7A + + + #FF7A6442 + + + + #FF1A8A75 + + + #FF157A67 + + + #FFFFFFFF + + + + #FFC07800 + + + #FFA06400 + + + #FFFFFFFF + + + + #FF1A7A2F + + + #FFB86800 + + + #FF7A8A97 + + + #FF1E6B47 + + + #FF9B2020 + + + #FFC05A00 + + + + #FF3B6FD4 + + + #FFD4350A + + + #FF6B3A9B + + + #FF5A8A1A + + + #FF007A7A + + + #FF6B7A2A + + + #FF4E3FAA + + + + #FFD93025 + + + #FFC07800 + + + #FF1E8C34 + + + #FF007A7A + #FF006868 + #FF005555 + #FFB0CECE + #FF005555 + #FFFFFFFF + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Styles/FlyoutButtonStyles.axaml b/OpenSSH_GUI/Resources/Styles/FlyoutButtonStyles.axaml new file mode 100644 index 0000000..e02a069 --- /dev/null +++ b/OpenSSH_GUI/Resources/Styles/FlyoutButtonStyles.axaml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Styles/SshKeyFileStyle.axaml b/OpenSSH_GUI/Resources/Styles/SshKeyFileStyle.axaml index 40df699..5a105bd 100644 --- a/OpenSSH_GUI/Resources/Styles/SshKeyFileStyle.axaml +++ b/OpenSSH_GUI/Resources/Styles/SshKeyFileStyle.axaml @@ -4,66 +4,29 @@ xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources" xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" xmlns:converters="clr-namespace:OpenSSH_GUI.Converters" - xmlns:keys="clr-namespace:OpenSSH_GUI.Core.Lib.Keys;assembly=OpenSSH_GUI.Core" - xmlns:keygen="clr-namespace:SshNet.Keygen;assembly=SshNet.Keygen"> - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:keys="clr-namespace:OpenSSH_GUI.Core.Lib.Keys;assembly=OpenSSH_GUI.Core"> + + + + + + - - - - - - - - + + + + - - - - - - - - - + + + @@ -74,215 +37,111 @@ + Text="{Binding Format, Converter={x:Static converters:Converter.FormatToStringConverter}}" /> - - - - - - - - - + + + + + + + + + + + - - - - - - - - - DarkCyan - #00838F - #006064 - #004F4F - - #006064 - - White - White - White - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/Styles/ThemeResource.axaml b/OpenSSH_GUI/Resources/Styles/ThemeResource.axaml new file mode 100644 index 0000000..73064b1 --- /dev/null +++ b/OpenSSH_GUI/Resources/Styles/ThemeResource.axaml @@ -0,0 +1,82 @@ + + + #FF4EC9B0 + #FF0D1117 + #FFFF5F57 + + + #FF000000 + #CC000000 + #99000000 + #66000000 + #33000000 + + + #FFE6EDF3 + #FFBFC9D3 + #FF8B949E + #FF6A747E + #FF30363D + + + #FFE6EDF3 + #FFFFFFFF + #FF6E7681 + #FF30363D + #FF21262D + #FF1C2128 + #FF161B22 + #FF6E7681 + #FF3D444D + + + #FF000000 + #CC000000 + #99000000 + #33000000 + + + #1A4EC9B0 + #334EC9B0 + + #FF1A8A75 + #FFF0F4F8 + #FFD93025 + + + #FFFFFFFF + #CCFFFFFF + #99FFFFFF + #66FFFFFF + #33FFFFFF + + + #FF1A2332 + #FF2D3A4A + #FF5A6A7A + #FF7A8A97 + #FFB8C5D0 + + + #FF1A2332 + #FFFFFFFF + #FF7A8A97 + #FFB8C5D0 + #FFF5F7FA + #FFEAEFF4 + #FFE2E8EF + #FFB8C5D0 + #FFD5DCE4 + + + #FF000000 + #CC000000 + #99000000 + #33000000 + + + #1A1A8A75 + #331A8A75 + + \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/AddKeyWindowViewModel.cs b/OpenSSH_GUI/ViewModels/AddKeyWindowViewModel.cs index dfc0611..a33f9d7 100644 --- a/OpenSSH_GUI/ViewModels/AddKeyWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/AddKeyWindowViewModel.cs @@ -1,12 +1,18 @@ -using JetBrains.Annotations; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Avalonia; +using Avalonia.Controls; +using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenSSH_GUI.Core.Configuration; using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Core.MVVM; using OpenSSH_GUI.Core.Services; -using OpenSSH_GUI.Dialogs.Enums; using OpenSSH_GUI.Dialogs.Interfaces; using OpenSSH_GUI.Resources; using ReactiveUI; +using ReactiveUI.Avalonia; using ReactiveUI.SourceGenerators; using ReactiveUI.Validation.Abstractions; using ReactiveUI.Validation.Contexts; @@ -18,77 +24,139 @@ namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public sealed partial class AddKeyWindowViewModel : ViewModelBase, IValidatableViewModel +public sealed partial class AddKeyWindowViewModel : ViewModelBase, IValidatableViewModel { - private readonly SshKeyManager _sshKeyManager; + private const string KeyPrefix = "id"; + private readonly IOptionsMonitor _applicationConfigurationMonitor; + private readonly ILogger _logger; private readonly IMessageBoxProvider _messageBoxProvider; + private readonly SshKeyManager _sshKeyManager; + + [Reactive] private ApplicationConfiguration _applicationConfiguration = ApplicationConfiguration.Default; + + [ObservableAsProperty(ReadOnly = true)] + private int[] _availableKeySizes = []; + + [ObservableAsProperty(ReadOnly = true)] + private bool _canChangeKeySize; + + [Reactive] private string _chosenPath = string.Empty; + + [ObservableAsProperty(ReadOnly = true)] + private int _comboBoxFontSize; + + [Reactive] private string _comment = SshKeyGenerateInfo.DefaultSshKeyComment; + + [Reactive] private SshKeyFormat _keyFormat = SshKeyGenerateInfo.DefaultSshKeyFormat; + + [Reactive] private string _keyName = string.Empty; + + [Reactive] private string _password = string.Empty; + + [Reactive] private int _selectedKeySize; + + [Reactive] private SshKeyType _selectedKeyType = SshKeyGenerateInfo.DefaultSshKeyType; public AddKeyWindowViewModel(ILogger logger, SshKeyManager sshKeyManager, - IMessageBoxProvider messageBoxProvider) : base(logger) + IOptionsMonitor applicationConfigurationMonitor, + Application application, + IMessageBoxProvider messageBoxProvider) { + _logger = logger; _sshKeyManager = sshKeyManager; + _applicationConfigurationMonitor = applicationConfigurationMonitor; _messageBoxProvider = messageBoxProvider; - - _keyTypeSubscription = this.WhenAnyValue(x => x.SelectedKeyType) - .Subscribe(type => + _comboBoxFontSize = int.Parse(application.Resources[App.SystemFontSize]?.ToString() ?? "14") - 2; + _applicationConfigurationMonitor.OnChange(conf => + { + ApplicationConfiguration = conf; + })?.DisposeWith(Disposables); + + this.WhenAnyValue(vm => vm.ApplicationConfiguration) + .ObserveOn(AvaloniaScheduler.Instance) + .StartWith(ApplicationConfiguration.Default) + .Subscribe(config => { - try - { - KeyName = $"id_{Enum.GetName(type)!.ToLower()}"; - - var ordered = type.SupportedKeySizes.OrderDescending().ToList(); - AvaliableKeySizes = ordered; - SelectedKeySize = ordered.First(); - CanChangeKeySize = ordered.Count > 1; - } - catch (Exception e) + ChosenPath = config.LookupPaths.FirstOrDefault() ?? string.Empty; + }).DisposeWith(Disposables); + + _comboBoxFontSizeHelper = application.GetResourceObservable(App.SystemFontSize) + .StartWith(application.Resources[App.SystemFontSize]) + .WhereNotNull() + .OfType() + .Select(e => e - 2) + .ToProperty(this, vm => vm.ComboBoxFontSize); + + var selectedKeyTypeChanged = this.WhenAnyValue(vm => vm.SelectedKeyType) + .ObserveOn(AvaloniaScheduler.Instance); + + _availableKeySizesHelper = selectedKeyTypeChanged + .Select(e => e.SupportedKeySizes.OrderDescending().ToArray()) + .ToProperty( + this, vm => vm.AvailableKeySizes, + SshKeyGenerateInfo.DefaultSshKeyType.SupportedKeySizes.OrderDescending().ToArray()) + .DisposeWith(Disposables); + + selectedKeyTypeChanged + .Subscribe(e => + { + if (DefaultKeyNames.Values.Any(keyName => + string.IsNullOrWhiteSpace(KeyName) || + string.Equals(keyName, KeyName, StringComparison.OrdinalIgnoreCase))) + if (DefaultKeyNames.TryGetValue(e, out var defaultKeyName)) + KeyName = defaultKeyName; + + SelectedKeySize = e switch { - Logger.LogError(e, "Error reacting to key type change"); - } - }); + SshKeyType.ECDSA => SshKeyGenerateInfo.DefaultEcdsaSshKeyLength, + SshKeyType.ED25519 => SshKeyGenerateInfo.DefaultEd25519SshKeyLength, + SshKeyType.RSA => SshKeyGenerateInfo.DefaultRsaSshKeyLength, + _ => SshKeyGenerateInfo.DefaultSshKeyType.SupportedKeySizes.Max() + }; + }) + .DisposeWith(Disposables); + + _canChangeKeySizeHelper = this.WhenAnyValue(vm => vm.AvailableKeySizes) + .Select(e => e.Length > 1) + .ToProperty( + this, vm => vm.CanChangeKeySize, + initialValue: SshKeyGenerateInfo.DefaultSshKeyType.SupportedKeySizes.Any()) + .DisposeWith(Disposables); + + this.WhenAnyValue(vm => vm.KeyName) + .ObserveOn(AvaloniaScheduler.Instance) + .Subscribe(name => + { + if (string.IsNullOrWhiteSpace(name) && DefaultKeyNames.TryGetValue(SelectedKeyType, out var value)) + KeyName = value; + }).DisposeWith(Disposables); - KeyNameValidationHelper = this.ValidationRule(e => e.KeyName, IsPropertyValid, StringsAndTexts.AddKeyWindowFilenameError); - SelectedKeyType = SshKeyTypes.First(); + KeyNameValidationHelper = + this.ValidationRule(e => e.KeyName, IsPropertyValid, StringsAndTexts.AddKeyWindowFilenameError) + .DisposeWith(Disposables); } - private static bool IsPropertyValid(string? arg) - { - if(string.IsNullOrWhiteSpace(arg)) return false; - return !File.Exists(Path.Combine(SshConfigFilesExtension.GetBaseSshPath(), arg)); - } + public static IDictionary DefaultKeyNames { get; } = Enum.GetValues() + .Select(type => + new KeyValuePair(type, string.Join("_", KeyPrefix, Enum.GetName(type)!.ToLower()))) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); public static SshKeyType[] SshKeyTypes { get; } = Enum.GetValues(); public static SshKeyFormat[] SshKeyFormats { get; } = Enum.GetValues(); - private readonly IDisposable _keyTypeSubscription; - - [Reactive] - private SshKeyType _selectedKeyType; - - [Reactive] - private IEnumerable _avaliableKeySizes = []; - - [Reactive] - private int _selectedKeySize; - - [Reactive] - private SshKeyFormat _keyFormat = SshKeyFormat.OpenSSH; - - [Reactive] - private string _keyName = "id_rsa"; - - [Reactive] - private bool _canChangeKeySize; - - public string Comment { get; set; } = $"{Environment.UserName}@{Environment.MachineName}"; - public string Password { get; set; } = ""; public ValidationHelper KeyNameValidationHelper { get; } public IValidationContext ValidationContext { get; } = new ValidationContext(); - + + private bool IsPropertyValid(string? arg) + { + if (string.IsNullOrWhiteSpace(arg)) return false; + return !File.Exists(Path.Combine(ChosenPath, arg)); + } + /// - protected override async Task OnBooleanSubmitAsync( + protected override async Task BooleanSubmitAsync( bool inputParameter, CancellationToken cancellationToken = default) { @@ -114,22 +182,15 @@ protected override async Task OnBooleanSubmitAsync( if (!string.IsNullOrWhiteSpace(Comment)) genParm.Comment = Comment; - await _sshKeyManager.GenerateNewKey(fullNewFilePath, genParm); + var genResult = await _sshKeyManager.GenerateNewKey(fullNewFilePath, genParm, true); + genResult.ThrowIfFailure(); + CloseOnBooleanSubmit = true; } catch (Exception e) { - Logger.LogError(e, "Error creating key"); - await _messageBoxProvider.ShowMessageBoxAsync( - StringsAndTexts.Error, - e.Message, - MessageBoxButtons.Ok, - MessageBoxIcon.Error); + _logger.LogError(e, "Error creating key"); + await _messageBoxProvider.ShowErrorMessageBoxAsync(e, StringsAndTexts.Error); + CloseOnBooleanSubmit = false; } } - - public override void Dispose() - { - _keyTypeSubscription.Dispose(); - base.Dispose(); - } } \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs b/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs index 57a6a6f..79f4d8d 100644 --- a/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs +++ b/OpenSSH_GUI/ViewModels/ApplicationSettingsViewModel.cs @@ -1,84 +1,242 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Reactive; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; +using Avalonia; +using Avalonia.Platform.Storage; using JetBrains.Annotations; +using Material.Icons; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Configuration; +using OpenSSH_GUI.Core.Enums; +using OpenSSH_GUI.Core.Interfaces; using OpenSSH_GUI.Core.MVVM; +using OpenSSH_GUI.Dialogs.Enums; using OpenSSH_GUI.Dialogs.Interfaces; +using OpenSSH_GUI.Dialogs.Models; using OpenSSH_GUI.Resources; using ReactiveUI; +using ReactiveUI.Avalonia; using ReactiveUI.SourceGenerators; -using Serilog; using Serilog.Core; using Serilog.Events; namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public partial class ApplicationSettingsViewModel : ViewModelBase +public partial class ApplicationSettingsViewModel : ViewModelBase { + private readonly Application _application; + private readonly ILauncher _launcher; + private readonly LoggingLevelSwitch _levelSwitch; private readonly ILogger _logger; private readonly IMessageBoxProvider _messageBoxProvider; - private readonly LoggingLevelSwitch _levelSwitch; - public static LogEventLevel[] AvailableLogLevels { get; }= Enum.GetValues(); - public static int[] DaysToDelete { get; } = Enumerable.Range(1, 4).Select(i => i * 7).ToArray(); - private readonly IDisposable _levelSwitchSubscription; - private readonly IDisposable _daysToDeleteSubscription; - - [Reactive] - private LogEventLevel _currentLogLevel; - - [Reactive] - private int _daysToDeleteSelected; - - public ObservableCollection LogFiles { get; } = []; - - [ObservableAsProperty] - private bool _canDeleteOldLogFiles; - - public ApplicationSettingsViewModel(ILogger logger, IMessageBoxProvider messageBoxProvider, LoggingLevelSwitch levelSwitch) + private readonly IMutableConfiguration _mutableConfiguration; + private readonly IStorageProvider _storageProvider; + + [ObservableAsProperty(ReadOnly = true)] + private ApplicationConfiguration _applicationConfiguration = ApplicationConfiguration.Default; + + [Reactive] private bool _canDeleteOldLogFiles; + + [Reactive] private LogEventLevel _currentLogLevel; + + [Reactive] private ThemeVariant _currentThemeVariant; + + [Reactive] private int _daysToDeleteSelected; + + [Reactive] private double _fontSize; + + public ApplicationSettingsViewModel(ILogger logger, + IMutableConfiguration mutableConfiguration, + ILauncher launcher, + IStorageProvider storageProvider, + IMessageBoxProvider messageBoxProvider, + Application application, + LoggingLevelSwitch levelSwitch) { _logger = logger; + _mutableConfiguration = mutableConfiguration; + _launcher = launcher; + _storageProvider = storageProvider; _messageBoxProvider = messageBoxProvider; _levelSwitch = levelSwitch; - CurrentLogLevel = levelSwitch.MinimumLevel; - _levelSwitchSubscription = this.WhenAnyValue(model => model.CurrentLogLevel) + _application = application; + _fontSize = _mutableConfiguration.Current.FontSize; + _currentLogLevel = _mutableConfiguration.Current.LogLevel; + _currentThemeVariant = _mutableConfiguration.Current.PreferredTheme; + _daysToDeleteSelected = DaysToDelete[0]; + + _applicationConfigurationHelper = Observable.FromEventPattern( + handler => mutableConfiguration.ConfigurationChanged += handler, + handler => mutableConfiguration.ConfigurationChanged -= handler) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(pattern => pattern.EventArgs) + .StartWith(mutableConfiguration.Current) + .ToProperty(this, vm => vm.ApplicationConfiguration) + .DisposeWith(Disposables); + + Observable + .FromEventPattern( + handler => levelSwitch.MinimumLevelChanged += handler, + handler => levelSwitch.MinimumLevelChanged -= handler + ).ObserveOn(AvaloniaScheduler.Instance) + .Select(pattern => Observable.FromAsync(async () => + { + await OnNextLevel(pattern.EventArgs); + return Unit.Default; + })) + .Switch() + .Subscribe( + _ => { }, + ex => logger.LogError(ex, "Error while changing loglevel") + ) + .DisposeWith(Disposables); + + this.WhenAnyValue(model => model.CurrentLogLevel) + .Skip(1) .DistinctUntilChanged() - .Subscribe(OnNext); - - _daysToDeleteSubscription = this.WhenAnyValue(model => model.DaysToDeleteSelected) - .Subscribe(OnNext); + .Throttle(TimeSpan.FromMilliseconds(300)) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(x => Observable.FromAsync(async () => + { + await OnNextLevel(new LoggingLevelSwitchChangedEventArgs(_levelSwitch.MinimumLevel, x)); + return Unit.Default; + })) + .Switch() + .Subscribe( + _ => { }, + ex => logger.LogError(ex, "Error while changing loglevel") + ) + .DisposeWith(Disposables); - _canDeleteOldLogFilesHelper = this.WhenAnyValue(model => model.LogFiles.Count) + this.WhenAnyValue(model => model.DaysToDeleteSelected) + .ObserveOn(AvaloniaScheduler.Instance) + .Subscribe(OnNextDaysToDelete) + .DisposeWith(Disposables); + + this.WhenAnyValue(vm => vm.LogFiles.Count) + .ObserveOn(AvaloniaScheduler.Instance) + .DistinctUntilChanged() + .Subscribe(count => { CanDeleteOldLogFiles = count > 0; }) + .DisposeWith(Disposables); + + this.WhenAnyValue(vm => vm.CurrentThemeVariant) + .Skip(1) + .ObserveOn(AvaloniaScheduler.Instance) + .DistinctUntilChanged() + .Subscribe(OnNextTheme) + .DisposeWith(Disposables); + + this.WhenAnyValue(vm => vm.FontSize) + .Skip(1) .DistinctUntilChanged() - .Select(e => e > 0) - .ToProperty(this, model => model.CanDeleteOldLogFiles); - - DeleteOldLogFilesCommand = ReactiveCommand.Create(DeleteOldLogFiles); - ClearWholeCacheCommand = ReactiveCommand.CreateFromTask(ClearWholeCache); - DaysToDeleteSelected = DaysToDelete[0]; + .Throttle(TimeSpan.FromMilliseconds(300)) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(x => Observable.FromAsync(async () => + { + await OnNextFontSize(x); + return Unit.Default; + })) + .Switch() + .Subscribe( + _ => { }, + ex => logger.LogError(ex, "Error while changing font size") + ) + .DisposeWith(Disposables); + } + + public static LogEventLevel[] AvailableLogLevels { get; } = Enum.GetValues(); + public static ThemeVariant[] ThemeVariants { get; } = Enum.GetValues(); + public static int[] DaysToDelete { get; } = Enumerable.Range(1, 4).Select(i => i * 7).ToArray(); + + public ObservableCollection LogFiles { get; } = []; + + + [ReactiveCommand] + private Task DeleteLookupPathAsync(string path, CancellationToken cancellationToken = default) => + _mutableConfiguration.SetPropertyValueAsync(conf => conf.LookupPaths, _mutableConfiguration.Current.LookupPaths.Where(p => p != path).ToArray(), cancellationToken); + + [ReactiveCommand] + private async Task AddLookupPathAsync(CancellationToken cancellationToken = default) + { + if (await _storageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + AllowMultiple = false + }) is { Count: > 0 } folders) + { + foreach (var folder in folders) + { + var localPathNullable = folder.TryGetLocalPath(); + _logger.LogDebug("Checking folder: {Path}", localPathNullable); + if (localPathNullable is null) + { + _logger.LogWarning("Folder {Path} is not accessible", folder.Path); + await _messageBoxProvider.ShowMessageBoxAsync( + new MessageBoxParams + { + Buttons = MessageBoxButtons.Ok, + Icon = MaterialIconKind.FolderRemoveOutline, + Message = "This folder is not accessible.", + Title = "Folder not accessible" + }); + continue; + } + if (_mutableConfiguration.Current.LookupPaths.Contains(localPathNullable)) + { + _logger.LogWarning("Folder {Path} is already in the lookup paths", localPathNullable); + await _messageBoxProvider.ShowMessageBoxAsync( + new MessageBoxParams + { + Buttons = MessageBoxButtons.Ok, + Icon = MaterialIconKind.FolderRemoveOutline, + Message = "This folder is already in the lookup paths.", + Title = "Folder already in lookup paths" + }); + continue; + } + _logger.LogDebug("Adding lookup path: {Path}", folder); + await _mutableConfiguration.SetPropertyValueAsync( + conf => conf.LookupPaths, _mutableConfiguration.Current.LookupPaths.Append(localPathNullable).ToArray(), cancellationToken); + } + } + } + + [ReactiveCommand] + private async Task OnNextFontSize(double obj) + { + _application.Resources[App.SystemFontSize] = obj; + await _mutableConfiguration.SetPropertyValueAsync(conf => conf.FontSize, obj); } - - public ReactiveCommand DeleteOldLogFilesCommand { get; } - public ReactiveCommand ClearWholeCacheCommand { get; } + + [ReactiveCommand] private async Task ClearWholeCache(CancellationToken cancellationToken = default) { - var loggerConfiguration = Core.Configuration.LoggerConfiguration.Default; - var cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDomain.CurrentDomain.FriendlyName); - if ((await _messageBoxProvider.ShowValidatedInputAsync(StringsAndTexts.ApplicationSettingsViewModelAreYouSure, - string.Format(StringsAndTexts.ApplicationSettingsViewModelConfirmMessageBoxContent.Replace("\\n", Environment.NewLine), cachePath, StringsAndTexts.ApplicationSettingsViewModelConfirmDialogConfirmValue), + var loggerConfiguration = LoggerConfiguration.Default; + var cachePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppDomain.CurrentDomain.FriendlyName); + if (await _messageBoxProvider.ShowValidatedInputAsync( + StringsAndTexts.ApplicationSettingsViewModelAreYouSure, + string.Format( + StringsAndTexts.ApplicationSettingsViewModelConfirmMessageBoxContent.Replace( + "\\n", + Environment.NewLine), cachePath, + StringsAndTexts.ApplicationSettingsViewModelConfirmDialogConfirmValue), inputToValidate => { ArgumentException.ThrowIfNullOrWhiteSpace(inputToValidate); - return string.Equals(inputToValidate, StringsAndTexts.ApplicationSettingsViewModelConfirmDialogConfirmValue, StringComparison.Ordinal) + return string.Equals( + inputToValidate, + StringsAndTexts.ApplicationSettingsViewModelConfirmDialogConfirmValue, StringComparison.Ordinal) ? null : StringsAndTexts.ApplicationSettingsViewModelConfirmationError; - })) is { IsConfirmed: false }) return; + }) is { IsConfirmed: false }) return; var stopWatch = Stopwatch.StartNew(); foreach (var file in Directory.EnumerateFiles(cachePath, "*", SearchOption.AllDirectories)) - { try { File.Delete(file); @@ -88,12 +246,11 @@ private async Task ClearWholeCache(CancellationToken cancellationToken = default { _logger.LogError(e, "Error deleting file: {File}", file); } - } + foreach (var directory in Directory.EnumerateDirectories(cachePath, "*", SearchOption.AllDirectories)) - { try { - if(directory == loggerConfiguration.LogFilePath) continue; + if (directory == loggerConfiguration.LogFilePath) continue; Directory.Delete(directory, true); _logger.LogInformation("Deleted directory: {Directory}", directory); } @@ -101,15 +258,15 @@ private async Task ClearWholeCache(CancellationToken cancellationToken = default { _logger.LogError(e, "Error deleting directory: {Directory}", directory); } - } + stopWatch.Stop(); _logger.LogInformation("Cache cleared in {ElapsedTime} ms", stopWatch.Elapsed.Milliseconds); } - + + [ReactiveCommand] private void DeleteOldLogFiles() { foreach (var logFile in LogFiles) - { try { if (!File.Exists(logFile)) continue; @@ -120,34 +277,63 @@ private void DeleteOldLogFiles() { _logger.LogError(e, "Failed to delete log file: {LogFile}", logFile); } - } + LogFiles.Clear(); } - private void OnNext(int obj) + [ReactiveCommand] + private Task OpenCacheFolder(CancellationToken token = default) => _launcher.LaunchDirectoryInfoAsync( + new DirectoryInfo( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + AppDomain.CurrentDomain.FriendlyName))); + + private async void OnNextTheme(ThemeVariant variant) { - _logger.LogDebug("Days to delete selected: {Days}", obj); - var logConfiguration = Core.Configuration.LoggerConfiguration.Default; - LogFiles.Clear(); - foreach (var logFile in Directory.EnumerateFiles(logConfiguration.LogFilePath, "*.log", SearchOption.TopDirectoryOnly)) + try { - var extractedDate = Path.GetFileName(logFile).Replace(AppDomain.CurrentDomain.FriendlyName, string.Empty)[..8]; - if(DateOnly.TryParseExact(extractedDate, "yyyyMMdd", out var dateTime) && DateTime.Now.Subtract(dateTime.ToDateTime(TimeOnly.MinValue)) > TimeSpan.FromDays(obj)) - LogFiles.Add(logFile); + var themeVariant = variant switch + { + ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark, + ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light, + _ => Avalonia.Styling.ThemeVariant.Default + }; + if (_application.ActualThemeVariant == themeVariant) return; + _logger.LogDebug( + "Changing Theme Variant from {OldThemeVariant} to {ThemeVariant}", + _application.ActualThemeVariant.Key.ToString(), themeVariant.Key); + _application.RequestedThemeVariant = themeVariant; + await _mutableConfiguration.SetPropertyValueAsync(conf => conf.PreferredTheme, variant); + } + catch (Exception e) + { + _logger.LogError(e, "Error while changing Theme Variant from {OldThemeVariant} to {ThemeVariant}", _application.ActualThemeVariant.Key.ToString(), variant.ToString()); } } - private void OnNext(LogEventLevel obj) + private void OnNextDaysToDelete(int obj) { - _levelSwitch.MinimumLevel = obj; - _logger.LogCritical("Log level changed to {LogLevel}", obj); + var logConfiguration = LoggerConfiguration.Default; + LogFiles.Clear(); + foreach (var logFile in Directory.EnumerateFiles( + logConfiguration.LogFilePath, "*.log", + SearchOption.TopDirectoryOnly)) + { + var fileName = Path.GetFileName(logFile).Replace(AppDomain.CurrentDomain.FriendlyName, string.Empty); + if (fileName.Length < 8) continue; + var extractedDate = fileName[..8]; + if (DateOnly.TryParseExact(extractedDate, "yyyyMMdd", out var dateTime) && + DateTime.Now.Subtract(dateTime.ToDateTime(TimeOnly.MinValue)) > TimeSpan.FromDays(obj)) + LogFiles.Add(logFile); + } } - public override void Dispose() + private async Task OnNextLevel(LoggingLevelSwitchChangedEventArgs obj) { - _levelSwitchSubscription.Dispose(); - _daysToDeleteSubscription.Dispose(); - GC.SuppressFinalize(this); - base.Dispose(); + if (_levelSwitch.MinimumLevel == obj.NewLevel) + return; + _levelSwitch.MinimumLevel = obj.NewLevel; + _logger.LogCritical("Log level changed from {OldLogLevel} to {NewLogLevel}", obj.OldLevel, obj.NewLevel); + await _mutableConfiguration.SetPropertyValueAsync(conf => conf.LogLevel, obj.NewLevel); } } \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/ConnectToServerViewModel.cs b/OpenSSH_GUI/ViewModels/ConnectToServerViewModel.cs index a8dca63..84d0083 100644 --- a/OpenSSH_GUI/ViewModels/ConnectToServerViewModel.cs +++ b/OpenSSH_GUI/ViewModels/ConnectToServerViewModel.cs @@ -1,15 +1,19 @@ -using System.Reactive; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Reactive; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Avalonia; using Avalonia.Media; using JetBrains.Annotations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Extensions; -using OpenSSH_GUI.Core.Interfaces.Credentials; -using OpenSSH_GUI.Core.Lib.Credentials; 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; using OpenSSH_GUI.Dialogs.Interfaces; using OpenSSH_GUI.Resources; using OpenSSH_GUI.SshConfig.Models; @@ -19,30 +23,68 @@ namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public sealed partial class ConnectToServerViewModel : ViewModelBase +public sealed partial class ConnectToServerViewModel : ViewModelBase { - private readonly ServerConnectionService _serverConnectionService; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; private readonly IMessageBoxProvider _messageBoxProvider; - private readonly IDisposable _hostSettingsSubscription; - private readonly IDisposable _keyComboBoxEnabledSubscription; - private readonly IDisposable _connectionCredentialsSubscription; + private readonly ServerConnectionService _serverConnectionService; + + [Reactive] private bool _authWithAllKeys; + + [Reactive] private bool _authWithPublicKey; + + [Reactive] private bool _canConnectToServer; + + [Reactive] private ConnectionCredentials? _connectionCredentials; + + [Reactive(SetModifier = AccessModifier.Private)] + private bool _enablePreConfiguredHosts; + + [Reactive] private string _hostName = string.Empty; + + [Reactive] private bool _keyComboBoxEnabled; + + [Reactive] private string _password = string.Empty; + [Reactive] private SshHostSettings? _selectedHostSettings; + + [Reactive] private SshKeyFile? _selectedPublicKey; + + [ReactiveCollection] private ObservableCollection _sshHostSettings = []; + + [Reactive] private SshKeyManager _sshKeyManager; + + [Reactive] private IBrush _statusButtonBackground = + Application.Current?.Resources["OverlayBrush"] as IBrush ?? Brushes.Gray; + + [Reactive] private string _statusButtonText = string.Format( + StringsAndTexts.ConnectToServerStatusBase, + StringsAndTexts.ConnectToServerStatusUnknown); + + [Reactive] private string _statusButtonToolTip = string.Format( + StringsAndTexts.ConnectToServerStatusBase, + StringsAndTexts.ConnectToServerStatusUntested); + + [Reactive] private bool _tryingToConnect; + + [Reactive] private string _username = string.Empty; public ConnectToServerViewModel(ILogger logger, ServerConnectionService serverConnectionService, IMessageBoxProvider messageBoxProvider, IConfiguration configuration, - SshKeyManager sshKeyManager) : base(logger) + SshKeyManager sshKeyManager) { + _logger = logger; _messageBoxProvider = messageBoxProvider; + _configuration = configuration; _serverConnectionService = serverConnectionService; SshKeyManager = sshKeyManager; SelectedPublicKey = SshKeyManager.SshKeys.FirstOrDefault(); - TestConnection = ReactiveCommand.CreateFromTask(TestConnectionAsync); - ResetCommand = ReactiveCommand.Create(Reset); - _hostSettingsSubscription = this + this .WhenAnyValue(viewModel => viewModel.SelectedHostSettings) - .Subscribe(async settings => + .SelectMany(async settings => { try { @@ -53,112 +95,90 @@ public ConnectToServerViewModel(ILogger logger, { logger.LogError(e, "Error testing connection"); } - }); - - _keyComboBoxEnabledSubscription = this - .WhenAnyValue(viewModel => viewModel.AuthWithPublicKey, model => model.AuthWithAllKeys, model => model._serverConnectionService.IsConnected) - .Subscribe((tuple) => - { - KeyComboBoxEnabled = tuple is { Item3: false, Item1: true, Item2: false }; - }); - _connectionCredentialsSubscription = this.WhenAnyValue(viewModel => viewModel.ConnectionCredentials) - .Subscribe(credentials => - { - CanConnectToServer = credentials is not null; - }); - + return Unit.Default; + }) + .Subscribe() + .DisposeWith(Disposables); + + this + .WhenAnyValue( + viewModel => viewModel.AuthWithPublicKey, model => model.AuthWithAllKeys, + model => model._serverConnectionService.IsConnected) + .Subscribe(tuple => { KeyComboBoxEnabled = tuple is { Item3: false, Item1: true, Item2: false }; }) + .DisposeWith(Disposables); + + this.WhenAnyValue(viewModel => viewModel.ConnectionCredentials) + .Subscribe(credentials => { CanConnectToServer = credentials is not null; }).DisposeWith(Disposables); + + Observable + .FromEventPattern( + h => ((INotifyCollectionChanged)SshHostSettings).CollectionChanged += h, + h => ((INotifyCollectionChanged)SshHostSettings).CollectionChanged -= h) + .Select(_ => SshHostSettings.Count) + .StartWith(SshHostSettings.Count) + .Subscribe(count => { EnablePreConfiguredHosts = count > 0; }) + .DisposeWith(Disposables); + } + + public override ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { try { - var config = configuration.GetSection("SshConfig").Get(); - SshHostSettings = config?.Hosts.Distinct() ?? []; + SshHostSettings.Clear(); + foreach (var hostSettings in _configuration.GetSection("SshConfig").Get()?.Hosts + .Distinct() ?? []) + { + _logger.LogDebug("Found host {host}", hostSettings.HostName); + SshHostSettings.Add(hostSettings); + } } catch (Exception e) { - SshHostSettings = []; - logger.LogDebug(e, "Config not readable"); + _logger.LogDebug(e, "Config not readable"); } - } - [Reactive] - private IConnectionCredentials? _connectionCredentials; - - [Reactive] private bool _canConnectToServer; - - public ReactiveCommand TestConnection { get; } - public ReactiveCommand ResetCommand { get; } - public SshKeyManager SshKeyManager { get; } - public bool EnablePreConfiguredHosts => SshHostSettings.Any(); - [Reactive] private SshHostSettings? _selectedHostSettings; - - public IEnumerable SshHostSettings { get; } - - [Reactive] private bool _authWithPublicKey; - - [Reactive] private bool _authWithAllKeys; - - [Reactive] private SshKeyFile? _selectedPublicKey; - - [Reactive] private string _hostName = string.Empty; - - [Reactive] private string _username = string.Empty; - - [Reactive] private string _password = string.Empty; - - [Reactive] private bool _tryingToConnect; - - [Reactive] private string _statusButtonToolTip = string.Format(StringsAndTexts.ConnectToServerStatusBase, - StringsAndTexts.ConnectToServerStatusUntested); - - [Reactive] private string _statusButtonText = string.Format(StringsAndTexts.ConnectToServerStatusBase, - StringsAndTexts.ConnectToServerStatusUnknown); - - [Reactive] private IBrush _statusButtonBackground = Brushes.Gray; - - [Reactive] private bool _keyComboBoxEnabled; + return base.InitializeAsync(cancellationToken); + } - private async Task TestConnectionAsyncBase(CancellationToken cancellationToken = default) + private void TestConnectionAsyncBase() { if (ConnectionCredentials is not null) { - StatusButtonText = string.Format(StringsAndTexts.ConnectToServerStatusBase, + StatusButtonText = string.Format( + StringsAndTexts.ConnectToServerStatusBase, StringsAndTexts.ConnectToServerStatusSuccess); StatusButtonToolTip = string.Format(StringsAndTexts.ConnectToServerSshConnectionString, Username, HostName); - StatusButtonBackground = Brushes.Green; + StatusButtonBackground = Application.Current?.Resources["SuccessBrush"] as IBrush ?? Brushes.Green; } else { - StatusButtonText = string.Format(StringsAndTexts.ConnectToServerStatusBase, + StatusButtonText = string.Format( + StringsAndTexts.ConnectToServerStatusBase, StringsAndTexts.ConnectToServerStatusFailed); - StatusButtonBackground = Brushes.Red; + StatusButtonBackground = Application.Current?.Resources["ErrorBrush"] as IBrush ?? Brushes.Red; } TryingToConnect = false; - - if (_serverConnectionService.IsConnected) - { - await _serverConnectionService.CloseConnection(false, cancellationToken); - return; - } - - await _messageBoxProvider!.ShowMessageBoxAsync(StringsAndTexts.Error, StatusButtonToolTip, MessageBoxButtons.Ok, - MessageBoxIcon.Error); } - private async Task TestConnectionAsync(SshHostSettings? hostSettings = null, CancellationToken cancellationToken = default) + private async ValueTask TestConnectionAsync(SshHostSettings? hostSettings = null, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(hostSettings); if (hostSettings.IdentityFiles is null) { - StatusButtonText = string.Format(StringsAndTexts.ConnectToServerStatusBase, + StatusButtonText = string.Format( + StringsAndTexts.ConnectToServerStatusBase, StringsAndTexts.ConnectToServerStatusFailed); - StatusButtonBackground = Brushes.Red; + StatusButtonBackground = Application.Current?.Resources["ErrorBrush"] as IBrush ?? Brushes.Red; return; } using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedTokenSource.CancelAfter(TimeSpan.FromSeconds(5)); + if (!Debugger.IsAttached) + linkedTokenSource.CancelAfter(TimeSpan.FromSeconds(5)); try { @@ -171,16 +191,16 @@ private async Task TestConnectionAsync(SshHostSettings? hostSettings = null, Can .Select(f => f.ResolvePath()) .ToHashSet(StringComparer.Ordinal); - var keys = (SshKeyManager.SshKeys ?? []) - .Where(e => e.KeyFileInfo?.KeyFileSource?.AbsolutePath is { } path + var keys = SshKeyManager.SshKeys + .Where(e => e.KeyFileInfo?.KeyFileSource.AbsolutePath is { } path && resolvedPaths.Contains(path)); var connectionCredentials = new MultiKeyConnectionCredentials( hostSettings.HostName ?? string.Empty, hostSettings.User ?? string.Empty, keys); - - if(await _serverConnectionService.EstablishConnection(connectionCredentials, linkedTokenSource.Token)) + + if (await _serverConnectionService.EstablishConnection(connectionCredentials, linkedTokenSource.Token)) ConnectionCredentials = connectionCredentials; } catch (Exception exception) @@ -188,31 +208,28 @@ private async Task TestConnectionAsync(SshHostSettings? hostSettings = null, Can StatusButtonToolTip = exception.Message; } - await TestConnectionAsyncBase(cancellationToken); + TestConnectionAsyncBase(); } - private async Task TestConnectionAsync(CancellationToken cancellationToken = default) + [ReactiveCommand] + private async ValueTask TestConnectionAsync(CancellationToken cancellationToken = default) { using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); linkedTokenSource.CancelAfter(TimeSpan.FromSeconds(5)); try { - if(string.IsNullOrWhiteSpace(HostName) || string.IsNullOrWhiteSpace(Username) || (SelectedPublicKey is null && string.IsNullOrWhiteSpace(Password))) + if (string.IsNullOrWhiteSpace(HostName) || string.IsNullOrWhiteSpace(Username) || + SelectedPublicKey is null && string.IsNullOrWhiteSpace(Password)) throw new ArgumentException(StringsAndTexts.ConnectToServerValidationError); TryingToConnect = true; - IConnectionCredentials? connectionCredentials = null; + ConnectionCredentials? connectionCredentials; if (AuthWithPublicKey) - { connectionCredentials = new KeyConnectionCredentials(HostName, Username, SelectedPublicKey); - } else if (AuthWithAllKeys) - { + else if (AuthWithAllKeys) connectionCredentials = new MultiKeyConnectionCredentials(HostName, Username, SshKeyManager.SshKeys); - } else - { connectionCredentials = new PasswordConnectionCredentials(HostName, Username, Password); - } - if(await _serverConnectionService.EstablishConnection(connectionCredentials, linkedTokenSource.Token)) + if (await _serverConnectionService.EstablishConnection(connectionCredentials, linkedTokenSource.Token)) ConnectionCredentials = connectionCredentials; } catch (Exception exception) @@ -220,49 +237,44 @@ private async Task TestConnectionAsync(CancellationToken cancellationToken = def StatusButtonToolTip = exception.Message; } - await TestConnectionAsyncBase(cancellationToken); + TestConnectionAsyncBase(); } - protected override async Task OnBooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) + protected override async Task BooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) { if (!inputParameter) return; if (!CanConnectToServer) return; if (ConnectionCredentials is null) return; try { - if(!(await _serverConnectionService.EstablishConnection(ConnectionCredentials, cancellationToken))) - { - await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.Error, "Connection failed", MessageBoxButtons.Ok, MessageBoxIcon.Error); - } + if (!await _serverConnectionService.EstablishConnection(ConnectionCredentials, cancellationToken)) + await _messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.Error, + StringsAndTexts.ConnectToServerConnectionFailed); } catch (Exception e) { - Logger.LogError(e, "Unhandled error during connection"); - await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.Error, e.Message, MessageBoxButtons.Ok, MessageBoxIcon.Error); + _logger.LogError(e, "Unhandled error during connection"); + await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.Error, e.Message); } } + [ReactiveCommand] private void Reset() { HostName = string.Empty; Username = string.Empty; Password = string.Empty; - StatusButtonText = string.Format(StringsAndTexts.ConnectToServerStatusBase, + StatusButtonText = string.Format( + StringsAndTexts.ConnectToServerStatusBase, StringsAndTexts.ConnectToServerStatusUnknown); - StatusButtonToolTip = string.Format(StringsAndTexts.ConnectToServerStatusBase, + StatusButtonToolTip = string.Format( + StringsAndTexts.ConnectToServerStatusBase, StringsAndTexts.ConnectToServerStatusUntested); - StatusButtonBackground = Brushes.Gray; + StatusButtonBackground = Application.Current?.Resources["OverlayBrush"] as IBrush ?? Brushes.Gray; SelectedHostSettings = null; AuthWithAllKeys = false; AuthWithPublicKey = false; ConnectionCredentials = null; } - - public override void Dispose() - { - _hostSettingsSubscription.Dispose(); - _keyComboBoxEnabledSubscription.Dispose(); - _connectionCredentialsSubscription.Dispose(); - base.Dispose(); - } } \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/EditAuthorizedKeysViewModel.cs b/OpenSSH_GUI/ViewModels/EditAuthorizedKeysViewModel.cs index 0e31ca4..ff25f3b 100644 --- a/OpenSSH_GUI/ViewModels/EditAuthorizedKeysViewModel.cs +++ b/OpenSSH_GUI/ViewModels/EditAuthorizedKeysViewModel.cs @@ -1,4 +1,4 @@ -using System.Reactive; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using JetBrains.Annotations; using Microsoft.Extensions.Logging; @@ -9,55 +9,64 @@ using OpenSSH_GUI.Core.MVVM; using OpenSSH_GUI.Core.Services; using ReactiveUI; +using ReactiveUI.Avalonia; using ReactiveUI.SourceGenerators; namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public partial class EditAuthorizedKeysViewModel : ViewModelBase +public partial class EditAuthorizedKeysViewModel : ViewModelBase { - [ObservableAsProperty] - private bool _addButtonEnabled; - - [ObservableAsProperty] - private bool _keyAddPossible; - - [Reactive] - private SshKeyFile? _selectedKey; - - [Reactive] - private AuthorizedKeysFile _authorizedKeysFileRemote = AuthorizedKeysFile.Empty; - - [Reactive] - private AuthorizedKeysFile _authorizedKeysFileLocal = AuthorizedKeysFile.Empty; - - public EditAuthorizedKeysViewModel(ILogger logger, + private readonly ILogger _logger; + + [ObservableAsProperty] private bool _addButtonEnabled; + + [Reactive] private AuthorizedKeysFile _authorizedKeysFileLocal = AuthorizedKeysFile.Empty; + + [Reactive] private AuthorizedKeysFile _authorizedKeysFileRemote = AuthorizedKeysFile.Empty; + + [ObservableAsProperty] private bool _keyAddPossible; + + [Reactive] private SshKeyFile? _selectedKey; + + public EditAuthorizedKeysViewModel( + ILogger logger, SshKeyManager sshKeyManager, - ServerConnectionService serverConnectionService) : base(logger) + ServerConnectionService serverConnectionService) { + _logger = logger; SshKeyManager = sshKeyManager; ServerConnectionService = serverConnectionService; SelectedKey = SshKeyManager.SshKeys.FirstOrDefault(); - AddKey = ReactiveCommand.CreateFromTask(OnAddKey); - - _addButtonEnabledHelper = this.WhenAnyValue(vm => vm.SelectedKey, vm => vm.AuthorizedKeysFileRemote, vm => vm.KeyAddPossible) + + _addButtonEnabledHelper = this.WhenAnyValue( + vm => vm.SelectedKey, vm => vm.AuthorizedKeysFileRemote, + vm => vm.KeyAddPossible) .DistinctUntilChanged() .Select(props => - { - if (props is { Item2: { AuthorizedKeys: { Count: > 0 } } col, Item1: { } keyFile , Item3: true}) - return col.CanAddKey(keyFile); - return false; - }).ToProperty(this, vm => vm.AddButtonEnabled); - - _keyAddPossibleHelper = this.WhenAnyValue(vm => vm.SshKeyManager.SshKeysCount) - .Select(props => props > 0).ToProperty(this, vm => vm.KeyAddPossible); + props is + { + Item1: { } keyFile, + Item2: + { + AuthorizedKeys.Count: > 0 + } col, + Item3: true + } && col.CanAddKey(keyFile)) + .ToProperty(this, vm => vm.AddButtonEnabled) + .DisposeWith(Disposables); + + _keyAddPossibleHelper = this.WhenAnyValue(vm => vm.SshKeyManager.SshKeys) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(keys => keys.Count > 0) + .ToProperty(this, vm => vm.KeyAddPossible) + .DisposeWith(Disposables); } - + public SshKeyManager SshKeyManager { get; } public ServerConnectionService ServerConnectionService { get; } - public ReactiveCommand AddKey { get; } - protected override async Task OnBooleanSubmitAsync(bool inputParameter, + protected override async Task BooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) { try @@ -71,7 +80,7 @@ await ServerConnectionService.ServerConnection.WriteAuthorizedKeysChangesToServe } catch (Exception e) { - Logger.LogError(e, "Error while editing authorized keys"); + _logger.LogError(e, "Error while editing authorized keys"); } } @@ -85,8 +94,6 @@ public override async ValueTask InitializeAsync(CancellationToken cancellationTo await base.InitializeAsync(cancellationToken); } - private async Task OnAddKey(SshKeyFile key) - { - await AuthorizedKeysFileRemote.AddAuthorizedKeyAsync(key); - } + [ReactiveCommand] + private async Task AddKey(SshKeyFile key, CancellationToken cancellationToken = default) { await AuthorizedKeysFileRemote.AddAuthorizedKeyAsync(key); } } \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/EditKnownHostsWindowViewModel.cs b/OpenSSH_GUI/ViewModels/EditKnownHostsWindowViewModel.cs index 8bace48..913fa85 100644 --- a/OpenSSH_GUI/ViewModels/EditKnownHostsWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/EditKnownHostsWindowViewModel.cs @@ -1,58 +1,52 @@ using System.Collections.ObjectModel; using JetBrains.Annotations; -using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; -using OpenSSH_GUI.Core.Interfaces.KnownHosts; using OpenSSH_GUI.Core.Lib.KnownHosts; using OpenSSH_GUI.Core.MVVM; using OpenSSH_GUI.Core.Services; -using ReactiveUI; using ReactiveUI.SourceGenerators; namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public partial class EditKnownHostsWindowViewModel( - ILogger logger, - ServerConnectionService serverConnectionService) : ViewModelBase(logger) +public partial class EditKnownHostsWindowViewModel(ServerConnectionService serverConnectionService) : ViewModelBase { + [Reactive] private ObservableCollection _knownHostsLocal = []; + + [Reactive] private ObservableCollection _knownHostsRemote = []; + public ServerConnectionService ServerConnectionService => serverConnectionService; - private IKnownHostsFile? KnownHostsFileLocal { get; set; } - private IKnownHostsFile? KnownHostsFileRemote { get; set; } - [Reactive] - private ObservableCollection _knownHostsRemote = []; - - [Reactive] - private ObservableCollection _knownHostsLocal = []; + private KnownHostsFile? KnownHostsFileLocal { get; set; } + private KnownHostsFile? KnownHostsFileRemote { get; set; } - protected override async Task OnBooleanSubmitAsync(bool inputParameter, + protected override async Task BooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) { if (!inputParameter) return; ArgumentNullException.ThrowIfNull(KnownHostsFileLocal); - - KnownHostsFileLocal.SyncKnownHosts(KnownHostsLocal); - if (serverConnectionService.IsConnected) - KnownHostsFileRemote?.SyncKnownHosts(KnownHostsRemote); await KnownHostsFileLocal.UpdateFileAsync(); if (!serverConnectionService.IsConnected) return; ArgumentNullException.ThrowIfNull(KnownHostsFileRemote); - await serverConnectionService.ServerConnection.WriteKnownHostsToServerAsync(KnownHostsFileRemote, + await serverConnectionService.ServerConnection.WriteKnownHostsToServerAsync( + KnownHostsFileRemote, cancellationToken); } public override async ValueTask InitializeAsync(CancellationToken cancellationToken = default) { KnownHostsFileLocal = - await new KnownHostsFile().InitializeAsync(SshConfigFiles.Known_Hosts.GetPathOfFile(), + await KnownHostsFile.InitializeAsync( + new FileInfo(SshConfigFiles.Known_Hosts.GetPathOfFile()), token: cancellationToken); if (serverConnectionService.IsConnected) KnownHostsFileRemote = await serverConnectionService.ServerConnection.GetKnownHostsFromServerAsync(cancellationToken); - KnownHostsLocal = new ObservableCollection(KnownHostsFileLocal.KnownHosts.OrderBy(e => e.Host)); - KnownHostsRemote = serverConnectionService.IsConnected ? new ObservableCollection(KnownHostsFileRemote!.KnownHosts.OrderBy(e => e.Host)) : []; + KnownHostsLocal = new ObservableCollection(KnownHostsFileLocal.KnownHosts.OrderBy(e => e.Host)); + KnownHostsRemote = serverConnectionService.IsConnected + ? new ObservableCollection(KnownHostsFileRemote!.KnownHosts.OrderBy(e => e.Host)) + : []; await base.InitializeAsync(cancellationToken); } } \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs b/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs index 3cf222c..8f5ad30 100644 --- a/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/ExportWindowViewModel.cs @@ -7,15 +7,14 @@ namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public partial class ExportWindowViewModel(ILogger logger, IClipboard clipboard) : ViewModelBase(logger) +public partial class ExportWindowViewModel(ILogger logger, IClipboard clipboard) + : ViewModelBase<(string WindowTitle, string Export)> { - [Reactive] - private string _windowTitle = ""; - - [Reactive] - private string _export = ""; - - public override ValueTask InitializeAsync(ExportWindowViewModelInitializerParameters parameters, + [Reactive] private string _export = string.Empty; + + [Reactive] private string _windowTitle = string.Empty; + + public override ValueTask InitializeAsync((string WindowTitle, string Export) parameters, CancellationToken cancellationToken = default) { WindowTitle = parameters.WindowTitle; @@ -23,15 +22,22 @@ public override ValueTask InitializeAsync(ExportWindowViewModelInitializerParame return base.InitializeAsync(parameters, cancellationToken); } - protected override async Task OnBooleanSubmitAsync(bool inputParameter, + protected override async Task BooleanSubmitAsync(bool inputParameter, CancellationToken cancellationToken = default) { - if (inputParameter) - await clipboard.SetTextAsync(Export); + try + { + if (inputParameter) + await clipboard.SetTextAsync(Export); + } + catch (Exception e) + { + logger.LogError(e, "Error submitting export to clipboard"); + } } } -public record ExportWindowViewModelInitializerParameters : IInitializerParameters +public record ExportWindowViewModelInitializerParameters { public string WindowTitle { get; init; } = string.Empty; public string Export { get; init; } = string.Empty; diff --git a/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs b/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs index 1d8fae6..0f5296c 100644 --- a/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/FileInfoWindowViewModel.cs @@ -1,24 +1,256 @@ +using System.Collections.ObjectModel; +using System.Reactive.Disposables.Fluent; +using System.Reactive.Linq; +using Avalonia.Input.Platform; +using DynamicData; using JetBrains.Annotations; +using Material.Icons; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Lib.Keys; using OpenSSH_GUI.Core.MVVM; +using OpenSSH_GUI.Core.Services; +using OpenSSH_GUI.Dialogs.Enums; +using OpenSSH_GUI.Dialogs.Interfaces; +using OpenSSH_GUI.Dialogs.Models; +using OpenSSH_GUI.Resources; +using ReactiveUI; +using ReactiveUI.Avalonia; using ReactiveUI.SourceGenerators; +using SshNet.Keygen; namespace OpenSSH_GUI.ViewModels; + [UsedImplicitly] -public partial class FileInfoWindowViewModel : ViewModelBase +public partial class FileInfoWindowViewModel : ViewModelBase { - [Reactive] + private readonly IClipboard _clipboard; + private readonly SshKeyManager _keyManager; + private readonly ILogger _logger; + private readonly IMessageBoxProvider _messageBoxProvider; + private readonly IServiceProvider _serviceProvider; + + [ObservableAsProperty(ReadOnly = true)] + private string _associatedFilesHeader = string.Empty; + + [ObservableAsProperty(ReadOnly = true)] + private SshKeyFormat _defaultKeyFormat; + + [Reactive(SetModifier = AccessModifier.Private)] private SshKeyFile _keyFile; - - public override ValueTask InitializeAsync(FileInfoViewModelInitializer parameters, CancellationToken cancellationToken = default) + + [ReactiveCollection] + private ObservableCollection _keyFormats = []; + + [ObservableAsProperty(ReadOnly = true)] + private string _password = string.Empty; + + [ObservableAsProperty(ReadOnly = true)] + private string _windowTitle = "Key info"; + + public FileInfoWindowViewModel(ILogger logger, IMessageBoxProvider messageBoxProvider, + IServiceProvider serviceProvider, IClipboard clipboard, SshKeyManager keyManager) + { + _logger = logger; + _messageBoxProvider = messageBoxProvider; + _serviceProvider = serviceProvider; + _clipboard = clipboard; + _keyManager = keyManager; + _keyFile = _serviceProvider.GetRequiredService(); + + _passwordHelper = this.WhenAnyValue(vm => vm.KeyFile.Password.IsValid) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(_ => KeyFile.Password.IsValid + ? KeyFile.Password.GetPasswordString() + : string.Empty + ).ToProperty(this, vm => vm.Password) + .DisposeWith(Disposables); + + _windowTitleHelper = + this.WhenAnyValue(vm => vm.KeyFile.FileName, vm => vm.KeyFile.Format, vm => vm.KeyFile.Comment) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(e => string.Join(" ", e.Item1, e.Item2, e.Item3)) + .ToProperty(this, vm => vm.WindowTitle) + .DisposeWith(Disposables); + + _associatedFilesHeaderHelper = this.WhenAnyValue(vm => vm.KeyFile.KeyFiles) + .ObserveOn(AvaloniaScheduler.Instance) + .Select(e => string.Format(StringsAndTexts.FileInfoWindowFoundAssociatedFiles, e.Length)) + .ToProperty(this, vm => vm.AssociatedFilesHeader) + .DisposeWith(Disposables); + + _defaultKeyFormatHelper = this.WhenAnyValue(vm => vm.KeyFile.KeyFileInfo) + .ObserveOn(AvaloniaScheduler.Instance) + .WhereNotNull() + .Select(e => e.DefaultConversionFormat) + .ToProperty(this, vm => vm.DefaultKeyFormat) + .DisposeWith(Disposables); + + this.WhenAnyValue(vm => vm.KeyFile.KeyFileInfo) + .ObserveOn(AvaloniaScheduler.Instance) + .WhereNotNull() + .Subscribe(OnNext) + .DisposeWith(Disposables); + } + + private void OnNext(SshKeyFileInformation obj) + { + KeyFormats.Clear(); + KeyFormats.AddRange(obj.AvailableFormatsForConversion.Order().ToArray()); + } + + private void SetKeyOrDefault(SshKeyFileSource? source = null) + { + KeyFile = (source is not null + ? _keyManager.SshKeys.SingleOrDefault(x => x.KeyFileInfo?.KeyFileSource == source) + : null) + ?? _serviceProvider.GetRequiredService(); + } + + public override ValueTask InitializeAsync(SshKeyFileSource? parameters, + CancellationToken cancellationToken = default) { - KeyFile = parameters.Key; - + SetKeyOrDefault(parameters); return base.InitializeAsync(parameters, cancellationToken); } -} -public class FileInfoViewModelInitializer : IInitializerParameters -{ - public required SshKeyFile Key { get; init; } + [ReactiveCommand] + private async Task ChangePasswordOfKeyFileAsync(CancellationToken cancellationToken = default) + { + try + { + using var si = await _messageBoxProvider.ShowSecureInputAsync( + new SecureInputParams + { + Buttons = MessageBoxButtons.OkCancel, + Icon = MaterialIconKind.KeyOutline, + MinLength = 0, + Prompt = string.Format(StringsAndTexts.FileInfoWindowEnterNewPassword, KeyFile.FileName), + Title = StringsAndTexts.FileInfoWindowChangePassword + }); + if (si is null) + { + _logger.LogInformation("User canceled password change"); + return; + } + + (await _keyManager.ChangePasswordOfKeyAsync(KeyFile, si.Value, token: cancellationToken)).ThrowIfFailure(); + _logger.LogInformation("Key file password changed"); + SetKeyOrDefault(KeyFile.KeyFileInfo?.KeyFileSource); + } + catch (Exception e) + { + _logger.LogError(e, "Error changing password of key file"); + await _messageBoxProvider.ShowErrorMessageBoxAsync(e); + } + } + + [ReactiveCommand] + private async Task ChangeFormatOfKeyFileAsync(SshKeyFormat format, CancellationToken cancellationToken = default) + { + try + { + (await _keyManager.ChangeFormatOfKeyAsync(KeyFile, format, cancellationToken)).ThrowIfFailure(); + _logger.LogInformation("Key file format changed"); + SetKeyOrDefault(KeyFile.KeyFileInfo?.KeyFileSource); + } + catch (Exception e) + { + _logger.LogError(e, "Error changing format of key file"); + await _messageBoxProvider.ShowErrorMessageBoxAsync(e); + } + } + + [ReactiveCommand] + private async Task ChangeFileNameAsync(SshKeyFile keyFile, CancellationToken cancellationToken = default) + { + var validatedInputResult = await _messageBoxProvider.ShowValidatedInputAsync( + new ValidatedInputParams + { + Buttons = MessageBoxButtons.OkCancel, + Icon = MaterialIconKind.FileEditOutline, + InitialValue = Path.GetFileNameWithoutExtension(keyFile.FileName) ?? string.Empty, + Message = StringsAndTexts.FileInfoWindowChangeMessage, + Prompt = StringsAndTexts.FileInfoWindowEnterNewFilename, + Watermark = StringsAndTexts.FileInfoWindowEnterNewFilename, + Validator = argument => string.IsNullOrWhiteSpace(argument) + ? StringsAndTexts.FileInfoWindowFilenameCannotBeEmpty + : null + }); + if (validatedInputResult is { IsConfirmed: true, Value: { Length: > 0 } filename }) + try + { + var result = await _keyManager.RenameKeyAsync(keyFile, filename, token: cancellationToken); + while (result is { IsSuccess: false }) + { + result.ThrowIfFailure(); + + if (await _messageBoxProvider.ShowMessageBoxAsync( + new MessageBoxParams + { + Title = StringsAndTexts.FileInfoWindowConfirmFileOverwrite, + Message = + string.Format(StringsAndTexts.FileInfoWindowFileAlreadyExists, filename), + Buttons = MessageBoxButtons.YesNo, + Icon = MaterialIconKind.QuestionMarkCircleOutline + }) is not MessageBoxResult.Yes) + throw new OperationCanceledException("User canceled operation"); + _logger.LogInformation("User confirmed overwrite of key file"); + result = await _keyManager.RenameKeyAsync(keyFile, filename, true, cancellationToken); + } + + _logger.LogInformation("Key file renamed"); + SetKeyOrDefault(KeyFile.KeyFileInfo?.KeyFileSource); + } + catch (Exception e) + { + _logger.LogError(e, "Error renaming key file"); + await _messageBoxProvider.ShowErrorMessageBoxAsync(e); + } + else + _logger.LogInformation("User canceled key file rename"); + } + + [ReactiveCommand] + private async Task DeleteKeyAsync(SshKeyFile keyFile, CancellationToken cancellationToken = default) + { + if (await _messageBoxProvider.ShowMessageBoxAsync( + string.Format(StringsAndTexts.MainWindowViewModelDeleteKeyTitleText, keyFile.FileName), + StringsAndTexts.MainWindowViewModelDeleteKeyQuestionTextPair, MessageBoxButtons.YesNo, + MaterialIconKind.QuestionMarkCircleOutline) is MessageBoxResult.Yes) + try + { + (await _keyManager.TryDeleteKeyAsync(keyFile, cancellationToken)).ThrowIfFailure(); + _logger.LogInformation("Key file deleted"); + RequestClose(); + } + catch (Exception e) + { + _logger.LogError(e, "Error deleting key file"); + await _messageBoxProvider.ShowErrorMessageBoxAsync( + e, + string.Format(StringsAndTexts.MainWindowViewModelDeleteKeyTitleText, keyFile.FileName)); + } + else + _logger.LogInformation("User canceled key file deletion"); + } + + [ReactiveCommand] + private async Task CopyPasswordIntoClipboardAsync(SshKeyFilePassword password, CancellationToken token = default) + { + try + { + await _clipboard.SetTextAsync(password.GetPasswordString()); + await _clipboard.FlushAsync(); + await _messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.FileInfoWindowPasswordCopied, + StringsAndTexts.FileInfoWindowPasswordCopied, MessageBoxButtons.Ok, + MaterialIconKind.InformationOutline); + } + catch (Exception e) + { + _logger.LogError(e, "Error copying password to clipboard"); + await _messageBoxProvider.ShowErrorMessageBoxAsync(e); + } + } } \ No newline at end of file diff --git a/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs b/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs index 2ae7cf5..b7e5af8 100644 --- a/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs +++ b/OpenSSH_GUI/ViewModels/MainWindowViewModel.cs @@ -1,13 +1,10 @@ -using System.Reactive; +using System.Collections.Specialized; +using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using System.Reflection; -using System.Text.Encodings.Web; using Avalonia.Platform.Storage; -using Avalonia.Threading; -using DryIoc; using JetBrains.Annotations; using Material.Icons; -using Material.Icons.Avalonia; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Extensions; @@ -22,6 +19,7 @@ using OpenSSH_GUI.Resources; using OpenSSH_GUI.Views; using ReactiveUI; +using ReactiveUI.Avalonia; using ReactiveUI.SourceGenerators; using Renci.SshNet; using SshNet.Keygen.Extensions; @@ -29,188 +27,125 @@ namespace OpenSSH_GUI.ViewModels; [UsedImplicitly] -public partial class MainWindowViewModel : ViewModelBase +public partial class MainWindowViewModel : ViewModelBase { private static readonly string? ProjectUrl = Assembly.GetExecutingAssembly() .GetCustomAttributes() .FirstOrDefault(a => a.Key == "ProjectUrl")?.Value; private readonly IDialogHost _dialogHost; - private readonly IMessageBoxProvider _messageBoxProvider; private readonly ILauncher _launcher; - private readonly IResolver _serviceProvider; - private readonly IDisposable[] _subscriptions = []; + private readonly ILogger _logger; + private readonly IMessageBoxProvider _messageBoxProvider; + private readonly IServiceProvider _serviceProvider; + + [ObservableAsProperty(ReadOnly = true)] + private bool _isProvidePasswordExecuting; + + [ObservableAsProperty(ReadOnly = true)] + private string _itemsCountTooltip = string.Empty; + + [Reactive(SetModifier = AccessModifier.Private)] + private string _version; + + [ObservableAsProperty(ReadOnly = true)] + private string _windowTitle = string.Empty; public MainWindowViewModel( ILogger logger, SshKeyManager sshKeyManager, ServerConnectionService serverConnectionService, - IResolver serviceProvider, + IServiceProvider serviceProvider, IConfiguration configuration, IMessageBoxProvider messageBoxProvider, ILauncher launcher, - IDialogHost dialogHost) : base(logger) + IDialogHost dialogHost) { SshKeyManager = sshKeyManager; ServerConnectionService = serverConnectionService; + _logger = logger; _serviceProvider = serviceProvider; _messageBoxProvider = messageBoxProvider; _launcher = launcher; _dialogHost = dialogHost; - - DisconnectServer = ReactiveCommand.CreateFromTask(DisconnectFromServerAsync); - ProvidePassword = ReactiveCommand.CreateFromTask(ProvidePasswordAsync); - NotImplementedMessage = ReactiveCommand.CreateFromTask(ShowNotImplementedMessageBoxAsync); - OpenBrowser = ReactiveCommand.CreateFromTask(OpenBrowserAsync); - OpenExportKeyWindowPublic = ReactiveCommand.CreateFromTask(ShowPublicKeyExportWindow); - OpenExportKeyWindowPrivate = ReactiveCommand.CreateFromTask(ShowPrivateKeyExportWindow); - OpenConnectToServerWindow = - ReactiveCommand.CreateFromTask(OpenWindow); - OpenEditKnownHostsWindow = - ReactiveCommand.CreateFromTask(OpenWindow); - OpenEditAuthorizedKeysWindow = - ReactiveCommand.CreateFromTask(OpenWindow); - OpenCreateKeyWindow = ReactiveCommand.CreateFromTask(OpenWindow); - DeleteKey = ReactiveCommand.CreateFromTask(DeleteKeyAsync); - ReloadKeys = ReactiveCommand.CreateFromTask(SshKeyManager.RerunSearchAsync); - ShowPassword = ReactiveCommand.CreateFromTask(ShowPasswordExportWindow); - ResetKey = ReactiveCommand.CreateFromTask(ResetKeyAsync); - ChangeFilename = ReactiveCommand.CreateFromTask(ChangeFilenameAsync); - OpenApplicationSettingsWindow = ReactiveCommand.CreateFromTask(OpenWindow); - Version = configuration[Program.VersionEnvVar] ?? "VERSION ERROR"; - - _itemsCountIconHelper = this.WhenAnyValue(vm => vm.SshKeyManager.SshKeys.Count) - .Select(GetMaterialNumericIcon) - .ToProperty(this, vm => vm.ItemsCountIcon); - + _windowTitleHelper = this.WhenAnyValue(vm => vm.Version) - .Select(ver => string.Join("-", Program.AppName, ver)) - .ToProperty(this, vm => vm.WindowTitle); - - _keyTypeSortDirectionIconHelper = - this.WhenAnyValue(vm => vm.KeyTypeSort) - .Select(EvaluateSortIconKind) - .ToProperty(this, vm => vm.KeyTypeSortDirectionIcon); - - _commentSortDirectionIconHelper = this.WhenAnyValue(vm => vm.CommentSort) - .Select(EvaluateSortIconKind) - .ToProperty(this, vm => vm.CommentSortDirectionIcon); - - _fingerPrintSortDirectionIconHelper = this.WhenAnyValue(vm => vm.FingerPrintSort) - .Select(EvaluateSortIconKind) - .ToProperty(this, vm => vm.FingerPrintSortDirectionIcon); - - _subscriptions = _subscriptions.Concat([ - this.WhenAnyValue(vm => vm.KeyTypeSort) - .Subscribe(sort => SshKeyManager.ChangeOrder(sort switch - { - null => key => key.OrderBy(e => e.FileName), - true => key => key.OrderBy(e => e.KeyType), - false => key => key.OrderByDescending(e => e.KeyType) - })), - - this.WhenAnyValue(vm => vm.CommentSort) - .Subscribe(sort => SshKeyManager.ChangeOrder(sort switch - { - null => key => key.OrderBy(e => e.FileName), - true => key => key.OrderBy(e => e.Comment), - false => key => key.OrderByDescending(e => e.Comment) - })), - - this.WhenAnyValue(vm => vm.FingerPrintSort) - .Subscribe(sort => SshKeyManager.ChangeOrder(sort switch - { - null => key => key.OrderBy(e => e.FileName), - true => key => key.OrderBy(e => e.Fingerprint), - false => key => key.OrderByDescending(e => e.Fingerprint) - })) - ]).ToArray(); + .Select(v => string.Join(" v", Program.AppName, v)) + .ToProperty(this, vm => vm.WindowTitle) + .DisposeWith(Disposables); + + var sshKeysCountChanged = Observable + .FromEventPattern( + h => ((INotifyCollectionChanged)SshKeyManager.SshKeys).CollectionChanged += h, + h => ((INotifyCollectionChanged)SshKeyManager.SshKeys).CollectionChanged -= h) + .Select(_ => SshKeyManager.SshKeys.Count) + .StartWith(SshKeyManager.SshKeys.Count) + .ObserveOn(AvaloniaScheduler.Instance); + + _itemsCountTooltipHelper = sshKeysCountChanged + .Select(count => string.Format(StringsAndTexts.MainWindowFoundKeyPairsCountLabel, count)) + .ToProperty(this, vm => vm.ItemsCountTooltip) + .DisposeWith(Disposables); + _isProvidePasswordExecutingHelper = ProvidePasswordCommand.IsExecuting + .ToProperty(this, vm => vm.IsProvidePasswordExecuting) + .DisposeWith(Disposables); } - public ReactiveCommand DisconnectServer { get; } - public ReactiveCommand ProvidePassword { get; } - public ReactiveCommand NotImplementedMessage { get; } - public ReactiveCommand OpenBrowser { get; } - public ReactiveCommand OpenExportKeyWindowPublic { get; } - public ReactiveCommand OpenExportKeyWindowPrivate { get; } - public ReactiveCommand OpenConnectToServerWindow { get; } - public ReactiveCommand OpenEditKnownHostsWindow { get; } - public ReactiveCommand OpenEditAuthorizedKeysWindow { get; } - public ReactiveCommand OpenCreateKeyWindow { get; } - public ReactiveCommand DeleteKey { get; } - public ReactiveCommand ReloadKeys { get; } - public ReactiveCommand ShowPassword { get; } - public ReactiveCommand ResetKey { get; } - public ReactiveCommand ChangeFilename { get; } - public ReactiveCommand OpenApplicationSettingsWindow { get; } public ServerConnectionService ServerConnectionService { get; } public SshKeyManager SshKeyManager { get; } - [Reactive] private string _version; - [Reactive] private bool? _keyTypeSort; - [Reactive] private bool? _commentSort; - [Reactive] private bool? _fingerPrintSort; + [ReactiveCommand] + private Task OpenApplicationSettingsWindowAsync(CancellationToken cancellationToken = default) => + OpenWindow(cancellationToken); - [ObservableAsProperty] private MaterialIcon _itemsCountIcon = new() { Kind = MaterialIconKind.Infinity }; - [ObservableAsProperty] private string _windowTitle = string.Empty; - [ObservableAsProperty] private MaterialIconKind _keyTypeSortDirectionIcon = MaterialIconKind.CircleOutline; - [ObservableAsProperty] private MaterialIconKind _commentSortDirectionIcon = MaterialIconKind.CircleOutline; - [ObservableAsProperty] private MaterialIconKind _fingerPrintSortDirectionIcon = MaterialIconKind.CircleOutline; + [ReactiveCommand] + private Task OpenFileInfoWindowAsync(SshKeyFileSource source, CancellationToken cancellationToken = default) => + OpenWindow( + source, + cancellationToken); - private static MaterialIcon GetMaterialNumericIcon(int count) => new() - { - Kind = count switch - { - 0 => MaterialIconKind.NumericZero, - 1 => MaterialIconKind.NumericOne, - 2 => MaterialIconKind.NumericTwo, - 3 => MaterialIconKind.NumericThree, - 4 => MaterialIconKind.NumericFour, - 5 => MaterialIconKind.NumericFive, - 6 => MaterialIconKind.NumericSix, - 7 => MaterialIconKind.NumericSeven, - 8 => MaterialIconKind.NumericEight, - 9 => MaterialIconKind.NumericNine, - 10 => MaterialIconKind.Numeric10, - _ => MaterialIconKind.Infinity - }, - Width = 20, - Height = 20 - }; - - private async Task ResetKeyAsync(SshKeyFile keyFile, CancellationToken token) + [ReactiveCommand] + private Task OpenCreateKeyWindowAsync(CancellationToken cancellationToken = default) => OpenWindow(cancellationToken); + + [ReactiveCommand] + private Task OpenEditAuthorizedKeysWindowAsync(CancellationToken cancellationToken = default) => + OpenWindow(cancellationToken); + + [ReactiveCommand] + private Task OpenEditKnownHostsWindowAsync(CancellationToken cancellationToken = default) => OpenWindow(cancellationToken); + + [ReactiveCommand] + private Task OpenConnectToServerWindowAsync(CancellationToken cancellationToken = default) => OpenWindow(cancellationToken); + + [ReactiveCommand] + private void ResetKey(SshKeyFile keyFile) { try { - await keyFile.Reset(); + keyFile.Reset(); } catch (Exception e) { - Logger.LogError(e, "Unhandled error during key reset"); + _logger.LogError(e, "Unhandled error during key reset"); } } - private async Task ChangeFilenameAsync(SshKeyFile key, CancellationToken token = default) + [ReactiveCommand] + private async Task ReloadKeysAsync(CancellationToken cancellationToken = default) { - var validatedInputResult = await _messageBoxProvider.ShowValidatedInputAsync(new ValidatedInputParams + switch (await SshKeyManager.RerunSearchAsync(cancellationToken)) { - Buttons = MessageBoxButtons.OkCancel, - Icon = MaterialIconKind.FileEditOutline, - InitialValue = key.FileName ?? string.Empty, - Message = "ChangeMe", - Prompt = "EnterNewFilename", - Watermark = "Enter new filename", - Validator = argument => - { - ArgumentException.ThrowIfNullOrWhiteSpace(argument); - return SshKeyManager.SshKeys.Any(k => k.FileName == argument) ? "Filename already exists" : null; - } - }); - if (validatedInputResult is { IsConfirmed: true, Value: { Length: > 0 } filename }) - key.ChangeFilenameOnDisk(filename); + case { IsSuccess: false } x: + await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.Error, x.Exception.Message); + break; + default: + _logger.LogInformation("Keys reloaded"); + break; + } } + [ReactiveCommand] private Task ShowPrivateKeyExportWindow(SshKeyFile key, CancellationToken token = default) { PrivateKeyFile? keyFile = key; @@ -218,13 +153,12 @@ private Task ShowPrivateKeyExportWindow(SshKeyFile key, CancellationToken token switch (key) { case null or { NeedsPassword: true, Password.IsValid: false }: - Logger.LogError("Keyfile is null"); + _logger.LogError("Keyfile is null"); return Task.CompletedTask; - case { NeedsPassword: false, Password.IsValid: true } passwordProtectedKeyFile: + case { NeedsPassword: false, Password.IsValid: true }: { - keyFile = passwordProtectedKeyFile; if (keyFile is not null) - content = keyFile.ToOpenSshFormat(passwordProtectedKeyFile.Password.GetPasswordString()); + content = keyFile.ToOpenSshFormat(key.Password.GetPasswordString()); break; } default: @@ -238,70 +172,74 @@ private Task ShowPrivateKeyExportWindow(SshKeyFile key, CancellationToken token return ShowExportWindow(key, content, null, token); } + [ReactiveCommand] private Task ShowPublicKeyExportWindow(SshKeyFile key, CancellationToken token = default) { PrivateKeyFile? keyFile = key; - var content = keyFile is not null ? keyFile.ToOpenSshPublicFormat() : string.Empty; - return ShowExportWindow(key, content, null, token); - } - - private async Task ShowPasswordExportWindow(SshKeyFile key, CancellationToken token = default) - { - if (!key.Password.IsValid) return; - if (await _messageBoxProvider.ShowMessageBoxAsync(new MessageBoxParams - { - Title = "Are you shure?", - Message = - "Are you shure you want to export the password?\r\nThe password can be stored in plain text in the clipboard afterwards", - Buttons = MessageBoxButtons.YesNo - }) is MessageBoxResult.Yes) - await ShowExportWindow(key, key.Password.GetPasswordString(), - string.Format(StringsAndTexts.KeysShowPasswordOf, key.AbsoluteFilePath), token); + return keyFile != null + ? ShowExportWindow(key, keyFile.ToOpenSshPublicFormat(), null, token) + : _messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.Error, + StringsAndTexts.MainWindowViewModelExportKeyErrorMessage); } private async Task ShowExportWindow(SshKeyFile key, string content, string? windowTitle = null, CancellationToken token = default) { - windowTitle ??= string.Format(StringsAndTexts.MainWindowViewModelDynamicExportWindowTitle, - key.HashAlgorithmName, key.FileName); + windowTitle ??= string.Format( + StringsAndTexts.MainWindowViewModelDynamicExportWindowTitle, + key.KeyType, key.FileName); if (string.IsNullOrWhiteSpace(content)) { - await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.Error, - StringsAndTexts.MainWindowViewModelExportKeyErrorMessage, MessageBoxButtons.Ok, MessageBoxIcon.Error); + await _messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.Error, + StringsAndTexts.MainWindowViewModelExportKeyErrorMessage); return; } - var view = await _serviceProvider.ResolveViewAsync( - new ExportWindowViewModelInitializerParameters - { - Export = content, - WindowTitle = windowTitle - }, token: token); + var view = await _serviceProvider + .ResolveViewAsync( + new ValueTuple + { + Item1 = windowTitle, + Item2 = content + }, token: token); await _dialogHost.ShowDialog(view); } + [ReactiveCommand] private async Task ShowNotImplementedMessageBoxAsync(CancellationToken cancellationToken = default) { - await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.NotImplementedBoxTitle, - StringsAndTexts.NotImplementedBoxText, MessageBoxButtons.Ok, MessageBoxIcon.Information); + await _messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.NotImplementedBoxTitle, + StringsAndTexts.NotImplementedBoxText, MessageBoxButtons.Ok, MaterialIconKind.InformationOutline); } + [ReactiveCommand] private async Task OpenBrowserAsync(int commandTypeParameter, CancellationToken cancellationToken = default) { - if (commandTypeParameter switch - { - 1 => string.Join("/", ProjectUrl, "issues"), - 2 => string.Join("#", ProjectUrl, "authors"), - _ => ProjectUrl - } is { Length: > 0 } url) - await _launcher.LaunchUriAsync(new Uri(HtmlEncoder.Default.Encode(url))); - } + if (ProjectUrl is null) + return; + var uriBuilder = new UriBuilder(ProjectUrl); + switch (commandTypeParameter) + { + case 1: + uriBuilder.Path += "/issues"; + break; + case 2: + uriBuilder.Query = "tab=readme-ov-file"; + uriBuilder.Fragment = "authors"; + break; + } + await _launcher.LaunchUriAsync(uriBuilder.Uri); + } + [ReactiveCommand] private async Task DisconnectFromServerAsync(CancellationToken cancellationToken) { var messageBoxText = StringsAndTexts.MainWindowDisconnectBoxTextSuccess; - var messageBoxIcon = MessageBoxIcon.Information; + var messageBoxIcon = MaterialIconKind.InformationOutline; if (ServerConnectionService.IsConnected) { try @@ -311,81 +249,83 @@ private async Task DisconnectFromServerAsync(CancellationToken cancellationToken catch (Exception exception) { messageBoxText = exception.Message; - messageBoxIcon = MessageBoxIcon.Error; + messageBoxIcon = MaterialIconKind.ErrorOutline; } } else { messageBoxText = StringsAndTexts.MainWindowDisconnectBoxTextNone; - messageBoxIcon = MessageBoxIcon.Error; + messageBoxIcon = MaterialIconKind.ErrorOutline; } - await _messageBoxProvider.ShowMessageBoxAsync(StringsAndTexts.MainWindowDisconnectBoxTitle, messageBoxText, + await _messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.MainWindowDisconnectBoxTitle, messageBoxText, MessageBoxButtons.Ok, messageBoxIcon); } - private async Task OpenWindow(CancellationToken token = default) - where TWindow : WindowBase where TViewModel : ViewModelBase - { - await _dialogHost.ShowDialog( - await _serviceProvider.ResolveViewAsync(token: token)); - } - + [ReactiveCommand] private async Task DeleteKeyAsync(SshKeyFile sshKeyFile, CancellationToken cancellationToken = default) { if (await _messageBoxProvider.ShowMessageBoxAsync( string.Format(StringsAndTexts.MainWindowViewModelDeleteKeyTitleText, sshKeyFile.FileName), StringsAndTexts.MainWindowViewModelDeleteKeyQuestionTextPair, MessageBoxButtons.YesNo, - MessageBoxIcon.Question) is MessageBoxResult.Yes) - if(!sshKeyFile.Delete(out var error)) + MaterialIconKind.QuestionBoxOutline) is MessageBoxResult.Yes) + if (await SshKeyManager.TryDeleteKeyAsync(sshKeyFile, cancellationToken) is + { IsSuccess: false, Exception: { } error }) await _messageBoxProvider.ShowMessageBoxAsync( string.Format(StringsAndTexts.MainWindowViewModelDeleteKeyTitleText, sshKeyFile.FileName) - , error.Message, MessageBoxButtons.Ok, MessageBoxIcon.Error); + , error.Message); } + [ReactiveCommand] private async Task ProvidePasswordAsync(SshKeyFile key, CancellationToken cancellationToken = default) { - var trys = 0; + if (!await _messageBoxProvider.ShowRetryMessageBoxAsync( + async () => + { + using var secureInputResult = await _messageBoxProvider.ShowSecureInputAsync( + new SecureInputParams + { + Title = StringsAndTexts.MainWindowViewModelProvidePasswordPromptHeading, + Prompt = string.Join( + Environment.NewLine, + StringsAndTexts.MainWindowViewModelProvidePasswordPromptBodyHeading, + Path.GetFileName(key.AbsoluteFilePath)) + }); + bool? operationResult = secureInputResult switch + { + null => null, + { Value.Length: <= 0 } => true, + { Value.Length: > 0 } => key.SetPassword(secureInputResult.Value.Span) + }; + if (operationResult is false) + key.Reset(); + return operationResult; + }, StringsAndTexts.MainWindowViewModelProvidePasswordErrorHeading, + StringsAndTexts.MainWindowViewModelProvidePasswordErrorContent, + retries: 3, showTryCountInTitle: true, icon: MaterialIconKind.WarningOutline)) + await _messageBoxProvider.ShowErrorMessageBoxAsync( + customMessage: string.Join( + " ", "Key", key.FileName, + "could not be opened correctly")); + } - while (key.NeedsPassword && trys < 3) - { - using var secureInputResult = await _messageBoxProvider.ShowSecureInputAsync( - StringsAndTexts.MainWindowViewModelProvidePasswordPromptHeading, - string.Format(StringsAndTexts.MainWindowViewModelProvidePasswordPromptBodyHeading, - Path.GetFileName(key.AbsoluteFilePath))); - if (secureInputResult != null && await key.SetPassword(secureInputResult.Value)) - return; - - - if (await _messageBoxProvider.ShowMessageBoxAsync( - StringsAndTexts.MainWindowViewModelProvidePasswordErrorHeading, string.Format( - StringsAndTexts.MainWindowViewModelProvidePasswordErrorContent, - trys + 1, 3), MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning) is MessageBoxResult - .Cancel) - break; - trys++; - } + private async Task OpenWindow(TInitializer param, + CancellationToken token = default) + where TWindow : WindowBase + where TViewModel : ViewModelBase + { + await _dialogHost.ShowDialog( + await _serviceProvider.ResolveViewAsync(param, token: token) + ); } - - private static MaterialIconKind EvaluateSortIconKind(bool? value) => - value switch - { - null => MaterialIconKind.CircleOutline, - true => MaterialIconKind.ChevronDownCircleOutline, - false => MaterialIconKind.ChevronUpCircleOutline - }; - public override void Dispose() + private async Task OpenWindow(CancellationToken token = default) + where TWindow : WindowBase + where TViewModel : ViewModelBase { - _windowTitleHelper.Dispose(); - _itemsCountIconHelper.Dispose(); - _commentSortDirectionIconHelper.Dispose(); - _fingerPrintSortDirectionIconHelper.Dispose(); - _keyTypeSortDirectionIconHelper.Dispose(); - foreach (var subscription in _subscriptions) - subscription.Dispose(); - GC.SuppressFinalize(this); - base.Dispose(); + await _dialogHost.ShowDialog( + await _serviceProvider.ResolveViewAsync(token: token)); } } \ No newline at end of file diff --git a/OpenSSH_GUI/Views/AddKeyWindow.axaml b/OpenSSH_GUI/Views/AddKeyWindow.axaml index bc145f6..9a1043a 100644 --- a/OpenSSH_GUI/Views/AddKeyWindow.axaml +++ b/OpenSSH_GUI/Views/AddKeyWindow.axaml @@ -2,13 +2,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" xmlns:assets="clr-namespace:OpenSSH_GUI.Resources" - xmlns:system="clr-namespace:System;assembly=System.Runtime" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350" Width="400" - Height="375" + Height="400" x:Class="OpenSSH_GUI.Views.AddKeyWindow" x:DataType="viewModels:AddKeyWindowViewModel" Title="{x:Static assets:StringsAndTexts.AddKeyWindowTitle}" @@ -16,83 +15,58 @@ ShowInTaskbar="True" ShowActivated="True" WindowStartupLocation="CenterOwner"> - - - - - - - - - - - + ItemsSource="{CompiledBinding AvailableKeySizes}" + SelectedItem="{CompiledBinding SelectedKeySize}" /> + + - - - - - + + + - + + + - + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/AddKeyWindow.axaml.cs b/OpenSSH_GUI/Views/AddKeyWindow.axaml.cs index 413e716..83f2595 100644 --- a/OpenSSH_GUI/Views/AddKeyWindow.axaml.cs +++ b/OpenSSH_GUI/Views/AddKeyWindow.axaml.cs @@ -1,7 +1,4 @@ -using Avalonia.Media.Imaging; -using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using JetBrains.Annotations; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; using ReactiveUI; @@ -15,9 +12,10 @@ public partial class AddKeyWindow : WindowBase public AddKeyWindow() { InitializeComponent(); - this.WhenActivated(d => + this.WhenActivated(_ => { - this.BindValidation(ViewModel, model => model.KeyName, + this.BindValidation( + ViewModel, model => model.KeyName, window => window.KeyFileNameValidation.Text!); }); } diff --git a/OpenSSH_GUI/Views/ApplicationSettingsWindow.axaml b/OpenSSH_GUI/Views/ApplicationSettingsWindow.axaml index db4488f..93a28b7 100644 --- a/OpenSSH_GUI/Views/ApplicationSettingsWindow.axaml +++ b/OpenSSH_GUI/Views/ApplicationSettingsWindow.axaml @@ -5,51 +5,153 @@ xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:resources="clr-namespace:OpenSSH_GUI.Resources" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" + xmlns:system="clr-namespace:System;assembly=System.Runtime" + xmlns:converter="clr-namespace:OpenSSH_GUI.Core.Resources.Converter;assembly=OpenSSH_GUI.Core" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="OpenSSH_GUI.Views.ApplicationSettingsWindow" x:DataType="viewModels:ApplicationSettingsViewModel" - Width="400" + Width="800" Height="375" CanResize="False" ShowInTaskbar="True" ShowActivated="True" Title="{x:Static resources:StringsAndTexts.ApplicationSettingsWindowTitle}" WindowStartupLocation="CenterOwner"> - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/ConnectToServerWindow.axaml.cs b/OpenSSH_GUI/Views/ConnectToServerWindow.axaml.cs index 29bb833..c8d6c59 100644 --- a/OpenSSH_GUI/Views/ConnectToServerWindow.axaml.cs +++ b/OpenSSH_GUI/Views/ConnectToServerWindow.axaml.cs @@ -1,7 +1,4 @@ -using Avalonia.Media.Imaging; -using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using JetBrains.Annotations; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; @@ -10,8 +7,5 @@ namespace OpenSSH_GUI.Views; [UsedImplicitly] public partial class ConnectToServerWindow : WindowBase { - public ConnectToServerWindow() - { - InitializeComponent(); - } + public ConnectToServerWindow() { InitializeComponent(); } } \ No newline at end of file diff --git a/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml b/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml index 5537851..4eea5e8 100644 --- a/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml +++ b/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml @@ -5,8 +5,8 @@ xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources" - xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:authorizedKeys="clr-namespace:OpenSSH_GUI.Core.Lib.AuthorizedKeys;assembly=OpenSSH_GUI.Core" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="OpenSSH_GUI.Views.EditAuthorizedKeysWindow" x:DataType="viewModels:EditAuthorizedKeysViewModel" @@ -18,7 +18,7 @@ WindowStartupLocation="CenterOwner"> - + - + - + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml.cs b/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml.cs index 2fd9639..d15ed79 100644 --- a/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml.cs +++ b/OpenSSH_GUI/Views/EditAuthorizedKeysWindow.axaml.cs @@ -1,7 +1,4 @@ -using Avalonia.Media.Imaging; -using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using JetBrains.Annotations; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; @@ -10,8 +7,5 @@ namespace OpenSSH_GUI.Views; [UsedImplicitly] public partial class EditAuthorizedKeysWindow : WindowBase { - public EditAuthorizedKeysWindow() - { - InitializeComponent(); - } + public EditAuthorizedKeysWindow() { InitializeComponent(); } } \ No newline at end of file diff --git a/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml b/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml index d987727..fc0260c 100644 --- a/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml +++ b/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml @@ -5,8 +5,8 @@ xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources" - xmlns:knownHosts="clr-namespace:OpenSSH_GUI.Core.Interfaces.KnownHosts;assembly=OpenSSH_GUI.Core" - xmlns:system="clr-namespace:System;assembly=System.Runtime" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" + xmlns:knownHosts="clr-namespace:OpenSSH_GUI.Core.Lib.KnownHosts;assembly=OpenSSH_GUI.Core" mc:Ignorable="d" x:Class="OpenSSH_GUI.Views.EditKnownHostsWindow" x:DataType="viewModels:EditKnownHostsWindowViewModel" @@ -20,7 +20,7 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + @@ -102,9 +106,9 @@ - + - + - + - - - - - - - + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml.cs b/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml.cs index 34d14f5..2deb318 100644 --- a/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml.cs +++ b/OpenSSH_GUI/Views/EditKnownHostsWindow.axaml.cs @@ -1,8 +1,4 @@ -using Avalonia.Controls; -using Avalonia.Media.Imaging; -using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using JetBrains.Annotations; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; @@ -11,8 +7,5 @@ namespace OpenSSH_GUI.Views; [UsedImplicitly] public partial class EditKnownHostsWindow : WindowBase { - public EditKnownHostsWindow() - { - InitializeComponent(); - } + public EditKnownHostsWindow() { InitializeComponent(); } } \ No newline at end of file diff --git a/OpenSSH_GUI/Views/ExportWindow.axaml b/OpenSSH_GUI/Views/ExportWindow.axaml index 32d91f9..f0160e2 100644 --- a/OpenSSH_GUI/Views/ExportWindow.axaml +++ b/OpenSSH_GUI/Views/ExportWindow.axaml @@ -2,49 +2,29 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources" - xmlns:system="clr-namespace:System;assembly=System.Runtime" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" Width="500" Height="300" x:Class="OpenSSH_GUI.Views.ExportWindow" x:DataType="viewModels:ExportWindowViewModel" Title="{Binding WindowTitle}"> - - - + + + - - - - - + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/ExportWindow.axaml.cs b/OpenSSH_GUI/Views/ExportWindow.axaml.cs index d040828..6f4c89a 100644 --- a/OpenSSH_GUI/Views/ExportWindow.axaml.cs +++ b/OpenSSH_GUI/Views/ExportWindow.axaml.cs @@ -1,15 +1,11 @@ -using Avalonia.Controls; -using JetBrains.Annotations; +using JetBrains.Annotations; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; namespace OpenSSH_GUI.Views; [UsedImplicitly] -public partial class ExportWindow : WindowBase +public partial class ExportWindow : WindowBase { - public ExportWindow() - { - InitializeComponent(); - } + public ExportWindow() { InitializeComponent(); } } \ No newline at end of file diff --git a/OpenSSH_GUI/Views/FileInfoWindow.axaml b/OpenSSH_GUI/Views/FileInfoWindow.axaml index c592bfa..0e97c0c 100644 --- a/OpenSSH_GUI/Views/FileInfoWindow.axaml +++ b/OpenSSH_GUI/Views/FileInfoWindow.axaml @@ -3,9 +3,230 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:OpenSSH_GUI.ViewModels" + xmlns:io="clr-namespace:System.IO;assembly=System.Runtime" + xmlns:converters="clr-namespace:OpenSSH_GUI.Converters" + xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:keygen="clr-namespace:SshNet.Keygen;assembly=SshNet.Keygen" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="OpenSSH_GUI.Views.FileInfoWindow" x:DataType="viewModels:FileInfoWindowViewModel" - Title="FileInfoWindow"> - Welcome to Avalonia! - + Width="400" + Height="375" + CanResize="False" + ShowActivated="True" + WindowStartupLocation="CenterOwner" + Title="{Binding WindowTitle}"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/FileInfoWindow.axaml.cs b/OpenSSH_GUI/Views/FileInfoWindow.axaml.cs index 692d2b9..07d0f0f 100644 --- a/OpenSSH_GUI/Views/FileInfoWindow.axaml.cs +++ b/OpenSSH_GUI/Views/FileInfoWindow.axaml.cs @@ -1,16 +1,12 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; using JetBrains.Annotations; +using OpenSSH_GUI.Core.Lib.Keys; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; namespace OpenSSH_GUI.Views; + [UsedImplicitly] -public partial class FileInfoWindow : WindowBase +public partial class FileInfoWindow : WindowBase { - public FileInfoWindow() - { - InitializeComponent(); - } + public FileInfoWindow() { InitializeComponent(); } } \ No newline at end of file diff --git a/OpenSSH_GUI/Views/MainWindow.axaml b/OpenSSH_GUI/Views/MainWindow.axaml index d8e5075..b40ea3c 100644 --- a/OpenSSH_GUI/Views/MainWindow.axaml +++ b/OpenSSH_GUI/Views/MainWindow.axaml @@ -7,6 +7,8 @@ xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources" xmlns:converters="clr-namespace:OpenSSH_GUI.Converters" xmlns:sys="clr-namespace:System;assembly=mscorlib" + xmlns:keys="clr-namespace:OpenSSH_GUI.Core.Lib.Keys;assembly=OpenSSH_GUI.Core" + xmlns:controls="clr-namespace:OpenSSH_GUI.Resources.Controls" mc:Ignorable="d" d:DesignWidth="1150" d:DesignHeight="450" x:Class="OpenSSH_GUI.Views.MainWindow" x:DataType="viewModels:MainWindowViewModel" @@ -14,31 +16,48 @@ Height="450" Title="{Binding WindowTitle}"> - - + + + + @@ -46,65 +65,96 @@ - + + - - - - - - + + + + + + + - - - - - + - - - - - - - + + + + + + + + - - - - - + + + + + + + + + + + + + - - + + + + + + + + + + - - - + - - + - + Background="{DynamicResource DisconnectedBrush}" CornerRadius="5"> + - + - - + + + Command="{Binding OpenConnectToServerWindowCommand}"> + Command="{Binding DisconnectFromServerCommand}"> - - + + - + - + - + + - + - - + + - + + + + + + - + - - + + + Command="{Binding ReloadKeysCommand}"> + Command="{Binding OpenApplicationSettingsWindowCommand}"> + Command="{Binding ShowNotImplementedMessageBoxCommand}"> + Command="{Binding ShowNotImplementedMessageBoxCommand}"> @@ -274,7 +333,7 @@ + Command="{Binding ShowNotImplementedMessageBoxCommand}"> @@ -282,7 +341,7 @@ + Command="{Binding ShowNotImplementedMessageBoxCommand}"> @@ -292,22 +351,22 @@ - - - - - + + + + - + - - + + + Command="{Binding OpenBrowserCommand}"> 0 @@ -317,7 +376,7 @@ + Command="{Binding OpenBrowserCommand}"> 1 @@ -326,7 +385,7 @@ + Command="{Binding OpenBrowserCommand}"> 2 @@ -334,11 +393,11 @@ - - - - - + + + + + \ No newline at end of file diff --git a/OpenSSH_GUI/Views/MainWindow.axaml.cs b/OpenSSH_GUI/Views/MainWindow.axaml.cs index 001513e..2b69ed4 100644 --- a/OpenSSH_GUI/Views/MainWindow.axaml.cs +++ b/OpenSSH_GUI/Views/MainWindow.axaml.cs @@ -1,10 +1,7 @@ using Avalonia.Controls; -using Avalonia.Media.Imaging; using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenSSH_GUI.Core.Interfaces.Hosts; -using OpenSSH_GUI.Core.MVVM; using OpenSSH_GUI.Core.Resources.Wrapper; using OpenSSH_GUI.ViewModels; @@ -13,32 +10,17 @@ namespace OpenSSH_GUI.Views; [UsedImplicitly] public partial class MainWindow : WindowBase, IDialogHost { - public MainWindow() + private readonly ILogger _logger; + + public MainWindow(ILogger logger) { + _logger = logger; InitializeComponent(); } - + public Task ShowDialog(TWindow dialogWindow) where TWindow : Window { - Logger.LogDebug("Showing dialog {nameOfWindow}", typeof(TWindow).Name); + _logger.LogDebug("Showing dialog {nameOfWindow}", typeof(TWindow).Name); return dialogWindow.ShowDialog(this); } - - public -#if DEBUG - async -#endif - Task ShowDialog(TWindow dialogWindow) - where TWindow : Window where TResult : ViewModelBase - { - Logger.LogDebug("Showing dialog {nameOfWindow} with expected result {nameOfResult}", typeof(TWindow).Name, - typeof(TResult).Name); -#if !DEBUG - return dialogWindow.ShowDialog(this); -#else - var result = await dialogWindow.ShowDialog(this); - Logger.LogDebug("Result: {nameOfResult}", result?.GetType().Name); - return result; -#endif - } } \ No newline at end of file diff --git a/README.md b/README.md index 26888ca..aac0f87 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,233 @@ -OpenSSH_GUI +# OpenSSH GUI -A GUI for managing your SSH Keys - on Windows, Linux and macOS! +A cross-platform desktop application for managing SSH keys, known hosts, and authorized keys — built with Avalonia UI, ReactiveUI, and .NET 10. -The primary reason for creating this project was to give "end-users" -a modern looking GUI for managing their SSH Keys - and making it easier -to deploy them to a server of their choice. +The goal of this project is to give users a modern, keyboard-friendly GUI for everything that usually requires `ssh-keygen` or hand-editing text files. It runs on **Windows**, **Linux**, and **macOS** and works entirely locally — no cloud, no telemetry. -The program I found -> [PuSSHy](https://github.com/klimenta/pusshy) was, in my opinion -not as user-friendly as it could be. I also wanted to use this program on my different -machines, running on Linux and macOS. So I decided to create my own! +--- -I hope you like it! +## Features -### Installing +- Browse, inspect, and manage all SSH key files in your configured lookup paths +- Generate new SSH keys (RSA, ECDSA, ED25519) with configurable bit size, comment, password, and format +- Convert keys between **OpenSSH** and **PuTTY v2/v3** formats in one click +- Change or clear the passphrase of any key file +- Rename key files safely (both private and public halves move together) +- Display SHA-256 fingerprints without ever unlocking the private key +- Open a **FileInfo** window per key to inspect, rename, delete, convert, or copy the password +- Edit the local `known_hosts` file; mark individual key entries or whole hosts for deletion +- Edit the local `authorized_keys` file +- Connect to a remote SSH server and edit its `known_hosts` and `authorized_keys` in the same UI +- Quick-connect from pre-configured `~/.ssh/config` host blocks +- Export public or private key content to the clipboard +- Application settings: log level, theme (dark/light/system), font size, lookup paths, cache cleanup +- Full dark and light theme with a VS Code–inspired teal/amber/red colour palette -No Installation needed! Just run the OpenSSHA_GUI.exe or .bin +--- -## Usage +## Screenshots -It is free to you, if you connect to a Server or not. -This program can be used on PC's (Local Machines) and Servers! +### Main Window -If you choose to connect to a server - ***beware!*** -This program - nor the author(s) take responsibility for saved messed up files! -***Make a backup if you already have files!*** +![Main Window](images/MainView.png) -If you need help, open an [Issue]() +The main window lists all discovered SSH keys in a table. Each row shows: -#### Main Window +- Lock/key icon indicating whether the key is encrypted and whether the passphrase has been provided +- Key algorithm (RSA, ECDSA, ED25519) and format (OpenSSH / PuTTY) +- SHA-256 fingerprint; password-protected keys show a **Provide Password** button inline +- Comment +- Action buttons: export public key, export private key, open FileInfo window -![](images/MainWindow.png) +### Main Window — Password Entered -##### V2 UI +![Main Window with password unlocked](images/MainViewPassEntered.png) -![](images/NewMainUI.png) -You can now convert the Key to the opposite format. -You can choose to delete or keep the key. -If the key is kept, the program will move it into a newly created sub-folder of your -.ssh directory. +Once a passphrase is provided, the fingerprint column shows the actual hash and a **Forget Password** button appears. -##### Key without provided password +### Add New SSH Key -![](images/FoundPasswordProtectedKey.png) +![Add Key Window](images/AddKeyWindow.png) -##### Password options, when a password was provided +Fields: -![](images/ShowForgetPws.png) +| Field | Notes | +|---|---| +| Key filename | Directory dropdown (from lookup paths) + filename text box | +| Keytype | RSA / ECDSA / ED25519 — default name updates automatically | +| Bitsize | Populated from the cryptographic legal key sizes for the chosen type; hidden for ED25519 | +| Password | Optional; leave blank for an unencrypted key | +| Comment | Defaults to `user@hostname` | +| Key Format | OpenSSH or PuTTY v2/v3 | -##### Provide password prompt +The **Add** button stays disabled until the filename passes validation (non-empty and not already on disk). -![](images/ProvidePasswordPrompt.png) +### FileInfo Window -##### Application Settings +![FileInfo Window](images/FileInfoWindow.png) +![FileInfo Window — password visible](images/FileInfoWindowPasswordVisible.png) -![](images/AppSettings.png) -App settings can be accessed through the settings context menu. -There is also an option, that the program converts all PPK keys in your .ssh directory -to the OpenSSH format. The PPK Keys are not deleted, they will be put into a folder called PPK -![](images/SettingsContextMenu.png) +Shows all files associated with the key (e.g. `id_ed25519` + `id_ed25519.pub`). From here you can: -##### Sorting feature +- **Change password** — prompts for the new passphrase via a secure input dialog +- **Rename** — moves both halves, prompts for overwrite if a conflict is detected +- **Delete** — removes all associated files from disk +- **Convert format** — the SplitButton converts to the default target; the dropdown allows choosing any other available format +- **Password field** — shows masked passphrase with a toggle-visibility eye button and a copy-to-clipboard button -You can sort the keys, if you want to. Just click on the top description category to sort by. -![](images/Sorted.png) +### Application Settings -#### Add SSH Key +![Application Settings](images/ApplicationSettings.png) -![](images/AddKeyWindow.png) +| Section | Options | +|---|---| +| Log Level | Verbose / Debug / Information / Warning / Error / Fatal | +| Theme | Default (system) / Dark / Light | +| Cache Options | Delete log files older than N days; clear whole application cache | +| Font Size | Numeric up/down; reset button restores the default | +| Lookup Paths | Add/remove directories the key crawler searches | -#### Connect to a Server +### Connect to Server -Right-Click on the Connection-status icon and click "Connect" on the showing menu. +![Connect to Server — empty](images/ConnectToServerWindowEmpty.png) +![Connect to Server — connected](images/ConnectToServerWindowFilled.png) -![](images/ConnectToServerWindow.png) +The connection window supports: -- You can also auth with a public key from the recognized keys on your machine! - ![](images/ConnectToServerWindowWithKey.png) +- **Preconfigured connections** — populated automatically from `~/.ssh/config` host blocks that carry an `IdentityFile` directive +- Manual entry of hostname, username, and either a password or a public key from the recognised key list +- **Test connection** button — attempts a connection and shows a colour-coded status badge (unknown / success / failed) +- After a successful test, the **Accept** button becomes active and establishes the session for the rest of the UI -- V2 Feature: Quick Connect - ![](images/ConnectToServerQuickConnect.png) - If you submitted a valid connection earlier, the program will save the connection, - and suggest this connection here for quick access. +### Edit known_hosts +![Edit known_hosts](images/EditKnownHostsWindow.png) -- You need to test the connection before you can submit it, if you do not use the new Quick-Connect feature. - If you get a connection error, an error window shows up. - ![](images/ConnectToServerWindowSuccess.png) +Displays every known host in a collapsible list. Each host shows its individual key entries (algorithm + fingerprint). Toggle buttons mark individual keys or entire hosts for deletion on save. A **Remote** tab appears when a server connection is active, allowing the same edits on the server's `known_hosts`. -#### Edit Authorized Keys +--- -Edit your local (or remote) authorized_keys! +## Architecture Overview -![](images/EditAuthorizedKeysWindow.png) +The project is split into four assemblies: -In the remote Version you can even add a key from the recognized keys! -The key cannot be added, when it's already present on the remote! -![](images/EditAuthorizedKeysWindowRemote.png) +| Assembly | Role | +|---|---| +| `OpenSSH_GUI` | Avalonia application shell — views, view models, DI wiring, app lifecycle | +| `OpenSSH_GUI.Core` | Domain logic — key management, SSH config crawling, server connections, backup service | +| `OpenSSH_GUI.SshConfig` | SSH `~/.ssh/config` parser, serialiser, and `IConfiguration` provider | +| `OpenSSH_GUI.Dialogs` | Reusable modal dialogs (message box, secure password input, validated text input) | -#### Edit Known Hosts Window +### Key Components -![](images/KnownHostsWindow.png) +**`SshKeyManager`** is the central service. It owns the observable collection of `SshKeyFile` instances and exposes async operations for generate, rename, change-password, change-format, delete, and reload. Every destructive operation backs up the affected files first and restores them on failure. -Here you have a list of all "Known Hosts" from your "known_hosts" file. -If you want to remove one key from a Host, toggle the button of the specific Key. -If you want to remove the whole host, just toggle the button on the top label. +**`SshKeyFile`** is a reactive record. It uses `ReactiveUI.SourceGenerators` to expose observable properties for fingerprint, comment, key type, format, password state, and file metadata. The fingerprint is extracted without decrypting the private key by parsing the unencrypted public key blob directly (supports OpenSSH `.pub`, OpenSSH private key header, and PPK v2/v3 headers). -#### Export Key Window +**`DirectoryCrawler`** is an `IAsyncEnumerable`-based crawler that reads `~/.ssh/config` identity files first (marking them as config-provided) and then enumerates the configured lookup directories for any remaining key files. -![](images/ExportKeyWindow.png) +**`SshConfigParser`** is a zero-dependency recursive-descent parser for `ssh_config(5)` syntax. It handles `Host`, `Match`, and `Include` directives, wildcard patterns, quoted values, and inline comments, and exposes the result as an `IConfiguration` source so the rest of the app can bind directly via `IOptions`. -#### Tooltips +**`ServerConnection`** wraps SSH.NET's `SshClient` and adds OS detection, remote `known_hosts`/`authorized_keys` read/write, and environment variable resolution on both Unix and Windows remote shells. -***Tooltip when not connected to a server*** -![](images/tooltip.png) +--- -***Tooltip from Key*** -![](images/tooltipKey.png) +## Installation -***Tooltip from connection*** -![](images/tooltipServer.png) +No installer is required. Download the self-contained binary for your platform and run it directly. -## Further Information +The application creates the following paths on first launch if they do not exist: -- The program will create these at startup without prompting if they don't exist: - .ssh/(**authorized_keys**, **known_hosts**) - (.config/OpenSSH_GUI/ | AppData\Roaming\OpenSSH_GUI\) **OpenSSH_GUI** and a "logs" directory +- `~/.ssh/` (mode 700 on Unix) +- `/etc/ssh/` or `%PROGRAMDATA%\ssh\` (mode 755 on Unix) +- `~/.ssh/known_hosts` and `~/.ssh/authorized_keys` +- `%APPDATA%\OpenSSH_GUI\` — configuration and log files -### Attention: This program will save your Passwords! +--- -You can not disable this feature. The Passwords are stored when: +## Configuration File -- you enter a server connection with a password -- provide a password for a keyfile +Application settings are stored as JSON at: -Your passwords are stored on your local machine inside the SQLite Database, protected with AES-Encryption. -Only the program itself can read any kind of string value inside the database. +- **Linux / macOS:** `~/.config/OpenSSH_GUI/OpenSSH_GUI.json` +- **Windows:** `%APPDATA%\OpenSSH_GUI\OpenSSH_GUI.json` -## Plans for the future +The file is created automatically on first run. You can also edit it by hand — changes are picked up at runtime via `IOptionsMonitor`. -- [ ] Beautify UI -- [ ] Add functionality for editing local and remote SSH (user/root) Settings -- many more not yet known! +```json +{ + "LookupPaths": [ "/home/user/.ssh" ], + "PreferredTheme": "Dark", + "LogLevel": "Warning", + "FontSize": 14, + "LoggerConfiguration": { + "LogFileName": "OpenSSH_GUI.log", + "LogFilePath": "/home/user/.config/OpenSSH_GUI/log" + } +} +``` -## Authors +--- -- **Oliver Schantz** - *Idea and primary development* - - [GitHub](https://github.com/frequency403) +## Building from Source -See also the list of -[contributors](https://github.com/frequency403/OpenSSH-GUI/contributors) -who participated in this project. +Requirements: .NET 10 SDK. -## Used Libraries / Technologies +```bash +git clone https://github.com/frequency403/OpenSSH-GUI +cd OpenSSH-GUI +dotnet build +dotnet run --project OpenSSH_GUI +``` -- [Avalonia UI](https://avaloniaui.net/) - Reactive UI +Tests: -- [ReactiveUI.Validation](https://github.com/reactiveui/ReactiveUI.Validation/) +```bash +dotnet test OpenSSH_GUI.Tests +``` -- [MessageBox.Avalonia](https://github.com/AvaloniaCommunity/MessageBox.Avalonia) +--- -- [Material.Icons](https://github.com/SKProCH/Material.Icons) +## Security Notes -- [SSH.NET](https://github.com/sshnet/SSH.NET) +- Passphrases are handled as raw byte buffers (`SshKeyFilePassword`) backed by a `ReactiveBufferWriter`. The buffer is zeroed via `CryptographicOperations.ZeroMemory` when cleared or disposed. +- The secure password input dialog (`SecureInputDialog`) intercepts `TextInputEvent` at tunnel phase to avoid Avalonia's default string accumulation in the `TextBox` internal buffer. +- Private key files are never read unless the user explicitly provides a passphrase. Fingerprints and metadata are always extracted from the unencrypted public portions of the key file. +- All destructive file operations (rename, convert, change password) create backups before modifying any file and restore them automatically on failure. -- [Serilog](https://serilog.net/) +--- -- [SshNet.Keygen](https://github.com/darinkes/SshNet.Keygen/) +## Known Limitations -- [SshNet.PuttyKeyFile](https://github.com/darinkes/SshNet.PuttyKeyFile) +- SSH config editing (local `~/.ssh/config` and remote `sshd_config`) is not yet implemented (placeholder menu items exist). +- Remote server operations require the connecting user to have read/write access to `~/.ssh/known_hosts` and `~/.ssh/authorized_keys` on the remote machine. -- [EntityFrameworkCore](https://github.com/dotnet/EntityFramework.Docs) +--- -- [SshNet.PuttyKeyFile](https://github.com/darinkes/SshNet.PuttyKeyFile) +## Used Libraries -## License +| Library | Purpose | +|---|---| +| [Avalonia UI](https://avaloniaui.net/) | Cross-platform UI framework | +| [ReactiveUI](https://reactiveui.net/) | MVVM + reactive extensions | +| [ReactiveUI.SourceGenerators](https://github.com/reactiveui/ReactiveUI.SourceGenerators) | Source-generated reactive properties and commands | +| [ReactiveUI.Validation](https://github.com/reactiveui/ReactiveUI.Validation) | Inline form validation | +| [SSH.NET](https://github.com/sshnet/SSH.NET) | SSH client | +| [SshNet.Keygen](https://github.com/darinkes/SshNet.Keygen) | Key generation and format conversion | +| [SshNet.PuttyKeyFile](https://github.com/darinkes/SshNet.PuttyKeyFile) | PuTTY key file support | +| [Material.Icons.Avalonia](https://github.com/SKProCH/Material.Icons) | Icon set | +| [Serilog](https://serilog.net/) | Structured logging | +| [BouncyCastle](https://www.bouncycastle.org/) | SHA-256 fingerprint computation | +| [Microsoft.Extensions.Hosting](https://learn.microsoft.com/dotnet/core/extensions/hosting) | DI, configuration, hosted services | -This project is licensed under the [MIT License](LICENSE) +--- + +## Authors -- see the [LICENSE](LICENSE) file for - details +- **Oliver Schantz** — idea and primary development — [GitHub](https://github.com/frequency403) + +See also the [contributors](https://github.com/frequency403/OpenSSH-GUI/contributors) list. + +## License +This project is licensed under the [MIT License](LICENSE). diff --git a/images/AddKeyWindow.png b/images/AddKeyWindow.png index e9c1ac2..0dad93b 100644 Binary files a/images/AddKeyWindow.png and b/images/AddKeyWindow.png differ diff --git a/images/AppSettings.png b/images/AppSettings.png deleted file mode 100644 index efa04f6..0000000 Binary files a/images/AppSettings.png and /dev/null differ diff --git a/images/ApplicationSettings.png b/images/ApplicationSettings.png new file mode 100644 index 0000000..e61121b Binary files /dev/null and b/images/ApplicationSettings.png differ diff --git a/images/ConnectToServerQuickConnect.png b/images/ConnectToServerQuickConnect.png deleted file mode 100644 index 3aa9787..0000000 Binary files a/images/ConnectToServerQuickConnect.png and /dev/null differ diff --git a/images/ConnectToServerWindow.png b/images/ConnectToServerWindow.png deleted file mode 100644 index 790dedb..0000000 Binary files a/images/ConnectToServerWindow.png and /dev/null differ diff --git a/images/ConnectToServerWindowEmpty.png b/images/ConnectToServerWindowEmpty.png new file mode 100644 index 0000000..93aa059 Binary files /dev/null and b/images/ConnectToServerWindowEmpty.png differ diff --git a/images/ConnectToServerWindowFilled.png b/images/ConnectToServerWindowFilled.png new file mode 100644 index 0000000..8f48b56 Binary files /dev/null and b/images/ConnectToServerWindowFilled.png differ diff --git a/images/ConnectToServerWindowSuccess.png b/images/ConnectToServerWindowSuccess.png deleted file mode 100644 index 121a1ee..0000000 Binary files a/images/ConnectToServerWindowSuccess.png and /dev/null differ diff --git a/images/ConnectToServerWindowWithKey.png b/images/ConnectToServerWindowWithKey.png deleted file mode 100644 index a476222..0000000 Binary files a/images/ConnectToServerWindowWithKey.png and /dev/null differ diff --git a/images/EditAuthorizedKeysWindow.png b/images/EditAuthorizedKeysWindow.png deleted file mode 100644 index a6fd89b..0000000 Binary files a/images/EditAuthorizedKeysWindow.png and /dev/null differ diff --git a/images/EditAuthorizedKeysWindowRemote.png b/images/EditAuthorizedKeysWindowRemote.png deleted file mode 100644 index 980683f..0000000 Binary files a/images/EditAuthorizedKeysWindowRemote.png and /dev/null differ diff --git a/images/EditKnownHostsWindow.png b/images/EditKnownHostsWindow.png new file mode 100644 index 0000000..1d1c363 Binary files /dev/null and b/images/EditKnownHostsWindow.png differ diff --git a/images/ExportKeyWindow.png b/images/ExportKeyWindow.png deleted file mode 100644 index db16808..0000000 Binary files a/images/ExportKeyWindow.png and /dev/null differ diff --git a/images/FileInfoWindow.png b/images/FileInfoWindow.png new file mode 100644 index 0000000..3f2421f Binary files /dev/null and b/images/FileInfoWindow.png differ diff --git a/images/FileInfoWindowPasswordVisible.png b/images/FileInfoWindowPasswordVisible.png new file mode 100644 index 0000000..ff683b7 Binary files /dev/null and b/images/FileInfoWindowPasswordVisible.png differ diff --git a/images/FoundPasswordProtectedKey.png b/images/FoundPasswordProtectedKey.png deleted file mode 100644 index 1244a3a..0000000 Binary files a/images/FoundPasswordProtectedKey.png and /dev/null differ diff --git a/images/KnownHostsWindow.png b/images/KnownHostsWindow.png deleted file mode 100644 index 345ebe8..0000000 Binary files a/images/KnownHostsWindow.png and /dev/null differ diff --git a/images/MainView.png b/images/MainView.png new file mode 100644 index 0000000..0762234 Binary files /dev/null and b/images/MainView.png differ diff --git a/images/MainViewPassEntered.png b/images/MainViewPassEntered.png new file mode 100644 index 0000000..a327dd7 Binary files /dev/null and b/images/MainViewPassEntered.png differ diff --git a/images/MainWindow.png b/images/MainWindow.png deleted file mode 100644 index be1a1d2..0000000 Binary files a/images/MainWindow.png and /dev/null differ diff --git a/images/NewMainUI.png b/images/NewMainUI.png deleted file mode 100644 index 7ba4800..0000000 Binary files a/images/NewMainUI.png and /dev/null differ diff --git a/images/ProvidePasswordPrompt.png b/images/ProvidePasswordPrompt.png deleted file mode 100644 index f859648..0000000 Binary files a/images/ProvidePasswordPrompt.png and /dev/null differ diff --git a/images/SettingsContextMenu.png b/images/SettingsContextMenu.png deleted file mode 100644 index 6c8812d..0000000 Binary files a/images/SettingsContextMenu.png and /dev/null differ diff --git a/images/ShowForgetPws.png b/images/ShowForgetPws.png deleted file mode 100644 index 9b11e47..0000000 Binary files a/images/ShowForgetPws.png and /dev/null differ diff --git a/images/Sorted.png b/images/Sorted.png deleted file mode 100644 index e278fd7..0000000 Binary files a/images/Sorted.png and /dev/null differ diff --git a/images/openssh-gui-light.svg b/images/openssh-gui-light.svg new file mode 100644 index 0000000..00d698e --- /dev/null +++ b/images/openssh-gui-light.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/openssh-gui.ico b/images/openssh-gui.ico new file mode 100644 index 0000000..2891934 Binary files /dev/null and b/images/openssh-gui.ico differ diff --git a/images/openssh-gui.svg b/images/openssh-gui.svg new file mode 100644 index 0000000..6459ca7 --- /dev/null +++ b/images/openssh-gui.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/tooltip.png b/images/tooltip.png deleted file mode 100644 index 1dd8817..0000000 Binary files a/images/tooltip.png and /dev/null differ diff --git a/images/tooltipKey.png b/images/tooltipKey.png deleted file mode 100644 index 0dc3a9f..0000000 Binary files a/images/tooltipKey.png and /dev/null differ diff --git a/images/tooltipServer.png b/images/tooltipServer.png deleted file mode 100644 index 5a66a4d..0000000 Binary files a/images/tooltipServer.png and /dev/null differ diff --git a/openssh-gui-bin/PKGBUILD b/openssh-gui-bin/PKGBUILD index 9f01b1d..2b7ceae 100644 --- a/openssh-gui-bin/PKGBUILD +++ b/openssh-gui-bin/PKGBUILD @@ -1,23 +1,39 @@ pkgname=openssh-gui-bin -pkgver=2.2.1 +pkgver=3.1.0 pkgrel=1 pkgdesc="A GUI for OpenSSH configuration and management (Binary version)" arch=('x86_64') url="https://github.com/frequency403/OpenSSH-GUI" license=('MIT') + depends=('icu' 'openssl' 'zlib' 'krb5' 'libx11') options=('!strip') + provides=('openssh-gui') conflicts=('openssh-gui' 'openssh-gui-git' 'openssh-gui-nightly') -source=("${pkgname}-${pkgver}::${url}/releases/download/v${pkgver}/OpenSSH-GUI-linux-x64" - "${pkgname}-icon-${pkgver}.png::${url}/raw/v${pkgver}/OpenSSH_GUI/Assets/appicon.png" - "${pkgname}-desktop-${pkgver}.desktop::${url}/raw/v${pkgver}/io.github.frequency403.openssh_gui.desktop" - "${pkgname}-license-${pkgver}::${url}/raw/v${pkgver}/LICENSE") -sha256sums=('6fb2a77a39be10e0b4d880d24c15563f258f05ffa98d6423e9042e085854f755' 'de5104be112173655a8a5950a4b129e0f28d94e29b80239bf7c82360c524bf9c' '9d73c85e0e47fddf9e8930b42caf0f89b39df7f6088a9ca1a08d0c5d2ea5ff42' '04765b5ced4962532281a4c40754d25380df5e89e49bf3f0ea9054f05a6ee34a') + +_relurl="https://github.com/frequency403/OpenSSH-GUI/releases/download/v${pkgver}" +_rawurl="https://raw.githubusercontent.com/frequency403/OpenSSH-GUI/v${pkgver}" + +source=( + "${pkgname}-${pkgver}::${_relurl}/OpenSSH-GUI-linux-x64" + "${pkgname}-icon-${pkgver}.png::${_relurl}/appicon.png" + "${pkgname}-desktop-${pkgver}.desktop::${_relurl}/io.github.frequency403.openssh_gui.desktop" + "${pkgname}-license-${pkgver}::${_rawurl}/LICENSE" +) + +sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP') package() { - install -Dm755 "${pkgname}-${pkgver}" "${pkgdir}/usr/bin/openssh-gui" - install -Dm644 "${pkgname}-icon-${pkgver}.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/openssh-gui.png" - install -Dm644 "${pkgname}-desktop-${pkgver}.desktop" "${pkgdir}/usr/share/applications/openssh-gui.desktop" - install -Dm644 "${pkgname}-license-${pkgver}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} + install -Dm755 "${srcdir}/${pkgname}-${pkgver}" \ + "${pkgdir}/usr/bin/openssh-gui" + + install -Dm644 "${srcdir}/${pkgname}-icon-${pkgver}.png" \ + "${pkgdir}/usr/share/icons/hicolor/256x256/apps/openssh-gui.png" + + install -Dm644 "${srcdir}/${pkgname}-desktop-${pkgver}.desktop" \ + "${pkgdir}/usr/share/applications/io.github.frequency403.openssh_gui.desktop" + + install -Dm644 "${srcdir}/${pkgname}-license-${pkgver}" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} \ No newline at end of file diff --git a/openssh-gui-git/PKGBUILD b/openssh-gui-git/PKGBUILD index f4b881d..bb37a2d 100644 --- a/openssh-gui-git/PKGBUILD +++ b/openssh-gui-git/PKGBUILD @@ -2,38 +2,56 @@ pkgname=openssh-gui-git _pkgname=OpenSSH-GUI pkgver=2.2.1.r0.g845610b pkgrel=1 -pkgdesc="A GUI for OpenSSH configuration and management (GIT version, built from develop)" +pkgdesc="A GUI for OpenSSH configuration and management (GIT version, built from development branch)" arch=('x86_64') url="https://github.com/frequency403/OpenSSH-GUI" license=('MIT') + depends=('dotnet-runtime-10.0') makedepends=('git' 'dotnet-sdk-10.0') + provides=('openssh-gui') conflicts=('openssh-gui' 'openssh-gui-bin' 'openssh-gui-nightly') + source=("git+${url}.git#branch=development") sha256sums=('SKIP') pkgver() { - cd "${_pkgname}" - git describe --long --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g;s/^v//' + cd "${srcdir}/${_pkgname}" + + local base count hash + base=$(grep -oP '(?<=)[^<]+' Directory.Build.props) + count=$(git rev-list --count HEAD) + hash=$(git rev-parse --short HEAD) + + printf "%s.r%s.g%s\n" "$base" "$count" "$hash" } build() { - cd "${_pkgname}" + cd "${srcdir}/${_pkgname}" + dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \ --configuration Release \ --runtime linux-x64 \ - --output "publish" \ + --output publish \ -p:PublishSingleFile=true \ -p:PublishReadyToRun=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ - --self-contained false + -p:SelfContained=false } package() { - cd "${_pkgname}" - install -Dm755 "publish/OpenSSH_GUI" "${pkgdir}/usr/bin/openssh-gui" - install -Dm644 "OpenSSH_GUI/Assets/appicon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/openssh-gui.png" - install -Dm644 "openssh-gui.desktop" "${pkgdir}/usr/share/applications/openssh-gui.desktop" - install -Dm644 "LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + cd "${srcdir}/${_pkgname}" + + install -Dm755 "publish/OpenSSH_GUI" \ + "${pkgdir}/usr/bin/openssh-gui" + + install -Dm644 "OpenSSH_GUI/Assets/appicon.png" \ + "${pkgdir}/usr/share/icons/hicolor/256x256/apps/openssh-gui.png" + + install -Dm644 "openssh-gui.desktop" \ + "${pkgdir}/usr/share/applications/openssh-gui.desktop" + + install -Dm644 "LICENSE" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" } \ No newline at end of file diff --git a/openssh-gui-nightly/PKGBUILD b/openssh-gui-nightly/PKGBUILD index cc34966..61cb549 100644 --- a/openssh-gui-nightly/PKGBUILD +++ b/openssh-gui-nightly/PKGBUILD @@ -1,7 +1,7 @@ pkgname=openssh-gui-nightly -pkgver=1.0.0.20260316.abc1234 +pkgver=3.0.0.19700101.unknown pkgrel=1 -pkgdesc="A GUI for OpenSSH configuration and management (Nightly build from develop)" +pkgdesc="A GUI for OpenSSH configuration and management (Nightly build)" arch=('x86_64') url="https://github.com/frequency403/OpenSSH-GUI" license=('MIT') @@ -9,15 +9,18 @@ depends=('icu' 'openssl' 'zlib' 'krb5' 'libx11') options=('!strip') provides=('openssh-gui') conflicts=('openssh-gui' 'openssh-gui-bin' 'openssh-gui-git') -source=("${pkgname}-${pkgver}::${url}/releases/download/nightly/OpenSSH-GUI-nightly-linux-x64" - "${pkgname}-icon-${pkgver}.png::${url}/raw/develop/OpenSSH_GUI/Assets/appicon.png" - "${pkgname}-desktop-${pkgver}.desktop::${url}/raw/develop/io.github.frequency403.openssh_gui.desktop" - "${pkgname}-license-${pkgver}::${url}/raw/develop/LICENSE") + +_relurl="${url}/releases/download/nightly" + +source=("${pkgname}-${pkgver}::${_relurl}/OpenSSH-GUI-nightly-linux-x64" + "${pkgname}-icon-${pkgver}.png::${_relurl}/appicon.png" + "${pkgname}-desktop-${pkgver}.desktop::${_relurl}/io.github.frequency403.openssh_gui.desktop" + "${pkgname}-license-${pkgver}::${_relurl}/LICENSE") sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP') package() { - install -Dm755 "${pkgname}-${pkgver}" "${pkgdir}/usr/bin/openssh-gui" - install -Dm644 "${pkgname}-icon-${pkgver}.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/openssh-gui.png" - install -Dm644 "${pkgname}-desktop-${pkgver}.desktop" "${pkgdir}/usr/share/applications/openssh-gui.desktop" - install -Dm644 "${pkgname}-license-${pkgver}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm755 "${pkgname}-${pkgver}" "${pkgdir}/usr/bin/openssh-gui" + install -Dm644 "${pkgname}-icon-${pkgver}.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/openssh-gui.png" + install -Dm644 "${pkgname}-desktop-${pkgver}.desktop" "${pkgdir}/usr/share/applications/io.github.frequency403.openssh_gui.desktop" + install -Dm644 "${pkgname}-license-${pkgver}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" } \ No newline at end of file diff --git a/update-version.sh b/update-version.sh new file mode 100644 index 0000000..6dc234c --- /dev/null +++ b/update-version.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROPS="Directory.Build.props" +VERSION=$(grep -oP '(?<=)[^<]+' "${PROPS}") + +echo "→ Version: ${VERSION}" + +PKGBUILD_BIN="openssh-gui-bin/PKGBUILD" +sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "${PKGBUILD_BIN}" +sed -i "s/^pkgrel=.*/pkgrel=1/" "${PKGBUILD_BIN}" +(cd openssh-gui-bin && updpkgsums) + +echo "✓ Done – ${PKGBUILD_BIN} → ${VERSION}" \ No newline at end of file