pax_global_header00006660000000000000000000000064150655630730014524gustar00rootroot0000000000000052 comment=9e3d73ccd0bdbdb26f56e1ff114a85523305bc30 SSH-Studio-1.3.1/000077500000000000000000000000001506556307300134305ustar00rootroot00000000000000SSH-Studio-1.3.1/.github/000077500000000000000000000000001506556307300147705ustar00rootroot00000000000000SSH-Studio-1.3.1/.github/workflows/000077500000000000000000000000001506556307300170255ustar00rootroot00000000000000SSH-Studio-1.3.1/.github/workflows/brew.yml000066400000000000000000000041211506556307300205050ustar00rootroot00000000000000name: Homebrew Formula on: workflow_dispatch: repository_dispatch: types: [website-build] pull_request: paths: - 'Formula/**' - '.github/workflows/brew.yml' push: branches: [ master ] tags: [ '[0-9]*.[0-9]*.[0-9]*' ] jobs: test-formula: strategy: matrix: os: [macos-13, macos-14] runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Homebrew environment run: | brew update-reset brew --version brew config - name: Style check run: | brew style ./Formula/ssh-studio.rb - name: Add formula to tap and audit run: | # Create a local tap brew tap-new --no-git local/ssh-studio # Copy formula to the tap cp ./Formula/ssh-studio.rb $(brew --repository local/ssh-studio)/Formula/ # Now audit the formula brew audit --new --strict local/ssh-studio/ssh-studio - name: Build and install run: | brew install --build-from-source local/ssh-studio/ssh-studio - name: Test formula run: | ssh-studio & sleep 2 brew test local/ssh-studio/ssh-studio # Close the GUI app pkill -f "SSH Studio" || true pkill -f "ssh-studio" || true - name: Test app bundle run: | # Test the .app bundle was created ls -la "$(brew --prefix local/ssh-studio/ssh-studio)/Applications/SSH Studio.app" # Test the launcher script "$(brew --prefix local/ssh-studio/ssh-studio)/Applications/SSH Studio.app/Contents/MacOS/ssh-studio" --help & sleep 2 # Close the GUI app pkill -f "SSH Studio" || true pkill -f "ssh-studio" || true - name: Create bottle (optional) run: | brew uninstall --force local/ssh-studio/ssh-studio || true brew install --build-bottle local/ssh-studio/ssh-studio brew bottle --json local/ssh-studio/ssh-studio continue-on-error: trueSSH-Studio-1.3.1/.github/workflows/linux-appimage.yml000066400000000000000000000055731506556307300225020ustar00rootroot00000000000000name: Build AppImage (Linux) on: push: branches: [ master ] tags: [ 'v*', '*.*.*' ] pull_request: branches: [ master ] jobs: appimage: runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies and appimage-builder run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ squashfs-tools zsync desktop-file-utils appstream file patchelf libglib2.0-bin fakeroot \ git meson ninja-build python3-pip python3-setuptools python3-wheel ca-certificates \ python3-gi \ libgtksourceview-5-dev libgtk-4-dev libadwaita-1-dev libglib2.0-dev libpango1.0-dev pkg-config gobject-introspection python -m pip install --upgrade pip python -m pip install --user "appimage-builder==0.8.2" echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Build and install blueprint-compiler (v0.18.0) run: | git clone --depth=1 --branch v0.18.0 https://gitlab.gnome.org/GNOME/blueprint-compiler.git cd blueprint-compiler export PATH="/usr/bin:$PATH" env PYTHON=/usr/bin/python3 meson setup build ninja -C build sudo meson install -C build which blueprint-compiler && env PATH="/usr/bin:$PATH" blueprint-compiler --version - name: Warm AppRun runtime cache run: | set -euo pipefail mkdir -p appimage-builder-cache/runtime curl -fL --retry 5 --retry-all-errors \ https://github.com/AppImageCrafters/AppRun/releases/download/v1.2.3/AppRun-Release-x86_64 \ -o appimage-builder-cache/runtime/AppRun-Release-x86_64 curl -fL --retry 5 --retry-all-errors \ https://github.com/AppImageCrafters/AppRun/releases/download/v1.2.3/libapprun_hooks-Release-x86_64.so \ -o appimage-builder-cache/runtime/libapprun_hooks-Release-x86_64.so file appimage-builder-cache/runtime/AppRun-Release-x86_64 | tee /dev/stderr file appimage-builder-cache/runtime/libapprun_hooks-Release-x86_64.so | tee /dev/stderr - name: Build AppImage run: | export PATH="/usr/bin:$PATH" export PYTHON="/usr/bin/python3" export ARCH=x86_64 appimage-builder --recipe AppImageBuilder.yml --skip-test - name: Upload artifact uses: actions/upload-artifact@v4 with: name: SSH-Studio-AppImage path: '*.AppImage' - name: Publish Release Asset if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: files: | *.AppImage *.zsync env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SSH-Studio-1.3.1/.github/workflows/macos-dmg.yml000066400000000000000000000422511506556307300214230ustar00rootroot00000000000000name: macOS DMG on: workflow_dispatch: push: branches: [master] tags: ["[0-9]*.[0-9]*.[0-9]*"] pull_request: paths: - ".github/workflows/macos-dmg.yml" - "meson.build" - "data/**" - "src/**" - "macos/**" jobs: build-dmg: strategy: matrix: os: [macos-14] runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 - name: Show runner info run: | uname -a sw_vers sysctl -n machdep.cpu.brand_string || true - name: Setup Homebrew and dependencies run: | brew update-reset brew install meson ninja pkg-config glib gtk4 libadwaita pygobject3 gtksourceview5 python@3.13 brew --version brew list --versions - name: Build blueprint-compiler from git (v0.18.0) run: | set -euxo pipefail git clone --depth 1 --branch v0.18.0 https://gitlab.gnome.org/GNOME/blueprint-compiler.git cd blueprint-compiler meson setup build --prefix "$PWD/../bp_prefix" --buildtype=release meson compile -C build meson install -C build echo "$GITHUB_WORKSPACE/bp_prefix/bin" >> "$GITHUB_PATH" - name: Build project (Meson) run: | set -euxo pipefail PY_BIN="$(brew --prefix python@3.13)/bin/python3" echo "Using Python: ${PY_BIN}" export PYTHON="${PY_BIN}" meson setup build --prefix "$PWD/stage" --buildtype=release meson compile -C build meson install -C build - name: Prepare macOS bundle metadata run: | mkdir -p macos BREW_PREFIX="$(brew --prefix)" export BREW_PREFIX echo "BREW_PREFIX=${BREW_PREFIX}" >> "$GITHUB_ENV" echo "Detected Homebrew prefix: $BREW_PREFIX" - name: Build self-contained .app (vendor Python + GTK) run: | set -euxo pipefail APP="dist/SSH Studio.app" APPROOT="$APP/Contents" MACOS="$APPROOT/MacOS" RES="$APPROOT/Resources" FRAMEWORKS="$APPROOT/Frameworks" mkdir -p "$MACOS" "$RES/python" "$FRAMEWORKS" # 1) Copy your Python sources rsync -a src/ "$RES/python/ssh_studio/" # 2) Copy compiled GResource from Meson install APP_ID="io.github.BuddySirJava.SSH-Studio" RES_GRES="$RES/ssh-studio-resources.gresource" SRC_GRES="" for CAND in \ "stage/share/$APP_ID/ssh-studio-resources.gresource" \ "build/data/ssh-studio-resources.gresource" \ "_build/data/ssh-studio-resources.gresource"; do if [ -f "$CAND" ]; then SRC_GRES="$CAND" break fi done if [ -n "$SRC_GRES" ]; then install -m 0644 "$SRC_GRES" "$RES_GRES" else echo "ERROR: Could not find ssh-studio-resources.gresource in expected locations" >&2 ls -la stage/share "$PWD"/build/data "$PWD"/_build/data || true exit 1 fi ls -la "$RES" || true # 2b) Build macOS .icns app icon from our 512px PNG ICON_SRC="data/media/icon_512.png" if [ -f "$ICON_SRC" ]; then ICONSET_DIR="macos/Icon.iconset" rm -rf "$ICONSET_DIR" mkdir -p "$ICONSET_DIR" for sz in 16 32 64 128 256 512; do sips -s format png "$ICON_SRC" --resampleWidth $sz --out "$ICONSET_DIR/icon_${sz}x${sz}.png" >/dev/null done # @2x variants for sz in 16 32 128 256; do db=$(($sz*2)) sips -s format png "$ICON_SRC" --resampleWidth $db --out "$ICONSET_DIR/icon_${sz}x${sz}@2x.png" >/dev/null done iconutil -c icns "$ICONSET_DIR" -o "$RES/SSHStudio.icns" else echo "WARN: Icon source $ICON_SRC not found; bundle will lack .icns" >&2 fi # 3) Vendor Python.framework BREW_PREFIX="$(brew --prefix)" # Dereference symlinks so the framework is self-contained inside the app bundle rsync -aL "$BREW_PREFIX/Frameworks/Python.framework" "$FRAMEWORKS/" # Ensure a bin/python3 exists inside the vendored framework (Homebrew's may omit it) PYFW="$FRAMEWORKS/Python.framework" if [ -d "$PYFW/Versions/3.13" ]; then PYHOME_DIR="$PYFW/Versions/3.13" else PYHOME_DIR="$PYFW/Versions/Current" fi if [ ! -x "$PYHOME_DIR/bin/python3" ]; then mkdir -p "$PYHOME_DIR/bin" if [ -x "$PYHOME_DIR/Resources/Python.app/Contents/MacOS/Python" ]; then ln -sf "../Resources/Python.app/Contents/MacOS/Python" "$PYHOME_DIR/bin/python3" fi fi # 4) Vendor GTK & friends (most-used libs) for p in glib gtk4 libadwaita gtksourceview5 gdk-pixbuf pango cairo harfbuzz fribidi graphite2 libpng jpeg libtiff libepoxy libffi gettext; do if [ -d "$BREW_PREFIX/opt/$p/lib" ]; then mkdir -p "$FRAMEWORKS/$p/lib" # Copy only runtime libraries; exclude static archives and dev files rsync -a --prune-empty-dirs \ --include '*/' \ --include '*.dylib' --include '*.dylib.*' \ --include '*.so' --include '*.so.*' \ --exclude '*' \ "$BREW_PREFIX/opt/$p/lib/" "$FRAMEWORKS/$p/lib/" fi if [ -d "$BREW_PREFIX/opt/$p/lib/girepository-1.0" ]; then mkdir -p "$RES/girepository-1.0" rsync -a "$BREW_PREFIX/opt/$p/lib/girepository-1.0/" "$RES/girepository-1.0/" fi if [ -d "$BREW_PREFIX/opt/$p/share" ]; then mkdir -p "$RES/share/$p" rsync -a "$BREW_PREFIX/opt/$p/share/" "$RES/share/" fi done # Ensure no static archives slipped in (can break codesign) find "$FRAMEWORKS" \( -name '*.a' -o -name '*.la' \) -delete || true # 4b) Vendor PyGObject (gi) and PyCairo into bundled Python path for SITE in \ "$BREW_PREFIX/lib/python3.13/site-packages" \ "$BREW_PREFIX/opt/pygobject3/lib/python3.13/site-packages" \ "$BREW_PREFIX/opt/py3cairo/lib/python3.13/site-packages"; do if [ -d "$SITE/gi" ]; then rsync -a "$SITE/gi" "$RES/python/" fi if [ -d "$SITE/cairo" ]; then rsync -a "$SITE/cairo" "$RES/python/" fi done # 5) Schemas needed by GSettings mkdir -p "$RES/share/glib-2.0/schemas" rsync -a "$BREW_PREFIX/opt/glib/share/glib-2.0/schemas/" "$RES/share/glib-2.0/schemas/" glib-compile-schemas "$RES/share/glib-2.0/schemas" # 6) Launcher that sets env so app uses bundled runtimes cat > "$MACOS/ssh-studio" <<'SH' #!/bin/bash SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" RES="$SCRIPT_DIR/../Resources" FW="$SCRIPT_DIR/../Frameworks" export RES if [ -d "$FW/Python.framework/Versions/3.13" ]; then export PYTHONHOME="$FW/Python.framework/Versions/3.13" else export PYTHONHOME="$FW/Python.framework/Versions/Current" fi export PYTHONPATH="$RES/python" export DYLD_FALLBACK_LIBRARY_PATH="$FW:$FW/glib/lib:$FW/gtk4/lib:$FW/libadwaita/lib:$FW/pango/lib:$FW/cairo/lib:$FW/gtksourceview5/lib" export GI_TYPELIB_PATH="$RES/girepository-1.0" export XDG_DATA_DIRS="$RES/share" export GSETTINGS_SCHEMA_DIR="$RES/share/glib-2.0/schemas" export GTK_DATA_PREFIX="$RES" # Register GResource then run app PYBIN="$PYTHONHOME/bin/python3" if [ ! -x "$PYBIN" ]; then # Fallback to framework embedded app binary if bin/python3 is absent if [ -x "$PYTHONHOME/Resources/Python.app/Contents/MacOS/Python" ]; then PYBIN="$PYTHONHOME/Resources/Python.app/Contents/MacOS/Python" fi fi "$PYBIN" - <<'PY' import os, sys from gi.repository import Gio res_dir = os.environ.get('RES') candidates = [] if res_dir: candidates.append(os.path.join(res_dir, 'ssh-studio-resources.gresource')) candidates.append(os.path.join(res_dir, 'share', 'io.github.BuddySirJava.SSH-Studio', 'ssh-studio-resources.gresource')) res_path = next((c for c in candidates if os.path.exists(c)), None) if not res_path: raise SystemExit('ssh-studio-resources.gresource not found in expected locations') Gio.resources_register(Gio.Resource.load(res_path)) if res_dir: sys.path.insert(0, os.path.join(res_dir, 'python')) from ssh_studio import main as _main sys.exit(_main.main()) PY SH chmod 0755 "$MACOS/ssh-studio" # 7) Minimal Info.plist (if you’re not generating it already) cat > "$APPROOT/Info.plist" <<'PLIST' CFBundleIdentifierio.github.BuddySirJava.SSH-Studio CFBundleNameSSH Studio CFBundleExecutablessh-studio CFBundleIconFileSSHStudio CFBundlePackageTypeAPPL LSMinimumSystemVersion11.0 PLIST - name: List .app contents run: | set -euxo pipefail echo 'self-contained app:' || true ls -R 'dist/SSH Studio.app/Contents' || true - name: Ad-hoc codesign app bundle run: | set -euxo pipefail APP="dist/SSH Studio.app" # Ensure files are writable and clear any quarantine attrs chmod -R u+rw "$APP" xattr -cr "$APP" || true # Sign only Mach-O binaries and libraries to avoid codesign errors on non-bundles find "$APP/Contents" -type f \ -exec sh -c 'file -b "$1" | grep -q "Mach-O" && codesign --force --sign - --timestamp=none "$1" || true' _ {} \; # Finally sign the app wrapper (no --deep) codesign --force --sign - --timestamp=none "$APP" codesign --verify --verbose=2 "$APP" || (codesign --display --verbose=5 "$APP"; exit 1) - name: Verify permissions and Info.plist run: | set -euxo pipefail APP="dist/SSH Studio.app" chmod +x "$APP/Contents/MacOS/ssh-studio" plutil -lint "$APP/Contents/Info.plist" # Show signature and linkage (non-fatal) codesign -dv --verbose=4 "$APP" || true otool -L "$APP/Contents/MacOS/ssh-studio" || true - name: Gatekeeper assessment (non-fatal) run: | set -euxo pipefail APP="dist/SSH Studio.app" spctl --assess --type execute -v "$APP" || true - name: Developer ID sign app (optional) env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_DEVELOPER_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_CERT_BASE64 }} APPLE_DEVELOPER_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERT_PASSWORD }} run: | set -euxo pipefail APP="dist/SSH Studio.app" if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_BASE64:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_PASSWORD:-}" ]; then echo "Signing secrets not provided; skipping Developer ID signing."; exit 0; fi KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" KEYCHAIN_PWD="$(openssl rand -hex 12)" security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" echo "$APPLE_DEVELOPER_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/dev_cert.p12" security import "$RUNNER_TEMP/dev_cert.p12" -k "$KEYCHAIN_PATH" -P "$APPLE_DEVELOPER_CERT_PASSWORD" -A security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db # Re-sign Mach-O components, then the app wrapper (no --deep) find "$APP/Contents" -type f \ -exec sh -c 'file -b "$1" | grep -q "Mach-O" && codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$1" || true' _ {} \; codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$APP" codesign --verify --strict --verbose=2 "$APP" - name: Create DMG run: | set -euxo pipefail VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) ARCH=$(uname -m) mkdir -p dmgroot # Prefer self-contained app for distribution cp -R "dist/SSH Studio.app" "dmgroot/SSH Studio.app" ln -s /Applications dmgroot/Applications hdiutil create -volname "SSH Studio" -srcfolder dmgroot -ov -fs HFS+ "ssh-studio-${VER}-${ARCH}.dmg" - name: Developer ID sign DMG (optional) env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_DEVELOPER_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_CERT_BASE64 }} APPLE_DEVELOPER_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERT_PASSWORD }} run: | set -euxo pipefail if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_BASE64:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_PASSWORD:-}" ]; then echo "Signing secrets not provided; skipping DMG signing."; exit 0; fi VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) ARCH=$(uname -m) DMG="ssh-studio-${VER}-${ARCH}.dmg" KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" if [ ! -f "$RUNNER_TEMP/dev_cert.p12" ]; then echo "$APPLE_DEVELOPER_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/dev_cert.p12" fi if [ ! -f "$KEYCHAIN_PATH" ]; then KEYCHAIN_PWD="$(openssl rand -hex 12)" security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" security import "$RUNNER_TEMP/dev_cert.p12" -k "$KEYCHAIN_PATH" -P "$APPLE_DEVELOPER_CERT_PASSWORD" -A security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db fi codesign --force --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$DMG" spctl --assess --type open -v "$DMG" || true - name: Notarize DMG with notarytool (API key) (optional) env: NOTARYTOOL_KEY_ID: ${{ secrets.NOTARYTOOL_KEY_ID }} NOTARYTOOL_ISSUER_ID: ${{ secrets.NOTARYTOOL_ISSUER_ID }} NOTARYTOOL_PRIVATE_KEY: ${{ secrets.NOTARYTOOL_PRIVATE_KEY }} run: | set -euxo pipefail if [ -z "${NOTARYTOOL_KEY_ID:-}" ] || [ -z "${NOTARYTOOL_ISSUER_ID:-}" ] || [ -z "${NOTARYTOOL_PRIVATE_KEY:-}" ]; then echo "Notary API key secrets not provided; skipping API-key notarization."; exit 0; fi VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) ARCH=$(uname -m) DMG="ssh-studio-${VER}-${ARCH}.dmg" KEYFILE="$RUNNER_TEMP/AuthKey.p8" echo "$NOTARYTOOL_PRIVATE_KEY" > "$KEYFILE" xcrun notarytool submit "$DMG" \ --key "$KEYFILE" \ --key-id "$NOTARYTOOL_KEY_ID" \ --issuer "$NOTARYTOOL_ISSUER_ID" \ --wait xcrun stapler staple "$DMG" - name: Notarize DMG with notarytool (Apple ID) (optional) env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} run: | set -euxo pipefail # Only run if API key secrets are missing but Apple ID-based secrets are present if [ -n "${NOTARYTOOL_KEY_ID:-}" ] && [ -n "${NOTARYTOOL_ISSUER_ID:-}" ] && [ -n "${NOTARYTOOL_PRIVATE_KEY:-}" ]; then echo "API key provided; skipping Apple ID notarization path."; exit 0; fi if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]; then echo "Apple ID notarization secrets not provided; skipping."; exit 0; fi VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) ARCH=$(uname -m) DMG="ssh-studio-${VER}-${ARCH}.dmg" xcrun notarytool submit "$DMG" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_SPECIFIC_PASSWORD" \ --wait xcrun stapler staple "$DMG" - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ssh-studio-dmg-${{ matrix.os }} path: | *.dmg SSH-Studio-1.3.1/.github/workflows/windows.yml000066400000000000000000000077401506556307300212520ustar00rootroot00000000000000name: Windows Installer on: workflow_dispatch: push: tags: - "[0-9]+.[0-9]+.[0-9]+" jobs: build-windows: runs-on: windows-latest steps: - uses: actions/checkout@v3 - name: Set up MSYS2 uses: msys2/setup-msys2@v2 with: msystem: UCRT64 update: true - name: Install build dependencies (GTK4 + Python + build tools) shell: msys2 {0} run: | pacman -Sy --noconfirm pacman --noconfirm -S --needed \ git \ mingw-w64-ucrt-x86_64-toolchain \ mingw-w64-ucrt-x86_64-meson \ mingw-w64-ucrt-x86_64-ninja \ mingw-w64-ucrt-x86_64-pkg-config \ mingw-w64-ucrt-x86_64-python \ mingw-w64-ucrt-x86_64-python-pip \ mingw-w64-ucrt-x86_64-python-gobject \ mingw-w64-ucrt-x86_64-gtk4 \ mingw-w64-ucrt-x86_64-libadwaita \ mingw-w64-ucrt-x86_64-glib2 \ mingw-w64-ucrt-x86_64-gobject-introspection \ mingw-w64-ucrt-x86_64-gsettings-desktop-schemas \ mingw-w64-ucrt-x86_64-gtksourceview5 \ p7zip - name: Build blueprint-compiler (v0.18.0) shell: msys2 {0} run: | set -e rm -rf /c/_deps && mkdir -p /c/_deps cd /c/_deps git clone --depth 1 --branch v0.18.0 https://gitlab.gnome.org/GNOME/blueprint-compiler.git cd blueprint-compiler meson setup build --prefix=/opt/blueprint-compiler --buildtype=release meson compile -C build meson install -C build echo "/opt/blueprint-compiler/bin" >> $GITHUB_PATH export PATH="/opt/blueprint-compiler/bin:$PATH" blueprint-compiler --version - name: Configure & build SSH Studio (Meson) shell: msys2 {0} run: | set -e export PATH="/opt/blueprint-compiler/bin:$PATH" rm -rf builddir && mkdir -p builddir meson setup builddir --prefix=/opt/ssh-studio --buildtype=release meson compile -C builddir meson install -C builddir - name: Stage install tree shell: msys2 {0} run: | STAGE=/c/_stage/ssh-studio rm -rf "$STAGE" && mkdir -p "$STAGE" cp -r /opt/ssh-studio/* "$STAGE"/ # Copy MSYS2 runtime (Python + GTK) cp /ucrt64/bin/python.exe "$STAGE"/ cp /ucrt64/bin/pythonw.exe "$STAGE"/ cp -r /ucrt64/lib/python3* "$STAGE"/python-lib cp -r /ucrt64/bin "$STAGE"/gtk-bin cp -r /ucrt64/lib "$STAGE"/gtk-lib cp -r /ucrt64/share "$STAGE"/gtk-share # Add batch launcher cat > "$STAGE/SSH-Studio.bat" << 'EOF' @echo off setlocal set APPDIR=%~dp0 set PATH=%APPDIR%gtk-bin;%PATH% set GI_TYPELIB_PATH=%APPDIR%gtk-lib\girepository-1.0 set XDG_DATA_DIRS=%APPDIR%share;%APPDIR%gtk-share "%APPDIR%pythonw.exe" -m ssh_studio.main %* endlocal EOF # Make portable zip cd /c/_stage 7z a SSH-Studio-portable.zip ssh-studio > /dev/null cp SSH-Studio-portable.zip /d/a/SSH-Studio/SSH-Studio/ - name: Download Inno Setup run: | $url = "https://files.jrsoftware.org/is/6/innosetup-6.2.2.exe" $output = "C:\innosetup.exe" Invoke-WebRequest -Uri $url -OutFile $output Start-Process -FilePath $output -ArgumentList "/SILENT" -Wait - name: Build Inno Setup installer run: | $version = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { "dev" } & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppName="SSH-Studio" /DVersion="$version" /DSourceDir="C:\_stage\ssh-studio" "installer\ssh-studio.iss" - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: windows-artifacts path: | SSH-Studio-portable.zip installer/out/*.exe SSH-Studio-1.3.1/.gitignore000066400000000000000000000007301506556307300154200ustar00rootroot00000000000000# Python __pycache__/ *.py[cod] *.pyo *.egg-info/ .python-version # Virtual envs .venv/ venv/ env/ # Editors/OS .vscode/ .idea/ .DS_Store .gnome-builder/ # Meson/Ninja build artifacts build/ builddir/ build-*/ meson-logs/ meson-private/ compile_commands.json # Local run/output dist/ run/ # Tests/tools caches .pytest_cache/ .mypy_cache/ .ruff_cache/ # i18n compiled catalogs po/**/*.mo # Flatpak/Flatpak Builder .flatpak-builder/ repo/ app/ *.flatpak *.flatpakrefSSH-Studio-1.3.1/AppImageBuilder.yml000066400000000000000000000033601506556307300171470ustar00rootroot00000000000000version: 1 AppDir: path: AppDir app_info: id: io.github.BuddySirJava.SSH-Studio name: SSH Studio icon: io.github.BuddySirJava.SSH-Studio version: 1.3.1 exec: usr/bin/python3 exec_args: "-m ssh_studio.main $@" runtime: env: GSETTINGS_SCHEMA_DIR: usr/share/glib-2.0/schemas GI_TYPELIB_PATH: usr/lib/girepository-1.0:usr/lib/x86_64-linux-gnu/girepository-1.0 LD_LIBRARY_PATH: usr/lib:usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH} apt: arch: amd64 allow_unauthenticated: true sources: - sourceline: deb [arch=amd64 trusted=yes] http://archive.ubuntu.com/ubuntu noble main universe - sourceline: deb [arch=amd64 trusted=yes] http://archive.ubuntu.com/ubuntu noble-updates main universe - sourceline: deb [arch=amd64 trusted=yes] http://security.ubuntu.com/ubuntu noble-security main universe include: - python3 - python3-gi - gir1.2-gtk-4.0 - gir1.2-adw-1 - gir1.2-gtksource-5 - libgtk-4-1 - libadwaita-1-0 - libgtksourceview-5-0 - libgtk-4-dev - libgtksourceview-5-dev - libadwaita-1-dev - libglib2.0-dev - libpango1.0-dev - glib-networking - ca-certificates - libglib2.0-0 - libpango-1.0-0 - libgirepository-1.0-1 - pkg-config exclude: - usr/share/doc - usr/share/man - usr/share/locale - usr/share/icons/hicolor/icon-theme.cache files: include: [] exclude: - usr/include - usr/lib/pkgconfig script: - meson setup --prefix=/usr builddir - meson install -C builddir --destdir AppDir - glib-compile-schemas AppDir/usr/share/glib-2.0/schemas || true AppImage: arch: x86_64 update-information: None sign-key: None SSH-Studio-1.3.1/CODE_OF_CONDUCT.md000066400000000000000000000254561506556307300162430ustar00rootroot00000000000000# GNOME Code of Conduct Thank you for being a part of the GNOME project. We value your participation and want everyone to have an enjoyable and fulfilling experience. Accordingly, all participants are expected to follow this Code of Conduct, and to show respect, understanding, and consideration to one another. Thank you for helping make this a welcoming, friendly community for everyone. ## Scope This Code of Conduct applies to all online GNOME community spaces, including, but not limited to: * Issue tracking systems - bugzilla.gnome.org * Documentation and tutorials - developer.gnome.org * Code repositories - git.gnome.org and gitlab.gnome.org * Mailing lists - mail.gnome.org * Wikis - wiki.gnome.org * Chat and forums - irc.gnome.org, discourse.gnome.org, GNOME Telegram channels, and GNOME groups and channels on Matrix.org (including bridges to GNOME IRC channels) * Community spaces hosted on gnome.org infrastructure * Any other channels or groups which exist in order to discuss GNOME project activities Communication channels and private conversations that are normally out of scope may be considered in scope if a GNOME participant is being stalked or harassed. Social media conversations may be considered in-scope if the incident occurred under a GNOME event hashtag, or when an official GNOME account on social media is tagged, or within any other discussion about GNOME. The GNOME Foundation reserves the right to take actions against behaviors that happen in any context, if they are deemed to be relevant to the GNOME project and its participants. All participants in GNOME online community spaces are subject to the Code of Conduct. This includes GNOME Foundation board members, corporate sponsors, and paid employees. This also includes volunteers, maintainers, leaders, contributors, contribution reviewers, issue reporters, GNOME users, and anyone participating in discussion in GNOME online spaces. ## Reporting an Incident If you believe that someone is violating the Code of Conduct, or have any other concerns, please [contact the Code of Conduct committee](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide). ## Our Standards The GNOME online community is dedicated to providing a positive experience for everyone, regardless of: * age * body size * caste * citizenship * disability * education * ethnicity * familial status * gender expression * gender identity * genetic information * immigration status * level of experience * nationality * personal appearance * pregnancy * race * religion * sex characteristics * sexual orientation * sexual identity * socio-economic status * tribe * veteran status ### Community Guidelines Examples of behavior that contributes to creating a positive environment include: * **Be friendly.** Use welcoming and inclusive language. * **Be empathetic.** Be respectful of differing viewpoints and experiences. * **Be respectful.** When we disagree, we do so in a polite and constructive manner. * **Be considerate.** Remember that decisions are often a difficult choice between competing priorities. Focus on what is best for the community. Keep discussions around technology choices constructive and respectful. * **Be patient and generous.** If someone asks for help it is because they need it. When documentation is available that answers the question, politely point them to it. If the question is off-topic, suggest a more appropriate online space to seek help. * **Try to be concise.** Read the discussion before commenting in order to not repeat a point that has been made. ### Inappropriate Behavior Community members asked to stop any inappropriate behavior are expected to comply immediately. We want all participants in the GNOME community have the best possible experience they can. In order to be clear what that means, we've provided a list of examples of behaviors that are inappropriate for GNOME community spaces: * **Deliberate intimidation, stalking, or following.** * **Sustained disruption of online discussion, talks, or other events.** Sustained disruption of events, online discussions, or meetings, including talks and presentations, will not be tolerated. This includes 'Talking over' or 'heckling' event speakers or influencing crowd actions that cause hostility in event sessions. Sustained disruption also includes drinking alcohol to excess or using recreational drugs to excess, or pushing others to do so. * **Harassment of people who don't drink alcohol.** We do not tolerate derogatory comments about those who abstain from alcohol or other substances. We do not tolerate pushing people to drink, talking about their abstinence or preferences to others, or pressuring them to drink - physically or through jeering. * **Sexist, racist, homophobic, transphobic, ableist language or otherwise exclusionary language.** This includes deliberately referring to someone by a gender that they do not identify with, and/or questioning the legitimacy of an individual's gender identity. If you're unsure if a word is derogatory, don't use it. This also includes repeated subtle and/or indirect discrimination. * **Unwelcome sexual attention or behavior that contributes to a sexualized environment.** This includes sexualized comments, jokes or imagery in interactions, communications or presentation materials, as well as inappropriate touching, groping, or sexual advances. Sponsors should not use sexualized images, activities, or other material. Meetup organizing staff and other volunteer organizers should not use sexualized clothing/uniforms/costumes, or otherwise create a sexualized environment. * **Unwelcome physical contact.** This includes touching a person without permission, including sensitive areas such as their hair, pregnant stomach, mobility device (wheelchair, scooter, etc) or tattoos. This also includes physically blocking or intimidating another person. Physical contact or simulated physical contact (such as emojis like "kiss") without affirmative consent is not acceptable. This includes sharing or distribution of sexualized images or text. * **Violence or threats of violence.** Violence and threats of violence are not acceptable - online or offline. This includes incitement of violence toward any individual, including encouraging a person to commit self-harm. This also includes posting or threatening to post other people's personally identifying information ("doxxing") online. * **Influencing or encouraging inappropriate behavior.** If you influence or encourage another person to violate the Code of Conduct, you may face the same consequences as if you had violated the Code of Conduct. * **Possession of an offensive weapon at a GNOME event.** This includes anything deemed to be a weapon by the event organizers. The GNOME community prioritizes marginalized people's safety over privileged people's comfort. The committee will not act on complaints regarding: * "Reverse"-isms, including "reverse racism," "reverse sexism," and "cisphobia" * Reasonable communication of boundaries, such as "leave me alone," "go away," or "I'm not discussing this with you." * Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions * Communicating boundaries or criticizing oppressive behavior in a "tone" you don't find congenial The examples listed above are not against the Code of Conduct. If you have questions about the above statements, please [read this document](https://github.com/sagesharp/code-of-conduct-template/blob/master/code-of-conduct/example-reversisms.md#supporting-diversity). If a participant engages in behavior that violates this code of conduct, the GNOME Code of Conduct committee may take any action they deem appropriate. Examples of consequences are outlined in the [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures). ## Procedure for Handling Incidents * [Reporter Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide) * [Moderator Procedures](https://wiki.gnome.org/Foundation/CodeOfConduct/ModeratorProcedures) * [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures) ## License The GNOME Online Code of Conduct is licensed under a [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/) ![Creative Commons License](http://i.creativecommons.org/l/by-sa/3.0/88x31.png) ## Attribution The GNOME Online Code of Conduct was forked from the example policy from the [Geek Feminism wiki, created by the Ada Initiative and other volunteers](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy), which is under a Creative Commons Zero license. Additional language was incorporated and modified from the following Codes of Conduct: * [Citizen Code of Conduct](http://citizencodeofconduct.org/) is licensed [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). * [Code of Conduct template](https://github.com/sagesharp/code-of-conduct-template/) is licensed [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/) by [Otter Tech](https://otter.technology/code-of-conduct-training) * [Contributor Covenant version 1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct) (licensed [CC BY 4.0](https://github.com/ContributorCovenant/contributor_covenant/blob/master/LICENSE.md)) * [Data Carpentry Code of Conduct](https://docs.carpentries.org/topic_folders/policies/index_coc.html) is licensed [Creative Commons Attribution 4.0 License](https://creativecommons.org/licenses/by/4.0/) * [Django Project Code of Conduct](https://www.djangoproject.com/conduct/) is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) * [Fedora Code of Conduct](http://fedoraproject.org/code-of-conduct) * [Geek Feminism Anti-harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy) which is under a [Creative Commons Zero license](https://creativecommons.org/publicdomain/zero/1.0/) * [Previous GNOME Foundation Code of Conduct](https://wiki.gnome.org/action/recall/Foundation/CodeOfConduct/Old) * [LGBTQ in Technology Slack Code of Conduct](https://lgbtq.technology/coc.html) licensed [Creative Commons Zero](https://creativecommons.org/publicdomain/zero/1.0/) * [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) is licensed [Creative Commons Attribution-ShareAlike 3.0 Unported License](https://creativecommons.org/licenses/by-sa/3.0/). * [Python Mentors Code of Conduct](http://pythonmentors.com/) * [Speak Up! Community Code of Conduct](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html), licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) SSH-Studio-1.3.1/Formula/000077500000000000000000000000001506556307300150355ustar00rootroot00000000000000SSH-Studio-1.3.1/Formula/ssh-studio.rb000066400000000000000000000106201506556307300174630ustar00rootroot00000000000000class SshStudio < Formula desc "GTK4 desktop app to edit and validate your ~/.ssh/config" homepage "https://github.com/BuddySirJava/SSH-Studio" url "https://github.com/BuddySirJava/SSH-Studio/archive/refs/tags/1.2.3.tar.gz" sha256 "8fc311467822c8c858400288386b023b9008bb2f5992966b57052ddd197c9cba" license "GPL-3.0-or-later" head "https://github.com/BuddySirJava/SSH-Studio.git", branch: "master" depends_on "meson" => :build depends_on "ninja" => :build depends_on "pkg-config" => :build depends_on "adwaita-icon-theme" depends_on "glib" depends_on "gtk4" depends_on "libadwaita" depends_on "pygobject3" depends_on "python@3.13" resource "blueprint-compiler" do url "https://gitlab.gnome.org/GNOME/blueprint-compiler/-/archive/v0.18.0/blueprint-compiler-v0.18.0.tar.gz" sha256 "703c7ccd23cb6f77a8fe9c8cae0f91de9274910ca953de77135b6e79dbff1fc3" end def install resource("blueprint-compiler").stage do system "meson", "setup", "build", "--prefix=#{libexec}", "--buildtype=release" system "meson", "compile", "-C", "build" system "meson", "install", "-C", "build" end ENV.prepend_path "PATH", libexec/"bin" ENV["PYTHON"] = Formula["python@3.13"].opt_bin/"python3" inreplace "data/ssh-studio.in", "python3", "#{Formula["python@3.13"].opt_bin}/python3" system "meson", "setup", "build", *std_meson_args system "meson", "compile", "-C", "build" system "meson", "install", "-C", "build" python_version = Formula["python@3.13"].version.major_minor python_site_packages = lib/"python#{python_version}/site-packages" python_site_packages.mkpath (python_site_packages/"ssh_studio").mkpath (python_site_packages/"ssh_studio/ui").mkpath cp_r "src/ssh_config_parser.py", python_site_packages/"ssh_studio/" cp_r "src/main.py", python_site_packages/"ssh_studio/" cp_r "src/__init__.py", python_site_packages/"ssh_studio/" cp_r Dir["src/ui/*.py"], python_site_packages/"ssh_studio/ui/" cp_r "src/ui/__init__.py", python_site_packages/"ssh_studio/ui/" (libexec/"bin").mkpath mv bin/"ssh-studio", libexec/"bin/ssh-studio" if (bin/"ssh-studio").exist? resource_file = share/"io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource" launcher = libexec/"ssh-studio-launch.py" launcher.write <<~PY #!/usr/bin/env python3 import sys from gi.repository import Gio Gio.resources_register(Gio.Resource.load("#{resource_file}")) from ssh_studio import main as _main sys.exit(_main.main()) PY chmod 0755, launcher (bin/"ssh-studio").write <<~SH #!/bin/bash export PYTHONPATH="#{python_site_packages}" exec "#{Formula["python@3.13"].opt_bin}/python3" "#{launcher}" "$@" SH chmod 0755, bin/"ssh-studio" app_root = prefix/"Applications/SSH Studio.app/Contents" (app_root/"MacOS").mkpath (app_root/"Resources").mkpath (app_root/"Info.plist").write <<~PLIST CFBundleNameSSH Studio CFBundleIdentifierio.github.BuddySirJava.SSH-Studio CFBundleVersion#{version} CFBundleShortVersionString#{version} CFBundleExecutablessh-studio CFBundlePackageTypeAPPL LSMinimumSystemVersion11.0 LSApplicationCategoryTypepublic.app-category.developer-tools PLIST (app_root/"MacOS/ssh-studio").write <<~SH #!/bin/bash export PYTHONPATH="#{python_site_packages}" exec "#{Formula["python@3.13"].opt_bin}/python3" "#{launcher}" "$@" SH chmod 0755, (app_root/"MacOS/ssh-studio") end def caveats <<~EOS A minimal app bundle was installed at: #{opt_prefix}/Applications/SSH Studio.app To add a Desktop shortcut: ln -sf "#{opt_prefix}/Applications/SSH Studio.app" "$HOME/Desktop/SSH Studio.app" To add it to /Applications (optional): ln -sf "#{opt_prefix}/Applications/SSH Studio.app" "/Applications/SSH Studio.app" EOS end test do system bin/"ssh-studio", "--help" end end SSH-Studio-1.3.1/LICENSE000066400000000000000000001045151506556307300144430ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . SSH-Studio-1.3.1/README.md000066400000000000000000000113041506556307300147060ustar00rootroot00000000000000
App Icon

SSH-Studio

GTK License Python

A native GTK4 desktop app for editing and validating your ~/.ssh/config.

Search, edit, and validate SSH hosts with a clean UI — no need to touch terminal editors.

--- ## Preview
Main Interface Preferences Dialog
--- ## Features - **Visual host editor** – Edit common fields (Host, HostName, User, Port, IdentityFile, ForwardAgent, etc.). - **Inline validation** – Field-level errors shown directly under inputs; parser checks for duplicates and invalid ports. - **Search and filter** – Quickly find hosts across aliases, hostnames, users, and identities. - **Raw/Diff view** – Edit raw `ssh_config` text with instant diff highlighting. - **Quick actions** – Copy SSH command, test connection, and revert changes. - **SSH Key Management** – Import, generate, and use your keys without leaving the app. - **Safe saves** – Automatic backups (configurable), atomic writes, and include support. - **Keyboard & mouse friendly** – Smooth GTK 4 UI with dark theme preference. - **Translations** – Ready for localization (gettext support via `po/`). --- ## Install ### From AUR You can install SSH Studio from AUR [here](https://aur.archlinux.org/packages/ssh-studio). ### From Flathub [![Download on Flathub](https://flathub.org/api/badge?svg&locale=en)](https://flathub.org/en/apps/io.github.BuddySirJava.SSH-Studio) ### Build from source You can build and run with GNOME Builder or `flatpak-builder`: ```bash flatpak-builder --user --force-clean --install-deps-from=flathub build-dir io.github.BuddySirJava.SSH-Studio.json --install # Run flatpak run io.github.BuddySirJava.SSH-Studio ``` --- ## Project structure - `src/ssh_config_parser.py` → Parse/validate/generate SSH config safely. - `src/ui/` → Main App Components (`MainWindow`, `HostList`, `HostEditor`, `SearchBar`, `PreferencesDialog`, `TestConnectionDialog`, `SSH Key Manager`). - `data/ui/*.ui` → GTK Builder UI blueprints. - `data/ssh-studio.gresource.xml` → GResource manifest. - `data/media/` → App icon and screenshots. - `src/main.py` → Application entry point. - `meson.build`, `data/meson.build`, `src/meson.build` → Build and install rules. - `io.github.BuddySirJava.SSH-Studio.json` → Flatpak manifest. - `po/` → Translations. --- ## Development Requirements: - **Python 3.12+** - **GTK 4 / libadwaita 1.4+** - **Meson & Ninja** - **Flatpak / flatpak-builder** Clone and run in dev mode: ```bash git clone https://github.com/BuddySirJava/SSH-Studio.git cd SSH-Studio meson setup builddir meson compile -C builddir ./builddir/src/ssh-studio ``` --- ## Contributing Contributions are welcome! - Report bugs or request features in the [issue tracker](https://github.com/BuddySirJava/SSH-Studio/issues). - Submit pull requests with improvements, translations, or new features. - Follow [GNOME HIG](https://developer.gnome.org/hig/) for UI changes. --- ## License This project is licensed under the **GNU GPLv3**. See [LICENSE](LICENSE) for details. --- ## Support & Contact - [Open an issue](https://github.com/BuddySirJava/SSH-Studio/issues) on GitHub. - Check [Flathub page](https://flathub.org/en/apps/io.github.BuddySirJava.SSH-Studio). ## Troubleshooting Flatpak Sandbox If “Test SSH Connection” still fails, grant the sandbox the Flatpak talk permission: ```bash flatpak override --user --talk-name=org.freedesktop.Flatpak com.buddysirjava.ssh-studio flatpak run com.buddysirjava.ssh-studio ``` 3. Save the file. --- ## 5. Verify the Fix 1. **Rebuild & reinstall** with your updated manifest: ```bash flatpak-builder \ --user \ --install \ --force-clean \ build-dir \ com.buddysirjava.ssh-studio.json ``` ``` flatpak run com.buddysirjava.ssh-studio``` ## Using flatpak app id to try and fix the issue ```bash flatpak list ``` as an example you use vscode to run these ```bash flatpak override --user --talk-name=org.freedesktop.Flatpak com.visualstudio.code ``` now run the code.SSH-Studio-1.3.1/assets/000077500000000000000000000000001506556307300147325ustar00rootroot00000000000000SSH-Studio-1.3.1/assets/screenshots/000077500000000000000000000000001506556307300172725ustar00rootroot00000000000000SSH-Studio-1.3.1/assets/screenshots/ss1.png000066400000000000000000003071011506556307300205100ustar00rootroot00000000000000PNG  IHDRF WMsBIT|dtEXtSoftwaregnome-screenshot>/tEXtCreation TimeTue 23 Sep 2025 12:23:20 AM +0330Ģvm IDATxy|T,{!! =DąAVj[EZ؟Z-VŽb "{ I&>c!}%@^>2s=77s8wp>/w_w-Jx@д/IP}p#4. ?ZHz6yRV^ګ!ioݿhk{-#hgQQљwYom84 4o/ p'Ǯ L;u⳥g= P@8u7m+ p'Ǝ-}HH;s@m=v7Tl-h Ҏ@L0Pi E~h@VMm-k@!iK0kW.jA{h[hg7=fVts[[ FM-khgjkt5moAM?7t±*EoMЎ] JRkAc-i\sKϥb;-sCJgCѦ[kgjipC[H jnm|{7F0 !fhCҙ֘YҖC5hkC;R%Y ! 6:`s 貎v Ѿӆ@ԬUv*(L0BH EI~ǬMikϖZ@m@Ny:yr գQC{j񥎄 @-:I z>|bb2 `<'JKK5thJ_wpn1nwumm}oѢEͭT#OV6|6JRZ%f{m[>p aEP?IAs̉~M7 YnFDÆ nmFii7pk֬Kr!O ԙs6ft%w(\h0ܢ-- ot$mZs!7]h&qdVQ (//ﵨyl0{:[ .ԕV6~B23?axBQV5ĉ%ʓ!6>ժ o/m\-jtY ^|H ԙ`pTjj:cjF$͙3':&&櫄JLLŋ֪Fvװ蛮>߰ZIyCX.# È˳R}<+ɳJ}ê-V;;i*gF( 5aa)کK-0,Ybg 0d~뭷6[xZVxHmذԅ| Pmmrd$l6bffl>{5Oiii$*Vg0j%ojv(0nhk/d1M.(Pyy:SW[[+áPl6ԫ}2Pu# /y1(*++TvLd25 C;Su&lu 8g8UVVv>ޡE:,e[hwoN` sa*//{ *Ԧf2;;Ľ#! 9K[vUUUխ{ЧhةL;s~gFwGMMM+4SrFӫR[[kv8 um0 ͝{uׯ[IfϞct*""BTVV_Aڕ+}jGGVŅ>%''iϞZtJJJTZZKi׮ %&>VR!Oԩr:0{Q\UWW)((XᡊrF_VVGyÔҬnɓh  WddtRwբ4﹬>&_t?:ݑf֮}OtmwcZtL&*.n~Ja Q{j(IS$KnI7W΅i+`[}yQI0`~}rɓ-6ݍn9Fܞ={j[أRGhܸ4]{*:t믿VϗyhffnN9-Sp*33Sn-oyVϟ>}ZOÚ>{坖$%&?lX,l6'ͯ{uL0r׿>q@7ג%Hzy***3~'(!a P~~N8bc:tl6 uhvٰifPl6kaPNIoy>4hRSSٳJ.f׆ӷ6=`G;TTTK);wN'rtwj}ML0Yf)&&ZvmذA+WUޒN ǎ;|&O mݺMN!ޒg(C]]vޣ>ߠu|dY#Gi? aơnMCvex#"•:efPRRwph߾H92 8@QQߢEtWN ۋd"5p`e mg,K_{vzW ~ޑ9EV^o(|CnC'N讻SO=}MD0go(#iQsM_թn[[nխަ\WW_^رv_+駟R```E'tN{\[ݽwW3\OQQQmVvOm֊R1G4k֬6͜9S+V~PػwOeeOZ/-[h˖-2L:RM6է{kt饗_ZrI׿5oÛL E5uꥲ aÆ.6bs>hddΝ#ժ7iO=[oE@_2 PMMM7 [yҧu <ücbڞo166FTZZf;4;\b_\\͛7}ڸ}wqbc4JvҿB7tvw+t}+7;vud֮]_};UVQSjɒ  ֒%:vw?99Y_~G˖ݡgWcCowf}ﻺYgB5kUYYYKBL&uo]aa*,faA7ce>kڴ7kSYY "%ڸ}\.xFݬMff^zL&-Yzy8pR3fL׽=tuBCCt]wݗ}>Ϟb65dH IѢE =_{wkMt_XhoZ??_UU+C:?oLUUU-Y~~ι ʆs\eXT^^)L&BCC{IIɝ~ͦHvEFFZGξ bT~vkժbsZ9&I*..m-+22z{o;EG/SZZ $ժǏ_i0h]]zZߚ>}&LDnkժ|llح:t.lF9N߿_?O} >R~]vYPwj„giժԚ'fyj׭[.3fH!!^Æv9f(!!- 3ͲX,llgeLtv]0b-TfͬtX7SQKԱcGЮM?o&00H.SSS{ymM[,eeefԨͦjHMMՑ#G$IGnk dҭB]6]gk=C]./_G[iDDDhkk?áfyJ[XݶTuuu^zYL&\.O~ӟW]X| zlV@@w빀>\1f}2\V~[/~˻H=Ǐ7bbn(=޶JNnfӏ#M2}DzĈ/}ѧCed^(ɭ(3F3g^?L;3hP|_wa(=~`@C0 !wF;Q(~`@C0 !wF;Q(~`@C0 !wF;k_w\RYYÇH)66v{Rpp K핾TUU<)**R1112Lsɑ#Yиqce67OS^^>!.=zTv$iڴiz9~~_Hc>?ls<44TSNwޣ-o}&zڱcU lv~ڵz롇I&d*++u骫5,go ""B[lQqq"##}νZ_*N5al6>}ZvҾ}{ɓȐeuW:t*##C#G+.֭[{̙Wjݺ >dffjӈÛվ}A>wUQQ[n,Y=>z(]uՕ2CSNHaa<8SaaBBB'=wD4~8n8qB;6ͧ>|Dv{$iϞ=ފjoݕ{J%% PBB|튋_ Ţ(o^^^{J ڵ+Cdiѽw=-%\5k F?3i޼͂W_}][l/l6 Gf0ZQQ.Io9ki;v'Nx nUƍk}BcAO~J^y^Fͯ?Y%rzg4xpBwKoKo׼ys[}y$v/=VTT?!رCR֯~u$/I80N/\gh+ϟ_~b…W룏>҆ }=%wQddNg=J/hX%''>#cn9R݃OЩSzgt]'VsرS˖ݥ-[veffx?1?%tbty='=_j[ڞkߓC7;sRK.S@-C#FСCOjezɿ*))^вewh1r:u*W~uҤzW/]~w8K(22Rk֬ȑ#TZZO?T7|s/x;}'N(33S_X,m>sԩ3g{=};SttF11cFO<7 $y7 9s[ IDATo~&nZ?I{5žx >}$ϯKۮ\R5o ./Ͽ^&I:p'kNPzzzVX,͚5SkּZn М9Zlo2t ԡC5|0Ik$Issf>C۷_;v'|gn|M$ݮÇkԩڽ{wDEEi|;vLg E2uT<'¿kc^~c;v|8o(`„;wz%%%i˖ӧi=g?F0<ڴi}w.6?VxNWtiܸqJL,I:y2W{n4(λ?f3FTWWm۶^xQcǦ)==]/.no.0d6u뭿}B4(NSL_GH Џ!%%E#GгSN76GEEK/Ն @}Umg=#>-[vO0ژN:x7ڲ3{җ{maUv߹Aay^[JK˚awwAZavڵK;w~5k駟$'F1|=؟K/nE֦Mbz_!˽gμ;wg7$I$%''fir2L^iݶ$yϻn79fu? PHHxeeO(%%51115kf͚ӧ{͟kxCT0/}݁sݜ9|#z䑇e#_|80NnݪgBBB|[N֭QsrJIp:r䈞}_r\>ל<ַi޼:tV|'t\am$)<<\l67{֮;wjjaZU?֭ۚ V' jU~~AsE<eKf,k$tړ's?G}LC },`I҉9r:7ony^LLFJ*..ȑ#ȑ#Z4wl;}:O6m='ɤ+\ɪQiiկިnwhl֘1j***JK,-gJɲZ***K?QDDx_wpo~I I$w+[5Qd{hkA~Inmi:lǎ;u-?Ւ%;pV(99l~S$J\ J LL2h/xٳguW9F{HMM|)o}-=r Џ~C} u`GnwF;_:uT'_܃,VFF TRR8-^|Mv555Ou1[ ),,޽UEEBB5l0M8A~޽[Ǐ(??OC\s5kvgefWyy3.󶩮֖-)''G555b^4UZZO?Tv] PBB'+$$p!=Bm߾`\{VNN*++X3F *77W lᄏVKeٴ}u}Y~~~}+77W#FPHH sNi9md4x`eeenӦڿ(&&V55ղۋL#GjƍZ:tH*RZZTQQݻw+?@_ p!ma$8w^}$'),,LNS999Zn 3.d^3lX&Iz-'Nӧu (I0 V/߯qIƏӧy?bpl~ڱc***ڬ$ɤVѼ|ݻWsՐ!)-)/P^^ 5R|>|`#ߋ A|EEEw*?9Fٰak*//u]0m۶MRMMÕ>Y)))>]|Ŋv{Z`cڸqFS(//_۶mS]CSLQRRM͛?WUI]YY͛?յ.Vttt ԂK/k8p@td25 VJJ~l6+<#R~\.g֬YPMrI-˩y*%%EC5{l)+먷ƍ﫼\W]u.\ѣG>{:R-^XW\q å^^}&MWVVIOXڰvp8TWlBgy9rDQJOO$޶!!!;iڸq^~eICSRRTXXجڵpkxWﮀ*4\1ZCsrrt1]{ުT^^O>٢KK"%ytMHʕ/;vl=}`AjŇe2dټaFBB-q 0rEFFTmrF~At7TEaaz74p&}qk Zd;DDD;iqq$)::xttw*ͦL= oGtt,9XY,YVEFF,bcc$~6]<887`~>v.&I UvvvF棏>'`EFFt9INZZz&l˗Yh>tOjiՂjkk}[pbAAA=zn*ۭH?_ߋ_\8͜y$iӦr8M%ڷoBBB4rN|A0S^*բݻwV3gwEzɳѕW^?ߥ*((H'Oĉm4ydݻW[ndbi3f"5mT_^a(,,LFҾ}TVVE*,LcUU{=6_0b!LOOE@t8p܂{ns~:tw풤T͞=KdZhBmڴY}$)))QӦM.E>ӎ;USS0͘qFn^g^m߾CNS!!!>|&M(ip~jyIL-l&~o6I~-- W~`|rJ!!!sH!sZII{o4@aMT*NC~soF֠`9FӧOShh^{u۷_Cr:+кum&pA`(9bh֬:r$K裏$ykԨQ7nl0cRS*5uh_w1@C0 !wF;Q(~pn3 ĂZ lj@O !5dH20 1Ð6dno;CFw:\f&Mfl.יc KD0 Li6tJnCGSuuN [S)):s fYfYVYVUVUfY6,VYfYVO{ J I]A0 Sg*C%x8UNUUVEUTS]C59N9je8 M[z+~4LnYLVYY`)0_6M Rp`/??lY2޿:`g< VZU*)-QqiEŲD%ePEijTNnKN鹱[2(l6K[fY~~VYd*(8H SXXb#HEE+",\a m=d& t(O4 j ѪjUVWLEEʳەWSu:/_'O)7/Ou,Vbd 5eVxYBd2Oj\MJVr-eTYVB˕id=e+d17@qq?H 4(.Nccp+(03잀3lZ$I ۋ.d%%%JNNnZ0dir:Uye KJt _'su4fgر*.)8~E4Vf3~kTudܜ!ӥy*ٰW' 5 !JII֐d%%$(aPb`l2L9f"TR$$gߌ&A(!Q875 o\=~Btaeg/k|\y VrUMPWgwgB(Zf_)F?'nϙ-%\.CZ߫Y:GhQ5|RA0jb! (A0 04 ԨRE;#G*sA޻W"d0P.SlfAhCꖳgܪj.Q0$>hh;"Exv)Y2r.dr; ].9Neٔ*iÆ*%9Eb Y^ѳO?=J>}Z_wGҹ'h (;U*UZ^:kwN(Ӕ "#'z.%r}tByr.;Dʾ3?9j9)DFRC¥ 2E{Wp$=UQ *||ܻa/PiYjjeR-)p9RvvXRRnJKKi{t{1m۶E %Iyyyz2 {It睷+::,.atF%E:}L;wG?UVq˘2@~[6ExUrMu!WM\. mCr ?`=$ߟ)w/LLP>\,=]QQ2}܉Śnm]:Woncڻw E*//WmmF bJ8 h;rrNje ײewhĈ:t萞xI-]LO>W%%y)**~sUTT;&M$OYYY:gEEbcWb`[[nӊ+_??|Raq=;vMHr|q#I@S%h\.\L(6_T8e|殭W !kLK?Tf,nE-!;U]]NƸ iYT@E0ڎ+WFo4aE<բ1zu$I/REEE_iƌ˼HII̙g:T=>:t ågyVGӐ!)f85EKKt1mٶM?X%e UYXɥJ9fYW'!u6 E΄8|*M-MnIwL?/=VxY*WtytB8 h;v\qqqP )!!^;w۲3mRvqUVV6d2y'|(x""YgB:|Tߞ VSCQ;{\%7-︧ ͷ&==ԔTRRVH0* &L(ĉjD~N,oҤrNmܶm;SNeŊP#gqB!Bdw P#Gذi ǏUIG_T}u>D3@D h%3:}~D;C$iɗ]|<3qO En./CoT*{z I+Q34VqHl fq6 37b> $z%x>X;쮬TM-.t]0X!.o$ _wG0x_pMbr=S@Ǿ5*j}jB!¡-mT?ɶ;^T_: cM0zc5ϭ! iؚɁ.]GTx蕕t3xN$#mhyd~fNړ~2T4w!JGX8jVv>^ZJxn.Ci9BCs =~4 G q-7QQQʕ{n'qQY`˗;~sϧ6m*22wy<(Yr&ڵnի_ B!#5>LOuMi6|)x<)6sOqZo䰹3;ٴzqyѣ!=5Nj*DBqA`!++3fۧ*=-֭{7ɓOx֞G.7\K}}=`|/^;gٲef,jkpLB!cwUTlIO H/gS=  nB&ڍEЍC DC;㎪}NUx)o] A: H Xb8`Q caf^,7͸gݭyƔKFZOBQeQ! NBI>yS6WtsG7/[7KC!!3~B\4M#PCymZVl\r)QqaF-)0CQ_ w~>1eԙ5=tW>:/o릉BL\/ha^' C,vr W_uŅEv Bx@€f$mf`R~%B!#ǎ}.|T|.)\ E#DGAaWڲ3_S1ǎ:w%Pٞ3~8?I_- JPkQF]w Fan[qAj ò֨B\d*B!#hNZ?DSC+)\ϸqFR76뚵aF,_9~?΁ M{"gfי?V/?[ct~t|n_iB '袭CC:JL係1ߩEű])wL'v賓| IKMZBq`T!B0 ±'ٳw>èBoAύh-]֚+(=Cc;[F~QƉns==!PUHY4EWtpxg߰H @C:t`vUe (3 Íi31 ;vK/lxrFedQ!@I0*B!Etklᣴ6Eha.BjQȳ0 1L0h9zhR\iEQ٧==4azw85=lʻ mcO9MM`H*̉pfy>vWL0k Zfƪ/SĦɱSӖM!RHDc߾}\2mdgv$B B!B` h젦CvqҾz]RD=Vaw+ G'/;Ok0cbivfւ:;o h=9/Rʂ|ꧫѣ}-ן` s^| %ϦZSx%WMWb;M(KJ1s|U5JcT8N aBq !B1Ba@0㧪>[lePToB{1 چ8i;7\Cֹ}G7z6o,ilU|z fSphOjB񃘭oxkM\13$ p_́ VZW!j_[yW{YU6'oՠ(T3.EwGol'ЃkZB!  !B1BYM447rI\nŅüzÌ+zSě g/y'{HϿn/?J ͘S7hnG0o82p?}CFzqj V:Ppf$}o$>~`#q!֯UIYY\o?\B!.4zp8L[g54&07\`TG_Y$tgZY ҢOwt)<'Z2PD[Wު `5Hd7/h燿kҢzun?qřIUf<}mOONxmfDWh% ځ`(i]gJ!Ĉ$;ضmSNe̙AKK3aΜ9***z349|GMd„ \z%}2f;nٲfμKUq|M^z)yy,[,q@ᄏ1c 8z^uLs9~o^c~kA[[+\s yfV~o_1rB!FO0HGg 7.EE]<3:DGxu6c%QBN.M6m|tFDs&耖ga8`R SXyW-YGFGw %|)jȺ/\Bn555FQ!H0w^˹ű}'Oꫯ~W_}`0ȥ^ʌB{{b߾},[ ?(444~7y)/{X0ĉWYY IHEE+cǎ۷SSsoqʕ+9u1cJ}P[!04`0HK[;5uu\.E%逵4ST~).$)^} ;|:!ݥ].L ?c]OX3cbZ=Zr+|˩|U<-7[Wa8w<`kC)NG+IA RWWGsK3~0"BqvI0=KdggxTWW^a+;w.`M^f inWVVRYnҘ1r.첄9um۶܌㡼yv9zk׮|A Æ F X`>9ttt3p-7sAN&%Ǽy(/ΝD4LdԠ,\x  ^f̘1,X0?Q^>zj㎄s(K8ϷowQ ƚL43gNlذp8'q=;6Gu^A!ah?Ɔ&\ 8wffl}Q}c-> En8?-_ÿnR{'M DP 9noFCQnwGCZ(*{BU{#8^oF>~c$P8I[[;tc E!@ ؿG#{Mqq ֒ Enƍ8qL27qc5##e˖2w\=ʻ`ܸ,X0ロロoLDC [$%c&z,X 7\O0_'7|M6qT ,`ƌTVC~^x:;;YhK@ ʕ+8nٲ<.7SNK{DnnnǻihhHX/ mF`&]>Q !(5!^B!.t&M3 hkkEQ MKP kRaъ3o$.)>?ÿ#afa@t 6!F׈W9_rگE_7dB0v&,Z>taT;%p<!=i%('3B\`baѢE֬Y(RVVƌ*پ}`6mG4n7uuuʿ@ Ν5kW]u%`Mu L ͛)))e)Xj5f"33MoՌ/ҠXUuFM%%W΃U:u-~jP{<ī?/vxj3SC*L]5J{?4=|G001kPf7w^TT]O8aJ0*uᮻ>m̙Wr/+Pc+B]]=|[[[[).7`F5+ǎGgg'pP(DKK &'SZZ(4i^˗'lL^˜1cO8wq=&Txdw~>uq%)))@MMEEE[kz.\Ȏ;^'tvZNkMQ#GRZ:3~-8)B!!~EAR'Z 3cVgno=u~А2Yz9L+S*Ұ AV̈́ f+0 PL٘U)=`1,KA A# E+.U j_HQ9:RB /T4q|I3!~qtk~cb1ƅBO H0BU!.k.ozzzbڊKhnnɓ[%%%7pTMlunDNΨ^"%%\<Y].`yF.P(DMMTii dd{|zz:V"NjO[ hhhDXnf<π9r4cǎ%L;T9v84-ɓ'cN! 1v~'Ba5> G0 0\VS=hjԫisP4:ԑz3Dɞ~U %R7bU7|vTNlJ>C!"LB T:/SZZcIMM{*ӧOW\\i4440)>77UUhjj/̙3ٸq]]]bmm455lR͛ǪUx5L4 MwwUU'kIKK#'gwכ0%}(Ɠǫʜ9szرK陘9 ="_~9PSSÄ eQRR† D"x<زe K܀l޼n***:u 㛚M% Y&---\}&VNPhCC#]]]?d殻YfK/[(/HUqcgaѢL>aݺ;w.eb ֮}>uZ-B Dkh0`bz51uo5J* IDAT!Y⑗*LVSaY~8D%W$9tNOboWvدRǝ3)dss)HFU !PH0p5TUuOMMe˖&LNIIaԨbkz*BQQ1'{U^Ι3 *++܋&'')SSǎ˩7IYY177ٳgw^nJaa!߾⴮QQnf6lȆ u"VX|/?֭[ٴiH4JJJ͋^WEܹ9sf`|^}5?*3f\NCC=W.cY g>r.]XMLdͽx}&T>|˸q1b]^աE,[[qQ23X$NjXl 6ldV_s͂^k(-B ]5j&FtڱFP Ak(Xk%J:zy{1MLJherQ8yx59 UxtRő:[DcP@tt->0 Bq~hq%MM\ݼnnn,oЄSGG'_Yp!'O rx>~?iiig]1<?^BA5=Nƍ稯o}I>n`f/*մYoBfyQEh$Jηg:M;pM}Z;o>z_S/$S~Tg4nX¤dged.! t`-&qjݒ7[bXdggqM7~z֮}F^/.]Cؒk&L,ܫJl*'>`iX2^!.D2^ ">}VZE(BQTŸqccB!DFB%_ O_VtO_hrH\s@ G;,x9Q7_?B$*==EhB!Ĉf'4e>>>c!58*z_uE1Gl(V8&tTVfnF?䗈4 H;.hS֣c1x@C19fW:8&U lB!F FB!4Ǎhn-_D9)O̳*HӤpGh8`49 !BUVuPfaZL"DŽCюaQ +;}tSgBB  FB!1fgs F"=K RUx:Ǟ>5M+i:CQR4d1HQð UinbF t(5G'Mt3^=ld ONǮ !䝌B!3T:9*/ BY wǪ͑Յ$HB&IJsߡi a~3@֚ l6X0muǚoR(NԤ R1N;5cf}{l"# B!H k !B!01Foh5Ḥ爣Z4iagWCѐ&( jcrRyk~J F7)9Ѵ$8 t=Z5jBIʖd-BqvH0ڇQ)/@yDf͚J(**M Fcc.Xi޽P(kڵ;ӧzc?ɓ'顰p4yy^kk 3zt?B!Prຩ%qme ƦF| %i# cU`jh`21"jQP<}&p2_iWB$բx;7uFU3ΨbF;OI0*! F@UUƏɓ'iooGQ?8xwUW5!lmm۷'<ĉy_`F}O=X #5*|_/7x3eΜ9,\, !BqPuyU:Ԥ)њSGLaױǹ5nt9ĪEJ {ݶi}W|buٞNoS W{е0.! F Ep4m殻b1֭wg?=~ʁy/4Mjٴi3.5k^3,>z3oYlv YYݻ'|Ov[?Xf +V,[o%,!BqSܨ׌s1iOY5txX|U+T.=V%Hj0բ>v612v|xu?nӾU9'Ov!Xq*'ld!8$'xk 믯%KCNN=L8CUq&L(̙3K39s i,mmm<ԟ׿?vwXZxY`_R3wm3ttt B!$ p/,L <'дo2jQo+z?Ok:Ս^qT* !yF>lݺ[~.**{>>фzzz8q$~4P뷢ǏǃqƱy Ô' M?p_Ν0M|ƄTUeҥw?B!Ѹ%4L6Wj0*ZFNHw{oft pioM g( J61qcW+opCJtlU_ y:=mgwB!wWp⦅FQ^/O@)֭CuTUvǂ`0>_?G?"Ν]w}dH!icc?/yqK0K+,LB!4uw<\Pܪ IȪM$ l-;clTDzPtN|f_J(B1I0KFFF'Xn?hX;v= HJJя]vcNV~__v(<ܹstnOOOc==+B!Fptz:C#1u4Jku}*rXSHžV{QEAq!M+sl"#wBI~umԩSYby,8|Hgɒ%|_n6lOPU5ýmҤITTlu,YYYu}B!B8ioF"P}4k kAC3zM;D跥PAռq`HBKQoO)gM80 n+vǧdgg+Ayy9lذXCS5+g_9㭷:B! 94xxϮLMQPpV![;R Kn$1͌~RxފQ !8d*;xGr3`׮pu^j ֬Yˎ;$33믿yf5*xq^xדJV\G!BoF@#1 Ey*Sճؘe¼La W(j"*)bpT:dRBOZ\EI>yS qG7/VΆDB!.<?n!M8YSúyԆ{zuaw%ڟc:a`MԄmCXl&D-dՁ6 &neϯS3y~ìxX4+zf/'Ꭶ?̈́04у h5aT,$!(x@X*4N>6[oYB!Bq;FzWPX%0p{Rp5G?xahkK{-޻PeDբEgI!İ`T!B!Α0akQsʞ?V7] B>ua kBsqPGsغ}}Pf`#W!D$B!Bs$+#̀)6ҹ[U9V[\RV7c&{Lx8=}uCW_B`T!B!Α#b8dwp^r#̴ 2Y sLkiX W(*ƨB/+B!(4U={.Յn 4Z;`&VE&]g UFM/G}=Sq|I{̇IlOfk*#gU!`T!BqN|kz=g{H;kϷ᝝lOrGr&A{|.VC&/myW`2GtL5"uuzo{IR~5zxUl8KW"&SB!1OX刧`n%(x߻3PYc红@ԑ8բ{;Uh=v:B1bT!BqNy'PFHGjӥU*iWYotzG?x4u _4wu-+uVI_T BqH0zrXgp8{:u*g9Î;O{oC[[߾⬼B!>ᄑ}0!&jΩ { ѵF?4{f孫w3x}˸:es;g(j+E hx)դ=>9;7<H!Ĺ"9BEEŀhgg{8uGAA\r %p"`t(ڍB!BWh`#}VW7V(  .~TͶ h`WLOO¤ лB]I7!SmwLh: B1l$aX<3w^6nLYxfͺ,4MԩSY &hB~ֳ͚uՠ躎5rd !Ba# H#jT IXk%<~Y;ngRS1P)qϭ |nHK좍*Qg(jXSXբ:425;?*&; F|s-@q޽.>򑏐Ŷm8t0`l̙MYYY:̎;$55ӧs3QGvZ~'(**M߿?۹Kx".RV~7p5/vNUq***hov1j(ϟOVV&O='^zJ IDAT$a˖;vP(DNNKp4 6rTUeڴPT~znV6nHkk+ K.B!n^E%tvP~>ٗW=h1kK3KnDq͉tn@%'Kcbi3Cd/`U@*>DP45.{?#B/$MPa,]Kjj 7nb̚5\:k7رc+T\v-ӦMc444m6"0seܸ,X0 6rwv[~?6l;VRSSxge !yC0 .]Bjj*`ꫯ+f0vXض"nݺqƳx7ٳ3gRzYYYɔ)cg:3 $H;]SD$㤉orj&ަ6ͽ$Sz]KWyi*'6'vDRd(H+$b9<3s b:0˙g0 {~Ow200@WW\|{ooQZr|}/}>¦MsٲN/Gkk .\]w-shj*Q@^cY|>^~e9t{+_yhүs;xoDDDDD"QhqאG[+ҏDO|ΨǙ>{Bѩ FkȬ2=g!mmmIZ6lX_Æ 8ի?H[[/F{Xٳg憆8|ӧyG?zގp |ߟS r(Xj==pZ֯|<dX1sDDDDDK8wg2) ʪֺ菵tik=ExOP-Z?WAUV__OCUcsR&-W|~Z[[8rvm,Y*5׏9g]]=CC/^ȣ>ʆ صkgN׿ [x?/ =8~zko>044?˝srQƘ1, W'ɔ-""""2k\>ZkI'xԸbә< Eه*>>Q RoҵpI:  FP Ti!C>/t+TڪoooX*8GFFIx644D[[+}[`}V^ի);vgygq^__ϢEx?dB_"|/őq+""""olyL/cƼ0:~o ,FgYHq9Mbb9Rh :Ϩ=FK0X[!,P FL&Ñ#G{=z%KͶv9Z32 K,(6 UNACC# Q˗;c{{/q%ۣ@ҥ^z{/s'Wرw}|7Dww7/^*!gVgWWoYk8uu֖/?uk굵JǺ8*}-FRęwL\=#k4{&38*_ %xXDDѫhlld֭<8hoo㭷s9>ʷ=<˗`͚5 λYSNq16l[oC|C'|n c inn榛ZɫwXzOfתor9V^Ecc#}}}=z۷Qeh]]E>_y,Ykװ|ry[ڵvJϟ#ɲg{( lڴg}f-jf׸t\#?C=ľ}wu֩1EDDDDf^9:liiRcԋ$w:M[ Z!=# GU 8h[/@DD ;f3kEZZZ֭{W^孷ޢ={kmٳg矧yضm+m{'7Ee|M 5Wns=@s.nvy/^WwOS*hlldm=Sw/Ðy|/k388H}}=K,[n.o%gXv-v_/9W>DDDDd#GN2fP-nӗl*su"D$}BYhr}RQ}\HK|˒9#M*R]NwV[q ^L+5jQ>QG&>'jh®+/<@]]a244DSS_ڟ3|>LEDf^ȼC?u~/嫜;%,y'YG &ֆ8kk6?z@q691]Mrv9AhN#~ޜ`g?=͋D;s@x7ityGQG$쮻7?x̓J% g]^xř^\c TלĜJ0k>ouVzf(Hbî\LZgL&w?Gee˖tWTB&V.O֥1¥A˪՚ "2W(e6lXφ }"|3'jUPO; Åd!Jo+%fdy""rmǨDgz38RT?gOB堩D*}E-VK3} ;3kk`TDDDDDص{ w޽c1cx7|3|wҡhUUD- GgE/jLT Ԥe}""r]/qLL/c\܇'GW E 9(rPtI\(RZ\+"""""׏p#39&G*qPR制90 TE}h?k*""JSEDDDDD?y7F$i8M.V0:$/[. i{c^5N?q) ՞55FJP(:%hAaҚ^e/ ED:蟴DDDDDD>r|lK0R-o| -TDDQwUamTfXr0D4}>P(:CU^UQp'g`""2\,w[x%䢣AAW騇h+RsHFE f` ,RDDQ)0R3gDtDUP Aԡ`tvI&[*բA\-;[=z3XL""""""Sgw[0#Z1*GjFVDբ悁b%r~r*""SA*~S[y 9K}T;1zoђO1$~w7BEDd(b,?[[8r9$El0fZԅ1UսCfh""2U#Y~L\=Q:.{JЂ`SN*EDd)vm@T~dfu`.Vz,3Pj FEDDDDDm_YjQ(C$ MڳU@:3jЗ;0g17fNږ85 FEDDDDDmJJI8'!4~:%h(A S^tYSo_4/TDDn""""""S]*EӒ@AϓIrcC(E_NzP~!K<ڊ\`TDDDDDd j>?f \v{z Eot(j)WE(BSyV|4/VDDn$""""""i߮>>B44o`ԥTaC(:l0cOc_Ŋȍ`TDDDDD:gzP8wzj0LH*E}U=LoˎO5LZEDS0*"""""rs_@%Mb|X@'2УtZ@_FÎइ4u,0NZEDdZ(v񙯥+Ek$M$XTLG4**v'jWc|x4.TDD鉈L;C E-Q v*M/v ERR%[#C>_ZIDD#UL;ykWk?. C-P$ EkLwDzrk5:-H7N(ʱAh́|Q ڷs󁋆-Q"oŕI֖ YJTrT]Î@޸-C Kӷ^{vL.Jok!.LL9QyצщIY *v;K_qcWDDf*FEDDDDDb>>==EP4$=\cO)j9A]3q}|kO@PuBvWȌSŨܽ╢ WbU p*9UF' v'<抡Kb^?M @""""""g~h&{u& G4UuwK F]awY~e\3KDD7""""""5=$MkXmUZ=j V|vAW} փ+F({o3O! EJdZ= W: D-;GxUzħa?%Y~ %=] EkzW G#AJ˷˃"" Qؾ}4:AKJ+/Mj.4f$hp)$ z !?9og߫Pf+""Q0*"""""B2hCѷ#mD8}"dyk)駚~,xnl~x#am_{|`/zEG`h>48 J,xw쿶I(:h&W|x-h⼁3,U_z:#L3MI:^XZ~kT^}0#nC-; BZ%""i FEDDDDdA{g?~mC5l=X鬁n/z^TEٚ콙 +32 4>QKza|3ۏ8A̦֖^k&ax>21h>^mR!|>ÅIDD"""""`E3P):P4YN":pKCL( 4qQ0! I$ 73̈!wó *R@|J ,<ʏo: CN" M?GiٕETDDƥ`TDDDDD(aַ[Pǡ&a1<14kL]z!#k4+ޯ [s֕R/>1xE2x^ĒjQIFJ%au`]& C 6s;Ȃ`TDDDDD;Ȟ(=UhDp`ɵQsQ@hx#Q(J?ai)n4ȹ6˳ǷP (e~>YhyGBq*?Q" Gn0`J{^%,76,-?q >k ->Ef힢 +EPRppQBZC;z@G;qKݻk8vPDD-""""" X(z-BђcPzvjCݟn%~v-?8PZc='xN_\Jvy FEDDDDd޻{gEOld?ێsxޫ{Pȯ&.p~pOo:βkU/wn̲yy4t뷼N3o{83PZ FEDDDDd^{g?~m)z|>6gmq|0Wx.ANk>[b]V-Ȇ[VdXأѐT<]:,ӗ,o yTȉ޹CX7""2)y33}~C'cP4Q ^3}ͨ}V(Zs)44Jdx#P y:.W*""3FKQ(|n*E E v mI(:00c81|97%.Ӱ;ڻ^)ǷR \R|Ϸ)kiBQJ"""""2콵}}移Rm&8G_~oۄEDD3"""""2o77=Eg'NyJ vΡPSTDDD&N&-,w3_chǜh('"""21UDDDDd. GG۷oއ'>/"""S0*""""29kq΁[A=d-Q Cfr8PQ;>Aw@ħ?PZDD*"""""s. E{t(ZCZg9AKgMZ pXJǷ EoQY69& \hM2ϧ-. 701# Dyq0PwFx2ESJ gXȼaEDDDDoĕVIn9ݽ6P4SzCQ!䛟漉p6{FUW>xl%p'7;ЊDDd(#LB[:#k>>3=EPC%{Z8u.M~>^ ;R%ȴQ0*""""28g&n7gI+ouo_瞢C0Ma&{*&҂ IDAT|c;3WPDD"""""s˻E'@CyUµSt9WkUs=/""""2GxN^6 i4- _䞢>ѠE (B9?Y[oS(:kL_03 i`TDDDDd<ϫ#g@ZИC?R?oܳg}!;\\)Z~OO;z;F!0Ϡ.""""2KyD0rTsNpIs Er+yskGf"Q1:ǢE( s9/+.""""2$lB# Xb]e݋q$B |6KF_Jѷ=EQw >," +OYYIr+PT[?7g-m46esJ/"2跺,yl&K:.&!QryqfR]U(:;'?hV( !~e!4סW I 쪭*e._ug0uO>’ŋ)4dK GEDF<1P@G[;aT1j]⅏0P\&w̲}ק}G.>CKw$z #.:O433Xf[,W(%g+{Ĵ֕o:Yq9eXܱB#\^<`TDDDDdxYikmeŊ't!X42r\,üoehޕ9"Bnob_Yuoލnju!+WvxqB!jQyEEDDDDfe7;imm!օ倧!\Z?^k62& )wσPqMhiUQehCwGBq(P*9%G&!HXd9B9%5.5PȖo3|4'|Inڐ+hmi.'Ɏ ""2')1m7zLjㄣTPY:MK] CG`㋗Yl9ѷ?RE?IGԎCq% Q;_rK:m9nֵ%y7imW~:hkm._U3.""""2KyG.KXn-aP"!!!90o&.8tB*!P6jґc$EBjV(:s?"lEH4ZBR__džXƆFj,dʕ+ؼi3Aa.nTZq-hE #]%$ft:*%jWS4 E+e]VqڭϾ>k]փ/ 98a]rZoW-O[UM:*E!F?$WCZh7ogڵtS_WG6K""~r\Bش&Z bq/N\k}ћ"g Sn$V@D+U&\vm:pB+~SJ~ *@MLѷPpƥTVWTݥ/?TWplټU]+ikmN<"""""yxX_OGb6t;v܂.nԑvkRM,0l .G9(jOp8N D{zC+Tw[a$~n&K-<>nCvY7_[|lB =h<}>3ZuUwQ_+FU?GAsl޸坝5fj= FEDDDDfhSEXb۷mcijMwm Uތ+dJd MU(7.-%'qx_dPJXi1/udiiwI.-gw/~sKԷy;^'cCf3%T+96 E|h}nNGM f=vukTEQyJEDDDDf9f3464d6ߴIlPnOEGCzxÅ!,k-Js*HoPk}it  GXcݑɀE@NrN\/^ſS]>236=ϗΜr JhrqWb>CO^$գEq.$}nm[6mf46BDD"""""sg<ꢪkر6oH`K-׃79ZnM-ɹhҘ ҩ HǴŧN\ Xxc3U-Mnpg6~N>0o qD=EgN:]%e0لoeK.?Wtўq(0Y];w~::hhh3Fբ""F<# 464|Rlɓ#%gz٨cE!c+ޑhǷũ!Pa~z3<{qU\#7kPsPUWN&iѹ 2+gߒyK/Nqx Z/qn`gl8wT`V0? N1| !,! C{7oΊeQh6I""~ˋ^ф]]ewK),h0+Z޴\o\q]? ўI]|r*k&ﶇ&8}>U݌VBƫT ;6^~"N).g'n\jkjQ Wc>zPtcdvRx_Q?(ٳ{7֬oC ,n`u\zm\Ë/1&.@M,%Om$p8}Gn؃q p`r\.j/V QioqJdQk <9rxEw]K\JzBs mYFj~E/`RŜqliwq;[6msbfT-*"(C<#P(4b2mB_o//r㘌ŋCͤ>!~?N=1^8Ow}sqHjpʹ0*R卵f2͍;o| o SV5Z1Ĭlsw__yd_Ql}{֛ofeWMfrBQBy E-]!.\822QQeTW.ƟN1:r̝N+p\QQSdHӴG%H'ebh}>_f$ŭ&灳PoxwyoHn4+rn6mJ璥475RTDdS0*""""2ǍG3,\B{^|%\6rdMC4q>*co,>Ap.6Ҹl4$}s{U&XeiO7/tpeY8E U`Lh6 }6oȾ]ٸaKԬPTDdS0*""""2$L6 5tRyrdxLd6HB';n[o5V]{`TDDDDd(djh`q :Yl)=_GSV/[κky9wzIh>>)%%PjⰲN!fahR-8$+*=ͨNs`z;8>.WL"ahPtYR/_F׊.V\A׊.V,_%KimiuQ3 ) Q0*""""@x^4b3lh0SkK ;nZx".^|O.\ŋ )JG(|0 vсaGe #@vsxP3}1Mzӓ|~VF`t#ˑf<@TDD&Mn')W AR} (J~0, :WE83#%!7M>Oۏ~'}_`z!G!i.G6%ϒf2eah<"""`TDDDDd+Sq) rXgik-7L<N>(./}{e Ea\QƌȵR0*"""""e鰩*(MRkBҧ/o;AP74w?yIWWpӕ+A~[k`TDDDDDU+'&է^%L ,8 FEDDDDDDDDdQ0*""""""""" QYpȂ`TDDDDDDDDD"""""""""(G,8 FEDDDDDDDDdQ0*""""""""" QYpȂ`TDDDDDDDDD"""""""""(G,8 FEDDDDDDDDdQ0*""""""""" QYpȂ`TDDDDDDDDD"""""""""(G,8 FEDDDDDDDDdQ0*""""""""" QYpȂ`TDDDDDDDDD"""""""""('; IW裏߳rX/~i?s?ˮ];.;xG<ȇnEDQWiP IDATP({ǽ +sl6a!WXoƯ_3SN˯|xx>LÇf``2عs ?~ÇߦA2 իذa=V3b>Ovŵo6gΜs//m =|pBߏ.]s@sܐL""""R58qUVռϫ祗^yݛo[nyx\5'c~wzVtx??^}̉gcTDDDDjڵkW :qCQW^~/\~[5EG?TQ%߳ØJ}n>oP0*""""5=`JBo " '9qn>u֍9ZZZfzi""R+Դu(UbpqϷkXkǴZkٿ1dװr _ox֭cU466222Boo/{>un>曷|q!"2) FEDDD\.7o_ٳtww -_{IU>67oos1jbK/]wkײg{ؾ}ĞuxwyW9t-|2B-dlٲyܽӎ}_~:L__455֭[ؽ{׸_O{{V\0wy饗x"mmml޼~Ghjjs>Oݍytuuq]{=F1W5;>2gwM7Tb=N;=z5SA~*=Jh%$mMlG1m_/^<3~{rs/ɖ[[K ={eeejooo!9yFNZvq@{{;ӏJ҃kbȐUTTرHK;]|:nGչ--SOȖ7 ;;[_Mv{N{ TWWC ""gСC,+;#"DDDDdVJJ$0 t]0TUr\HȐF/jnV_6nduxa„Ұ~8w.궞 }رx,uy!!amem1z*?""{pQ""""2˞yF/"ԩӒ ͗[x_ش2uNN~'nVU{{;?駟)( t ̭ģ`=\fÇǟEMsС/?(ZgyQq//v{rvGD$Q""""2KVcI𲸸 dff* #F$!,L<޴Ill ҥ]}ղ1cFC`0 {5:uJtڱkAQ???,Xp#AѠYػw$^FHŋ2lڴ^}5L8LAS ../_$u 883g`0`=l}赼fݶ\»fZ\pAZk*ٯ ٠+/_'y<pe:׏} &Θ:u v-J7 xuxuСC0dErW_~[nC^^M7VCW^|鸻 {";[~!iӦ̩S?o?+GDD(YꊄaUQSSR477 J#G& DQQvJW5jwբOVZR[[ף [u Cj ަhLL$̶Ѳ4W׬]p]CXXqJJJq gϞCVy\t キ (; 5WVY/J#]㾖d#"̷;zO9#" FȪI`}q8Q(~ :urC\jrrxnl][neta̮.ppz\uvv6b0agdd֭_΍ֆCcE$߮FHQyy8uK˦[jwzҾY7%""""RRF?l^QZZj6x%g1p_VբIA+WQQa[`fff$++˦7*=Yh/I˓ s:ѣEKKKQUU%.9ylOEs lb*//ǥKm޾ɖ a]zQ 8((9<̮mv^cFpL䆷Z j?N6/6ڊm۶ەVO= mձcezU]_wO ?Eqq$j4;IrK˯dnzQ""""\OeWi(0z:u %/lv^ /a[!g+̞=K_=>pDMMMؾ}I 曗j+HO?*6عs֬Y+M7skf"7tldڴqj+hhhjjjs.4h $&AMK;z7ܰ?;l1IDž QTT7c=F6䫒o:Oҥ˻_=o5?)S&t~E@\O>cDtPz""""IJ(|'VP(2jJsx/JގU_FLFpp0 q%\Eo~paIT߻FVEqqb˖-ƍ_Hx_vڂ?\XqGSO5Vz+Wj1Vs]vɾʷMsss{h4b5f7xMv ^~6mQDDDDdѣS qV}ZoӰ}޽g.ʡR3f4Nң_/>|_} OgQFbqfćw#GұuW8%A3f4.[p?\G%"駟#??/_YfbӦؿrss%0tP̚nZ";cO_Ѐ#Gq;t:1|xfϞnWW׫ow57unԨX=;8r$]x?npww+_s?|شiR)x1zt n}o+nTs3着t!DDQWWk] "c(,,}z&99$s ˡ6...BPPT*KKKQZ*=lX[]UUU^&h4׷GC+**P_Ess34 d22arގ3gJEll$UU9mQk[uuu(/N u677Zm <)ըM8:_шSR_7k#K_ٛ:܌b!!!v\B`@gbii)k #PPP(GZav޶DV;OP@ @ (D60(9DDDDDD(W#09Fha`Fha`Fha`Fha`Fha`Fha`Fha`Fha`Fha`Fha`Fha`Fha`Fha`:P^^*477:?>3oZ.vƚ5kz[X-TVV]"Zcc#V~ j~G⋍Wzʕ|^._γFׯf^ izWؔmrs/apJ. ]N׺DDDd+8tkD^^^HI#Qlw;R0kk]ԩӸtni l{/@ffƎPTpuu:Fkk+mێCbĈk]kQE`ؽ\~W =7B@HH04KAn{@ʞ꫶jyQ""ȕ+ضm;<<<0sf*`4vyyWPYYyhUEE5@\muuu(..hy @RR<<ZtL5Z __ZZZPZZhhhRBhhuupqqc`@uu  ԝhDuu z=diF455AVS' KZZZP]] ???thhhRF(kO9{ʬP_3|͞Ks̕2t=PVVJ-psuoʱNzTWc^zo~xzzͨ pg~555BQTT}ؓqꜽ\o4[뿜k穡Zpii:: sPZZ EhiiҢ"1gl38zhS,^~ظK!w߽2 }G{{ヹsg#441mv< 0glѹ@ZA={RS~؝;CΏ3fLŰaطoJJJ[l;wI*:7۷aٲ(((ķ~3SJ( ̜ o(--ݻES-bYseoyT*LQFĉHO?*,Z⏺Jl۶pE\x@TwhC##px"B۷_a t^  IDAT9sf!,,lYt2v-\+www,XpTVy>>5+Ug{{;6nFӬsn%ƌfdg@RA_]8ܹ K,FLLsa=kY K96mw-u{m=_TYYm۶c38q&ttt`қ3nرc0y$嵵cK;e=`N]]=TTT 5zgΜ\l\b۷uuu{iӦ·g8\;8p YYns vڃ F\\@?> ]GPRR}c Vq>|Æ ÈpuuCii)b˖X=L?=(ӏ",,ͅ7t&\RG%>899oHM???֭pwܻk߾KTpI?(۳g/23P(8} N>6̝;P^^={bРA;v 47t(//~,_ {#GG8y2>&- ggg_~Θ7o.PYY/;nV۷o?bbqap@Z- q 鉜yillDttRS˫W|ff&Μ9 Z- {IMnFl߾EE?~T*cǎC4{HLLD{{;N8C[='(ud„(**¹ŝDCu:=!C`޼}}}w< ,Wq{ojj'e˖ A&ozsMKD}}} \kSc?.֭+… VۀsmCB梱97n||QYY ш'C===C*&&cǎ^4cɒEO??_kif;v\tL9sf SSS~s2q=+&Å ,Fm!1_ZZ .~۷曄m}|?©S:絴[#$$X>|5;|\\\p-7 S@? =$&G}---8q"## UYY90lXhۂ"=qETW`q]?.(rϘ1]!!!8{6Sy^ݧ#=u2=(4h!ɓ'A!+|`f͚)u"##+뵸p,8 nĆ z#˞vĞkdw\~G[qD樲RO9O... M!PPPo 酅PTVG}qqDOcCj $'ʷdb|Ѷ}} 0sfha̙B: s .G DDDXq._C^^q - ϟ@aa ||_ 4\C=9rŀHo ԢFF( TWWۜǐ!CDV+h4"1Q:111%(,,2-- cƌFhhMC!66FVTT ///!(jJG>466BբF#ꆖ477KFؕ˗tPFTTT"&&ZF :x9nuQ""LKaji'Of I>y̱gӶYшOĉhkkB~FwIZNCӤr/z{{ ôiSq1lڴ* ሏG|> Ԅp^^(((&!4gggursFhkUXDmktk%*J-=@P ((qqq0k,ڵ Ǿ}퍘hgAR JRb`˻} P*P(耻Z]NZO}ĖmiԎɕ峧5#ߑ9r;MsDYꉽ)230ZXXHTW`ܸqVeMOڜ8.{N\Onr _}ww;F۶mG[[ni ByH0Zwݦ真AseH-uvsaRgӶrC35mUPPB3SxHrxP0wVit)_"TWWNWIggg!?@4lڴp-A*FS2\pAvcǎ۴Z=hOc=q9s#4G[Y"""Z/漎ZP+w6Ǒ՝СC>:u:{۾>︹BPJ{ yyWl*󞔔ggg۷55 rs/ #""SNC())q(=uGpvvFSSiʔœk  ,-[mvmmVŭ.3;l՞ nlٲ7oEpp}ܬCII).\ 8"|FAP`InLq9lܸ P(())Add$T*LJ%ϟ-[_DD\8 ?rssqdRR444`֬BHO?m۶#<< J  qh#?Y())A@@ PSSAZRDtt._m۶C &M;S@p"uĉZךӧ~Ξ= t:uä[l/|}}VQSS 6Ly.[h4466~:,ӦMa' sNr[*CddpaBqGyy=B "޴NN6eeeغ+DFF@vAqq1PWWg|}U ܹsW'!""C;1x`H={͘Nׄ*WzDgغu>TŋϭơC1fhN'Kw[O?gy/9+---} :aaapuu nn Bjj  mmz4CvAxxL᡺ OFAKK+ZѸ#9ybb!!!pwwN Nf gv.(777 2--P0rd2&L]pȤ $ȿ jC:' RB4h̙V6󅏏 =ތ ŬY3E?=<6455A!88=S_^߆y,B!/\/oo?&9,STTyEGGM fDGGAR:C  rDDD >~PJ%@ J%1nX:YR^^//OĈ[[PSShZ8;;q{!!1c(A``  ڠɓ'a΅9-=;\VMh4923D2 ** 477#F$a֬f&\J%BR={8;;ί޴NjQ[R2 FDYYÅ hDIIi? 16w䶷Fll,z=ӧCRʕ+1"QzG#66Vt/ck;jcwmYzۦ[9{:Ę10ggg#G@nmN_Km=9b;wKP[['exm&˜9sV;Cbx#1q8j5Z[[qq0c Pg| _΅ZZZP__p9d٪U v0g7Kw@xWUդ_yک;]v#33 >/֭_;W\Hh4bÆP(r]׺8D}Jv@ Z &9(؈fQZiis*ԄѣS%jjja0DiNFmmQ~\8(TQQoFmmBܹsp"BCCVU رcu~%"""""BB1qai'''L0II6HD}'1sZ455III>сCR!!!6 :j F[30 똘h RZZ6[[[Rh4DFF8WN #Ç;nCdd$Q__ Uܱ6_ ""r<Fݻ^l `w>׋ ǭ.DOw?S IDAT<ȯ`4 6_~yΜ9+~衟[ W_'}||p=+bf=X#@8;w.\ڵo#7JKK%+ aқq7.TOK:t( CJ%: lSTTHO?*/V+Va٪PsXT7^h1cFcehkkC~~gdoFNN'|ӦM5߱cDZa86{111%Xjɾ3cϞsrr_R#""Bxڊ͛`m|pmκ0dXqh((-} T/\/[O?+`۶ضm:(([~ 3WP^^.C1}4qoq(=Us.IP4((jPt}X^lٲU6(S\\7X~ZVHĉF۶_YYg J%nq\?cǎQuuuX-}۷E}9VW5)--ŁiAQo;}70{ &xE[vuJ>|8r$]tXf-VzgXӵ>ѽGbWjC )oyW({g'NoWz G%AQr xc5~ӇP]]mγq}?믿K. y/\OX̧;jFs>ߒ \ttt^B]]d?SYK"E[F#xXf-<fZ⎎'I_#|$^^^^999xAhsaji裏˞wooo<4! {OOOQY:8q$ӏ>/!!3gbʔɈLDDW^pƛaǏßHͽ!!p~”)&MDxx8HO?I& yڵ[-z9׫W% {xx'OBGGlي^{]iZ[~Cj I `0O oGW_mÒ%-`s GSS0pEӦMų> ???de /d<==q cBZmm->|':JIӧ!5ul@ڜ7߼uu_,έgA 糦|o_ALL4t:^{uyhzw3<~|ɧ#G&c͚7eڰ>L4O}F2<;;G2ĺ;Rkĉ0{,7//T*zbz'0axWUUv^­aYfg?{AAA:t~I6%@GLL4?dGug<믿 _'Qkooo,[=3K|9Oy͚5OwwzMMM0ͬY3ybqchDi'OZo:HÖ-[q IҤ ?$>DEEYwEamq?>[?T*N/hv,g{TUUI\f[׫'''^5vDll,G`)`Q"""">*̑hfZa% ^ z24bW4S9# <O?,N8i5(j}N 7^ׅ̕OPKOM6߰sObΜpuum҂O?j~^rA﮽j_+cK]Y:J,-[$$ 7$Iɓظq^ze,Zt6maKDDcQ"""">dU5kh4JA,R#%u'C[oݺ gh$%%ZͿBP&r/tޗrzwŋ!((* Em1+⛛L[899!!aaٲ? a=t?ֆzݯ^Ɩγ=jܪ =O.ڛŮ<3>}o߁Sl܌2dFHre ]fIpIp4,L<칣W7gȐ92Y^rEbE l+7$-/$4e0m߽˗%?{u @` ػ(Hd[d[&ld'}w;yf;;'vx)d[ eTET#)`/ AxX X@9} mnJ2+a}P²kFk055ExfyYg`pppPYGƐ}0Ҧ䗿}EmٲYe(˱saTϟ~{Xn-</===axWǏH/FFFGgk۶_:kЀgI[~uuPWw b)En޼6bjj-[%%%q ~_׏{?+f r!j7~?Mxq9b1FNòeKGa۲}[ZZK+Ҋ(Сh4HJA$u6~UXb9rsshG&'}MDDSc0JDDDDE~իWpUĉ8q۔H$رc;~_DUd :>pj5-w /_ ܮ_oQ[nO?%|Zāa#Yg+-- o y<|>{ll,d2٬\q]ܽ{wmrrr"f+o;aO}IK_MY +V,XvUQXFWXo~MW֏O? 4۶mO7 U| 8rZM{w͆Y@aa37naFGGFT (7ǟH$f|V 8g?g\vg\dTV.ڵON:~𗨮B])["mزe3N>W Z ^K+';PVVh4B*> a2u]#!2d)>._!x_pgԌbeyy| M{hMWq?}$>1>2RA%'"Ǜb5 "ig/2 ؽ?2a0DDDDD<, \.NMTVVP,DDDDDΟߎ)oͿ=8#%˗wW G Sd2Jxg'ćhKDD4'8͕G1_'""""""""(-8 Fha0JDDDDDDDDD Q"""""""""Zpт`DDDDDDDDD0%""""""""(-8 Fha0JDDDDDDDDD Q"""""""""Zpт`DDDDDDDDDw\ڈׯbB,CӢEEwN ۷?lÜjVwރ^]˗O~"lݺyN""""/ FPWw אAyy`4gGrdI#KBB-[ phjj~(455cٲDjll ^HL>~pرcז.ۍww1ZMPQQ~d2X7n܀P.Zwȑصk'&.9YŊכ`È/.`\.Bggoذ~hXՍ@ 0o} )))""z1%""""(u%(-- [f2wᄚekסѨP+ A\\NT|>JKKpe]bccis: s YX|e}H$066!3nmax<$$Ol;NVT~``###HP*aZ68N(g`:STN'Vbbb"x<Z;yΈ |2C$M@ !3^/jUuNvbqqJa[˅_dtuub R3- FmKJ+Wf3Z[PPPgq%"??fv^%8nݺŏ!W6v'D!ryy>;8%q( |[o FEb]ѣG׏UVJX~!(rҴ6;v\ΓΜ2! 7nNGh ?)I_ 8p Ν;s@R7߈DDp0%""""cj 55ҷ8YF#D"xPUsҟ|~'l6;@*Bgll 04hh~nt¹_pgQ\\ŋ +Ӄӧ`߾xWQC֭{*. MMMhlLK/ɓuhl}#Jo'HIIƎۡVaZq ^{ըF [6|>|;N޽iFvʕ8q(,,\.^N ۍH{˫kaXPP"?x^{U!>~z{efUՊw233[~ ,[TKDD4DDDDDc_l>01СEEEغusPZVVG6Vbcc7~?l"2U(p:8 e&aD bccq!E~~tQHMMMj$q])$$$wVQCwGCC֭[9=SPQti%NCRR/^^5tZaԭC .\ rNJ=X/^_K̘@0ߵ%k}I? /<>2ۃ._˗DH$zwyǎ&DzǦGhҦ06D*/y@'{l/O"vC*bѢ4bF䣳BXL& ,tuucѢ񉮿L& J,D"l6lcPWw uuVmeKь1%""""R( [hpܹ2/hrr2Z[[$E! m{fښ@۱$H IDAThQ0s¤N YYռHJW^v[Tdtvvattf0uE32a2fG" uC|>|>_3V/p& 7p- GhF} """"?2׸GG]뚚go@0{mb4Tl6;, Ja0d8>ĎZm"$ L&# דj XV47QZ>88\JL@,7b]hYQTTM6⩧v-8S><9""S`hٯۍK.`:7BH4aP7"|uȐ2tuuGͥl墽G|ѰnΝǗ_O{tIhl&iBnBHmZZZqn{ľC·Ġ ?Y,VQYY χG?\WC*]t:V8t\tYY:R)݃7o̙/!PR FcDvB{LXqDDDDDhԨXk"33>F8Yzґ))h4xl6;`| EΔT*ENN6bOj!˱bY~y;%P֭[pIܼyw܁^BQ "`uG$g}qAIHLqlfî];Lb47~!t$lV "??_Eb0py8p QRRTY6 /^wVCCfTWWi蘗*;w@*ᰣfrOK u\a3CMw=xHJJ¦M Ir@(Tt"%%bHMMX,Nʕ+P\\4m89f #77b}P(//ʕ+Jb8N`ɒZ^~JJJI_R)JKKT*r16Ejj ֮]+@w)-- 2 n Je,uk`ȂD"GAA~T}'";sn~{#XGhόEV#xLИ{)B-TODPX,'DDDDDDDJ&V!<(*0 0dQ"""""""""Zpт`DDDDDDDDD0%""""""""(-8 Fha0JDDDDDDDDD Q"""""""""Zpт`DDDDDDDDD0%""""""""(-8 Fha0JDDDDDDDDD Q"""""""""ZpbDDDDD ^maX!iQYYIs۟CvaNށ? [VU UUwAww].`0 s|c0JDDDDShlD 066ф>;˅%K*Y_lRFSS!JQY0>~wqQڵiiiQg0dׯ7))+..ҥ1/-{.ƍرc+dɒG2rP{r:G =]wDDMx ]]]ºԈY68N(g->6. *U¤#agZ@N9rbB"@VA& B* QWPBM|~?w&BEbf^/aVT:vSݟ\ "(QD"QT۩Tj\@56Fr5(KHKKE~~4ɃG)9YXJ\n߾b3g04dƷfn{˰zu-`tt{EeFp ##O=uW]6aQɄSАYX&JvB~E44\訰\.Gmm K o~Cj2PWwJ@`Ӧ XhѴ&|e=\.WXׯ_⢰M;6Ǐԉ@ sMM5/.ĉ0M¶b%ذa=pE_ `3nd2cp8%t_ʕ|>@JJ2{ٰ3vD"Ayy֬YNwDD`h*>;-hmmCIIqTFD"ף8|~”'l6;@*B8bJ9\*,,@s tvv 胸v: [FLL '8p vz)p>[l^38N,]ZEҠTn 8~TdeeA,)'ƾ} %%;vlZjř3go'xW׏'N"77+V,R9ыmtb=M6"%%nW4ĉEaaGAMM5233AaddMM5z=:۟]I{ > Z8q$F޸qqضš ΝG}YlܸAĉhjjFQQ**C$F\ǃ͛7E}DDDDDDDc_듾B =a:,\TT[7GuYF|E82 ֭jx$cժ;dxN}D^z Ԕ֮}2FFjj*~wqZj;gHQCwGCC֭[;NTj|ٶs%_ԭC .\ .\Ķm //W3.^χ{V(ݠѨ!qѨ#A_xyIw`fna͛rJh4E(c$//wNCQ WQ[[6E}1HgfeeELlS03 Fv;(.. E':~]ÁAD@>MyP ӧOceHKK9w !!Cx]=DR ^R Egj&uvvAO g G%;v|F YzF . rW"/++CWW7L&4uT`1rmʹrDRf4+=t::Ə)GN5xddf%$ݻ; X@$G\ M3E' >D"AFF:PTTH4'H$pݐJp8XhflE=_"@|||IJЄQr##$T#~ju6""Q""""(&lYD \2H4=z'Uz6㗍I:n{V<l Da=j>Kf~KVbɒ ---8r(OI;bII 뤎35$ ^^/مㅞ'_dMw(z.4kt424kjjƞ=>umKPG677 k2׋+W /,N5q'=Voooĵ >{ߏU듄dI:Y{&iσX,FZZVXW^y))ɸy֜GILmhD}I{II: FeZmv =Wp~e:]_FLu}(4B766Rn.]Fʉn^(}S(Dj"|1;:hjjFVVc5K__?VF&''lddlƵkׅ. ΝD"&牖D"Aii p⥰ aBp̗`U(qF^111(// F}}S?}}58~?Ƅv.کѣG {Eccc8qdHGӆFrN3i}?v N>=9= $$$';88WL`kADD_DDDDDhԨXk"33>F8Yz PRRhp%lv(leB 6R99hk?V\.NJgu\ۍӧ f. 6l0>?6 Xp8H$Xr¶/--KqIܾ}111EQQ! ř3_ۈGWWN'֭[JYfgqMzy׏b^] tZ|e=L&8* 1 0:{hZD"@.c͚հlxn߾#Z-2j`/_AJJ2Tndž k6墺 Ν{)Jep8@z" Y|,ACUHMMX,<L_ + ןEOO/xF핗.]hVC__Xh4?غu3}{dd:; S[D{DDyxGr#֜+. zAD r h4l6CV'O$!;;0mGww7zzzQ^^6QիHheeeEgg'LNlvTV.ysmnՊ^bttyyyxNfd2|>d2$''b16n\숉gb1 v!Ţ EEzz:+ a122$<Zmz=(..BI$Cblk2Y,PZZXbB"r!''7nfT*EAW%RSSsNσT*X,FaaRSSr r A,A6>^]-JÆ 0tmmgtՂܜp9==EJVQ^^+Wn0-Ұl2 }Ɂ7N'J0XD"BT޻c088xL|>!###-rPTT( \شij&gkADw97?߽#4gƦn`t 4G@ @=8h>&DD,K1q8/+WzCDDd*V^c>>S!1JDDDDDDDDD Q"""""""""Zpт'Hj5e-X,VJؿeUUPUjLW^yyΏMDDDDŽ(Qrr׋|1X, Xl)vyv188(|DJeSBTIpg!77gN8zgTV.O>1ݙV:t۷?lÔ۾S⥗v>(QrssQVV X|r/^Bv-ztZY`c6g ǣ ˗/Atww,jkk:oxܸn8pX|3x-? FD"ƍҥ˳ F):X06wECUܸq;vlGJJEWW7nFJJ|weZ=~=deeh4C,&466bx 82t((ȟuccc8w> 3:|> !>?Nvb8L=: Ff!55@ …jxq9~?mß|KT%"G(,."r^SSCi3O\-ˤW ѡڧv{F%N$4Idv;~Pr>.bL###HOxJ$ްa;S;Zl-[ʐ1`hz{{!HXkKeiHD?C7cr!zh"P%\Q,#)IW^yyV}dff7!3сhmm/ H|hLw|L O75Y"Lzl#j^|8tuud2n޼_(cJ<&DDDDD4ф̰Z @pL&n$G ζu{" >/l '']:w X`5׋+W  X R"d2My-NۆښqiQQ=,*+_(ϐ@ =$Y.=s3i/TO7b]o%TRfpА9^lH||< iF;ߏ˗KKKH$•+W^]8u47H4BHPs·qn''7>>uQvZuvAٳcޏaZQQXbbbP^^Aן,+.+l {E_ox39Dv;#ЫeJ;y8l[χ)ۙ-Nŵk#Q-L&X,J}F= 8} ^/JKKeF1"ݟhשJODDDD cll FXVX3OСoT' n7Iۙ"O=ۏ?ޏd:݃m۞X ! mmm8p J% jjgԞX,w"##>:;;wLXYY)::hjjFww7<\,^\ 8#..z}J%:;iQXX dׂduq?OH}?ޚcrj"Ǚf͛`ZӋAǡ˖MffG h4Xb9jj;++ N(:;;a2ufr^$~}0M0[;)) hDOO/bbb'l#ɐQ:;Á,lݺeW& Lχ@ LdTT,ƍ둓qMb1 X N+WH(0:jQ<==EZFyy9V\!Ɂ7N'JG{)!xzښOQ2 nJervZadk @ww7ӑ2@TJ.Je**#; ŀ.>_xFgҞZF^^^/\Q'DJJ2V+rrr?*aCϘ`F$ :OMZUVjD"A&vAÒ%XnmؽZQwy|GDƦF' Bc}dԃ?{wԙY%k<3c<'!d;d'9]jWVuխ[uCzg@HH @deY5 d[TXz׻R?ڮ=m]""z5ygJ.5| !>(/0IL(8 Fha0JDDDDDDDDD+Q"""""""""Zqъ`VDDDDDDDDD0%""""""""(8 Fha0JDDDDDDDDD+Q"""""""""Zqъ`VDDDDDDDDD0%""""""""(8i=""""WA$m> DɈkעrS៣hI׏'kkh،K?r% v`ɎEDDDڑa@]*A Nb͚6V|>:;\rvNu־`z%|8ykTVVb3a0JDDDDQ@}}}\)˱qܽ{nf>>}GNJqh4سg7f3"0iabbbba  ++kBDD-JEg5kıcf䓏a0v4?BqVV+j`NގH$Ç(梶H$~H6~?Z͜u$ d2ƌGG Ja 19iEaaacT*tZP6T3nJ27ۓ jT*j%0@""'';nL^J:ͦ&""Z DDDDD)+McӦ͆".R(ܿ*_l`0<_q> %/oڵx^ H[[֭xf>(&&&p ?tttʕ.ɰgjQ""""%ڜNiUO>znI333P( b`53diI1vҥqm\q*33##V45]- ƹ^GsUTWWcUP(Zt2?G?g0n[9Pp:hiiŹsif8|>yfI׮ڵpg׏P(Ԕ.\Ďa6199 GRjv42 @ff&&&&pE;v|Ksctt @vv4\Foo/?__Ns9b Pp=L&[𜈈^4DDDDD/k}IN}k\7ߜkH2n1d2`a3щ;0bD^^.ˠPTv8xp؞a@zz:<|؋2];e0葝?3! 8x8TTbffׯ߀b{+ zqΎXp:hn񉔮o}" !??@tZ_|CaaaNABbbbH[4%Z (Ktgm­[uk#hxV{wI4qxý{ݍK.088P(A~bHGGG`4>=D8F$*l ch4 ǎf͚f{jZ1Ќ̌.+ 1>P4&???=6o2ߏ۷PZZV8yz%lذ999JꃈEb0JDDDDIOzrbl!WfUz ZGeeuGq%>}PTp:n1a+HR}oo.p$ A@8Nk4mcηB}0 q\s kWjh7/ #a3,B;擏,fl)|vχIX IUUؿ^5|><|])}H$ƍ_YY^³"""Z F[-.|q&'\)]ǟؔJ%ADJWWW᷿W1V %6ܹs 瓗Ōv1{Ȉb+JO.+jͅdD{{GB8RA KDpe֊m 8Bc\Dӕpq\p`FG鉈`0Q_mmӿPp\ؾ}ۜOVV& _ *mذ^ 枅L&CII1>ʼn_h4BP` bppW^BbL&̌hn۶U):Oɯaa6Á#GރVEAA>L&#\i T*5FxN%#^u?~_~yYY x7R[켃LN>6R^] `~ jX,fT*\. d2@t6hQQ!>|'JRDc VD Ӊ{,gfK2%}Rw%9T^oʋNP(011Al6:lڈs'aO###ZGQWWB!npv󐟟`hhCp8XvS+-(騭y~BeerssP# #CTwdQ(ұjU-t:^tb۶Ά H$T*E(BII Lr߱qtZ'QR* ^~*Q\\$saPTTOӇ (d^o͛аY|4>6V.BRc͚z޽K EEJp\p¨(јP(xi9ػwϲDD|B #ZG(k[p`t 4K@@JyDDRvCDDDDDDKB.5| !>(/0IL(8 Fha0JDDDDDDDDD+Q"""""""""Zqъ`VDDDDDDDDD0%""""""""(8 Fha0JDDDDDDDDD+Q"""""""""Zqъ`VDDDDDDDDD0%""""""""(8i=""""WA$m> DɈkעrS៣hI׏'kkh،K?LO;pJ3T*QXXh%;ra0JDDDD hkkGFuu 100; ׋5k_XZ-֯_|켳\. %j%J3UnN zz꫓ZG ?G#\:@Diir^B F`OxÐH~?>쯸vW׉ϛdsQ5^ܺun;!Ps.,:::ig;EÕ+W09iktXn _e7o1!dY#{:nƃ[rdggAH_ˎ(FGO\7ݻph45Ĕ38r=$fbb**a]uu _PE}}8ykh4ٳfHv4z{011B*VEYYi\];iDncxxH$᳜GaTDD`0JDDDDP* ֠&mppǎk]O>$ ƭ[mZPU8z:+ vD_/l1D"8|0b{nn.jkkbHN 333d03fZVHi̴$ 166Zwd2>La }Rzr<9LNRAnn.fdvcjj NH }I$Rd'\.8.h4jhZx<l6L&(&N'fnA"]1HRX,fALNyQXX7f03⾋01333+Ҥ T*x^LNNh4&-0=RLO^y:N@ռ BDD c0JDDDD"ARNӋl6 $ zn݆ nmD]*ƍ+ߏ{p8t Ν* 46nu)100(!HP[[{ݹs/ IDAT/_;B۷7.qܹwy gϞF(D"Q_0<c% B0Lh ͨ+))ƚ5} Xn*++@EȈMMeKCҙOrXn-rssRt:ҊsCӊkjja/zz`ժڸ>bϿsχ@zz:*++ Gc -- =䤸]w=|YX,fTjk8z{qy۷Ӹp"iQQQ_[|ivڵkP[[@ 7o+gp0MwrwNEgg'!˱uk#?4SŒdi;%d203ijm۶߂ q&UUKr-hy0%""""z\3S yRuԷ̺o9%\UU^;];8s{\|[P[[g54l:::yX,Eyy8cGvv6N1-..B@WWW\0r044Uj{?;5| v曇j|>1wy[<7لP(Vtuu'[4 8W2ܻwعs~?0< b8?=rщ-OlmmE0?C~~>lQZ/Y(gƍpvݻ``%RVV(l­[uk#h VWSS ܿ߃gϞjt| IOOѣ^bll Cww7.]^;(쌉D"vr"#jlffJRTVV:ՍH$jCrrK 5YgFIXI8$uuҊ𰰰 }F py #*]TWWS$F#ɸ\.8AD"BYp\P4&?? uxkKDD/DDDDD/ٵ.Bla৅r\IRU?#:rّKMCWW7.^ӧA z{tA $ A@8N֎.l޼ @1z XO:R)|>߼Bn.&%QPՐJ6Oy7آFZ6dmOCIlS8{,Q?ݿXӧ=' z^yyI?b'g&+>ѫ(Q~R]vtww`ttYS)jEYY)o7<1C|Ͽt,,͛066mJ#H`6_ǜ76RiWXP(P(f@8mGۃD"8yk~aaիąT*M:~ z^R * ˻$+@.<ogw_tG8~.c!Rǎ}HhJHOO_0{b&v #a3,BUȟT]] u]]]!vdfMv?X-3aD"hgX d2.4[Lܙ2'|'v;VEQQa ܉c!fm*;09iKJDD- ;;Hu[[[\pMOYPäT*!B(*--J&'ms.A'O#+UWWAtvv޽Ix}ڵB8s~^/>ӧϠ*Vn۷olzK T㫯N/ Fyy997M(++Cww7&'Ac||&!rTΝ &4&&===qڵ<5r!HPSSWIVբw022قG٦PVVի+)E ~^%>~)~͒^*^WQQQ &&&000^[Q_zA@qq1)addV(z}} y[ v{044!8N]Uӡ. CGYY^ =OͅBP(@RR޽;nwDJHRx^B!`߾p8d(O8Fz. [yyy(--D" Aף6mwᥘ"#B"v]~L&Cmm T*^k׮ڮSSv!$AVVV[Pq &''!7())A ZΝ;!JׇիW-TVV \x*رj())NEqOm1%%%j}0ؿ?r^*++ FVV =</(--)**a2zy&44lN%% EML<\.O]""_FPעWUz`t 4K@@J.TODgѫs켃_Ws ""zVr4?WA|iL`5F(k.PՍDDcQ""""""J0>>oyyPbit޽{GDDQ,lҀq\.a3Vq(]`0JDDDDDD T*֯_ ""znXcVDDDDDDDDD0%""""""""(8 Fha0JDDDDDDDDD+Q"""""""""Zqъ`VDDDDDDDDD0%""""""""(8 FhI[ "nnCGGiH$LF]UUsw~NEK2~8U\[Cf44l^12b*ځ%9ra0JDDDD hkkGFuu 100; ׋5k_XZ-֯_|켳 ///ѣGCz|IχGATA:'*{=""Q""""hkkGAA>z0$hE*ߏ>+]kubf2}6\T0r@!--V&E" ׯ?%͆??.JKKgnT:g122غu+rr{8DDD+Q""""O\7ݻph45Ĕ38r=䤼_Oa5qsƺukw׋{wY4nG{(DDD+Q""""~RH5qm8vx\욠|1 n'V0njjZѣ,,Z8ƕ+HOO͛m*Y|_PPņ8NhnJ2z ^!a_uYYqH6~?ZM@|tt R`6|^ -M E%S*U03R33nCA*&. arA`2tξuݘdB̜rtѨjx`~f~D"Zzf ˃(Qgtzlڴ@1".Q(殍|ׯ@NN6`0mϨضmk0k #&'mp=q .f ߋ\q .0oÁs^]޾݆kZzŶ߿/. PU(..FKK+"J%jjzpJ}MM 7;9id2vڙ?xgϞǩVqq;|wG$|q߿wޅ: A PSS qY} 6a1T DO>5Mã^?""-1^-?x55)J?00A'C-xfff @ ffp8, J2~--h4/tTAAh?x###8~+dee⭷Cczz/__`0QYYK.`Hb[ku47_Euu5V^B ՊK.8zYcc|8r}&x^(JX,|ͩpp>h48x, XISSv\p;vll$.G`|~h4ZlذHzNGwSl۶oA7|;{<߿kנ@7oWL~s1:: ;;NOO7HDD$DDDDD/k}!d2ْyԷƵ})窪*ځ޸qsf.љp:X6Qꦦ jxh0a2? nݻRPTT wͻa@vvxVTTTv믿Ç(//? 8x\s*.\T7nfll|Oz8r}3 ӉgZ,f:zܾf HW4ѣIͦ+*Q_Z|qܾ݆{Cyy9v!n{?3DKJ`У(>&""JQ""""HYY钆uk#B?CCCu6nmh}:˅D-~ǵmڴ[4].&&$! :66&Aoozz&:;jn֭vCB03h\0hj>-ӉIQM&0r%LkT2bjj*!-** =6<wѧdBRyy J5Al߾-S}j46nA8~ .r:]!3&{P(%]]]!p3Zvq }JR6&yX. \Z!qhp>0A+0|q1 {݋gϢ.@ףׯcHJDD`(EO.Pnr2zAA~B咖&Jӧ8s ~#A\}5ؾ}ۂ}JRTTN* ?.h曇R Tar[b.عsrCCC/V?''M~:;p\ ܹn| GhQ$ oBDDDDrM> qF\s :r%Hζm UE`uGOјTIuu5"^7n%#g5.F,0Mu|sY<88Lׯ:>>LL>4 J߿|>>LDD8 F[-.|q&'₥'i4CT*!&&R я̴,P5k!q5D"n&&jB mO's990 BWWd2J˃bF[[{ndddp/(((@(B\.o6g( opBLagZX,q7B@}j\~GUU%o!Y,pwzڎIliHX&(ԔUUU ` C8ykX,fdd`MpȑRz;33JWaB&KCNNX4۷f*bA08jjmE_k3G~~>^QYY;w.T㫯N/ Fyy9 z0j5,3T*\.a2QYYc&"iRfK2%}Rw%9T^/ r)**Ba٠uk#WϹ (..>>jE]] mC~~ޢVXXۃ! pb5O}p:]lZG^iiiq?o>6 yyy &fLLLF(D"d¦MQ]]tZLLL@bIN"VB@.OG^^.mۊl1NEqqqB?%%%qvCR-5T*EMM5F#x2ֈW:n6A ZƎۑ_mDIIiN QQQ. z @VcΝJիk999PXwJ_FPעW |`t 4K@@J*TODvss켃_Ws ""\`@@+t4&i0DDDDDD\.<O\:n0%"kђ~\j\. !==w^`0JDDDDDDK,;; [4`ll. iiihh،U-JDD/ DDDDDDT*֯_ ""kъ`VDDDDDDDDD0%""""""""(8 Fha0JDDDDDDDDD+Q"""""""""Zqъ`VDDDDDDDDD0%""""""""(8 FhY`4}3I%""""""""(8 F|l "g@ IDAT8B"""""""zi="6ӧOczz:]7`M(--YjEkk+!""""""z)-U0 !:5^D "z!fM=j5$))̙38t ,x`Zy{(DDDDDDDOKh~ =,h]03{LO; a0.nT5dI Bp:]|P(j5HQNl6`r҆hJkJRs"""""""z ?<LM~?.^׋%=^^^.4 :::av͆jJ?338F0T* $D"JL}l' """"""Z.`@?ITOmOYm`0+7{eۏ{NCVC  bzځ`0ٌ-KzTT+kTVV@"vG'ؗa]0LuBD"Ǝ;QXX n'HPTTP(k* 999ʂ\.G ZªU}6q~ ?~=Izj -a,,Ig% @ks1 =Ghv.06{<)^>D0'ڌR1` ^ϥ؂ DDDDDDDDDD⧙YxiQ3H FlvXR=z]bGDDDDDDDDD+C$;_!:S4g/f\%Xl!DlQ"""""""""%rӉy"~z|>Y]х=mϹ,M{T_P&Q"""""""""s s"y0 =tQ&\F0%"""""""""sO\f`t9Tѹ .V~f{7 0%""""""""Z"#++x<DCQ?g>]BL(~>mq~)@p.1;H E~wԹS>BЩ:vȠĐ1 {$"Zx7^>|/&ɍ??_ɗ?OˈPt(FKFahn9,ݓ'?=7~LUUoxvvӧ_⶘2as}WHZT-1$2M2m0i?xg}XTlU/tEXtCreation TimeTue 23 Sep 2025 12:23:29 AM +03308] IDATxwXS7 {mG]޻Ο:k]uTV޳ukl3GK.I؈y<ܛ{O&{%)CYh#KyUBWDDDDDDDDDTJ=4-@a(JH)#K I_F>r[΀-+-ѐGc78R""""""""CAQcX8ZRc~т Fʎ  s Es D}E Ds HK$-1?~AQ+򹅡y5T4b7-hi~@DDDDDDDDD% h~CтG*$s~(e<0в?4$5|3)̓ +--HIj"""""""""* Ϲuw7jK <]Ob")l0ׄJy Js%+d G=IsGڕXEg~Qi P冶(Qbj GgOݛ.)RCաER_`XTY M CMto0ZiѬ~Թ6tSm *EG $k"9Q3&805oDDDDDDDDDrHwlQm&̈́&TB jkGuQc(P.5R~BQPPSOwLVM"XT*v'"*#UADDDDDDZNxr~~~RCjRhBҼQ]Ub浮kֆ2hBMXvimT*j'"*kU """""W*!!|0ȑ#1ҠU@f"zTw,Rn9g9}-j(5u^Çc*TJ1 %"*Q~аmWHFv8 Z6oPp]sBRݰTR.""z'YYY ED"YJJFGGGGfȞ{H;I{nhsiC~QCբڟsv}n_Ǐ ..>>ް{NNptt47oaРGGG;vHXٳԩӢlذvvv5k6}+W9v1q\)(;w BPWttc݊FPm+(1>ݻoܸ)Q1b .ּѪUK?thYdd$"##Z,p( @hC Fj5< D_~,\4h-[J*T8(,ZŶ͢z(ϟAprr‹/r]?>> ܹߠm62/jXl1Mխ7o ?O2 "=aQU֤k/ZTX[[M%&?o,P\`IDD/E\\FǨZE^?Xb%RK8;ˈM(;f@@!,,aaaDܺu P/\2[ {Zj EŊ.prr2P$''VVV Gtt4M7t<tXYYF_իWw=V~bڵѩSuKMMEpp \]+E<,,{p\{xT)?~wu0ATFVxzm(@,077G>F_Rĝ;wcbŊ\r9,--@4mxx8E%##AA!P d<} 666R* 7n׫WD&(*J(JGf͚^ի"88 ЉMLLZSNcENJAJo޼%k7c׷p)JDDD")) JvvvlpcCa||KR255DGG#886hs~G Ett4lllr~T*Ezz:,-- U6B/  KXaQ BժUn\*>uݻ_xTTVXqʕUX{ЫWOiZ{w Nk֬իD}|1rp!<K…cV4i6lسg/6m,zy 'LfV䎽{w}|}6{jĶm[hN~Wv dhmL0V._0cq?N8 Jc_{ff&NgԨ\M:5k4étձyV! vrr‚߉B ؜p\xc颮7>`ؼyp7$ :v&M/ 88ᆱBb(u:"ZDRR_t8q`Xuq,\ 4Yf̘jp[n8qP 1dpak3W\Effh[ӧ7F& uѣG" ǎWuƲeE#j5k/6o"Z5kfSRRjPB :t0z)z~bb"fϞ9@о};kz| tO ++ * K./T0U r5jƍؽ{ֆ u6Ro'O>TK۽] ZjeݻOw1lHavU`ŊzmЎ10B6W2>14vP( ^8KnmRT9''Nymn]_|,|}\ܺuDڔCf-^Y E˟zbyS6,,, @sRfM\z Ks 4 KBhJp Ǖ+W)T*N>ѣ Yy hzm=F͚5}^/_w ]9FZVeY ũnYlS*{&Ox$$$`䩸uvE /nEeh9v lmm( CZZ*,-`gg{{&&&٢jժz쮉NNN\R_p &&2 ?~"4A/}DPjԨPVjx{{ť-ZyEժU ]?ػw? bbb3L5|oG~Q^ߵkg;8r/^L,_6oވ>} z +W.G͚5.vS}f9=bpttĒ%p1xv8 f͚Zjԩ3jV]ǤR)~uʕFU] դGĐ!_~C6… M={aڴ`jj}`Μļy {:t06m j7o 6mcǎt_ܹD^V֭hӦ>r*=&<>}TtϞc̙3[5`Рx^JO['++ 3fOjܸ/_b10a gςᇟ@P0ܽ{ 6@bb"lExNn`1HNN3_}| z]qO^+߿A4aj6ECT[ }.~j!@U&bժ5zUкu+a\ET*BM^UE^055ERR222!CCRa7n#LuVA8p [*ׯTo`׮t CDmѣJ\/ 55oDn /^L];G6mf'T0Kh'O wggg|>C=HNNFZZRS0a$<{Ǹv_a\ݢ~jՍޘMQ233h>}zO}<{eCŋҥ3' [El;?ϓJ8v豎Y۴p 333s^K!(Hew),[~Pi'N{>ѫh̘ B^XUC"`Ԩu6_ロ۷`Ϟ7nL(TK~~ 0++ ?p 2r$$$ƟlҤ1Єr\`?~8}bAV-`4<'L|.mrvvƒ%p\\\ E=MYdZVd=h{.݇۷"11vvW{UQڵk֬5<11Q8 F1iWP( SPdz7… FGDD "">>zܹ5ã2~yMQ^/* ?#N< ,Z4wŪ! IDATUkVq$&&JO|5VX{>Dž  @s2tQ18n"<<=(tӳj{@BW1W^Czz:Ο>z{W_k-ڊ'ZqGGK.#55UԽVWdd>}=nJ('Jo 88111ppp@> 2}}CPhUVU9%%IIIzr5i86jCQT*մG>!-M ZQT!N|PTJ!Hp-:gUzuQtizzM}e/*d] tjժ)+]YҨQC$%% }zĈ%667W\ *UJBTTBBB +V O<3 _R>>pttB@hs!,D_ )o;jtXTJ?dp]Jopz}T*,YL8Y7337;.kǘ9s6~A G˖-вe 4OݻO<EF{/n~aMM9kըჭ[7xBCC3( ay¤Ed7vCc=X۶mc rJpssCxx8GRR"*VtcW [E bbbR L@mp[Pe-upp5? a#* ˖U0ig1bΟ(HR 9?bǎ2 :G7'*Z4c1p1ժU^9r%9>T-;wݻkNsJIIh‡  RSSڲfȐJM7ǰaCp (J.OOc-Zso시96mBV 5l666^ ع3{cN8~\3棶U[۝zu/Q^sK ǰa#aú6E{]tt4n߾5=tR}1  %F 8Ço4Zիװf: W4Ͽuyu6m77W'1p4FѫW+ܹmtYT񛐐[8p¨[W&?Y+/׷0Rƍ0j aappЄ5jС4da'NKI_.qq?kBΝ`bb":݄P466VtSQ>OI cv/'d2Y_^v6za jn1E_VO%K,L8!!6m&֭[]m*IE0z]!]xj׮%,sww&McҤw>oߎ (/Lfw7u>>z븸OwE0q$~vocƌHJJ=ޱcGL:E'Znh{axM۴iSL0.ח+{u'''g߯'>DEpQqpvʞ=0h@>meS~}-**  wwM̠E@ T*E߾ %Ν=Pj(x]3[;;; UE֭GӦM н;G ݻf:C t0ի0adT+W[mɒGӦMkk+sV[ӏ { 1bhܹs!>|~iM*N f]pz ܹ#U'JZooo#88$>4...d ,^Poӻw ssj3gРB0Wa`nݺk oߑlEI;s K,´i3DT#-Aകj׮>x[l޽ /FШQ#kijءּ(puBB"S+4+---f͆_7ԫWO[ɓS5W1aDvfR3fx͛ҥKEzS(kEp|ٸgQ_7=R=zu/ܿ/^ 5kn[3=<}zcܸ101y-JU^\nEva'J_6OVM(ҌkR1zf @վ}{lٲ׬Y3[ =]3γgτ/']65kv}uvv]OJJ~zXXXF 4j۷;4޽{жm>ƾ|2._ Dww7EѲe QwϞ=мysl޼E?HXR%W :̒%KEh*UТEsļS =ܖ-[ESЄ<Ě5k1aB퇨zJ > AA1adlsi7Jnhn]1vhAbʔi8q$l1|PsAGz_nmm-9رѨQC=RSSU }ڵutÓAI D\\<`ffhӦы噃P=sL5BuV_*bƌi۷N<Ǐ ==vvvpqƍuӏs#!!A8t/`sNBuSZ5 9{t95lwGq-ի{K΢6`3lZ?s&<ٺuFftnnBU*ڇn/jؾl;accF5DrQ+++|'@?M~BŊAM~Bwm%+++P}}L''# å貳Î۰moZn `T,P77vZصw?~{11/`bbW׊z޽{e8rX[[ uF˖-DAuGժU{_K ^D?nO\G|mWsW]C  s]4l˖-7T*Q^]["nnmT[lkdW68ᒵܜUst߫إIR&*Eϓm*]3?IsdLllP;ݡT*qAՙ u󃩩)>ht=2aT[YYcܸ1.!!2e*>O*bA?j.]̙+p[ uEiU\ fO>66psΘ2%;gWcdwR ֬r ݁|ra-vξj9mt] 'r6mx|@hIRN'_*xx{Lձ7n"r96lX Xo ;wĐ!~yzv؉ϟc/ Iz6&%%/ ]NltWIQ/;w_6mZ_lINN\.׫TٸgaJܱwh^ 44) ^ŋ/ ^z`РM` G|%HR!..m%HP_) @n7ZTŋDGZu#OצMklğ/ :SR1T\Yã26mZ{ԩxޛKRa?PBnomL| 2>{^-cϞ}z }gb˖Mz3q$IK.B( k׮ =iӦj+{{{v[<ҪRp=hѢ0/㕔$FƖv`Z^&_2ёcyWf ̞mxl=[p1R)4gg'ļPB h۶M)sct=*[0u tQ5[^C}ai5Hx^urƍqcJ)/ZʕakkP"Jaccct)/T^`~8} N:%Ɯɓ'p\]+bԨ5j$"##W^Ù3gJRJ#G'b||IIIxnܸO&8p`h1gzݻ+WXVn]ԭ[* Ϟ޽{82&&/_)ܺ*gWQLggG>/>ccD uAuuw#[^oخSVs@@3>9_Tahrf%H'+VM4.frVVVdeeeUlߡئZ{g}hҤ1j֟;vD"ћ bŊX"ڵ{mڴ¬Ye&вAfЬY3>!T(|gqJV*U ?Xf-vVk3&ɜv#/wU55MSS+CSS >tm`s_ffƇ(UP]_vI* cZR_~zx,IIIn] GV-_/^**j7oAtt accotUxv%L&CRRR$ lll;T:^`{ž={1iWѡC8;;!::'OĎ;Pd8d?cЦMk'&NN1L$%%ȑnpFs;;;ʅ`Tw ԩSpss3lcΙ| 9mllvvQC<<*C׮]iyܻZ- qYDT2-j֬at$qbiiݻ{n""zEr888 -- ii VVV%vmz^`Z{a˖زehv׸8L8K|Rjmロ''gmpss mۯ*J[lZDÆ Q,,,{>e}[///n jՄ3233lhmԝ3**111za5lllܹÇar g}5\8q G6mE,/^RnQf <|7O>D"=paJf%ןYHIT/s|o"""*R)`aaBB,a;T L&L2>zmQLѣGC:w;;{ԫ(uww_ӧ G ŋXXXK0i8y0Ϋ1/Z?~ǏNZ5*KW B>5$ &L֭[Zhc4]da}hۣg]CbEB *JsԘ#G`P*;~Ν@"*ĤR)FY>^gy%zLMMajj*|NqHDDDTR)ͅޯxR:u`ؾ}>;~Ō |BTZ>旳\s533C*yw1b8|"<<<`fFzG4fZFRREA_|7&0aB8 ߍ6=avՂSvJ֘9s5kV}Qgmm #Tj$ KDDDDzm*FKΜ9 ..paÎ! Cpp0^EFFLMMQBԭ[Z%jee#,,o'Td2m۶~_pjU!h#۷ow }8dee+WBFФIc@^Xv5:gςP(^XB Xn5݇A*F u\/1w9s.\DRR"\\\о}{4lK,v///dd(M_ٿqļ#ԩ߆eTXI/L& 9C=Td""""z ILb&qw3&` .&&2O^o*fP Brr2Kajj kkkDDDDD"y@%nYT9nj7-d%""‘dRDFFJ%T*HXH$HŘcQc0JDDD‰pua0JDDDDDDDDD IDATQ"""""""""*wQ`JODD&88@DDDDDDeTrj9Qqss/& Q`DDDDDDDDDT0%""""""""r(; Fa0JDDDDDDDDDQ"""""""""*wQ`DDDDDDDDDT0%""""""""r(; Fa0JDDDDDDDDDQ"""""""""*wQ`DDDDDDDDDT0%""""""""r(; Fa0JDDDDDDDDDIi7U *T`p{!33 VVV^"mIMMEDD$J% DR"z=Frr2ի T?Ddd$ƦZHDDDDDDDD:@V-_Q\cݾ};22wߢa4բNNΘ4i2m ӧO<|͚5EǎW\ ͚5+vQ`Lȑ>|N8 JN:\_"[wi7#]k_}5=ztٳ~.^M~ƀ/> 00-Z;wStQE_qy|T׬6Ʉd=+ ZjzkqlmkbuWEѺ IHȾL$Y~ 9d x3=||T{}֮}ϫ,3 J(f1DGGƲo~֯Sv,B!B! F{0<֭46nĄ z\45u}oVk#:NY***g3UUU:tlذ͛SQ!DVZZJMM-:0{I}}g7/ !B! F/`/c24i/^s 1mϟoQM3;t5d#___lŠvR=66S#۝Kw^aa|ч^ezހ؈RnZ)(8K|||cǬY5kSN~{X|\.!, o67nbx틉ayzUXX{L/H!BqPvusgV{yG{HOOg^ÿu:^[yl–-[[5(Cz-ZHnn.NN7|g  Srhiifڴi̝{~5臔;2d] :{3g xWٴi SN1w~\}r &obhZΝwgdddgsaٳg2e K,pzØ!$$ $$Lee$..nxHNN&--!!fw._UUU< uuuKUV2ed)//gteZ[[),<J`6#v!jkkf|c<[Re„οckkkF'7r~?˗/pʔy5=zLٿp}a_ /g}fSCCCя~ҥKvߤkߐ!CxW{9|Hozc45u//9rGR:=C/>'fŊ[mB!CMV8msbke0)/~,[#WWג%˩Lq 󟟺1wSPPm)='ۇZ˅hZGfEK={ 99ޖz˕}K9uyz;C{M>}W{ F?c?uZi4yQZFLbەkyB!W'9F_ٳz)B!CMMMJ( 4{5J(j2?FjWnGGGeF tM^bY]%2d!ׯ7^S+JЬ^%Y~;vl[ /_Rh4{NerGGGeee 6x???”c-B!^&~~~<ӽ !jѣGu-[΋ֵg0^8rOF^1>>>Frw8).}ۿ6/UyUݎW"B!B|= !}ʕ+`~{ baKb0vms~:Nvة7lذx'0|x"vO~ro:ʽ1͛kWƞ_SÁFiIDqyy9Ǐ`pOٶmi[TRV3tK_!B!ĵAQ!香,Z>,]z3󣪪\N'wq;mvރf=1|p ??orb!)i,j2228t0|FEoaXhiiᮻϬY7BQQg˖\`4[mSb0*~?̟?V> … *B!I0*BQ|999XV>< `61n\ȓ1 'd,]I&ifORWW?!!!$&cI^m6l(k׮a˖dffQ]]V%<<^{ѣ455vW4@~^~%RSww>***PTDEE1mT&M5,߿P_J!B{zdKqMaӜ۴6=LUU}msq}Y,;}`zTTTR[[CDDK.T={r\.. j;NII V4hIIcF9rETTcYd QQn=44c0)ul6L=!C_עzKUU6 @tt4))^=%..B!B u:s갹tLJѫ=BQQ Vk#>>>2|p:կ0NGݸquuuL4 ^OFF&| +W@pD6%%% 2*++b0w-;$zmdgg3bp FUS[IIIϏ|mۆVeݞb_<`Ĉȑ#GdŊo]0B!B!}=p\ʜfWڱcسgq?@EEElٲp Ӽ / Ϟ=KYY .P wגͨQ=z4SNQ?d`zY466{rŊ[PTWtWp1Νˀ]ihhӧ3lPbbb=Zt%22297QQQiB!B!$m/a5Ghhh`ʕL8pSrhnnd2iSr:HCC=FDƢRc۶mKDDDlRN8AFF&˗/ZA<ȑ#ذa#ii6m}HڝjT*JY@@&Sg`400ӱP`bq Pvw:-:w9N.^B!B! F;(//r2{lz=F={r ƏOHSrشi3 Wz`a۶m 6ɓ'R^^hm3qDbcc2e2wo@ ѶZ޽˗eF# w2x`.*SpjQZWQQQIXX'qDFz: d $22LL@ˣ6tt:illd߾}.IB!B!}\.fϞkZ9~8&L`̘рghwCCd(hzbc>Fbccq9rcハk#G2d`%-))eϞ=444lodĈ?~QɄb(hmmjhh$&&~&+yy L̚5/Xj2ظq￿FÌ3Г 6QTt`3-j(!B!BKRP<+ɻ\.*e**++q:8jj,^ut:g<سzSS1,Z`233q<fTT4tOˎ;jcNNGv;6l '\vq:̛7x2{lWl۶fΜɢEHLL$55ҎLs&t/* (((CzٹsgY`A_:݉ZWw>l,/h%$&8@EEW0}9u5jee---^[B!B!Cz^/v19u* ,8JzJJ26m"5u1fhh[x̙BBB!..b HHHԩOrfe^ IDAT7+mmm"l&\.2_gLL7;1LPUUʼn':t(CC+VU9xzMmv(++΅u܌?aa 2ÇaFr $$ϰnʸqIrLEEg:urرUV)\kf;DDDpM3HKۍn'4:ZNߟC\OB!B!+$'OBpZZZ0L̙3GY< ͘1q)|}}INN&)iRl6̱cHOO'<`ܸq@kk+9rPvSB1B!k*y$Fh4fͺ<>Ν;χYİa5jd/R!ĵbP JB!7Y ^$$$ $!a`o7C!DOy%#B!5Mt}ZB!D1l[m9e@jۯVB!Ne* Zl*J D% ~I0*BA϶@ۍNh4(oB!=k``۱T*t:]T___$B!P8NF#z7&B!DRh4FFp8XMh4t:RG닺 B1 PT!BDb2v:}B!DXKK lB!׭45--- FB>n__lB!=___n FB>rld !B`2l\n$B!qv477(B!WS`` r^}u*}kk+vs p !Tjt:z^YEQ}mvSB!FS[[`E0t:Z8!Fq8iiiFF톉KԱnyEB!%v|||PTn J>?: Ewedjiih4~wJ}~9oW>wWN瞿:{*kll瞧G!B\FBt0t:ihh@ !pЀ톈On8nB!jt8V ERZpO6vt:0rvEYY:p4 Rgt=jLJ0ϟKaY6nbժ]^kÆMl6bcc:f#**Yn"(Ȥ۵+3a4j믿I]]{SzΛ7Wi˺u(Vܦ&'4̈́p ӈ_al6NARjJ^{u/^ĉ`4;v ǏB!Vp:^.02|^!VY\^v5dzpy|`|l6iiXjXj*Oo~MJXX6B3}Zmܖ(ߺu}1wyj\$'FffϧJz-7{HHHRRFU] . ""|v`X4i"&||wxY1o{WS6m*74³lڴPbcc.D!B v }n`r_wa‹] U@qj=o裏)(($>>rORXAm8.mNy( L͝;_~'O8 z/JMM-ARn@?0}y^^>ŬZH㨭%==9sf+uM&ӧߨ<` 6a=z FB!C=6`TsBN~ W F曗w*߳gee  u:%%%ʑ#Gٽ{$<Ȅ .kMB GSSS&%TTĸqI444}ݻ`2³b29\;wӞhd2tN5B!B\ݾBqF<77N3""3^=1 흆gєdL& ۇ=iX{^b!<<LuL^pvArv,˅쵵_xI!BNnt|}\1*B+)%%cǎ~njJI\\,[~` ""Ξ=K]]11!fO'2edT*ii d!ƒINi "23:l68z=fs0F'AVFB@$BKK hZ&Mxy_X!B!5AKPVVN]]DFFX2t:=a]IDee---t F!W?+VΝ/t 8oA?422#GrQ̝;G_&OġCٽ{Zk׏CkWVHfϞaڴ`Æp֮}o}fbcc;wvn8NQ,_{uFF#$%BB!\v;DDDHӢims*˾ݵbZ~{G 7?g||x]'..oCAAB!B!FPMNiʈ쥖D 8|(J(fDFs,##E/`(**jmv)]Ϟ=+BovB!D>>FRSw( ӆPSSÈûΡCqB 45ٺdE*===u.xW_}cIL&9ʯ~.:UB!B!}ޖ-[P~ʚ5}{*j'x3gv3E"rLV???bbbίt6`@<99)agW{v,*@ӿ?S]!B!B!;l#F ͘1x-\.._Yf*uϟn^feZ^^< ҫwiqq |FwG2X,|G]UnY4T/]qpl8rr:ehow*B!}JnU׸ٳoСCl޼Ǐ?30aB2_(b):'|Oz?Frl!OxB5z= 7LatB\RSw?C;h4Kx T*:-ǏS{ם[Zp8I9gx4h넸xEEX,cݺ:Ξ-B_:cو$$$J5]!B\RSwVy$B>ZZYR&!A)3 w݉)t#F׿*ۆϻJhۄw{|lx}aƌU?)݇@Ѱl~z?~˥;aB ㄆ^{B!׏p9e%O0X,N< SzWT>[Zp77+]6nf[Xԛ+F '?o~+g'?z{!?f zrrr8}:W9gCC#yrسg/K<üʿz^B!DqnI0*f̸?ի%1qX.Md$ӧq>nhpp5 ws ΜS'>ӧPGFfӅW_{񀧷hhh(S^~U~;BOছf*HHu!CSOz <O2hPB!B[$B ޺ w\,uS8}O>*juh Z[ɏQ1-~ϴl!ؿQQJ(&9y<11ٿ?])۵+0P'V3466rPI0*B!U !VwwMqJzb\СƫNǎllkעh0Xy+*ƍKr_TTWB͊Nsv'|͛t:QhZeNRgB!B+K Bv` ݆{qdus7nw@;hUl`Z؈NSzzxs>y|6mw{T!B!zmvst.'GV@DD8ǏCwkjj)((`1^%lٲEѯ_׾۷n^5{?~*̛7p:gͣ N`` #F gJR6o5"""7on(((7رc`v;Bn7111L׾odj/;N;~gn.={Yq [ogbC/)˗/e}_1Y 2Μ)`Y[J9sfsQ֬yW(_@k m}_7\y))֒ɼys>[B!Bv;E7lH`` SL͛0rHƏxX;߹MezD6'NdcZ52|py]Q]]Nc8h49S]xuO 2fct c„L7~zfϞMNiѓL||Gĉl IIIfLJq:]DFF0qD:lذHRRR^c98Ne/YpWy1S&,,SNx\~BCüڑ餼B%񤧧cۻV@`r] 2ij:FwL&ƍK"((H)S<ԓlٲ;vQ]]Ͱaø2{N_>clܸj57xsV,ZP>|UUU׿>GPPEE^St&!B!$m_Nl6ӿ4.QPpVy^^^Z]Y!!!J(ő#9rTVV~^VRR–-[gԨ477d)MJJ rg6nw*kveV[HIIArb˖-w>b6m*yyر*,&LHoMMM|2i$j5GeӦ,_Tplfĉ{ @CC=gΜYֻXUUU=~nlRYYcݮN2Ln:ڦrT*L&uup7BՌ=^zSJbΜ^fOƒ4:&0aBJMB!Bq%I0ɓپ=TT*0|x0<<Ç܌`!CqdNFCyy6#G1vFx];N<DDD2} J{|}}ٺu+cƌOoތ7n=bXj gݺ9q"[ п6Z JA IDATYYYIaa=z y{v\倧fz t;L,Lj#xhl؋B )++4h{yyyT*b{ɘ1j!c0j5FEEs WXX(--ރ}qkjV{={w>njl4fh#ϢG ^e{6j4Nk4j򼹹)f|hRMQQ1DDDPYYIIIR NTWWc2v4 `*** -t:Gcc#UUJYKK e}󶉌??N6tחFt:jƫWlSͫmDDs)7o`웺\H|N5 9S =8+e-]B!B!ʐ?A1 XVOR*#SQQɗ_~ɰa;v5N8A^^СC`Ov|vyyzaܸnØ>}:$? ^q{*>~naPvr.B!B!ilU7uMsnӞ0UUYu .•5>}WY&=\!{XEo[VuW*VEoZ eZIF6BH! ` x$}9wOǎu;, v*|uAI镦x555-]UJHjқf<߄HZ^ll nƲeq:k0L2dEF/"WM6]`` g-]HbZ[Vv.CDUMZ.ADDDDͪjڵ6 Uw<\(զ;FEDDDDDk(@PP&S~ "".BDDDDDDŵTbZNݕEDDHH iq:%aaaZV/"rVkߕZB/""""""Rnt:Bhh(8Njkkx~;v|͡CgȐtҙlٲE:t&ɘիpn7III >̧iA555 ^̞=Ə`͚0iDL&;wbÆLDLL ÇrHMMaذdd?rκ 0|;bXXtK, S+**ӧ7AAA86m̂iXNN馱ڵŋp1N8ɰaC)..fݺk׎={Ogȑ#X,dee3g\{1FDDDDj`TDDgǢl~wZܹ8|8d;FN:_Ի-^/74??? Ϧ@ƍsN||׿)..!""OIIfȐ$''[oo~~gA1?`7n`;>=Kݚ:DDDDDʧ`TDDX,q ׭[4?v#fqQRRf]:,BEEE$@hh(F0w,p8R`C6|'7}nRRR|j &&B"""""W"""ml&11AO0ZYYIRRもg8Nv,~3jpI̙K׮]۷x^fuι,ϳQN?p{n348WDDDDDZ7"""Ҩ WUU!d"-ii(//g=]Krw b1F_iiY_Smpn5uDDDDD)=Mee%77LѾ}"AAA-Xٷ0`@K#""@ll rtӐ_廊#v_>rrrڷO$' !!l BEDDDDf FOAlftV3|wv:VZ$((jkHr:LII &ؽ{K.@޽y_EEEaƍ>svؑ?<ӿ>=3g.>eel0ѻwo|״kN'SNccȐ!nK vp@<?]!4&p8ܹ Dll EEDGpwKff2Eiƹ:v駟Gx^nznSe˖<o ?s懼;v,z a5oO= ߍM1^,[n&Lȁxw/ϛ%"""""""r۷o?ǷPeҚ(mXx P~e8qѣGADD'眎;yw9H޽~2fclׯI3c1zOJjOPP Sի0|0 dR /1vʔҋ{CDDDDDDDDÆ %#cq,$$Dφ&S0ڈL233{g\UU׃TL FY~gaA\7t:IH yܮ]>|7o2nM>f3cƌa_еEDDDDDDD$>KnzY3X,<>F~mG?d2a۱l>:nf3V*k=+^}u*ۛooGOg1kg v bccF"""""""z-_>p=wGleĈ-]" Fa >7;cq̬gyV7K8yd7p=:ubyYA-۶miih.>cƘ+V\܋i! ERR0q֋RlʨQ#Xh1r3ENl޼FaF6ọ>FL&~ /gyɓ_s$%%QQQAnn.L6hV+O<8gCZZ/jjjزe+C᫯V\ηIDDp`Zk:rqN8IDD1 _@~~YZ\uM#G&%%sUWWs!L&:^ԃ9p jh׮!"""".==E7ZE- /?}gMr㭷e˾bӦMȑ#0FzaӧOo1g\ h׮.' _ڵRbbb7ckjjx㍿B?<<ᅬ{';wӧwk1{p8_^e%J9zɯ?wp=g;88&dROu|W};^}5JKx$&f}+Le!?vaC& MGr*z/* b|=lP22V^haa!!XV,bV8@)P 8ש>#ڟs]¸q71ed=0sQ[[˄ 4Vh8{lV> …2E""""(p>s۷.Q?[MIDDDDZƺuZQ o6}zY 5jd...d2Xxx1|sDDD\"""Wk<^vxvzc'"t FEDD,3fdԨ=33ni ~~~MraX=Vr;paLSx^N'~~~cZtRQQf???NQ&R0*"""M3l[krmps9N:Ѥ9DDD$^Rns`|A x 26})--U0*"D FEDD,ß8pl_ZZJFJt|ALH ?13ѣMCDDJzq 4]2d***0v+i""""rNs lR( pbN-  >N[ѵky9X,\wݵTHK2LX,,X39s&<<3gdƌ,Xb=*""`2***"++˸(Nl+mm۶{sLFJΝl g!ӟ^lR( 0o|v;ƍ=fx#ƌ3)**2}w:Lx6d{3|-[uֳ~FEXXp.7M67鵉\n&04hݻw7>V+ݻwgРA`TDJ ̉'Φo>gSVV8r(MϞ=ILL%;;BB/u%#""^/?(I<@]`˯P^^c=`bcc}o9?1s{nu73{O>)((`] :ng̚9]t&>>'O%񐕕M||<|طoow߳g/oS KDDDdnӮ];jkk,"&&͆dR0*"D FϡXc֮]Ojj }&44ő#GXl:t`!mCOvϺ4r]{a>2>Aac;7 ]v֋~N׳g| :ߟg}Α#y$$$0iDnu! IDATy|䤤$z u1c:f}ΝX,0iD|FDD֋D~cpЎ""Ołjl?\-Εf>YN5fl@؉E[ß+jRSٺuu]Ş={&,,~s={ٴi3eУGza2ؿ˖-g<{mΝdffq-Ю]T-&::!C7}DmFuu5qqq :Pc9t(2ILLdcrrMII)VpHhhHK|f3lؐɁ!""rXf-l6ӽ{7ٲe ?}g3XIqqi.V^̈́ Xv-EEE 4={u.]uU0Lddb…}]UuTWWuV-Zle,[=z0p@<7ǩ?;9s0frٌeEӷoBCCٻw .4Be5kֲg^ 0mAQ3x|xfԨF#Gfo~vQ#}̤0Nt.W&a?M6p"f3;vdA>!=Suu5UUU;>^Խ&Agn~TŘ[DDDDDDDDb(=@no;L&v ,js^xHvyy9>###AXޞp98^VVdq:k8t(5kְv:F~ eܸq ?4((Km󊈈\JZJQQQX,?X,XV"""Btt;#t>も(++ *߀KJ)**6/..h\Zv9c)++7^l6sn^ѳ """"""""r))=@zAff&7o,[Bcׯ/"==\ټy ]wqh[n8%%uCwx<rss9t^={knԸd?9,^:w@bb{8p Çٺu;w䫯ٿڵX{8vxnjjj ,X۶mȑ<adeeuo׮]Yv-'NtMqqO ee?ٹs9-o`Zضm5551vX];tHeĈl޼={H߾}IKeo߾رLbcc۸kXp!={ 22A|r<tޝ;wRVVƭN 44ħ(:w멪"66aÆwyoHYf _~YMv3f s#'Wt5Od21r֭q<fny疔jjrssq$%%1rDDDDD`TDDD5o|?~bjjjk֬%++>}zHAA+GƸ|n7r3~~~PPPlobx-ZwO2έo>p8ظq-}ڵkѣ(,,dÆL<зoL&+W"=}%ƍ=5w 5Bff>ZX#""""r5S0*""" 8ʸd"++sHMMrnz`.O``5^/߄>;~M>$$;pnLd2ѹs'cM3^Ӻu`4;{#&M4jHLLw{ """""W"""Ҁ?!!!XNi$% 6Э[7RSSʦc c#ylؐIQQ[R`2###2BѺHjkk%55էbbb(,,T0*""""rS0*""҆n~x&;#5kֲt2n7 9votJ#p htn?`vu8={ݺu_z1\.ϹY}p8صk{i>fz(iCoXee%II###[pe5̟~#t4 BIo] ⦛}ezzu9%8nk눈ȕIH;8z4x\ILLlpb!99 #EvILLl6SXX 7\Ijm8pOJjρ9c)ik!]v!++sѧOo)))!++h:u-cXIn] lX,ү__22VRVVFRR"+d„udgod}tJn6n׷o'"-TVVKNBDDDDDN FEDD¤IYz [lp@n]{FÇ9BTTT U&QiNE{Q^}/1ht-\ FEDDDDDDD䊷lr#}ᇸ{9#VHl6 3r䈖.SZ""""""""rE[l9=7E>fSa62e27<˔VF\ʥ`TDDDDDDDDH ERt"""2((8j%:-]ҷq&ѣ{Kyy9{a2.su""""r!VH7B{xԩӰX,/pMc[Li1+|q=wPeWQQc=M7eҤ ѭ[WzT6nX,Z6Wq݌1\\ FEDDڐUVOO?I׮]paUݻK<'ƍٳGK"""""Xaa!Pw?IzzBQiV FEDDڐY>Sԩџ֋ncݻ_g9Nmێ?\诪b]$%'&&xHMM!((klYY[lرB7`۹k/uHEEEDDDԾ"rrҥKgBCC" 5UUUq!HII><99$>>fy""""W f3/BQi6 FEDDڐrL&S;/?fٲE#/~0wGڵW_/~03fR}y2dOjkk:uSN#??u?8.\Č39#~t|0&Npl믿0sXgmYYZ_"""mȃ,>|g%%%=z֋1cF,ӧ7f,`tѬ] 2`433kV\źuy'}6Iܹeee|gguAc"??_cߓk:L*}?w/8k?g|SO?gfO;>uʕXx2vLl̑LppO> +V3f&p FEDDڐ>=VHgժ5/aE/0lPѣ;6duܻwֺLpqO;:tm]o4zǞ`m>}6doCRR{n pu6XߥKV^c|~N'II^ ;\b FEDD̨Q#@⋅LW&Ob,ׯ/ӧٶm;~tֲhb***  v;6b!<!!7x|z&Iw6yO?`ƌ v"""""r(iBBB瞻9|0|2͛i}0ŶmرQQQxc_<ggxݎlfٲE2R󣲲cl6P۪t̐!m^0|iՂ2zFVV6Y]qqqoHffYYpl6nݺx]~ [Pz< zmҹs'rrR^^_QQANA:ulׂ uKEDDDD(iCwj,8üy 0Pw7u]KzJ}zeקᥗO?}:bcc9~8{쥢 \XX"{vE'wq֭glܸ]w^<Ø4iE/??In]IMMrh>obcc"""""KH&;{#ǠA7&:vCMbbF~}; F7h@fs;)..&&&0b;edf;wqa**y>h>_n2>K/e˖'Oҽ{w~3fE;sF SSSl`dffQRRBhh(Đ!}*"""""L4rYO5;`N(Zzxre\U?;=nEEE9-\HەGppVb"_˷g@-\TѼzzƨ9 FEDDDDDDDDQ0*"""""""""mQisH`TDDDDDDDDD"""""""""(6G9 FEDDDDDDDDͱt"""ra[VOH+%YUUU-]4-6G9 FEDDDDDDDDQ0*"""""""""mQisH`TDDDDDDDDD"""""""""X[|VZͦM aC&f׿M0s+"""""r(icf3:GDD0{O/jN˅ժVH롟`DDDڸ$=}%vÈ4ywkp8ٳɄ[u0q$'']%"""""r FEDDڸ z̙3ðllؐɧ߿`㼍7q5=ۍO>ENIKK;5g`&Q0*""k;% IDATPZZƗ_~j%99}sq{;I۷Ow[2d1GXXÇАbDDDDDD.Q6?nJhhL^Qy_EDDDDDQ6j2q@L&U58'00K_%`TDD1L$$7z,((UU.ui"""""" +G\\eee;VhUWWKbbyϷX,\KYH"""bԩ#̟AbbЫ =?22`ۉf]EDDDDD.d;n#!! -Z&}?h@7o>~ji˜N'xޖ.EZSi3Tjv8Q^q\JJJHIIn2*ۍ墢LDDDDʰbE:SHyy9:u_"99^3//V+ My@)P 8ש>fSa62e27<˔VF\ʥ`TDDDDDDDDH ERt"""rUWWsh>$$rֱ^Ǐs """lVEDDDZ"E{Q㙢SNb߿M7m2S0*""҆Y7vA_=BF_MM +VdPXXh􇇇Ͻރd|ŋH򗿼fĈ EP0*""҆XO~DϞ=0L[?~!TTT0g  rgoI@F3/ŢTYVy^߿_6MiiJJJbƒp0PXPP555*/P jf`8Phޣ,өj~)Q86{*Hd`Hi2 \.nl6[?Fͅ6M  B BQƕ﫷ǐbbnnjm6 %F#uCR&&9|@;_h}uOBю`8X$枣 FY~3 EHdM=(q*`X@\R5(q~@Zw#3uG0 @ԃ[SC&2Q:8!K F0 H8(C0 H8(C0 H8(C0 H8(C0 H8(C0 H8-nݷ{0t'065kuտ$effj޼-n+$Iv.`r+:` $I`8_/=jߌkԨ-r 7ߒ$ :TS\"m MzSoSޱ~ (qny ͛AZj$v9kTSS#Inpk~$cIMr?M1&u`8xt2IfS0T8֧.- x۳g $IYիyl fsUV6eeeU$ժN8vة*WM ڽ{^z5R7o$v :DPH;wRuuuk+?oӦMfHڳgpj6Z{TSStu޽Ap8Rrի\.WTCIII ÒpXJKr\}N:_;dZO6HL< >p?NZj^>S{9Qg~Yo$/7׵7~8-Y,\7W^z'cQΝ%p'f^zE[?p@Λz959shT~~~kB!'jù߿kwՒ%+))ܳ>m~饗4x`%IYYY6~}B͛.l{s'ٳ̅o߮+9Y+G Zc͚u&I***?nz-\8_/< ݫ[`8UwѥSN4~8I桜7&?`Hq:tTsݐO>5_3Z;v7߬RyyrsI: r 7\g:zFqJKKy?%馛'())I:I:S.S?}z_~汽{u}@aQP Ђ ={4ü}!ڼyFh\.kOMM5_֚#sJ /^x۰hrrr: hWTUU$i!ԩSgG>yy9%@}hYf|io$SP_::QмyQ`nWwxw못(P0h |KIҰa4e5 +##㨮SvHٳ9F_zeSpX%%%4HRn]PhÆpH ~,IZkUWWAtݩ rssC4`8SRRZj5Z[}?%nr8pfժպ+99YK.kzuYV#JOOW߾}<W\qr^|q< ;QEyy{rJYVoZPÕJIҌј1cdZ5yyMRX,^mۮÇoV鬳ԯ~ wڀk驧QNN^y8#~{9zT^^.ǣ_:䓴gϞG+`83G .]Oj0,{n>aUVVF~Hs&Nc=V6lt ,wozcƌVff<|>^yUIҩr4um6!|>\ܨ6a0>K>$iݺonݷ&QI_Hs!IZ~֯orȑ#4nX}Ra͜^zi!:OSUGw֋/6_J}OrWHĝӰa'J&N\v]\rV^-Iڵ aGW^t:5~8{9hzzyd=̓z9ڱc9;[2%%Ŭ#j=?i1ڸqʔΝ;cq*33_233KTVVf.0Yxc1C欬,saɓ…kǎQzzIN3NvxMZZ~_C %\^-V׮]~J`0(Ir:;,k?Қ5kURR"˥\qQ _EԽ8pwEs^,6!.)ȳ%_ӧwt}2I~I>I[ndh4le\ H8(C0 H8(C0 H8(cuuX[aĺ(ġHdЦ L;>Q\crͅ`55䱺ah䙚" N?B5|*hwG}abaf J@q@+n))e>h^a R;QL$ JJJaei@\2 CVUIIIJJeZe6 (q~(~79 =r:fxQlJOOSEE|>vĉhmml6nw,@; UYY)ߢ  zJIIivax/55Eժ$`,.\c0(UZZcK]v6G" ΄B!y5 @4 *//W0G·E0 @ @c~ O?t:C;QڱE}>Y.{}qf8JG0 @URRR@;ޘRC{9III"E  ~Y .] [ ͋ 2 #%kCP0<.'đv $4;QH{:x&8Fhx`@!pFhFnu9@Bm|  s(hҪUo(Ĵ@ _Ck֬mjɒ;Ж-[U\믿w޽mZ|>}1@駋4h@Ѓ/--U^^^hn/$m۶]6M(/W_ E%iӦͺ?k8<3Swq֮]'+׫5koS3g>1 =0Էo6fԨNP>ZN[lվ}ԭ[VK.;FTRR[nvܥK.<}jj[4fV%K+^zEk.Q Iڹs6n$׫N:leVرSUUUJJJR~}5h@a[bvة.@6lԎ;եK&%Iڿ@-t¾}%I:[h:miW=zT7lبoNef6>Dl*(($-[֮]נ͈Õ wUڻ7_6mRiil6w*~n(ŋ?3sέ^Cqq$E:uKwmڴ9}EEzaV-bΜ7׏<2]PH7xS68R@_\K|TuM{k ڵk@@vޭ.](##Ceeeڲe=l3-))Q^^}}~G^W|J۷2 C.K=ztW~9C{)@L͵Q IDATw:X,Zb&-qgŊ )ѵkB!+33SYY夢B}>jѦ|-_BҥjkkKnݷҋvzб EuoӢEeZuY}:uң>`Igaj[ٲe: 5:@8$bjzɓ3{2XR_|e_~D{ѹ瞣co_f>t֯ߠáfggkm0iĈZbwvпСCk.mٲU#s}U\\ElWZZ6mڤh}'#l1b&N`^o/u晧<Xc3ku饗^ѢEeXtөڂ׻ᆵ&Mzn:uڥ SIIImZ:_ mܸQpX'41jxÕ{0ԺuߪQt LLMM~ĨQ#|-ULvEd>U[ך5kJR.98pn& +a׿aW^K+zb讻OzV{ケzך E%izmRS?N7K6=FHpEEŲX, * P.]T^^nxJo߾F'zQ#[w\2 CUUU--Y, |VX2ed+hӦ۷RRRZu߿_PHzca8= zW$I럛쵺if]{ڼyKuj͚ͶٰafzQ/ڤ&t,$`0(r#z@ I4\sA,fɡCjowi„ڼy|> 9.ZWjjm 0PT:Q:7ٮFR?zqƚ 4g֬%p`,ϧZsm%vkԨmVHOOS^aF?N}^n[m매( ZϢEP4R$Z;us7ߒ$]~ϛ}N&uIUWZ9sԦMe4h@u钣s?0Hz 6h 6Dsyyڦ*++K[nUYX?[!i޼jjiM^mizmڴV?`0SNq E%)==]:䓚lSSS>Ba'auرc]n0*G}>jFHp _/_|)Ţ>}L-4LEz)z7s4vuUTZZm۶++F~uvh=*,,Rr[a4衴?WIII/%IC 9캎ѣmv}o߾r:*//מ={TQQ6IztӭZvvYZ~~wTSSC{nmPyFqFHpVU\0Y}O?]$Æ@ `=/şieggG?tĵL0^f~IZ:Zbz行#pnhѢϴlZty,%%%w./LEwtW^>wREo%I&vVį'>f4Ym6C]RzQgY5WW\ii2:ȿPH`P@@ѣVEJMMmt|II^:uʐRii*++գGF盪TEEl6RSS,TRRF?WIII)Q bQn]W))) ׵if}:3t?:{zUTTNQ+|>*##\>ҶsF{֪L6?:#ǚt[nSz`*..>h={~::G`4LSL^^RRRedZͩᰏT&/')a lA0Z#MDoۧ漤YKG#E0ھE0o Νk.5P@~C;4wrʏ۰"oY'xJX:8ϧJaAuCѺxdZ[<0$38]^zqbP ZCIIfb] HfOQ}DhDFF^jjjZhiirrrZO$lx m }Ԩ{nܹs6vVQQp8;wi:q&eddDڹsջw/sAA^H#ݠǣ;vjc{o>g`0m۶+ )7Ծ}MEEz4޽G6B!رSUUU֭=_{cNUWW+7v{k۶ܳΝ;o>gee9k׮VonyF@y=͘JKK}cǎ~{Th7k zC{SIIyUӦ=p8_~l?{˚5E >LO=駟{է.$}=t޽lctᅓ? t2q]:6λڰaVnC_\?TXX(IJMMԩԩk BѣG鮻hbN6n$W\qjjjksgDzꙨޝGҝw-7C^? sϽ*.fA\r$̙I}sѝw퐿71CFZ:\6Mn[555`Du6Q:k >PwO 4Psg'Ҕ)iY z\ԩj„fX7jH+*--5uZ|n֭V^W.KbJw`haaƍYݻwWyy~%''k{=twGIIIԮ]u7)##CӦݧ\^F BիAWEEt_ӧBjx)v_4sfZ]*((mݢѣGi޽z*++op3c=s9[^8YZf~xK/4{E#G]>8]&W4e54hn/>}N:iA# JIIi󦦦U{^9FH@K.ҥˢ > FIc>0sL~=;]pQo4n}Ԩ=eXR&rmڴYojժ7n9307yy 28۷_oL \iOKKii4h$w^٬㎻~ 8@woԾQ0ԣ>[cK_={׭lӧf&Ogf'鮻0[))ɺ֩Zh&M:-s\iQ$ۭ?a>s}'/琾@K B r:-v^ө`0h.Ԓh0ls!~."]}Ez%޽:s O?W4FO>>|Vz&M:M+W~P( /w}_˗иqc%R+,,=222TVV&`zF~ 8 E#< } 7J;vTeeB9O`tŊ>+ݺuӈ*I˖}-ϧ^zjɒڇaIҷ~ 3ft޽{0 h 1 05QK r^Q4WTT$I޽{cٲ\uee5\vkcͰpӧ5r-_Bb7r::t;vkIVV]Sm5E#k}cui X,lfxY"%''7:k.Q/Izzŗw8z~l6 Puu!}d9hK3ՒZzh>(TUUotjm|F̙ϩ@+V4=R|˵bJp漥pX7t#u'=Sz晙> 7VTuuuUW(==cu=t-XP7'vک̔t UK$̬ۣ4*}ěH0ZlQ`0h7hԳ+M@"ӧVSȾs|#G$}Gھ}9\~Ԩ Bzwo>$ݛݻEYP-[6j^^9.Z7X\r6UKnn?Am޼%j(ƍEz.[!q8"8F i,-yF!z4iiZf_^^3bѹs;p8/0 1\޽{_=hFF h.~'/s*++SO=c >C>GNڻ7_澝;wuYg0 ?E/F1b^ŋ?kpUVk\g]%))\xwբC|b(=hছnԆ twiΜ7իӀ+ѠA5Ro9iDrr~s+~ז-[5iiZCN֧.ϿK_~^z9.K.\FTiiVFgy}o~sf|N\r|ڵ[&ח_.1O]wnV 4P}UEEvܩ{3OK.լN:Ǟڵv'V p(n ÐbQaaw~~еE0 @IIIԳg`fffϞzGVVuuN9d{9:QmÇ5z?U8֩'?9U;vԱpo^ r;n:y<9mΝ5|06b?7|[K.SUU?<]v٥sVvvPEEE֌VFF>SS\G-Tyy 9N{~?X,Qs]/>`/_R3ĉj^1\}1;N=z׵g*((htA-erdZgKٽ{,K9[\n{]۬?l6$"2T}uJP(`0@ #ƕHa]|ey5(׫={Z*;;UXXm۶+9-%ޢi׮]E0D^^RRRedZF81$%$~؂?lz[-`9FZ؎;+ڵkN;'1 8Nfth-*((8h-r:fؒcjjQX7 |-͟@?T)))ڲe6mڬh.Ν;#6oޢR~m߾CJJrp=E[*u:D0 &O>_IIIڹs Իwo]xΡ,tp8lN UXX}޽v=UPP*utphoQ˥,# (@ ?WS\2ڔalڵveZU[۫P(d wtphKMMMUN***TUU@ ߯p8l6 CH kZr4? qDu}!@?;Nu9jѺm4(ZES=^'ЖFЪ@Yb]8tF(BQN5tð4X@2q=a(qj2TSjuh!өX$r9XB0 @;Ww%Oө`0Z_JmO`@N3`8bZp8UTTRRTT$P`8v U\u)@BxJ vc] Z(XdhNݡ:v]n[q@VQQ nNl.0`0(D~@b]x<UTTr)99!3 Cp| ÒIRee-2UXX`0(e>{KhB0 @<|Y,jjlJKKURR,, QyyVnn7wPāFd9NUWWTOٻ.V@"  EBOŐaXrve٢G^E0 @h.lJMMU0W(R(6h?~_=,,vvVka(h@0 @i,l6l6\&GBxv|ęzp 5\0z"q(F^7w>@"k*ܬ`FSz@4NFsҺ;>Q:ܚR4 Yb]5Q `@!pF$Q `@!pFɉ IDAT$Q `@!pF$Q `@!pF$Q `@!pF$Q `@!pl.߯u5O>>=C& p, eeedd죴(֭ &&ljz Evv6%%xyyWrAHH%%%dg׉V@vv'44 -'"""""Q _CCCFmRaٲ\~e-_eժ8cw/AAAy܎YS.qOo~nN]{ `o/ifnV22wKp:dyFϏ+;-)"""""V}WӦMeڴ?E̲eBQ* 0LfśfY,~W_[( =<.]7?[=_7|-ƾݻw+7^^c2'44LYY'tl1*""rڳg/n3fhjjjxgЭ[7{2y2$jlيIICXn5{֮]4Ɵ^6mo3^?}L6Cͥ_tq;sfɒdee4ӧ}M=FEDD.@[b0B=z_~-܌d"?-l ϴ#G_GGGףGJs+c||ZOƇ~̡Cسg{1ʄr1eӾx)^?Vu6 IrŌOSUU^׭Pfs[|}}y睹,[YHMM 1-l/c޼w(++_W7,Gom9櫯fw3f4 {ӌ2|~)|ͷ8=8t¸qc1Lne__j/_e?)|vŮ]ZԩS䓅8N;w:OsC1M&ϙ1ciC^^q͍=͡*""""""""1[VkMJJ C{_fμ%KIuu5bҤ9 ϫA~~`ۍz49p`~|-۷ѣxyyÔ)Xٳg7igI&Ν(((B.]>|Ǐk]DDDDDDL;nm66` 7 \|JJJ=\WCDDDDDD.6u8P u㇭ls5i5K"""""""""(Gt8 FEDDDDDDDDQ0*"""""""""QpH`TDDDDDDDDD:"""""""""(Gt8 FEDDDDDDDD8r*L&ӹ# FEDD.m @HG<=c?""">ȝ(lhh89Y,wlZKyrp\8Nck- oUQDDDDcZ3Lfc3LF `TDDtlrZ=2>Љȉ5u0ɄjmE)mo}}= x{{cYeDDDDD.H& ł7p8b`Z2 G/.s]ih]]. ?"""""g~\.c*§`TDDXmm-NZ"""""NX,fjkk"""ֆϻ\.|||eDDDDD.z>>>\N"""IMM5~*"""""?8s]9墦??"""""M~~~AF/t FEDD. Mls]f,~*"""E49c5zS0*""r\WCDDDDC6V Q H}}=9f:\i9snӓwޙ{""""l6|^/\ FEDD. gj3?ڵ먫3vL-Lhh虹YTZZ?}v6Oҹsg1?]blgXn I4ӻwq[aZ,ٹA9hJыQ ș}ݺw߃\.jFA@@%%%l޼O?] i_lr\wKjNf̘δiSݻQQQiw~{> nݺQVV,YnHkCyl|*..|¿Oz?jH~񋟱yVUUqAzԝXVz3[Vbcc܆pP={<(..fAڿ{RSwr饗cN<Ǐ㷿J bٌ#F '?y;CN' Zz޶eL<==vάlJJJiqb23ЫWOͥHZחݣ{oyyG(**ۛm^}cXã ۷ILL9\. )@>#˹ߵEq饗/21Ѽje֬60AAA1ߞON(**KP|q @YY9mg7q]w]rq]wc{{/DEELӇSAMM 1 ܪ|&G5׿>OqF=^{ O?}zxڵh\A~/5Α~z>uSypư0th/y|s.>%%%>???6f͚M:u w1x g"!a[7#QPP@Nx""""")@S'~O?]̴iSk1L|G|GTUU#ߵk7uuu,Xqs]w!44_r,ܸq^={bXs JRҐ㆛V^zs.,X^RRjL-\aaIokk3h@}a"""(,,dݺn<+ᅲX֯OWO<Ç7n,/㋊馟0>믿_4a1z{p0<";G磏7bbbHIO?$22@DDDD9"""p8XOwNav;m*p.Nb`xQc8r%/zm7?< 7w;z&>8k֬eq}fc'[`` ƏGII ,dΜ8t(zd׭n͇|φ puXXm4E±!ո?77'Nt/ti.Cؼy ' עlDD7#m4wi^ޏ޸q3qqPԩSܦ91*""ҁPTT8B7j.⽈aMs?6 i7jHH[SY`!_SWW̙WÌދ.ݻ /nz^{uvngY̞= hsYp?g}Vۖ8r$þOCZj-9^㝿9VZsϽ@nn.6fjڴ)2ELDDDDcTDDׯ/OmSy8+++[״O:KKK[=WYYYުf]Ͽ`-!m_s&}y1L]$66ԝuO)dlj9>$44m֯_Mr֭[b:Z"""""`TDDּk bccرe5iw|}dgгg^zү__/]vUW]y4y{{c2z/-Cq-',w*m}>زe+_z3ձi;66 )1-))9s(@ƍ˄ Yn=.̙s}!--3%9y\uՕ\y9L/""""DM;nm66` 7\3^y9н{ԹENICCruvkv|3g^CPPoZߐ͗_.bډNxxx`X0g<:2:(:lZٚLjQ9>\}U EEDDDDFCEDDHNUk;UD1Ǜ?5EE>رOo,NDDDDDGcTDD1z]s\49 H`TDDDDDDDDD:"""""""""hUz(--wPXXߟ I_/⮻0&? `Ĉn[ڵϕ*^{ufͺȳ~mRشi3-',s 6RPPnM6Nyy9ѻw|"nzt u;X|<== _> , 3fL;n3o3r͔r%%̝;뮛CXX3rhTRSw| XSSPP.?O |#""r^Q0*"",_w˸qcl MrFg :tE0*]YY|B;v VGNqQ#]sHLLHH555>޽iF0*־}())=cm)q F7f V빮(@3ؾ}cǎaȐDy_G|p8>άY3X,aÆt:(.>JVV'^ʀ23ʈ ^^^7j[/""r1긟EDD:[HbY,z5^WVVrj|Imeܹ gw IDATjذ5޽j:wȑ#ܼyPZZJr70knS+|bР-xsP'f#.cǎ9""9yX,t„ [VPkrH>˖-ƍ]{}LL4[n&;m[ [l(L8atd-?mfg""r^! \Wrp\8NZ p| {t\|=رclۖB>l@cКMdd3fL'66͛P\\LllcZTTLzzÇd2QQQ{}bQ0Į]Yz Ç#))jjj 3},???|||8x 7KL>Zdd#??Ç42228t(~'HHHuѻwλZSSKJvrr0qIw{l3g˖[l!>>/\z !!!`ݺ_LHJJۛuGHH08{=z'3r8l> ł_f0mNPRRʞ={ӧ7444[nne1thq5ȑ#ׯ>>ֵ]ÇѩM6S__OJv I$::]vQZZf!?LsC߾}"--Çs"1q0G7nt{nl6O>1ͬ\0fd#;;zSLfРg{>K.@IN@yy9:uW]xxeeXV̹+Wb$% aРATVVL׮] h|={ƒ}1c:&((:RRGPP C$b6ٸqƽ oEJvƌmcQ@0_0bVC-[0{,cќny͹ݛF||9BDD2;7ksV)W1gl:udϩLJ3aΜk ())aӦML<ջwې}|'<&QQxyyСI@c8xaf̘n[1b8Æ 5p8HN@||***عsǏ3zEEE[NC&_ڵXv~~~DG`РAtxzz2un }1ѻwm?mͦMq:̙38^{fqa23n3B$'o`qN#dch]r%&33 #tn tEee%;r\L6Y9r |b|ogd죨n x'l;///V,TEE;v|ѣѝ27w ݎdӳ՟ݣ0a<1>Kzzqk֬%22m(___-Z[=gq\uƾ{k{t4]н{o5ok۵7440c46lgϞF[6[EZZZYDD#P0*""/Gm%pooo9VQQ\. Z FʢGn+{{{J~~>} SYn=111tzZ6BѦp!ccѝ}mЖ fz%--F3 :t:ݻw`-RPPtMh6!--maZ9J $'0;ٹsf]mIϞtŁ,Y\qen8R:wvAJ%;;mOvʭ|`` ֺ}GGp;&f***}lܸ3)++tt: r{MyyyhS8EP&&^^VQ8h288JqDBAAA?PDEEb2(((h5h;&hs]{h}}=EEE-J`TDD8t>>ޘfOZ __VQRR⶯ydZ1TVVzjԝN1̳_Ց7 pj^GqˆTCee%/[9POmNvyNJ#5u'%%Nll1ܼ~+**d26nDppC6X>k(cZի'zrr*cjnrn%@QQIP]]EmO>B㜼-﫦}o*ڱxd2rrr5jJ֭[s;lr=XM?rQ[[ۦhk޾f=]l[9({d2(cik۵7Z>k@HA.]<1O0bUUuyZ]]>ϛ"f-\.555neUՏt[t}ϭHGpfZb`ٺu[@׮])++ȑ|ZVgKOd2$22Çөn[k=:uСIS\\|{k^[EFFRZZlQSYTk 4fZZ:iil6aݺc6oQX,BB1dee֔r(..6걚3@Ldd?n0tm|5 >aCClvJqq1UU?X)$$bJ tra{Ŷ7>Jh:n׮fsm=;4z;N[VrH=FEDD:^z2p`W!'011l6ٹsKll !!!|5ƦMX, 4y/ARVVƚ5kӧqCIJBZZ:~1nSYYIVV^t^^^tOOO(--m1###49u',d$ȑ#xxx0b6=;=w=۶V0thVHN'9eMnӷov Ϻuq\{+Oztf̷cǎO_ѻwV2C}̘,ZE>_X,rrr޽;m>lf-8Nپ}$%5=i۝:4%Kҭ[7zH0,""rR0*""\ru;Xj5 ӷo<^3g^ʕYr 3{5-zu&$ ozi_ǺuYz uuua,ߧ8:w̔)[,r F;Xn=aaa̙3Mmtض- ҥ۪mՖ nSQQb%h\SNl'%e;V@&gŊ|KC&t!ڵԝr vfadf`߾cۉðaC<v뮛kXb%.Ν;A6mT.]ƛoӓ> П:d⪫deWPXXhLqhvMUU+Vp.d_~UWDD8f_76 \.?JJJ܆ktЀᠢnݺd9,ED.&='""`w696u8P u㇭ls5i5"""""""""(Gs)喛uDD*xǨt8 FEDDDDDDDDQ0*"""""""""QpH`TDDDDDDDDD:"""""""""(\W@DDDѣ """"""<"""Ν;*tXUUU rh(t8 FEDDDDDDDDQ0*"""""""""QpH`TDDDDDDDDD:"""""""""(Gt8 FEDDDDDDDD8tm[J<Y?=^ラ Ɵꈈ\t5557U9#~DZZu˗_=???9AHmk7~2vϔ;wIJغu -80s8(f3 twA"##[P\\LNa<==ã bL`` .[QSS`˖1f޵8pA׮] j\vvtܙȈV똙y^zù֕SSwbZ;Zۏb!66t@ii)YYl6u {/ %e*ѝ JKKGll g-DDDDD.V FEDDù<۷஻6{䑇رV=[m!""""rR0*"""b~_c2x?ѿ?***?]zv_NO>ΝxG0`.l֬Y 4}7'3g xzz;sTw˦M9Jf̘NPP|R#Lͭ={q8̟?Hs㎻x=mVXX/y ~ > Fzz:xxÏ}zAqqq ei4UUUnesr_7 s;Ҧ:/m!""""r!Pz1\r~_3gӿ?v;yyyٳA2jHw?׋ g-D3udcƌf-rod2qjxGxG0?AAA>|ԝY̙W u6HOO?ٳgWm0y$>w}(kZٷovs`QsIL_~ Z""""" 35dfaWjjjh=rp\8N;#-,,$?#GeOL̄ 1͔R__ODD7z&7 1 NUU%%3gεx{{߿AAA=ZBAAGp9nbbb:u V2jkk=[nhL&&I&MUU5G%22o,.>J^^Ǐ#$$Ziiv&Nؗn=z[يJdԨDFF͚9Cp:())l60FBBB߿P\|}㉌lƎcz6BDDDDܕcyb614HM3@ulv'v`xA :G\JJJ=͚t:444p8([nf"""""WNNv{'<< ???ٴi3ii锗IPP{ѿ?8ȧ.KPkl߾Wp뭿YRRܹ9u=>%"""r2 FEDD:_@YY6mfOn` d2WtܹϾ DDDwhAAZvRl>d!;ѣGIKK.^9yyy$&&$$e4#CHc6aܹ3|\UңGwz~_s啗*p88MvV"4T IDATOn3kL,ذ8Nĉ2`@L2""""r(BCC(//7Lx)}lʶm)rd̞} +VƏGTTq\F>6lѣGsΌ7h+**X>l*++oxCY\ ,\)G"!a@ﭵ[ חaÆZR>xaaaL>!mmC޽j:wȑ#1|T3Ge[mllclf @H’s?Lԭ[54U3?u̝{otiЄ-btf3]FB&:%nԜzURRkNy&]v]"Iկ$X|QP^[? ~^Z<O4.Kv=, Yp\vx Ãx˗r:rn޼ +zڹwΜ96Y,UVVhժav2 umCIj]\˗pڵ:p/BF}2L:|:<95㵕O?DڼyfF?}.ݮud2…ڽ{~~'NԊ˵vj|~IRWW.\y^]~Cg\;w={ygL&<gk*<~!I2GY1cnܨ  f=}Eu붚cv̊{l{{vޣ9sʵ~ \}}}}tzٳӣgύv|ݍExVX]vk`` 9D5Yְo%I}=x̙7pW !4;./?VؚS*..և G4~-ш?;չp|>>gòRYZ4w555͛`JJJysl6ۯe˖);;+kIҵku1:`)v쳟j~G6$l6f;|>}aG 5sf[WwE/ש[n[~_>On;--3<⮢b޸WVVVP]jʰ08DaG#|>UVVSYYy`2y OVUn;lJڊ޽={vع>}‚Ēww̙jnnV$9N=z(X8ϰΝ#ϧ[zWFu?Ҵux;w&]ѕ+W}6I͛R56ՃxWǎ};w딛A}}|̙ *c)//{p8僃joo񹗖`0]YI_Nׯƍ`<+L1fY۷oWGGjjN/g h1`;wn4}p/Ѩ YS38|NǏЊ5sLeddIǎ}%3X,QxL9(͖khhH-JLSaj gX"C={)O7*(pDWbq:~|Okǣݻfje2嗇S"80$}$bUTT;w FDpҥsoIgΜՎۂǤb**owæ`%$M>]Q>8Nl+C_i;|߁_;󩯯/kݺu[v}J-r?L1!X6sfua]v= l*ШW^>[rtNNF51+ojZtIp_sssl69} FsoI_S[[٫?Hyy I$ (+ aV<֭ iRW6LF]b_-ڦnر-lA9i33@`kuN<-_lLmEff222t557Mdx[oTڷo×,yC.]N]á[nĉr\lZ币^;wSYY֮]CǽVvv>gT]]!l6̒}>Ӏ mNFIYYYZM]\veںC={N_~yHiii>}->|X\IOQQQ>䣘׭_WQQшG{?KWz{{eX-[Ӽys%IyyZMݹӨ[nvnkzͥZ-ݮO?D5:q~rssSJG;t9>|D9sʃUީ^+''[۷oΝtБx瑛1b3=O64I9C/.Nt7^(vb%)/~y@c]`07I gK2Iݒܒ$yl'/bG `1 /e4(FxNEg"%2FiTNN1 $b2t*++xꗿ {bbI$==]=== `J .:ɏ`\z=r&[r (==}KFDL&,tuttLtW)CK:C_ L2VU^WuNtW)^VDw`X`hNP4YVw{z{{+ժN?y*=`fUgcNtNggz{!F!9g0?~$nK40/@ А[zD `I&h4*++K&IjmmlVvv233e4d4C HtWG&IVUVU_PLUգdٔnuuu~4h`*z#BO YVC1y0I Gfzvv$iichhTZZd2↡/Q&hhhHj6e6:_hPG0 $b4@Twt`4ѹ|?#&@8xH1 ,V?kL~LRfxUt(\d@G0 "XCNe#'C#0rFL9QS()`C0 ``?m@ƜIR1 `!0@.KjkkW+V.\x=ca999K5gN,y-p႖-[K%$$y5\xmkbƌzŲl2MzK.ѣڼyJJJ&q9N5770Z& g'7,?`ԯ !//Wk֬7kLi]jhr0r#Ţirbv5\:mZҢz'˥ eee4όۧNIңG2Tn`NS}}2 ZLx) e(=$H .t鲼^$@6D|>={VW^ g2pB-_L&k׮ܹr\}fsVZ ]~M~*+kr:zAX?toLfK+L^577󪧧Gׯߐh… nƍzl6mܸA6Mw4V_U㪫ktuUTThѢE2 S]]vIR{{k4{lk̐9`uҥKѣGiӻ*-- ɓjkkڵkUXX^u1~b<L&Sp_FF$C#SkZ5 &i|TOv(}aBS[_>c2_Wσ;wl6v<{p8bqhԺutڽ{%IMMM2 z۷TPP,FOV YPPFFΟ6fj޼:xVZ3ׯ&iܹzoǏ@4(&T<T\\,^oo\.**WʲBFAeZf*''[^W.א~fhժ*3*++U__YVPEn[.lV-ZH+Wh jڴ1޽{׾}KQm-a- <7Fy l&Ii%e]zj>`uuutsr?p:% JrIrk8 )|x}䣔\Eh1 T:`j}۶mBÕ^ŮMv5R1h tګRWɓ';U"O+WI+(Lhs~H`4P\zo GHrJ+HS2`4ޜF=Ny.G :WVV_I}h9dX&Jj+D!IήYfnx G)t:{4)Z44WXGW5Hy$Ng׌FK@ L~7Yt:$4WJTFц{&{.۷nr4M> BR~xOׯ_{BRSi~9>^HR$B<7،Q<7?yn2{2~喴r`}hUR2kxN !xvuuȑCg~_^s:t>@*zep FQi`jd y~4`|b ,琞> ^ES4PTY9(2'sPHe t~y 6Ó5\6kP0X0.` 􄼎RP45KɞY1Dd=0q|1G FahddX蘆R1Mdh@EhdZC% G F-C'Zm>*1&Fgܢ:baًP2^gMQǺNF[1R41C6-9FZohډu݄F[1Xf2UX}4ymU"ǪMBtTGkJ8xL&M& {IDATM}/Q ( <K(*Oo{ix>J0:Q815^b2h}*D |He8}dPT߀1BIx]ai牮g4& M5#ќ?x d*>Ir4ھTx7!x1 2S@4#DzIDATxxoB\; w+Pww] %?7nvx}|̎w޹ !B!B!d0B!B!B2F !B!B! B!B! $!B!B!$뀴,&R$B!B!$&Դ$>R%B!B!'MiA J!B!BH#U%nB!B!=SE M 1 IB!B!B'"ft-d(vB!B!s~|%))):$b^\ !B!B!iQ_%9)%6"yB!B!/Bg|{|B!B!BHI^bb|π8:yB!B!%>g|GK4%.k?i\ !B!B!I/yDAH\MAћ(PB!B!BBJ=(ZHIka>!B!B!$/BD$IC|.Qq ŇB!B!ēPa9")D_DQO§{@;#B!B!$-[rDͼ(rqH=qz|\M!B!BI~)E(Y4M$ф uzB_Ao@tfay_+Q?hBEQwg&qE39&lOտ$ !B!B!$p2ݞFW^qL&/i G}F 7D$(,iӖ m)S`qk,QQQ> !$9uꤔ..B!BH`I|Q###>|tvžտF5ZK`I u_J8kXz׾D;4:wqGキCj!4::A!$'Nr!B! D?a?Z_1" tH{'|q0_/($ܹ\ru J1BF !B!Qǎ?7Z 7s@K}*"l>#GrB!B!B2ҥKU3gWbݖtYWѸ7 |x?.ר{a֔c %J!B!BH%$$1ˬ{\d\?ōE WaԸF- hTTtqB!B!.]*1FGtjNQ$~}' -|)޸E (B!BH! /_FYh!$, ̙3'$8G=jk%1^qygQ^0|G%B!BH)NӧO0JQ$58pnùsĒ/_wuWq)nܢfugh| xxKzS:SLAu(BHچU !B7pB"$ɕ+ݣ$ќ?aY^)WNTqu&cfEQB!BI>ڣ(JR {tĒ-[ZSS~QOFMuފ.oܸ龨a !B!s ! A޽>N/x\.H|'q,)S| !B!B У! A)ʕ+v269yQ`*SLv)66*TZ%Ur/ؒ;$2Bo 2raB!BHr B;k&X_lْU6LX'W14^ǨoB!B!ݢ$ŋ/ٳg AL9r,YHRmF` DaTĻLQױ*3h# ̄der!!9s\rKɒ%BRV-'ɼye]";wn)T._RiԨ.\(//}qYf[СCD? !QJe8,^D_³r=tڪ|91k=.\8ٳgu[ 6A^3=3Ho\ ?&ӦMO>࣏>$:;k׮Z_#}c}>Jxx۟29rVĀπu&K'FwhҤpz#EQ4odWa ֌tO 8~Xh,Y=_'|KEݠ`kb)ͦM=ٲeWG,CN2mڴ׿t) QtSwٱc0m,aϦٳko^`dT1&?~ hԨkFO? УG7)P1.\K,u_|i&VCeBQbE1rjο޻0h{ᮻQdѢ߹={v!~yÇ)B?,3{M?3}_Z ʕ+I^7Kre%-ÏƍG\ Yh"E7o&7/ cܸ ր7/oXx\`]>Aq|"Y E3g"֭[[|: 2}tS5\k"l uZj)ɧ:uJC9wr{K/=6N֭[eɒv6ȥS{Q.F֬Y#sKAڝ'r}`[KH(D# 3}XΟPrȮ72@nK놠pwΝ\ýi-*2$V+… VZ-{hܹ/̙S͚5$9^hӦ/_N_ckҤvraWpzc ! )S_"TBԩ>"zD |T6"G= *U8kLd{Z/SKRXQ{9 BHQmӧGy}E_Uj׮rF!)֙3gNI(ܦ8qc$VMy#~{RREEXRNmyUmժ.ER4lP'Ȉƙ3HmmQ@|ZN~\7DoNm6.枻 l)Zl_ԇ^[>?j)G*g83ro~ z߈7-[t1k?7"ZjB!)Cd۶z6{NWs䣏>|31o[ڥK'da$V5e^I䧟"*f͚5Nb@{ D[c#? ߪe.E_E jL:='X}Z! "MSxXqտv행'G'a_1-Y}B. u>DO? pOn9ri ɉ'b({ \ު'3ۦrzo'6%l/MB?'Pjp:EQ'*U}{|FÇ]wݥ7dw#Ny"L"(GRkʹϟCY:"NRm9~5Rph"U5=u>}]z!i:? -5|H@B_|N;|ŧ~4aQuyKtA' ͚5Vɓ'.A⧟~u쭷i~G4ܹnݘƒx̜9E_{1o?YN ynXY8e`ir9}3Ctįv-|̘2juBԂ3פ$SotI*2>ΓHs'AM ?c5ԥKЈbfDŽYݧOL(' cC :"fBHuǎUtv^󁢭3tp0S{~Cd?s[Yl8qN…{ئ ɳ>笓 ʷaboDk8iN~75" öNZV>==z/Bׯ_׺=q8WׯߠyMw>Ϯ]{h{bIc\rݻW5\+(XWM D.Zj¨IPN|`f4xaRbE#W8qeFP9.R|y,}F脆{VZ־̴ᇟ4wѡ@;s6<\ȱxRIh5k6aT^%)@V(>B*PE2y T _7nh h,X B<3}1:)Qߜwn0ch`#<<3h=#SBly=Ay㍷ً}#׏>d"2ԩ:ADzF}YT |!0=o:~FЎq"@uqވh~l?\w)KTڞkg_C mztSԽm 0D|b/8!~C54sF;3<EȮ;ݻh &LtF'Nd3ZniƏVR {kt=97`{i|oa|F}XGNI#ly>S= p7` ONd'-r!nL_y%&}U ! }omh8(ߧ{#A>RܿmpofJ򐜵En_?nKrmD:zmC !uSL) n2a"q5[ݔN aBCo:Juo46Xh.&sh죁רQ#Iiڶm#j@".&CӮ][mhPcBcA^; EB Bҟ}&}ٗX|7u'[C4SB3Nt"x7:8/pSS MG!֨Q]y ѩ5:pAx@c_xErM=qFC٣\ XBB㈅h@ 1tM Ū0 E;ۀN<&O`~?!|!"_|1Є.㫛;]Ejt 덤> ԬYj;, |fŊwqw0j !׮S{ BD8!,]L~stp^vCϸЎ/[mnEEwyO/ 5ֻg7_Kzb!~n {pry^czE[ECr庖O?EQNpE4'OD}6@@5ُp\t)Be< ;'5v>xNޏj{F1E13`@?=qC45@6(9a@y܃q]k:)☢?i&LybѻwﶅD(P.CO [g~jHSk|9؈p8Ghڵ\O[7 AF =ߝƍj fP{ZbW^キա|#ߛp<5?4\I|ޱնQ'.@;wJԺSOh <;B_Ywi= Xؿ|eV^-O< bg}"xr @\}׭[.n#ڸxGi<%6c$"TMɰopҩSgE㮻nmQ=zE\x# `$ts{{?CY|d$p{'租~ip3낋$_g1!X/hDB0h˶e ,Bh&t DQ8;TrLpv\#o:dpm@~jMjܺE+_ILjC|^ z%9Ж/_¨@<܅QbiwZDQMOhxx‹g͵}/uxr wD/Vŵ W6}#/vZ !ƍo/ۨQaH -pp^"駟ßqOCX{ ^haM9lkBLOa;Li0 3<<,>p)FE4r-bǎgal86la VUD`ՇAx5@@0!I{zܻz~tҰa}}6♏*e {1gSaժ5z5[|7 rB[7psb]x5"0A439Vxv05|_6a` Y#8?I'7iy1ZzU}< -ycڴi$aA Cu4 bJp;WCj+Wq@\((/Bh̡PИ05 WfׯW$ '9xk>=TnC-o .i#Fa!#b((( pɆg:\Ώw =1efbpYӪU  s<@AJ%t ȝgpFcpW9Ggѓ0ڹs'[T30Qw}Gd6O_&&(7O=N&$9 D G ׯY1¡ qC viSswp$ap=XV-p:>Q Hu& @:eδ HiFo= 󀺊!Hjp^{{4(k砼8sy ݺu]&L9p~v۝py¹M,F@8 m4]r`y_Gh{~ 0gsϽpS-8Ma3 M#@ʙ`.4;;2er됐`Ia{n*p C%w t :BH&LI*>%!!5@Ө 伃7:澈7O<#|BB$0B}xai U>(K(q;{B>0jD t0 q9^I4ov!u/brRcVFMOH pL,.pcO]1s f? 1E q! E7@AxgFq:/N rBbxxX ߇9~; \5 5iN'a<7܅z7?O*Ǐsi `ҁc `1zf0(o9HqA^^ upcf7 NXvK"r "]qw&r$=湔\N6z NAO &<ֳ={ۺFE/DQFFybS8E!FD\Ŋf/h@9QӰ@.o 1;? 5.V1NoH :NaԽK#ʽ>  !mٲ?ΐx_)Tȵ(>PB! [s;`̜9KJƕWK N1cp3:/ܿ_~;kY\$8u݁Fo`dNuOP& G 0O>v:_g^z{t #"@?ǢB19$91t̑+F2UNa(MN;&t88=9|iym6&rA@j(*tbD4YuZ2q՝wޫQ Dx콵ߝ8ݢeQIyaBq|z/Z>qQPk& z4Ҧz9hfg{amܯ$a gD|}|YY Su؃)p7{pOQcϸ Ť'T}ЪU ^JFpljm)O2>u'6KNQONmzWmQB(@ ^xź&sit#4 p=zv09C9`EGJ{H<?K-,1}0"`܆%J_4.z_٦Mk}003ڥPxQIiPs*#6F$E(3prZ1JI !Xt13LxhrbP۷ EAxx6> .B5}a'&5{/#<7aeׂ+ tz*orr)L$pw0O7 .]Z;G 0!C\Da@+S6wpr͚5[IK (a\,ؖyC\uh "`B)3_`ZL<3g:M.'`:{D;SZhKe zCs=BpsT|UWO!"[o_pc:P ezĈe˖Q1>Yfrp\?#hb@ #M4ϱ?Xq/C {2{/S'olxٺu+ Iv%:_ж4]@|j'B"(څk bb?墿O?~\ۼyh![oEθc 4x>^ ~gKCT|k{|;Wz՚8 hv@!ɇsHhk|۔k@w}vB (m¹FF!D=C&gQZhQyU8?8(1Yd.:Z@tpgɒYΝ;k f;J' K.,J44͈6B,t8 ox@"ҥusZw Lun˜|o~@>t$lw&|p-? `A 74jQ؃,DgPT߅*N f:5R|Mj\O駟J~Xq+g}_zذ?ލi3`SU 仒{?\2ExsC/널W>?7wErc=g{Lu嘎<:8/ T2;Ų(̙^TBAt3)Cޱ?u/yA HKwO/@|4b"`䀇s-<Š@4=/DUT7`ƽZ71>޽| AM (Q b+0`p _\ gr@?\a? )ړ@l3^0D3F!*Dk pA(u=DྊBb<[i ZjcH"y} :zb u's޶ m\; x:NHr:םHW¨r:y8FH8÷ /^5ػnbuAƍ4n4.ɚUKHkvkݻv 4kFcr@qL9tbHovN|t~|iP9,X3 .a` =tZj!}v`n!, 5OռkvO!`\PQEr<3L`X`0ݹsG=7ncϠ1pSAlLw3 ɸЉ7nRg!.,d@2Say#N7G| '`@$)iѢ|ϑ#:H7Ț5e ^5,DZ.]{?4XQ!ї"-n 8Ds35jᢄ0 Q?.J܏?ބQSҥs *ܿWe7'5euqĽ~zrx \j*#qeϞMYbX^Va$#,?p'w;1a0MyBLD^82NyOgO[FA‰D /:}BRxx@Ctqu&&l=mJ brnSBI6%'> p3͔tuУG/HlwA_~U"7 aᐴ~;uTB(hԸFؚ5k!k႞bT a͚~X Z) lA Yt,Ӈ^deC_Vmmy4idu*cTᥗ^ԜH<hׯո-)8"g:r}\FZΰ^xN$<1 ?f*#Du%z'_JMp+WFI.~W!A.BHB@vK &^7/9mC !B!QIs1=.D<\;/_V82HMRUչM) QB!B!: $$D 8q.' AKKpRB!B!>={v!$59HF !B!넠 ɕ+9HHz(!B!r00p'0J!B!BIRX*=!B!B!>Bhґǒ(!B!B!>%$iHcIaB!B!0J!Bu+Æ ׍7TYl:_HRҧq|GRXQ! .ĉdr"ٲeܹsKɒ%jժR~]!CF׮]k5 ]t@cIJ.^(cƌ3f+[J.-ճ/^,7nU_ s>,_\od͚55!?]$.]ҎBʕn:B!(SL!KB\_dҤIW >xO-"B!\xA~]9pӧg^뽃F [>$$&gIDĥXM6化L2IRj[>|`{.:u{?¢EKTLI!Nc1bN! 2@v.o޼Y&ObߧOoB3mQ4KҪU St( ֗\r I^2l(}&ձ9w|kĉ-5XJS妛zȃ}l!'L!r=%Ky{7׭[g ϐ֭[@7|o+W.DB!$uصk{m6.0ѡCnR&L({SNŋ$((X+իWK9t#V 4oTnu˺^x%9|~4 ++F7m,;w;=0 d!IzV⋯c="]vп 6~c;~Yhʕ:*ϡֲ͑#)SZ'5jTe>~ejxb>}c=еkwy)i߾$w?^y䱎#}m@d*B:tXK͚5G}u#F 矿 !B7U&mڴCj׮N}-3g\ťi&.`}r]u@L[瞑:uj?Cd V'@;OwyK{r2{l?w\pQf"ŊScAm?kǽ}<ݙ{ǭύ6ѣG<͚ :iO?\_(Qj߾o<]t:{vjq˖wz<G#kiۚ+W)Z-[jcErΝ˗/KΜ%<<\ m۶k=f?X5eh9ό;N6lبmcKeʔN{z(DaY8v츺q= o m+WhB7oX@ ݻw-[ ;w9*_X7k ;q1}ݷ3w<#x=C wI=eժ5q97o&=&L X~gϟ:uGn% Td˖U'=q/_^!I+VTs-Qk G/uw%՛0:hKրnVq-.]TkYxQjŊ5ǟ|={; bZz&y8.^ŗ/^PAy ZsYEڐufHHXSwLٳP_wy1t@9JW\)_}!-7[ RL bp9گK0tBIhwիWk w˖-:mܸQ|qLjF,X$m޼E!UZE_#rꭷ޵:[g]cǎ:-_ qmz~<Q꫃TTt1asczN: 6atΜ9.ܹs(@EOasu.YFo ! h/jt~'G nO(M< o J*1$7w̺.\$ocg8pw&9oi \!*\8g)UQBH,05k}ic@pЦƵRJ>h ޝс3:sSi…t=[nt :sp U~wۢh&,m?gN}b@F1Y#[jg̙q;-[o`0I4!9~OnG Qʕ+Ȁ/oZmp Ͽ>'<-Fx>yO߇ ˌ6B"Jqa#}uuc;>KkwRh1O=:.] %B!ą{HMʖ-k vh8EQ H 7_ N"pБq l S +8;!NΚ5[oذAE/8ڐ5kִ"vNدV'Tfyt~cq-=f@qO>EQ^ E16|ද/h#" nmvbx`(ZVMz)1f[׺&EjNAotCN*ܷB|}8EQ+>EQ仃##^i .\Xƌ0Ǐ`0J /Ds27n6q<b?\軠b1yT[f l;&O@X6(;C#3_+ˠoC<̓Ou!׆3B gǺR-/`s hS` `mۘh{ȘP`۸Hqk#}}A< hA4i 8z(.Nb "[n#qAɈN:X_| > ̜9!EGF >5C5 d!M˖Pҥˤ^+{jX?ߗ<ɬYƺ D8y1Rù]oߡSB!Ը9sڢ(BqVZ*B(h$09syWl nD7#"3|Ht EEEkh“YU00t"T˖-~zQH% Л}[W_h#&8oPwh̀?| soCxĉ-#mmt` AE& Yw/˧RFp61܊;vSءpB{ہ: 8~7wqB fFꫯcl|Ah}2N;pm.7s>z٤0@l&t^Ʊ>TY pDE~C\i!  A:D@ܵk>#ԉz'8|nC'a*ZZqgqs ];ZDb5@:QG*oh=Zv 󐇂ɛ=9aׯNhl" 6g4^{M/5аD[4bWٲW :;crQs:isX< PD d?|w}hH ~=%&BH§ў;v6)tՙ<^.:ϝG"g>Dt0z\@`!Ņ3 NğUnVm&س}~l$ ao0 VB ~wu>+V,^lVCЀM %gNWA9Xlma#bߞ{i Gwb,pAs#47.91og89oIpº{Tf:KHMEQx1u 8p`?Nو\pnltymxO9ic>'I8٭l`4B~G%:KPB!BkwA2-7tccɉ3iLNvtPl4MR5ǠMPzDI%qiInTń`a TBu:{( UaЀ?.ڵkj5sCb(pD_xOs?(qw#ݽssUB/8 DpB$*9;9 zI,tA#hR5jЊj |B;﯍5rQD z{ԩLʣś\ nE20 tAsT-Z4SgNUV㧾*@wL>SʈhEi8<"6 /gg pϴ\1hm߾j.+qAsr}2I% #U/RœyMS[hE.wܿ\S)siڹmҤB%8lӦی h駟k{ńN.Pre5!ĄG˻(tJw΃3IQDqKTqMP B? ˗:頂m_П4P% j'3~FC @K2y=ACGC[ { F=3*^0*1rWDc4P F^D<U[<&t ]UzǞW^yMݬFCC rT4Nyak^P*'D#*iƇ?WRy$ pJjPlZ8f&:ȹLڀE{̘xͺcrg`#(f5YkB!$pH+rpBPbڃ={d1BǀtrAF o|!2!hݺK^EЪU B9ru܉&\lQ//7*p:N2UH|lKppW(~Zj ڹSE{{U1ݾ}{P 0"n[oú=zt~K8mh(wL>,@ug#:{n}1"ocǎ 5.rBoT}9p#!EZF@#Op/[L˨NSEώȘ0nx״ =g ⾇:urF*,Bh" :uR%?SNFYOruk#36l؛K3gNs&:k?eET h׮]ۧ "W ->SEȧ~0|r!mspU_pQp")b[_ҥ0J!c`l栄 Fඃ{3_|9U`.D믿(W^~]C+W OF;NamD&|DVX)ǏvJ\1R׋۷mμy dƍRJ%ɟǎwYfIzׯ'ٳgmvȌ3B)]:6z,܁j6U;s挬YF:,={B!B!$.iM#C5(Yn,XXKIݺu$w:o>:u5:+Y\Y}=nkJٳG<$;%K꼂 СUVΫ^Hސ Y|;rOy~b"QO`Y<.Qlͤb :|aٺuk¨/EI޼<XzB!B!\d8anNJk,u K.͛y0배0}8,v̖-%Uڵk`u6]?pBҭۍzÆ Fɟ?zἬZ:)Ḽ{NG/nσ#k[u /]0vlܸ:vyXu9sDž/+ٳpB!B!qȐCӰm۶$Yf ř/_^@&t:0wةC5jIARTIiܸ̟P﫟1Ν;g_d]clGǎda,T rX@4D'2ɓ'$qX?;wI,RjL8&=EQ)X 5(۷sC\gϞ knQB!B!GFM*Dϫ(|5jTyC K,͛7r%R֬Y'jՔ̒%K:t(,dDXbu7o^K;R=F!:BCq|<|4D8ɍ !v*vEW!ESW?ٳj&|ǜ9sU~$D IR ipڴic g\y Qe8I O͜9Kv- (ԬYSu"O)!B!BXdHǨquΝ;bB˗uL{% [ 4_P__@~9plٵ?ӧ˰a#tB0=vX!B(ؽ{$%K8fAASΝ4*/^LHi`9]C ͛7KժUr 3C!B!\*O6 1Y|m % 5BBDGG!o}5t鲽N\S(BQhB mKL6]Eg.IE<^sɓ{U={HJ\%B!B! &WtUܶmm۶]u;鄐weoߦ"d1y={'s?s&F˚5cr;wh8}B(k.V_Be̙'w;IIR%<+N硸 TܧF|F'5gҥdNB!B!vcT Zf( ʷl٪Bի+'OYHxx)}RF5-BCc׬Y+ŊkaEZnQ~esDݚs-ifѣ߳'`׉p}sR*D 6(ZXQ{]V#3'q,rb`!Fܿ\xQNT˗իȄ Nl2=a r̙CM&kıAQ&M߇gϑ޽{}=9ڟҺu+޼qIkNj+-PB!B!ѫ(OPP%hľK]..ôr+5/%\ 'u֑u֩ЊȥYJK,` 4e e*B̬X%ԭװ.]:sʔ.khh@8WQa~zRz59rDnڴI''B 6Š 86 ˖-׿1Zk8ktl UE<ƍ١HKe/^@ ܹsJӦMT6`p\EUF~+*/_jڢ6!B!B80/1ϼt5SS s=z|PǏINT( p3tĉf'B!BR` DXS)1E;Zlc4A8D# 6j%{CȔ)5ĞB!B!0>A;r^Bޑ?p"RJRj!B!B!$/FSD!B!BI(B!B!B2F !B!B! B!B! QB!B!BH(!B!B!$AaB!B!0J!B!BpP%B!B!d8ҝ0˗%::J!$#(!B!B|#rye!"".,ٲe2B!B@I!z)G^{&!B!B& p={F!wB!B!;i^ExI+m"B!B!z&M;F !Bh9o1rpWM8V#;?|`}ݶmk=B!d(B!i)Stɗ/T^U'*DȹsɤIeݺr B IժUSRFuIIo|ѧQ$22R_wU ( B! BH!""BO)_~YpqK}M-[2ҥKrYٶm='HΜ9%1cWrwILʕ+*8d"mҤq,a !B! W9} 6B|:uZr%u֕~zI\]E's̵:RHiӦkFA!zWCig@ҢE3޽v!_xG;eqϢfQ]ɞ=.P_K֯(kא-0snF<$3gΒFJVͭNl\RsC! 7C 5߲g&J7dv}otuu͚5o۷o qm >E޻;oQo1wH1c޽[Y2p` s|T(?~nʔ)r1)\v@ҥ?nxyw\> .Qnj96Ck;vL:wfϟ4i53LPbE7ɛ7T~q|zN>Z~lٲxYtyXYr@)]:\}F;_Cp۷O9F'N͛s5`+JϞ7k;'!B! QQN۝ҡC{{~aV'@(D^zH~}]c:TgvqvQ;>}[;zB?`0PpA-Z,-pwYYvN~M_Jeɚ5 #GYb7S+u֑7nNQO(_ɓr[;]%D)SZ4mzu= XxϞ=[JѢE-a$Y /d?;5k}|@@ŋc:tMFskTwLifXo,$ABybqa[=!BI (  %wҶmoQ#. wmΎ+몣F !̙UX`hD* e9䏄/,`׮]o@۷Jre5,Fj]r>Gu ]s;voڴjy,a^ɛ7&^{S]ryp;,YB>c[6l qؿZ} 3<kp(Z\98jgoFi[tieժUSW)mgaz~gk{{Ϗ>u/u|JEĹ crqm*ϟMF~:~u<B!6FK2ebŊ 6N p @gB#".w9!B|W_slٲ䓏nL0QA//^Tq QÚ5kTp~B+VR8I! u֖ DRzRsw:ujKn7~T575: ! c͚{/DVL΢JH3`"8p RHa?kl^C$$/>oqҨQ#=?ٸq}J޽ׄB ٢hw&O+c>s.oR5'7hJB!$F%>r劋}I8ݻOqFGTSwv!?d05kG!a-Z4W!͕-ry|rT7j,zL !IGW(ܝժUe,Xh ?ZqϪ'xzC 嵐4DG,ZDWZEՋzZ[ENR&~Μy^sەP|'v0x_Cݗof8yD70hǎGRzݺr]|gĐ\!B!IEiР:uFs:Aɓa<|@X8Bq6!+9b*7B<_õ7w ~6l*ֵ|7ʅ~-[$w&g|C%$)@NM ! _?7oN&Ͽ5*TPu9b?g|FA.6l~r(Ŋu k0 aa%%%)n x3U裏-QȑٳgWxJ。GSAg_A3{ \hC7|Gv$˖- p¶HHM?3sV;3cUo3ߪ;;w &=F]+WҼ(eҥB!0*1! :B;i0щhӦ%W7CuB챼O@mUKi $#G($pY9I9;*f*ZT|}puڳ^X@ξ?+BHRТE30YQD7` R@{}T-/QIݺEs0؇g 8a$ * ?kpbA[oX|W<*HC>K8&N޽{g} z졃}Oo [!C~ý~Q}z- /B!p(13,\r˽ޭS|@fNTtrm!$6o) '(DQ'AHDϛ!O_UJHb@X*\ |BL_~%}ݗ/GJ*-AK<ɫ0 a N@30"U|W_}'N7{H W[2l1cB?ܚXp͛7>z_G5?e˖Py5:^ӧ:]ǎ!P٧J޽#re/_>=M4;܆y!WpzJ|ukMt(>wRsukܸB!^ a^cykLWS5!v4 hcB8tS(HKμzOw*A%:"BNyXN萘\g~Kd ͛OJ !hx*4qdy7uܹd„*(B!BGHH0F@k:]:E9h_#B:_c C !iS ;zy~LX Qr[%%{7hh&s*FKxBH\9D:wdVWZ-_~L(B!0JIӤLW׿VIE͛*U$_+ !$YstDjUOC!BI;P%LCF sdC-\/^ YBHH"Z}ѣGEGͲeJ۶L2 !B!$@af@P0Yh,a$rVPy9FSTUKezA >B?ec>[T!闂 /|`B!BHAaf@%0s4i nr /FEѼy%@A"/˩@ko MB!B!i 4OH^aC%kϞT9eaב7ʅa$ S&ҳB!B!$ҩ#(_?+z{%r&B27e@ *[V!B!vȐ-[ƍ3hI9r… Iݺu$R wm۶k˒+WNR..IŮ]eʕֹrVlԬYCJK [xKH$sBSA&'A|xܶM",=o}J]$|̶:=(Ҹ|9!B!BH"yyu㯙2]N!lMG_-r1IjVZm ]lbE%**J;&;v씛oeo!s̕ne}eiҹs'ɟ?{6lD%ү_ ~{'Nȕ+W,1:*UR*VqnjO%kl8KڷoⵓѣH,YdɒݻeϞR~=-.\ v܅ eѢRv-V>xL\h܅t&M"Ne˖I٥cq~Ηh_Z)>z!e9!m%Y ERo *o|~DS_!B!"$$5]k:]:E9h_#B:_cMsBD &M0S2@Rarڴr+VB3gԹ  *(s]vQAȑ*ze+W.eȄ e 05kVKuxp0 y'Nq DՃҺu+)^΃StʔE~֗h8 298z))%^xҧ\9R"͓+[BK)yB!B!$˗#%G;Eщ'Y[niܸ~KԚapVf͕.Sn=sdϞU*Wl tc+VT'88H¤^:V-X@.CO@\dĚ+Wu=ו~%Kj7w} !Հc^\(S9&-dB/ B!BId8dye-*BBDB-Q?B3Pfk{F]b oݺ_4@`E8<-Y[-ٹs,XH/Q atsr E:P^=iڴ\tQ&L Y"n6ٳ91!Ci:Ə(ΝFYo| S߫k dѢ% (gϞe$. FGGIy\Ν[/zS*SB!B!>1ڨQC1c%Q*OܚrJPBzZxDʦM[I1c"1?k֬5Kt܂1ϴL:oeRpai޼=%9} ,S|f4irm‘G}9_u"mF6p|m1ȑ#:\5k_俼v R v˖m]qP ->SFuYlt_Xq8놈 pw/Kj/{vfbB!B!.NE(y7vk:9#mۦ!h!\9a;wD#*68q3c¬!]]ŋu֫H!>Ӹkh}LXu vC!Z).Y kѢ]\Xbk,= nWDTÍBVF "'ypb,YbWTDZM0b1עZ9EرCm] 'wO! ?F!B!B 'VE i8sldD~HcC#%ЉG{*aϟlŲpܹs (^Y0=EdFyW\ x:Cw->YװuVi޼]aÆՀp&8M_@AK8nu9'á@vb_x=KJ!mWWmWX%wwg@R1utPxXؐ WHŒhG+GA߻SR:L 51xcxw3zISGD.?t07\|Ҭ}׬YS%ɐ¨;pBu1yFkc(T۳g>!:thxw4L֭Rǐx 꺃I:ຬQZp؏O$ ОɝT[jMD riΝ;may%KDNNTGdWWPa{4P+V{ pq3L.ӄv;v\ D߁+/pyz@pLl4il۶Cܳ Ahʝ;gѬY3D?߭[{P]@2~5 :ͭZsnjOׇ=:0oAtxQ\g߾FbȑX#`7g[+vjc$  B K~}mGD trMȊԞ={]rGc>:DQ;eW(M6x^-[(ф`#F2AЉ}7BH4:xzwFmuJ׮]3gyAKSt֮]#B2-sn+gZ!x֣$ClܸQlIuR4`?Jb=$Њȝ3gi )q|;^3r`={ ?5!˓#O /0!#)5^'_;ss|̙DU+&R+(c}xO$Oyb Zb$"p QMt$cǎHի琒1|Ťaض-Di; iBHFq$@HzVm 4|t`3REVB80o>yAVZˠf'q}198}Ì?\Ǖ;9OPEtyCDzҩS ?1sqp:B1B bo4)FXpQ77nE |j)0.b;B"8\qlMz|Du'-_X1{l=.{S`0w„Ib[ݩ3%Νe8̹~NAv .\lXs-lWRY~OI |L 6Da4!Ur۸@D\hN7m 4hP/.;\vڪp}@̅gϛmQ"QwMtrsH{׉{`r^'۷KaD['2>x"B!wrQ ^?S6(gә/7mBHF"M 6#$}]7jci ;|"&ܑE Ip%t<N[2C@Z SΙ+C̀nxx $DNQ(L>mٲE0tѢEsᡈDt|Ya׮=ֺKz^o!M+^|_lε#aМ}pNoD@|Cw( ] p#{q,SRغ;`n:0#44x"B0vڵkG,9_·oI ֢#- !$QvEDF&h !$šD6v )w_hhTo 8C?z Hx>þ}4ap9ȀܖC4|D8\ǠOpK\bS!:S@w`qA?bV_ޖPeHWzG/ɓ1M8La@7pLp>.|u?bTyXM1Lw]=yt, Χul!]V=@lr6}_0a6oigw‰ 9q⸤uЄ[y} 6ig:!Fh#ڵ[?\8gCj 6\ߗ̹>/.pޛs a laԟGb5^'\׉Coa "a!Ʌ̙zњ ф;k nT܃}ImBII|B $˖pr&+ ގ@s]O</ϘQQ… _BB('z[8"+mOlkJt&'p_AH4L R?$(fժU.+_Oʌb$NVX*@GĀA~M d}ǟGb5^' !~~#IWr'H-WwC!,%EۀB2 i*=9r䔳g!=ӗ06AT9҈\|pAx@䤄 @s0{3UFP|uӪpaP ݰa]YfjTFA:Ùb1C%5P#GEG wo.(3~ŋj=7 a˾}x9P@~uXV޼yb ^W)}lM8Ǝub65s~ܣӗqbppݠ;Iq:N|=>X? )ts59w\ #F#˗/w}ҋ=aݸp7ݢE3]GRk|N!9_Iw$SL1zě$)Op/soߡ۩{ϏĴZnmkT9[`Aucq[|yuIbQa^cykLW9kn *uDDN?B'| :IS9RE!I O}wҥB!B!B W\rɓHfM-QDEEɒ%K姟~~I}7XF-:=oe8}|GIxqyŋˠAouʕ[֭['l۶]^}e3|wR|y瞻$""BƎ'ٳgB!B!BHdXar!}}qqMƢEXbc>?\=zt?T&N$vBܹaSO|DK 5jT MK'|$!!!:Hdd5Rz59{%*eʔO?l4!B!B!$~20:nxT^]Y}mG˗UڸqCCw#©} n˲e,quW ЪUKF ѥK˥K妛lchh%#B!2e !B!$mm!/Vmܸ іYJ,) pYfܸ :^v"V87 w;rkOmР4iHc?NֿƄc{)UB!B!B' ŊSC~IZh.z\AO?.D?~y,|o!B!B! /yfxx.wq9rh;&0tpKXQf̘%:yL\xI*U$qQHѫ۱K*Vޞ={B!B!?ygP bta"}gXR̙lٲӧc-[^ #Ç#GzŋZ, ԩSKCwKxɓ'-1wB!B!B⇎Q/pCS믿W_ƍ ilY]CQ޽iX~2e%_yDƌ+YfMB,:xֲ-5P5j Ydg}F z]^ر(QBΝ;йs;!UV9sw*}7Xx)iӦ\!cǎ YBY!B!B! Q/z-4m YtϟOڷo){ j*Yl\pQ+cJ…e 'ǎӂNgϞS)r.E~;Lf̘BlΜ94to^.zi eȑ?Xbl>ҥ~z뭷B!B!7> p3͔tu `k =z"'$,ԡLrB!BIl۶EH'5!d)1E;Zl%B!B!d8(B!B!B2F ! B̙jʒ+WNYn>|D"Jȕd-seF?׾_QR yȎ?ʞQ$)ׯte;~ 6lN,e_۸qB!BHF(!ZZ 8 $J/5yEOd߲Es!B!$BaB)TTX^Ξ='۷$XJ'дit&F_oPB!Q B!\da"""B!BBuuJKJNͤPXA{Ⱦ s%R(NBkT9$*"R'{%GrY`:.t03u9RR toM$88X9*sΗ˕+QR.VRѣ2k<7~ef"ݻujKy%ښw^8q̛@!B!0J!]ˀLt PZk8z1Ki˔%DrO˖oɁi %RF5yGҥKd2x%V=KbEo,_FuyG`buV(_Nz!ŋuY> @z1PZld͒UV"wmB! $YYfXRn̞=WN8.ݺ(B/df#>w9F=4{w3zy)ѽ2_.=oXSEQ8<ٺKs2wКh.FF<#!yr se"HJ1`@_E_|uu~@K|챇Q2etٲe|-r1ǎ T9lH{CBA/J׮d%B!W(&1[lg}uӧOڵkeϞ}r9ɜ9(P@*W,ŊܹsCNT)Zٳg+WR.BH꒧jY{E_}U7% 0@rW*#,oTe9m\>}Nu(fhc@tT̺\$'HJ+W.)R̘1EATt7vԮ]S*/o |V[b-hkIJť>#c?\Ph]%44e]B!(0֭ Kxx)[Ν[C'SN pdԩ-B?W.EȒ߲ʑ]~\.:jyl3=RwG_Ժr|F9j͏8qJ29sƄ#2cN\]&GO[^vwe9~d͒E6"lHHՌ(W6F $B!d8an .j1իș3gWU\tl޼Eϓ'ԫWZ.xp}f˖U*V(kR!sm~w1 ["ar-oͪUĉP 74ŋ/z6A۰h"ٵkmg$kRXqiܸAl/cNkɓ%Sk?HF$W7^{@x-^Toߦߟ7okpZa~B1.JaF̗N:Z"5iHB0.hof/:*]8qZVT|dd/YX֮Sۺ˾dǟc%J ٟg^>ñG !B!!:#Wm۶dY-n_^]UtҤ)ұc{)Y~3U E'С*tFF^ HR%-!d@,k,QԀر :Lʕ++ ;xxb w^*B|yYdϞR/ȪUe&قSdiRR%C.h dԨҺu+nt]'N7 R?8qkذ]^m. 9sD`FrBuE4=s,{}SWMBl I-%zn_IDGO`@`[c/s/r߽w{wksyj}n=B! )FEEK6UϟWQ~zRFu1̙3*|atɒy2׬Y'jTgf,14p-_-@ݐp/^\ͫ*U*[۳E۲esɖ-pxBx Zjp_rIu;v\;ZN77#G]vɁU`:N%+V-1ܴi UVe_5TX(QB!\_ ?(Z(eQ` ̙k933e"r#:mHƲWbTdj\p hKм˗/2gc/o=ݩXs2|?hRWrEk6TR `4͏vqB! Hhh[(b2]+S:*" Ieʖ-gWq~'B܍ qW\t颎W!uESWQ`~{ֱc!?I!,6mٳG}aZ ɈG׎a}8a%)r!'mѿe l K%u>S˗Fi[Za4sPR0FlB/y>[U- I(U!3T)[` .6lXWݝTڥްqxcp0_|.wV ,(G^-^Xt86'q)ZhL^RB!B21j\XjGG c/ҥK q )\" ou¹Pxm7,s,Z?~Lƍo eս]2b?ākCv#!.K&M[,޸t#Npl}e܏ՄBM;eRY)4frrصJ'͕JBۏZޱ3=+< ^\~=ng$r$6i^o%s۶a;oV b c1Mvf_uzU+ˣHcxpbl5ZVsjOd95jV;vk\`}A^6h.QjB!r=ªrMC' Gka?ľ`<'(A0Ξ=+yM/]l]58OMg~ `N yEѱ?v ȝ;o.{fJ^g'B;"g­HrnKȜ#f.qYO$w3DEDʹ~хdäD*&m%K{-C텙VwPz*e9"Æ`Dw Y~59~gϞy_){PqϞ`/^,O4jÄH-ԑ &Y(B!Q)\ cl۶MݜAA1 "$AÀ*p7,gBޛT?s%旬Yɉ\Ec;wpz'p?~\sc;4.rXܹKԹW:h/&6lPq7o^υ1ܷ ݻO!ozƌْP'wKtv'<~ Im;a yŹM]z빋'~YWѣ(K2-G!BH1rwV\Y+\Jvޭy8Nrf͚.]f-ZUb$CCc׬YG#PhQKܭQ~Cr"]UxDUMVau)*s&M\r9O+]Q۷fu@ܜ1cCݰagkpaͯ JI"5kT,ZH9˗ׂRGӰ?wk.AB!B!ԂѫzPP/^|ڵu&-Z47oViڵeਬ[[NVTG5*UȄ ,>7n(ӧMSX_^.]:Yg.z˖-bt͚5]pu6l@CO۴i#FvYBLDe~թs-[˖-4ؘ1h{=wwtA-[ߟ%Kf)P% W?ۤI#7o~MKK͚X*i&B!B!BR8ׁftu :!9C=Bs5lS0(<9sܜyG;w( M$u(SB!B۶m:7&剰ȫӕScv5"80>"k;*T?tL2M'B!B!$ OAPuV't2{z7-+UB!B!h*Ih/jR!B!B!IQB!B!BH(!B!B!$AaB!B!0J!\_bEH"={6wYٻw:tX2:ϟ_}X2oB$ɝ;|G2~$Rcэ;wlFaGT@gXv? z MGƢ_^v]J?G*hj玭1;.eil.ݏn@%KOpC-cXE'O~БJ ~9Io6lhh"9cʔ7;Joqi_ta37 kwuo6k~^幗_vI&Ə1&uꩣsϊ;YqW)OG/^%nkq~}Z:_hjό[cۯ(=#+.{쾞g`arsNѮ]XhE#E'}1[p#0 9ӢE2dpl޼%~.m>V4m$xeu:vAէ(x'FKJJ0FǍ{4Fk#1/^46j4 _W}eiMϵIm׮]Yco= yDTW+[ly6vԨQUoԸi\x|foeյj9hl?-šֱsn=&@4n8F8! So9-gu5e˖HsaÆhժU=_lΩf3*㡐O?ȍѮm(){].5m{z\.D:t1EPwO_RN:e}5j(wpKѹ|!ڵOq_?lKfġrٜ2񎤓o EN}P=}|Dz}F '(5bİ$5)SތKYip.(X:R~7x]\zEٖA]wWe>=οЇ>5uksǞ~?wCu^߫ƜĘbۓx|mQ`ѡ. 5ݒI>?ar ]tg!وgf#i=W_^Qw|lo?[đ#bqccРAq˷wFn޼93xcǎD,\(~?G6VzH2+b iOƴs >;78v) r ~( 7`@$uߞ+V .۷?.iӦ}>7]'S;F^嫗_Ə> yuÒ8ӟ9sVDT-`߽89ל7GnI?>>)g|8.z3^?uzNAAatF[8֮^BɎ=6kѶcbL;DCؾ5M-GYYi*Cc\ݳm1Un*N3:Vwꫪ|f͚ŗ'2N].֯[:/ԵY7:?`9o|=t/_:3niqquYފҒl4`<^t^z99r,Z8=l}IyfԼ,ק&uKx1dȠXlY4k,FxB{A"xy;kNѳg;o^6Q'G۶m8\ѓ^75Ip~5Y0X웮FǷg-vf+/[41[6iSa'/ȏb7(Y=P?_';iDMW߱ys^!#.Φ mP^1/=ګ)aYŸj}&MZ&%߱saa #zQ1˖-F!]替7p] 6,NGn߶1̝ 'uWy*n˷[ޭq5JvW}|,l֭>tCΝ77ntzr.Qd{]{JJ@@@@@@@@@=zt-Z4~޼yK,Y4-[yw7;vWk<"&R"q /H&dŋǟg}>?;vloZ/**_?~p/71~s@> p߿o3mK.XbȐAѸqX`a_wM\}bʕY,,,#'?hӦM<FVXXMGNZeSN޽{…beqܹ⻷ JKKM6oUW]=h~8pJEVESvիd7i8ȇ4>;Jܾ}G9>;o~‹k|/;‚/~O?\x^ƍ仞v)Ѯ}ؑD׷΍|w_v1n6lhl"6mOy#;cMUM8p@}<|W5U/ă;qlnSO ?l)w(=s⢋.u~Nw#I%@6i$zڵF~zΝ;cSb ٹ58M7׬8~_|lٲ5 /mۓ68lo?{u)~Wq6mFM`֒K8y٥k^k,xɒ,v9G{og[28|QȑO?5 i 3XxiP?gqz?rk,Xb1rD<cMOvlذ!ۗO|sΊC3k}tA|(ƏhŃӟw7eЧR˗E0-֭_=w~ܹKvR3ftE*?[PkV-[mQ̘1bYg'KfU.Z$y罱?}ʢeeڷwFћqKiS/|sY|nV&}p .87$A|69F GґjiI#7oO:=Aqss*Q4x\.1aKQ4z,^/CES˖-o|KWogKګKGBv)߽[xWSN]v{,U$Q4Ïxgo/ES%eUxeU4rVWzW*h*$q:5xР*ٹsG|%Y@/7h,>UOHCh7t14#F Gss㤓NN.Wݳ<^p~|s^S+,5Zګ<ִIY}4֮]^ ~f#Wy1oGI }glz4p]q晧qIlLG.Og:@/iPM/O/OGeZl&UYqjw47}U֭[[5֬YP?jIѷ_*;=AӦC$@] ?ڶmĠAݤć>8Fӟ+VV[P8v|ժ`.nJGr7z&>׾L-Z$Z'AeO-o3{ ZBgk֬åwUh=S}>&@Nmܸ1mD܇ZTAZFӮY&qF SnjF" \jSwy;x8RsܸG}zegΡ~5%KҠ9+㟪5s?IW5g`FV}s[yJxcaݺu>#ڴi]92Evl֭Ѣylcelm|-['W[E_MΝ;xn:d:SqvکoSݳc6 Ŗ6n'|bDe#I҅$ߩee U>gЪf7oޜNmESPPJ] 3un1Ю]o'1,Z0={xgdƱt=Zt췲H|Tٟ^xeo%5kZeu ߹ '^~z$<+;ܳyJ KT>}⒋V{HsK-ZR9/GKOoM4EE*߬Yҗ>_V;~f ;ǙgVe^=㦛Jd.]>ڬi8WvJ=whYQz~/U~ȍBfP7.cX:"oѵk8Q%4,X0[;Ϟx8sc$]߳G:tH̝vrӹDUͿwקfm&[T'}sU9s|9O+f#ӕG&I&2s:uz}ѳW3{n 'qb\:*4=K6[lXw;kNIyقK:9;G\2zT>}+V4KWx]&Lx1c٥ /^ UޭߊW&۷m!Cg%(fΜtbne6oޒ#۶m[|;ߏp]+G6{*l^8)JziNs|nM㴸qIl:V{+c)qFI$=dsUVu#'/ .8/v'[xI)<|<^BJG!oLj#KS'qM]ǜ::.{Aޞ ~ūMrڵ9YoHaēFf9=gws_a<-[ftl*]`hU:6 $>;իWDž\]i?ޞ=<^uWPi_J[ޭxN\(ڭ^ݻfztTS>Fj`Ν77ntzr^Tlhҽۮ[YmwY~TWQ wQ wQ wQ wQ wQ wQ wQ wQ wQș]v+WŴi3"FTٷ}[qםyo{M6ÏqƐŠaFvbw򿵫)bjߨq8iѫhѲ]Kgk5+Qȑ-[dQ&j,]4ߦu3ft||{`8Vz_#.ViOEAaqsbsI61+-LebѼIѴywTtyK<|-j[G#ar_QZZ_&M'o͸ګ'q,hޢ]EY|FYYiqqߋO@̚xڳS(: x7'@<~mGrm6ѡCXpq޽;ؿtyqhѪCZ17 Q4s֘;hԸYvy}MZd#BgHYj~W;up2\r"-sXxI;8m['qI;vo. -Z6Ɣ)ow7nq?8h֬Y\rɅqʨQ._ˊEE_s/l+M\4D9Z۵kGv[PPPo֛g[mJKvT96(@ǎM1k֜$ ,={vkV߾}oD-jޭ[w1xȠַ۶m5;w_ѸQqմic.`viӦqccy159܂ Э(IbKxlqѥ۠XKQ8ƥn˖- u_ڵ~c\sen*GLZ.8>b u?ɏŃ>?wyov?]> fϩn3`LsxL{sQg|([~ڔV(t͛7F(Zt󟩱ߨ9 ˗ES=d\w ^O>9>$:3~&֭YMrNcV( o>iXrUo^y5.s;c[onj3k,TղeXhqSlذ!Zj084k6^U3|GQZ}w14μoc_̈́Q8u)6m[nzzy}˖-}*%%|X+Aڟ= >,y|rÑIE6=ߍW6펋 .Jlټ&eJf(Êtt,Wt2a }5tGJբU1nݼVUP}/t[݊nQ[zdpd[n]9ėRw 0wѸqӓuɖ3Jnneݕn#dQi_#FFFF) TPP4piҤiM:v, țݻ ¸񆿊;vW@p*.v OPQў/)- OPF{U$?aa۷?go޼9}ȻN:ǥ\ &MMbأb o̩gƕW]ό"e.hֱcgC s /<W,SF| Ѵѵ ѪO~Q\Tꦸxg%.h#F 'yزek}wE]xY/o5b:koSG>TuxW)*FKJ]J(c۶mAU{˗UI#G\xwX,x஠tވQF G.b M6V_VV?Wsڵ^rE 0(5oI:kִxb˖-ǝwlҧ?g~vCǙgVcݺ5񅅅[My%}Wkiq+~Xfu-:tgu~ 48ZivsUsqDNWX>dk5^UqcȐ2~˖3>Ӧq08>V|Ͻĉb_z+ ǕW\{􊒝;7ĸhQW'$֬V}ύ'}4ޘXdrz$A4o%y%`#|Ѥiwѩhܴu7Y7,K_½җ3@Ccr ΍~_]~ѿ`zۦM#F Iv=MԜm(hԸq|C}ߙ-4Kj: 1kywScQ}: iyR;,r%ITpҋŤ'D-8 u5j(>/$XxakW'6ѿD 6⠎O]&zkѣٱa۱`Σl ѤYy=)c1 9Fr͚5O7ɣߟD1rĨֽgիokI:iҋu,\0(̙U9͛7/:~[skas937yڻÏ<̗KIL;wd%}d\G?bUFڵ%߹OJ4''Kgm˓^Hsr{ذa}4ĎbݪY1鄽F[FN҅/DY;#R6k}].}e{7SϿ)ǒO~'{}NCYN1?·xibߌNl|1g(@1 91wqŃ>˗ F,τP>Ps҅{ɹoU>/qb̩gC<`֬>WC}rw HGoZFt~rx?O͢}cgG鞛}U~*[c͏9N(.*=F Fݻ#s DڃZzN,,(|hO~ ίsӹ8{lQlj'OgZm_ײ/e006Eپ~/6W޵kg̙zg5hqpt24R>gQwRa!1 ǰݻťK.0*nݪtQ<۾}GlѪ|>.j|zEKgV7jݦu}mڴywlڲegСcl& M>8\{ΜCŏ~tklذ!οؗ_W:b49ڥ)ѵضeu|u%;6e[vX|CQ~wYiv7 ~#o[}/1*40 ǰ4Fh"ZlGDѮ]8w,UVG-X0/v=.Te)OFF&syM_vQu=EQaՋ{|}s__iLMMfR-CW׭=(*~gtA#>rv)~/s.Qn׷EY&粰hFƥp ۼyKּ0_6z4*{7kyG_1cXa}t%ׯ_rSO?Æ_gǯ]69kv+ŗ֜EA'jݺEܷf7u7ny"?gV`w\_Wq̙KKYhOgf{OFFc믿MIZY{ 1k֜XtY]bY0`P4n46o'Oǟx(m:$'_zE 0$$q/]|Ŏ^@mƵ| S^`[&N?lTljMǍ7&Lc׮]?O /.o޼Eع#-]7|:>[jݺuU8 n]+l^7.qoM;o]= *߼=DcmY-2[ЩqI6,ʎ_X/4LATW~mVw+޻ ;k_meB"7=!&;Naaa|֟kS^?w) ڷ||:+1g1Cq\m;w+V Л>Ѹqӓrdۙl{]{JJp:3]/z1˥ "@=aFkSUm=  %(@POE eeBJ&*UKFeee!8ڵ#^*.nݖX| AQJJ,H]~aAATiQ8ر=&+- ϒ@Q!Q8J '#####################################################################################################################################################################################7(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(;(@y###########################################################################4-[c|1zCe9Or''+V,-[quN[#ݺu뒀6/ m۶ ~ kٷJ6m%KGcȐ1bٳ;ʢnݺI}56n@uFӵkЇ>XcoϏ{4hPQVVϜ9oecӏѢE޽{uGC"F7.xϕc׮]%驞={5/^ӦM.8p@,X0^$6O>iӦϿmڴl*((3ge#T<>|xvܦM͛gѴYfm۶XbETWgat {ꩣK.s5kݾĘ:ujv܈'D oEGtǃ1Z[ B ⩧9 ~Z6ⳮ֬Yu p qwfa<#P_ziRn*$6z|uc/ǀiӦd$ؖرccg8T:'jCY,ގ޽{ySe1cR|@fa;*gJ#R/䢸c`s2ݻWU{۶mF-Z%I4^ESt_:buѢRMS ѨQltڵkRŗQ֢ElK00$Tyte: &M\Ҟ^ߨQTvk*_]Oh^{=xcjt9 =~HѺHG>qwGV-= kQ]kF+_Fr+Gvfs5V_`6Z.XzM,X /_%.tyf@zȇ/K%ے5kVt1(.~7ꍻ jUꁴVevz.8dw,C)]p)n޽[}AH[>hR:_SM?ǖR?q[sHUXX.ZcYXYP]G:s9iC)sQL:-ô\z?WTTLرӕp};M/Om^t:w#IiiȚ1)K2bt_+i-Ew׮]e gAwK:%[nѲele˖'888ћ6:bAYfdž 3OVOM2%_}; KK VeeHt4Y]رc4o<6oӧOl߾}d6mUhYꑱAS|RbhAN5kرӠ8 ?[%~ul_˖-cСq#UVqQy+С]\tׯoqzFΝ;/l#4>_4y??!bРAqy ' Bŋ4yfY>뼀WM9)jn_#I<2TV-ڻk=$[k׿dQ#ߺup)..,!҅@Jnn >#=k+()ZAAAf=At_a[|Y߯tu&-|Ot0Z5F$M*UeQ5׿^q{{73Ϭ=Wc]W?l0O^޽l8 ۰aO:D+7]ž/|>#F?LE Rw񃅅Q`۶w_(nbq>Y' u5ZV˷ nk߾UF@#E۴imV^}r׶?}sƵ]F_>Z4:FѣYGa@ Pލxѣ+{^]h:B(ޭYPuz9Z~]CESfP<I[#S]c]*ȣhpźcuy]w2.!T#W]F|%Q4UOA~Q;a4UЀQ8'CpT!:P>8݇C1O8z'r Z DOȟ$;h9}i-w$R8!#9֗ #2tױF8rFrGr̉IENDB`SSH-Studio-1.3.1/assets/screenshots/ss4.png000066400000000000000000001707131506556307300205220ustar00rootroot00000000000000PNG  IHDRF WM/tEXtCreation TimeTue 23 Sep 2025 06:35:37 PM +03309:tEXtSoftwaregnome-screenshot>2IDATx|WoOpwVP.BiPwSwԨP*@wwww'$9wyˬfw nfggfggg;{cB!B!BrB!B!B!9 B!B!QB!B!BH#(!B!B!d4g$b"NB!B!B?YRHJ#PB!B!B",!f1(!B!B!9*OQ2mS<%B!B!$y^!8FipB!B!6g´Ll!1*(vB!B!8^ +):FcB!B!C0hNfik!B!B!$kЙ慕 v!B!B!L ka'#ĴQ~{o!B!B!$sIs⨯y&@#O!B!B ?'+]Aї(PB!B!BOBJ.-$&\O!B!BI?8Ey D(u.b%<MKuO8u.'B!B!PQ% G構pބOע%06}#B!B!@- f^c9.RoEzDBSmhmB!B!BHOt9SݞGwT$07iXܣ D ύ ƶ !B!B!GZRowfJ| J80(WQ46ar}t7Q4Z!B!B! w!i֔|16z󿈧}QnCGF 7 FWABh^<Ν;vRRRXStՖ",G !B!>$:::5**dttԎ#GNWw!&}4XjSoRhaQ^5X\g4hPzԔɧ-!􌘉BHs\B!B!F111}>fN Ԅ՟=Wɽؓ{>R%iV} (+/\^N%GS %QB!B!Itt IIծ]Ik 9foB8j*..-KKE|ܪUk~t>'ONMM(J!B!BHZӧsg۶m?[ġОӽp/MR$Ȣi Q5^X)5DѿNTOII J!B!BH So۶/kV>qhFpA8F}EUhqFkTݢ(%'B!B!\2۶mQ\]vmѮ9ODTRlɛ)5[pKSBHք9F !B"dYH8AXɝ;ʕKI/8O]Pqd:%rڋ1\c;)gFz4+/ĄB!BHCұc*/NYc8h#yuM7UGvc4kԝrMbsX|ըBHֆQB! \l%Q>v'h \X"iUqu1 oyRSS!B!qGQ/p9J҃Py80y/2@WK9sLN>MaB!B8N8!OxbK.S{+^Wb ,6!? B!B!$@=,r>9s1;j/Rf@"^d*&&F2O0V)RMt3Gi0H"So!Na:P%B!B2dp.faB_RRߎ b9~yP1dy8ܢ4@4`"j` B!BHA(*dsɓACĔ?~ɓ'hdrڵH_:!F؟{TGn!ن?+˗/}[d)P KٲeJ*Rn]ZpY`ڵC$_ŋժUƍoE:oҤw=u]/`ذan:9|Cռ J }, 6d5xiٺu>+ ~_].]Ԩ/B:֭~WJ.%W\quou94{Pt$+VtwuB =DGGK/=/Ärbbb:CB!>c|$\p\g)>EzQoem]vdk֬ՊgRf ֭ԨQCHZJz]ȑc:رS[ \ =ÇZ We=.;~N| ɝ;9B5N~'qw/e!>jԨ.\G>Skl<0c^{CΝ/ӧԆU׮]$k[ʕ+'$[K!Eu9UmsvI k֬D2qdٵk/zQٰa>;9p%JȈJ$|?̚5uy }@Z:th'$p֮]g ⽢ v ?&M x1)_p">+_~9R}y5>yéݝ!p"ЪP|L0e>~g .ejH'~qŅ_?!k}JaM^EQZ̐7#ݵd>pBݻwmvȟeG˫ `@v#̝;OMh?(P@) 7vHz9Cĺw*Euy}5ػD-[oY2lc.؅|ic$r;8I; ,Yd_ gժztoI 児PǟکSVK}?c>B JեgϋrJx{&y5{ty=66F# D̲ҢEsiڴIPS믿ڗ_~ǎx`uPz4(S@]c4d0VHQ4w<ҧOOiݺH,,@%cƌS')6Ě,_R]֭[J~HEcǎi(:\=HBqH?@o.:[le˖Yr>ȥ]jkTsk׮FvX?.\w# # ^xw) ={k۶mXvŒ?>_rI_~ AGʎ;\9rD͛7["t s 7a~x(Һ ԩ-AZXжm+R!jT\Yh>MmL& ˄8q$na g͘1S/}INJH7 % G}H#H7krÇ=DVܹK+VtKRa@/@:;-t>ի\{ .rN!Ho |zn"h T 7?,ժUsVTIsqԫw5*󺊣VugϞNQ eAFuhFgh߾Sk֬_.烽{9f!Iڶmsw-EVZwܮ7{9MV2dm!>H … -9A+f/{4s"U~-<t5ZifĝVZB!C?dlݺMvW(D?B)0q;EѼyY׷u޽d2*p7s@>;}7+/qtvP}6X'0PQBҖ-h˖-ob A)HZ lƌ'O*hw;D>89ݛc=h#һM:]ׇȿ2 b@-[6h^jhk*,?t>U C86@ /6TP+Oiju} 쓍/MhT"'(vQNjեw*,ay رc媫 ;'<ӧϲ}M Wt:_#U}!]tn `s!yᅗ7G.R^*]_MJS @4k ӻBt~H;vkoU;#Fz"ܛot>vCoѸ䢆ef͛[{$lυS4Pr,]v>Go:{W؇%KXy8gqoCd͌4љB8ᅬe:wd |τ 'ȶm,Q5O)xoݥv0>E}4$y!7o,eut >jB}[o]ً/>yɒ%냻y̘ȡuw'9+O?"}>ûG!oذY9kgѢ2z: WJ۶mtӭe>C-e5{(dDЦMk-ײ;[0`uinדDG 0$ ^Gh.7lXϺu8Wׯߠ|~w>nCۣsS=GE{nkNgyլ<>;(Ssy;njSTlHUD1-7x egΜm]'N$ԯ޿*U,>)FO>o}0 MJJm"̑+W.} G`;s̳398.˔)cKKÞ?\;L-M:)}7^uٶm֬Q97}J%EN/eBz_vMkNF}uOK4ǨQIf8pFs އj՚pS0:cHigՀBKhݺ fy83ym-YjEk4;p ˌZr>o@ze}/?0b_ԭ[,s#?";*# ~|'U ?žhq :7ꫯ;_c:hS{4)uo̻k9oD6 B54^sFdžYps;ݺ]FL""_gfnXνV\oH[o=~w k=C>hal{A:wڴm?N}g;w5$'OL&ONi5ŋ]#Duǻr " ҿa\O1hdشizjǴk=W#yZ>A# `›OC8νp;QT\ ^oÇmHDgA`"[ULEDt:;09F׎F@ 7\o|ѣ4d9_'7̦]:jha".&A0l׮64MCO>JFtwM^ F!0*;﫯*-^w}ݭ\/t+Wn!n c /}y≡:8,W?s)<#aڵթQJeSk+tdoe ?v!~"Ao&-ayhwޮyKVӧp:b1Ȇ$6L\WpSAFǏ)b_~\{p챇U… KvĸBC]&P7):W^y@؁#>aSvX" :xʕD'("\QG3([:WQE˗/dt@PW:pyOzE6Գ>Ĺ Gu E*`@䪫.W7|+ao _~!%3|۶m[[)0 3\_~Sݯߵ@oN>+=p~=z&B~/F8FW\|}7Z.'v8?!Tb!H#b.bo6Ey=؉ocpEQڵo / 8rg:eDԇ֭r 6A 8DM>C#X#ܹڍ jU״dF>؈s|kժ"Fq:|+E-1[9ԡk0I+,|;Aˑ. ǺCy#&fZ` "ZB9I\}U.A#[ /Y&)'&/fc9jit3\֏A ￉֨eSNNagj?7ԩԩS]@@( ōTG'D i}~CY|$p'<Ǝ'CBDG\g1!'8W%9EBpw:q b"}B' B} ..@zJ ^stmҥSBlӦ-z/ppDS" XNCh/ 1qD@x+VRaXJ=Qc¦s"ME>y8qS\sO$5 D/Nݵk LС8x- ,۸qC7t7}LMp^Ο79ßqM;MqŶ DA2ySq4` <[&4!8_QC0 AS|FFl#5jP! \sz*L"ՇAYkiFѣǝCƒ{ۨQM-{=\}:u85^Vx4ۀkQTv "g =/0p b"ɱ^ =GvFC% ?Spn?idaԛc^q"힥|1s,$4w׭[fH@a4yv h>CgCʆ NJg@?a ;1 +{ֆnpL#dXWZ\wo( ώ{i7B!G('Qll Ɇ{:\B~^߹Nn.T4`r'"OiQ{ 5•a1yuLKhw>}`Iti&bΈjpw "W_-3|; 믳:֗Q)GBB{nV;wvv;2b؎ 킜# tw~?i Z؝EիWuqMUW"=-A DDq h#7baOS@ 4tSAFۏ#:Jp9]8]SLAќo8y1|okd{|=Oԟsnmp1 @?/\~G0$PXk?p75݃?4ߏM8Ma3 o|u==!؞`il!(wCw'j79fG0 {<1ci -~:wI3I_~9_oQQ23Rqל.R3Z 4j*U* #1 >¨{GYu#ܪė82ÒO@>v A62"E͛套[SrSyg"":/v1Tk? ԀWzm,\_(Db5D< `B@W-;vH]s !`״cPPa`ժt<{@r@ sLK,.Țad掣Ϲzvq36&=x?aDA7|['w94 A %5t pB ]sMg: _5]?\[iX8|zNjA:0p=4e˖9EQDG%k믿t)1 Xis]^ }EC=~1NS{3)I`KPߗ ߱cs /uoJP)#6(.f?`[n9Gq3S(.ψ@$8^va4,CuL#4㇀01W7`$0h 垀> iEbvsh Z8X+^BHF ,'_|q}0Q81m)GK^v'vq ^'cQ!ֱ‘?t~ݺ|cr߾}€{ PvpUe`Og7R ҕW^&'O{?!EEL "# ^q}zzOh{!,YRAX?&M"9b5D G(=/B}q]믿}Ƶ׀ـsW U=B K,p@r;ݳ]/]c}v~A˞ 0:u#"?ʓO> y(ʗS *:|ږ#_5;{:`B} &JoҚ ڲRƑw 7{|HΈ}8)mOoS B}3~ם6(@Py܄Vt:J;( #ҢEKK<~pɬ<󜆋iFCLS?B%%A~ιsi8RӦMB e;vK{UXH!"b]/C}*_ nf$ ŋ9emn B2 GGy˵P~G>ZpzbtXdʛv osL"po@i*70@;Axk#T0@ =s pqy};ؗ… J*UաeyMܳM1>{]vÛy{8:vǏ:Nӡ :L #{;pt|;O/:WT*-*E==CE7#2gB_K/;fupL[RE!`@eܡ^zi_!k8G)S4u F6m<>su~{~Q;]t"^X~E% ^ⷂd\SVwx!KM#"v[3~FD;A8)to7oޤŠHƀ ~3 '0k \3!#|H!Brl?%(~c/Q`Jzb8 h\v%B2@7MJDO! }PE} @םV(nH^o݀Zh͚5TBG8J1rmP 9r:FU@7{jN{#!dTkFUt/?}qnٳN2Y@ԇ]pَstTk74șڗpg_}^z'TJL<}оDNpA?n{@h)TjUY!MmAƴ\wW7M>a6D:fẋ6) zp@PS?8!(7mX+'sp̡q{Ռq0(\(HA0` -3ۗF*k(g !b7 `…?G64b%z(0N+㺃Ԏ{/66}7 mjO{l Dw쮻P`p]/FLҳPkЖR d'8 H jd+aPz |JXFE#z$mИva']uCmĚ?VP 5=/Wǐ!yȳjFLE*j=1rQI#6ȕ?idBF9!; կ5Z1=@Eu bnذI9l'jZQe$p TvYpƝ8vщoܤB\^ɀBeժU1cjNxD]NEptVRFUA  -[yLFA. ) ge t^~%եK'mz/(` dcҤs͛xyuࢄ0 Qۄ[ 3nZ2h hOanFY'\plF_! wpz8A18cejZ@ߧDbEwu.{ϟWS ѤI# |:!0$978eǏW|2$۷mG>h=#Yׁ 0ժUkRCSp0ѧ9oH;&<0o^pa11zO.Tn.Bk>:*٧$*yQyyL1gس"Ypɬx ķzۺ1KH` $QI4v਄P5[DqQv5uvK ܯ,KEu\fv:MX)F? lApI|2AܭާO'kbu׭{6XӦMS2qCm i7nV#脗)SV|a믿["BGêUVҧO/3MF?믿L$7mluD&ZW ?ŗ'8%$ᅬ :Bj18^tqcxǞ΀{=<%;G̣sVB78+r${wP!b_~%syl^|9!%@QYf YG} "!MN}j(wִ[O*Ȥl W>yUVmm=Ņ 9 !Ĝ>;Rmۣ+B d?&+¹Pyp{U'$"RpayGڙw^ZH| 9!˞8Ȋ(-4AIf 2B!! HpwR% ~p#! 3a:}  Kt0J!A5qo߾M!L A:0#$AVD^Aa*U,ڵטÏD:H!IVOYPrer? 'C !B'S)[V4 wdFq0B!B! wp!S\y $&&ʬܧ̅(!B!ʕK Fl A yJp2B!B!~OxQB!B!$B+..N9c;0J!B!Dc.X4\9B UB!B0ڋdb) { $BaB!BP XQ"P%B!B!8(A9sFɎK!BH"**J!(!ABB!BHfh_*!Ca4J!B!$އPJo(B!B"{")!P%QBرS!B! RՉɒ(!~A*4B!P %Y\rIf@ax5S%9V'B!$r'~Oqd(?ǛJB!BHv#u $'Ba8qsQ %NJJNB!i5Q $'Ba(r nsQ%KjjNB!5=%2$(AZ.O@vU sqyz(%ٝӧOD!Bzx-}DL=JQ%znJU!ԗ@jGD" 'B!${.|U,ݣst:})Fโ.&&Fr%Sm>P(Ɵpi\vA45E-yA#[/ER)?~:%B!$k9jO#Hasd_^w(zARRj;&][rr%H޼ P%If2"DSSSScP/^L ,(y2?ZB!B!B;q:tHv#6mT^ܹ]R,Kq44MH8!$p ÌS@,0J"_%j;ܠg,A>֪USʖ-C!B!BH"fll}0UTI5k"7o%K9 ?+(]\\y^(WS8D$QT^(!B!Biեrʲ|2Yz͛هF_/Q=I1,P N'FIOMN>?FJҥB!Bd 6jX)*3gV. A=sCR-D>ӧOKbb4oތ(!B!&;u-.âIx8ǒ(ht\9 ~RIiܸ+VL!B!F2em*LuPO+p%CIXn3BK+WSќ:uJwg\+W. C\Br27N'e1k>BC$%%㣤A<ҥNiW-޻rs~uDY;}},).󋦤H:M!R$$$h cBDZ@׌Ěޤh)!]G0%+qrw8U:|ywgO">W~}Kݨ=#x"ߨ@kD*l[Fak׮󄐈׸G !Y t>, {Cso搼tYݣwG>A.\+BO4<<$SNUȁPt|$..^ʖ-#5k֐ r0vZyᅗy.dР<9rZFC%`4i„2{ٿuMBB @:w$ف%KƍO^AU>uYb͛_͚~G}H.I/9(nXe2ymŽ-\0$Sh9O/s#;*WlsUEc.q@Ïe.Ki׮ݲo> H-;w.''{̚5K11?<Ͽ(ݺuK.+Փ;w!م%K 9FG(ZBEegy U@%ǪCM4p։嵩SssUnf̘-[n.&_]|6::&b/F%D V'Dc^2]s!$RA=s8|>(b5puo @?m۶9qNBi۶0x&n4eTٽ{;v:7Nio…VZұc{ɟ?.;nߪq-ɥ^Ⲯ_S8Yر5kjhKK)nMVcZ#!U  ߾}\qsϽh){#7ޖAX~ݻw>6jeʔw!1x9zxIB<㏿tpϟO*V({:uj2}#&6\~Yb? ÇUϿ$֭zsro^2dѢE-5.D05n8>}HBRfMӧ4!$@cY $RAyB_(ݑM8sAO8\@ja9#)RD( Ol +WU.cC,ݻZry))dϞ}:Aۭ!4o).[L5s~gDQh&ժU;v/R[Pn1ƺOܡC{˯U}/^"DۂaC ;wO?eEUV~YT)}7o~@x瞷FNJNdOsZJyiH}-yK4~_}3V* @Æ -b ҷorE !g!䓑V'avz!:#$S{ās&=/AB*> kvZP߾[:< Ѿ!e˖<3fRDt|x4lݺ߼yFŋU @mfkÆMN)B5az?,Oa4Eiӵ܏ǏP'7lݺ%jNC̜9S u1mbO=;+fאa-Qf !6mڨ^( JW$!uR y.m1c2wu }Cv'[ B1m4Z/_]o߾: ~ҥvZ?.dǎZ!&uqӦ*!\>g}Zʕ+'v3tƙ3|ŪR(y*ٞ?~!yU;X]o1oRreLvZkF1>'|F+ɇqWS0&)?˼Kth`}嗇{5Zuh*فhz%k~ /!DߤMs(FuZR w *HUӧOٸq*W_}syi.R;pGW8Xb}WZ:.Bڥw7깓Աl0mMtl7E|ay6xMԯ_O^}S\.R r-!pvQKSń4:,g k0"#rsCx&9|fǀ^F @:~)4]v٥Vf!&eD8GMӐ!Z@ se& Y_KÝ*~3w-]ZTZV`-S ePxiNi[d 9ԙ0/G0u ɚ *HjgEhri}D[:%š uP[!<=(P7dOX@LzQw 7*5[ܹ0/0kƍtʉ8y 9EM}^ЇAfc츌1R.GAtL~7k :pۄ&wlsmq]od?"rGIT4L\Q*l Pv}]_֙J$X~#ڵJJDҮ^ι\Ŋ… ;Mv:Zq~U>SBy-ڄ]jhܸ˹8E뇵w`i`4+1a>C C GB#2jpBO tCNP|+V NhP!s؝/^*K,v! ;vLG_.IfT f{ _|p"oPΝ%2e%m'N$he6;&@bbBq~s4Krezݻ3{6SR޽X);og}a h-4?O9Fy)GYl7Q%06lبNM6X{D_R%UDBF=<6  HU#G(>HLvHK?uujNrw~TpiA"΁9E~CSٳW><΁B5R#dP| @Eؙ\d#uwuGJ$octƌh9rjO(h9,?5nd}}iԙ3g7r гgw+믿eӿ~{˹\rݻObPv;@*&/ћոYQ˙IG-ҩV>#̼8i[=)#HIi8.^tt\](,H[:esrs6%QA6\ Jҹd^e=:^j%E DKu{Ço_qV.ҡWX!>::/}Ǟ7@hzM]^-wѼJzu@}H|r5/^B]0F88*T(w`6{jC>أ} mzI@a4Dt(rA4h0ud׆c"NFtzŵZhQ n…9B"E`Ko\xguDm%Z@ #Vlk!90%JF -9}≡ҹxb:uި;u(^zXvjtV.(U# ?dB!ШKpg}2[Cnt쯎۷yɋN4(GaF3gXtD {tй O4(=a5k.ܹ/?>F!"%ᖁx w> j$7$W^9q0<$G  `S#ia Nq̲1 nDn0>tca/|2ϤGcw] \ͭZK.DS!=~ P8bEQ\[npsg9,waky,suذh^=U</lF}#ߨS6utm f3#i:>ovn;jq\ϺesCCeutYQ{3Vhy6q2rgNNZG"H !=\n(8>ps@ 9FQᏉ !p嗦) _'ҧe^#o@ȷck>R`ɓ';իW|/uVOSRR"*Xju/r8N:@x:ivЁ4 ľˆbk¨)k|+|nQE3f895lۀ(ZYwu@rx} P{*t|5ȝPQ ه',b݀GoY>Z "孷ޮ^pmp.S ODa-9%Q҉iK tw۶mпUE/q?g$*Y}9b|8 P(gמֺK ɪ1"ȣsԱcG!B#HgTdO8sM,¼m[K 4o`:Gć=#"E7| eP)Q'iGB +_N RC4 !9+ԩapm7 ",YJƍ\.=(Bcb5 `9l.N8B!آ \+ x]j[Nj_PuOBx{0(\s??@F$&tt/GɤISS0{,!Xd;F{nNo,xNl@NVjU9\3AOB*Cq*U80N(!p}rwk^c^w@j B`EL 8v780pCDUŹ:mڌ,%?{.t {oN:"}-^.QZtjhUzMC_;u+^aؼ gǚyԅ[O)l9tvODyϩ,[y 5@ +Wr>U8u(vx_,7 8P8S g60'=3NQm#8:l5]&%>?)FOJ!D"pU+f^t!:j+XPp=ø xԩ2f_TY:#Dб KW_s ?k,uw,ȱkUD8?Oۇ١C[0a]F R4Ta˧OHe?Y#e l?3*xg,}o߾Z|^K.*8o¡Cp_s| Xrᣏ\o*L 8@8WQiԩZaG؉̧ wrnٲb߱%J w!+vb /ȯ JgK!I$jJE=m R1l-jEeԬh%EY˭ݏ%ʋPΈmE[W6ikk|ֺq ybʦ4( +/q*SAS_ZOZgRX @: X``7ٺuK}BOF)Mjժ!/҈+3}HFAuoFQW:?W=3m1PEB$u{(!B2~أGo a*ځȉ굦J~@1(ŀXm(7phH!S'wA l߈&O<->u{Rau.a w/c\Ö2 L:+F91ᜁ !(r`" \5kju蟳T;Vpbǽ)Ni" y:T 0K{_1u8vO'wP=T2b'Y(:PB:TUsCLrS|Ga}`]r,+Gc>`[:uR|_sU^ /lp" !Z>Pi\ $tp q,'L4A!Z*ngDTFB԰ ?tCWND}TAgbbD.V.[j\pO= nNRFuu+*W]uX/=8R;th/ou *wU3VU%ދu`]|:~C4BUz|c_!3\|3gdQru4 %\g8&C*70cدڵk;om;8'ϱ^tEF*JH;/t@&JT!)8=o^mQ}+v<9JvuG˙žxC򝵾=:guݞdum][z2wIA3XsOH8anN˗/+W$={^ed매`8KU5lڴYɗ/TZM. ~0zAk ٭ۅI efM \ҥE忝;w9Q{XDU@{ү0~lܸQ%N=VXS v!$'0QȈʻd#0J/xlq b'Εd0m  =p`.jD^ͼysD"Ӧpq`n۶]ýrlܸ:'!t$KÆ\ҤI#YpK0ag%Z]5`Dc+m$ 11rҟW SNQ/{jڴ3o.%K[bm[CҡC;H ` b4 \u %$hW-4[m;%$s/x\} q~r0 b6m97UmР iQ8J!frYN֭7qnM;֭NQy>/^S< *)֍ܜ؟`Q!Q\p:/9%׬YmR| ֭[>Ҕ)S{:G:th9QǍG0۲e )Sd$/K.#(P#3!YD:ww[9"L9]9wG&}ȑY *蒈a*UrmYbEu1>}Z#GZ\Tj~Lر˙B,B;wDBbJ/(4w-m̛@m!N6M/ڍ՗pF ip ۶mc *༅(۪UKtSs̕;wJFboBD0Ū$s4duj%/]VUs H ޻#^_kܮ "4@<y&oȩSd ,Ϸl*  Ho^粎!I |ٳ䯿<7`z$K86ѵkװUN˝;uL\|1%%}Nͽ}Q ) C7#(N! ¶&!!y$KB ƲHr"ޤ ! |N@DQݑA$`r~Q ӕa=n2+g.[GCIIIƅ\PɎIVgGQPB.Ff<风Q@(K=e֬Yꔅ(\%pij.G:*)Ru]v !{33MT! :Q s NU'e鎓Gp(/?xΞ>GH¨m˖m.[nE8EQ n&UTvYPpSH*p9e_Ը6wئш;v쐃KF\A͝;wKΝ<R\iK%ǎ"Kp>*j%z$ҥ $K鲿 9kÕ٠A}] n7d"FLu `lX|>BE%l.d*j"zBhƍpƢWj Ҽy԰mX̘1K{y,WdqK^!B!B!Q̋3ϣmf9;ŞPWd%L ÇKN7%\(T 8I`x 7RSS{ZǤWB!B!L<ŗy]͚[[OY@8;}vJ9;ڦ3G#ٟmh!F&5q5!"<y8O%˖-B!B!BHPLjw6`kd:W#Uf5B!B!P=TXQ'B!B!B(!B!B!$AaB!B!0J!B!BqP%B!B!8(B!B!BrF !B!B!9 B!B!QB!B!BH# ɧ)ٚN˙3Br6gΜ;?%%ERSSB!B!BHF!l%%%YB!B!B!![p&$$!B!B!,/)JQdL>qޞ:uJ%o<z%QQQB!B!}31tRΝW:[0yB2vA7'OM*>>No\B!BH?)ɓa[0ؗ%NdaBϜ$;AŝnΝ;*T(!B!t%ki BOVn0BHVܔ%.͛W!B!p?~u? ӨErIY1zZf1Q%B!Qω'&!$t0z $c B!B!$#1}OF~0,_\^Ytd>H]B!B!d胲X5!(I'111+W.!B!B2A%  !B!/JHq􄄛ܹ%B!/JHc,Ǐcɒ%ر'ד޽{J\kӧOĉew>%%K6mZKvmt'N?L5k$9-ZTZl.ݺuhjґKB!B! )B  (o˾}uVRbٺuL>CV^#>˗OMII@֯(\P:[H\}.YtSݷo̝;W5jduYj__'$2B!B!$3a_Ca'k;WXNgK/y'NRQgһw/9s\ Ϻ ;u ~̞=[zX),B!B!̇1/Ҷm[`8y-Ky>Z"i Ijٺu2mti֬L<5対:0F9L0IL!BQho& R3Μ9K2V]nM%;o h}^ !$=1jqXGH_T)ٰaܠQ,P i1c,k);w.%&B;۷Uyt-Jɛ7HY1z^W\bC=uݻK/=2ӆU*^#u`$)餦rB!$ݠ& [K>7POO?.]:i]Jv@@ttڳg%'H|ydɒҢEs^e'OGɭ21jxcZ/ĀA%K6}A_vZ%/~ʑ#G`m[KBr~04gZ "ᅬMK׮]$>>^sn۶CG9JH۶m,1oJ@B{VdԨoFJ>UP3g>n=wM7Hh QVҳt|uԖomtӧBF-0*۟3:}L9ztvؼyS15*mMp{5rALE !ׇxw,} ַKqUg_hHի 4У3رSJ(!e˖XgV-=ܿҾ*:Pxi%b#66%|@h3 hcǎ/9QB! .-…4 n5 n:^ֱQoދdM6]_| _R2eܹKMm߾֬Y+I*\xagv,X$DKruV.|Flk޼rau!2]zUr?{Ǎ[ev(H7oرbyY.Dۯ`. ]wgD(i{nDéS4' nF|߇oҥKK}edF1[{珷DN-: XC0R+$>FٛMOy/#T0Ɩf 8ʖ-)=d̃¨En RY 4w"ht͹lΝd_]vmmxܹ[< wq.רQ#f#U .%h0;:y=wj_~u6%[#}6\B{:EPo}jE; ءxua AB!$TԫRDYhˠ́d N4i)RbJ`:A_ڴ-C6ᬃ@c%^*Aq~?s8pȡuA?6ʗ/ 4Al/}'(Z`t՜%pΞ=ۺQ v}{N{]?#˺o\W􌎔Zp!u!<!5[m /R&|W!иztD~ȏ  %@&S{~P_yo46.kpb L4tp:tra-dCV-ti-d䓑дx]aop͝;O-B!P8x𠊑K!ƉW'wBeVPAg{ .o-Ӧа\WXb2 ,J*eDPmЃ0 m"Q{>!zx Bj7 \9B`r}L&O ue ]wݡ$*`c(0zsq`t^FSO q =h7@#m@Pvmu7 &iCY,h"\ {GoyTǀB!$B_ +.5 8 !4nڥ=<'O ?(_nx]|ma²TA>\ 4;>#%w mu[C= H-ǧ|m0 ,!"1a߾}u~p /QO}=em%r@\ {' >"LDO[C>.M .m/8 ]7~3Hiɓ~~d1vҬY> 4KLc;JˤUuK/ |wqעKހBިTЄ0 napbtOH+-~ O0 `Υ`Ww'߯\F-U(v)ܣi_> }$mڴ -  D8,?51;0 m7_Pw Q˖9 |>AԽG#!66P !Bd٫5e*6c Q \ #gLz}6 } n͚5*P { 5,l٢&)#|#m\pb8*]tWo`3s.PW8E!BPı ރFaaBF4P ^S)̌5B(!Yrʪ$6p(oTȋʖvLhf#yDE8Qf(  (˦„d@+жm!B 8)~K?LNxT|<9P NUiiL3}c sݫpQzw[E^Sw V%$p a +u?<F0r*Pj;hoذAjժ%! Lʂ]v?۲"Ef}L~g(*-9^ !B\(.^j nc8"pn3'8^χM9GÇLc]#"d \KHv;Nhٲ~.H1c<ދyT!~EQ4pA!F(PqYw0C* fĈ.kB!yx(&Dc&# T?u*YzôAZI8,XPP|^\l:!" u.'-cgC|2 !Bu˗CE;! !zb D_3&uou#o? XE0R0+ؽ{Eq?&M4a0ؿ;[qJKPD /&dt8<30R6Ѹ%(>4'wyCG1ZbDo9By4P9tyy}7omv`sa^zi& Сy16`Q<=-PhӸ_t$ rȇ%h@" _PLB!;h3!]E=ujP۝:uH A^J8!>&Ob ՟E׉6ڛ<^BNgH ڏ(ya H,_ȗ_Wո#tw1E4bPW\7e۷oG4r>hvwF zh?~6xҥ%_!t^v%H2e6`LU[ =5{\桠_k(ߣ!2} BC曼&'BI (NO cp$P֫WOmÄ< l7 vZ J~7oІCTR˖-\3f`6Ql=bW_˗Tq UT 7=LB;7 mFB uBD])'NL.W_B!B'ӦMW2^%]#_r\e !$;QT+_K78 œ̙syn0 !4!!AGgQ΅vw¨7Zl!#ӦM;p!ɓWʔ)c4R;Gˠ'!B!;iOwrqhrrVFt.B\ju`޽2cLu&֫WOܹSΝ/}vVssƍ Qa=޽"#ʕ :PnQ믿#*uABҥ+dϞݖ xFӰa=/C M6*P‘;wԯ__֭SNiWhoDs,Y\څJ0XdI}~QA(Yc1}Ej,^|hѢֱ$9+X+!B!dC~YBDr0Zh!KܬYȿ- ;vto~Kԋ:!Kȝet}IG )SKn]EiXx?eY>o|k߫ʑ#G-!;v\S SL|xXYN٭ۅNtŊ z|5!,YLڶm-СCw[IIIB!B!BHF={%S/>>j5k-%"汄RJ%ٴi橄~)Y.h:UJTsժNaBi%EfAٳg{wx I;wY|{ǏW]\p$R2fXuv)pB:0  9fX*>/͛J]uKkXٵkB!B!Qոoup zjٺutYE;cCDҥK[AcAQN:N;s&Uʗ/-wݺ*"i5*!d" Bu 9wȝjuً4Ap-U ?WƑfaqjoطo^4 /R0{ 5TG޳,,x )\PQۅ YۮB{!B!Bq¨(08 ̙9Bk׮P/66ZóኄSGo PD8ĹDиhRkZTKO@(TGUzIJ:.o9'B=r޽[;桨a,_X pFEEk9rO!A 2FP4bݡ,pNE|{8?KzyW9GF>sXhvࡠ b̙y-5? dB!B!BHsN0E17 4ʕ+9;׉y0[>|H5^f -.tDX{!BHmQ E=g..aB!B!ہs4..NOYS53gRB!B!B%G1Μ9u}JMMQ!=%%EbbbB!B!l'B!B!BHz0J!B!BqP%B!B!8EUzB"iӦKfe-ɀ7`„IgB!He5֭$R9~~ƙ3gIfw}в֭}ܴid'u8}w-{U0+!d5%;dFlU\+WVznWJ޼y]㍷e+?zݻw^ze?UVk055U~'Pu:bHYx$%+r` vB:L8Y6l 2X"q ϥKNR~=ɪd ]~N{채ɗ/,YRZh.JtYɓG#+|r7̃`_-K,{龠_v-xmhȑ;w.PQڶm- ` v̇(!瑶mXb͞?Q~C5j(}VAqΜȿN>jLxM7 yqwun*U*̫Zr?𓊝5kPqv۶E˝wkCnˬS={ ` fBZlܸQU&LNظq uUV~^sU]_@RR%+ ǎkRFu)SZ*W2[_@0mذ]?x̛@,AlٲU[4:11Q.]fɵ^8 !QB#>oف.]:7dp;s6)uP}]^(:uHs^xV#&&F%$)))2r'V!OzYu}  !uؽh3mH$'|F_@qO)+r47{$VƍL0u4hҥemh bv˗Oݛ#G~sYEkժ)={^_Nm~O>}zI0}B(!ׇxwd֬9Ѱa}k]vV\%}8 ^zʠA=^>FsرSJ(!e˖XgV-=s}Uݰa}INN:FH1r>O ` {tcǎ?k4B!$krzKp" ۭ`x [ul[SӦMzuW_|W׮]dʔis.m }v^Yf$&&rᅝ=@]`i-ʕ֭[ XyˡCՅ(NvA>T!=7omCٝ~ "m޼/JI;_,_B?> ܅h,Xe3f13mh8uDtZ͈СCMcQtiСOqLBB̞=W?8py ى~_'`\ {Xq3{3} ?ЧHJJRW^0&H6ʖ-鲰=ta&Br F b;vLn6 #9dȝ*`=6^,\6>SmsY4|;/:azLCBp"o7>5"䦛u|nҤ6@=?(᯼ڀ70]58D1F+OhCt:I9|l2=ѪU YgFr8Y?d6^bn0`T+qsGlB!U ('\hA5>}վ((]l;eڴkvʃAQLfERREA[8&kB& `"m";"PL2+ۈ_XrCB|sw5=z"pΝ;ߥ7c,WLہXW_m8RWVQ"`X*F9r_┵j9rD:"' XJ0R0߫/J=|uy5 YqL^ߵkvg+e}  D`pb?K"ZHĀonpd_&O%Qz8 ' aD4j \颋P(Xvl#(}!xCj&G!8RGZR vZ 1o; q h" |Tl`@1 BH6 |_\v AW6Q⦽{DQP# sf}]Jeu .TDn ⒻԬYS}\~3 `!4ڏ5ao9!ĝ,kv@Ο`3cK|6VRc*yKõw7 q~7A:x 4!⚆{p w<\ "k0y!}A \BgUFAªq1偷|y9ZA[ xC /q܁g5\=g?~\o7Zn%Da}{N!39|3'OuGߦMk*PB.961sQpX A2QQo88%{1e6k ΋/E4Lc -sv#*|AȺuuEk^B!D6($g^- _f {_kL4^r^2f7/}Ɉj[ݚ5kTt/ #9_mnp[Ԩw#\47Rh-[caѧZvF'-cg#4y> :˗CE8ubbWAB OLB848y *ouѢGhr|ҥ=rM*SsAΝ;s_z镚G#kӦMZiNCsi!B/h & ցMBt!]«7P8!>]&Ob m˟E׉چ#T젭>Ӹ{n C~l޼#%̺a:9^ѱc˱cǵ¹Xw#WhY"7/ A.W wH5m}߿ezC1OwB=V\|hCnXo-)#FcHaH*2r}mC(6f{1A$%ohQϿJwACT<&m6FW1l ).Æ=n9Fa\?MlNHĖ_-!<Ĺe!>P!#G~b4lX_>ya HV_Ն:]FaS c8T!|wMǟpY >]T4(9XǯF4\]tRgyehoʔ(2\0{<t2wSDB}4n0p14nN xxK*lB!MO Np8$P*ho lm- d6U m@3͛m"AԲe 9 8a Qr36`1ꫯWUW]L[dU?QDhC>D0-R`>PwցmN߶Ewֱ=A!Z=ʱG{(*80ĉm6=8hCԇxko٣%k|h fفЉ X7X9za;v'*yQyyL1gس;ĪYp,feU7@t[.<}:Y/+V7|Wn^}BC,^g>Go<0m<<.-Zd`(Q@;VoKBG>#喛,1u4lսXBmhY oagRSS,4?f5R&+.B!x#Hh ƌb@tD+,wF]d} 7WF ʨjم'|0(RfS$h lקN)gTth"ssc!,FEQ%p(BbLEfk>H {JG[aÆ;ѿ;w#n -ӍB!s9 #H֭#oy1SRRUan~BHcRJp?Ks gϞ?'W Q:a@(!̪Udʕn_je:qgr_=ވ/䲬77$\Nyo_&a'BȗDgDSD #2b{HFg њ#݈F)E<["B!˴iӵ8. '/8p@40:/K5PS;dȭBPDL0dMIB%B;x=1 vhp9/o|rc[ ԓBeرҦM+gQ'*Jb_PѲ^@ ҪUKg%NL"C]әᅮ{ UD !BHIK#17=! >h֬ҤI9~H2sbJRH!9xL0I5kܹlUe~+-"VF2eȉc:YR@\qť2z/[Bj:i$KP-nV!9C"duB!B G~Yqϟ_'BHd@a]W 3͜9[/_n }vZX^>&&Vw&W[΂ ҧOo 0GC?P)!!QݦMZDS3OinSC-P Z)ݻ+ 9Oo-G)\tYB!B!D0/6<=){vBw.k*pY$&$X<"ɧ5AǤW%B!B!|}N U/%0q!RRԬY5%[#>;Rmۣ6 B!B!BH(!B!B!$AaB!B!0J!B!BqP%B!B!8(B!B!BrF !B!B!9 B!B!GYF~7̛7_oB!B!Y1f6o"f͖~eڵkIJ:!s疢EJեTRr8qҥ%%ȗ/!B!B!Lfݺuh)_ԯ˩Sɲg1cTPA5k*QQQSW!B!B!$3q(ܜII'-a^FNHϞ@l2ټy))X0.[˕wyMeU̗/TZM. [8ܢ`Dҵ|Æ| B)RzЬYL:M,XdM]S{U{U[j_lݼWWWuon^ߋ߄ے%[V,+QT )1 F{~4 "OUFw˼)mx_1cfx?=S?H'N/Y6DrѣSoowz"*Gqn۶Źp2E{"r-ET|ߦիo+bt2tvww KKR0ɟP~OccS1GE{(Ziii)ӟsZrEŵ9r0">oҷGe컎;LzyO:z.chL_?vx({qlK/cԮ;.'ۊ4gΜrɀ_:Z(}[ֹEݖvڕ.GOOozߗSA ec!2;w{O(Ozeh(=`<==]/ח#3g9sέ[se-:cT/4'N_?B\X X#Dgϔ7ź;}u=E(bd6 {+##<\z5R^LC+XrmИ?MO<%_70~ƸQ&,~F۹swcEјkzW?WZYn(Z[[Q1o׊n /rz]ouY9vs_vmz׊6q})7niv[}gyw]cЏ>(;w|636r 1Ǿ^~uq]4eluwS?+?WuV Bs{8kHԐ`:F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F(uttXCyCCCJ#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aHF@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aȎ0 dG#F@vQ ;(aNcƥ7(0 ӓj^OqjoHp5y/ '8:u*(0 ՙ:;;\ 4ދ#8͘13;v,A(0>(Ssss񗺮fWV^a)1ji]ԖΞ=J3ރ 0~^E0N%;v]w?^#EhK4ދ#8Ua4?PNsΦ[fΜruttǏ=gFab0NRCCC8uttC3ӼyҬY0ӓөSRggzg?0 '8?Fqn};hĮ-N#F@vQ ;(aȎ0 dG#F@vQڡ7tАjZa{OGGCWW- S̙8'$Q鍏0]E*N̈DZZa{3gRq!C?ġ=iΜ2#o' 1BY{<0kr1J~Q1\q?#Fr{{{\4k`)sTb~aD(W],0ׁ hy&F&pUŔJSo4pSVNFzSpX1W`X(WU7P3\(Za9-]WmA43szW'pU&pacعsQ_W  iÞCey%}߻˂+eP&wr~>ƮtCyavkZ]a)o޽髯vGsΧ4gNk%V'%777s礥K3͜93෿]ںukyfk*9rmޜZ֮Mtj1#MſGT$µ*SRD!xi1[n)t카2NrsZtiyɡCݻ#j}Ѧ`q<}">ܳi=ɓm/,C`<>SyѫU]rM7]|Ν[inQZwQF}8vO=deՐ~yޞǛW_}U.K0o~?H[1"g}Qԩ:5~U"FHh׮i۶m4k̴dɍsf}Z]o2D;w,={={ٙ3g,_ļU1x͸s~-Ke%;soW<;J=˖|-5/ZT}Ϟt/SoY¬5&0 })ziS:#x[T`a~7hP [o2VΞ=q-l^ʕիW3bꚸ]y:ax"jʯ^5;֎)_e-G(:gΜKۿ-Cxk8)SO=Q4dQZ[wǎ-x~<۷/}OqrU0׾_9\1o0}x,Nh[Ğ=jΝ.g4{'M_~UZm]5q72bK/g y>2h6?gGgw>F{;ӱz1uz'O3~X/ij;oTⱯĘԘ_Nj{Zi֊ieFr5D+G? e[ +1"* 7\_}/(O߼ysSośzbhDX?(ZqUXS#T-m(Ça7Q~ݸ"4+gFc߬EѸ @cǎ4VR݈DUф=Hq /R~OJ?~={ˏ'Nzӕ1mݖ{}quErXmTZ~IyǡCz-6/1rD߿Wɞӧ҉W^Ma#bk0ZfߦkctꭷRܓZ/^e6ΜkT'0zu1=f͚ZĈQmm'k/F)ƴJw^kwIk׮)7Yr[D-1 +WdzH"a0,56}MEV'1g8sLxs?OE u{/f~F:ek;IEHC9ӬBh^[ӱgwqgZэſ)uK=4͛f wf 0 3pCf*7nF# .'FV#bj-/QƅWL!F.V&ʒ%eHX/?X1>NqGJ0]o=6GbĺxMXcz{,Q9r^-up9A/F:#]i122pM#Xh4k[FcP1z;6+*O'#vTd{ "vyWۣahױc^MyLωieFr {F>ڠC1:bpWVZ>rUz=wզa0Q)?1w==lo]<9(l܏q? tޡVWo2?a9?.7OFD׋,eEha\? {uYGsq?fb}<^J<?HTƻy+|~jCyb)Xd}eX<F7&\i3,| LGog!ޱ\ΕL(SRIcl$,Ʈ1]4Z1"*Ĕߕ+/n`#*/:<\1*.[W"TV"EUD/GL#ډ}#ǎߕXGu4:R F"bb%6ZMw|;q?jT׿h-?1vƴ+M6;t`b??(^nmj?0fͪ2X=yY.X/?68_b5O%k ˖-KO?0QdmZZf/ݻPXfǛkHQ" ܌Ԃ]50wժJ7bXocguk Z0~X;gq"LI:Zmۋqg_Vx'bP#FF<'FŨ1?sZ=~WS<6lH7]7X&1>=ϟtw;wĮN]vG]f1bL2hH[^/~׮Z`g]~?D,>z<ɏ/Dǖ;LsG^3c٭ w?~<>'vG`ʊ1/]orch 3zښqzĘ]GL]feB5kמ{zb$V_=P\^ӕx"p}\nbomu9 #~LC߹sOq~Yg~\p=bu619D}/yU:\xᄏCQ,Gt}ړwUكmjUTxm-Xpt^~\az׮1?KFŒ-L5>|1:1<~ƈ0{vkZ~}՝ePQ(_Yoj^.o.;3n\f]Cìr MʝchLќM͙lu;;N=}4gNyfܺ<5n]'ӹO?M=gĜq_&0> 8xct|B{ގy` 6#N?toxʩ OJ/\;^N[v{1k" }}w͒\7X S1먵bc[yٲ4MƦW_}%qſ߾h嫳8E]=u޺U,?N1b)-~AOnT;:oݺN_L[EA TJ6\~<~a*9PKnL WnQL7>ߛRL#aX(Y}m b)_?KcO% 7=V`$JCat 1UM FVn}NϜܯ0,a*Bp :.z0|o?Qev.o =L.3QQV.F\n3'1qܟ02al0yȡrьZa:=l7/1~q?^=== r"~-`CLM&'|}vZa{LnZUoq]9i~7jMښkz*G&N3c DV*&#ёRq-vZCq?C:5Ew]]jLy`)NBPL%<_hȉ 9)#uvM6܊tLwb0- \O4>G믿AN<ݙhEӌ3ݻ3 uN8~򓟤tt&2E҆Z7G۟>?\8&aiaOF6;3g?¨QH}E/ܗb}Qr 2m]:ڙΚ5+ر+8p @~6Z, Lk˭RSSSyeVѣv ?.DQEɏ0ʴ0ğh59͞=;w9 @Vb%QpQ2Ҝ`Koh3Ǜɓmi͚RbXSyQ| L;񋼷S~^Ѿ_ uNڳgoڽ{OZt-7 LD)w?uߠ #aii8*R|N_|%}t/N/7kJ n2{iӦo'߆P롢(yFGBi|[N!i,>639r4}|wIofڲe[z0}MD;LY1j4tvvo*GΘ1#Z"{P={g}N8z{{u-LwqGewرiљf͚Yni7޸͟}k߷j#z>K;vJgΜIq3fϞS|k_ۘ` ya4aGs5ՙSwwO9%=oyԗqurJʕ+Ҿ}(3]wU;֭[{⦅͛WnАr{9BO~{y3gNW_}5.iKKK:|:zHTan+6ܓ/ޖY>(mٲ8ߪ2r{˟o߾ t1:X B)aϞ}ߩ}a+G^։'Һuҿϊ0Fc~Νh֬Y~󗊯}VXf͚UCӳ>A36F.\ݝ.%=CN߰SԄlLWFpV,!=ӣ>r|ggg9}"@nLϷ#R]&OU qG2_ |twtr-،):#5RO]|tСtWӢEץg}Wob0> ={|~Y&jWuL^J)6\ k} _/N GF͗^z*:;wJWߖ`m+>D5Ζir47`" Lmmmܾ}G:}lEi"Ś1J36d*qF;bhF| W%1/ݍ{jX64R>tr ~Б4cFSڸqcH1zM~zWm,Oԩ/w}iڳgZ Hҁ{8]v-*HО2Ν;/-X0??XUS[ZfgϤm۶e &agǁqh:L,6i2|y0 X]ȑܹisҚ5kҜ9h,"jFB… ?V[k]v}SDH}䑇'|Zמjru֖!;͞=9 Z۷|7]:btFzdf(PqࡱcӅCÌà|'ﴷw45 0ź=\mMMM'n}GcSu]8t_8 6t:#G:" 6ōmolldQ`0dɓ'{ CU|i9VopF;>sqͰ89 1eEٕ~o`OF;/rwWW?GDݵkM}h}s.#RtAفa48q}{km(?{(>]DH؇2R(7fZ w9Q[4Ύ_lذ?Q4m94ruюP?P,z/qt[SSS(%`5Rs>_F: gد_]`ѢU-GE~d֬=q'0E ,'vlذ,N:Z8\Li6҈сqƍo ---E ")Lv/_NOyt1:b$mlǡ84Ì}e޼y,U{yt-7'"ooggwoW!:TMiѢF;(Z|@ZWtƅa`'vz|>جѳ3 hO|MёeG09GF*68ԏ.MNx=Q+w>?0Mw il|WSw8 E84՝^Iک=׏Ljc)M@ GhL?6p3T5:Q;Bt\Q4'*&VJS||( f GB㎢aS†wO̞tiPM4`bp`_O*7Q1q,Gv?GBOF3s4FДq61 rڄa44\QzHG uhhL#p;A0"V1O:9j\0Z= ?$V&C0] ZLat IBatT&eFF9dF#06>IENDB`SSH-Studio-1.3.1/data/000077500000000000000000000000001506556307300143415ustar00rootroot00000000000000SSH-Studio-1.3.1/data/io.github.BuddySirJava.SSH-Studio.desktop000066400000000000000000000005231506556307300240530ustar00rootroot00000000000000[Desktop Entry] Version=1.0 Type=Application Name=SSH-Studio GenericName=SSH Configuration Editor Comment=Manage SSH configuration files with a native GTK interface Exec=ssh-studio TryExec=ssh-studio Icon=io.github.BuddySirJava.SSH-Studio Terminal=false Categories=Utility; Keywords=SSH;config;network;administration; StartupNotify=true SSH-Studio-1.3.1/data/io.github.BuddySirJava.SSH-Studio.metainfo.xml000066400000000000000000000103431506556307300250040ustar00rootroot00000000000000 io.github.BuddySirJava.SSH-Studio SSH Studio Edit SSH configuration files CC0-1.0 GPL-3.0-or-later

SSH Studio is a native desktop application for managing SSH configuration files. It provides a user-friendly interface to create, edit, and validate SSH hosts without needing to use terminal editors.

The application offers both visual and text-based editing modes. The visual editor lets you fill in common SSH fields like Host, HostName, User, Port, and IdentityFile with form-based inputs. For advanced users, the raw text editor provides direct access to the configuration with live syntax validation and diff highlighting.

Key features include inline validation to catch duplicate aliases and syntax errors, search and filtering capabilities, SSH key management tools, and safe saving with automatic backups. The interface is designed for GNOME with support for both light and dark themes.

io.github.BuddySirJava.SSH-Studio #ffa348 #57e389 io.github.BuddySirJava.SSH-Studio.desktop System Utility Development ssh-studio https://raw.githubusercontent.com/BuddySirJava/SSH-Studio/refs/tags/1.3.1/assets/screenshots/ss1.png Host List and some shortcuts https://raw.githubusercontent.com/BuddySirJava/SSH-Studio/refs/tags/1.3.1/assets/screenshots/ss2.png Host Editor https://raw.githubusercontent.com/BuddySirJava/SSH-Studio/refs/tags/1.3.1/assets/screenshots/ss3.png Synchronizing Raw/Diff Mode and syntax highlighting https://raw.githubusercontent.com/BuddySirJava/SSH-Studio/refs/tags/1.3.1/assets/screenshots/ss4.png SSH Key Management

Fixed many major UI bugs.

Added Undo button to the header.

Added Fresh new drag and drop reordering system for hosts.

Added Keyboard Shortcuts to the host list.

Support for GNOME 49.

Added Keyboard Shortcuts! Access the full list of shortcuts in the menu.

Massive UI updates!

Fixed Clipboard issues.

Improved performance by async parsing.

Host Editor searchbar fixed.

Unusual behavior of host list fixed.

This release introduces improved icon design and enhanced visual consistency.

This release introduces improved icon design and enhanced visual consistency. The application now features updated branding colors and better integration with GNOME's design language.

https://github.com/BuddySirJava/SSH-Studio https://github.com/BuddySirJava/SSH-Studio https://github.com/BuddySirJava/SSH-Studio/issues Mahyar Darvishi
SSH-Studio-1.3.1/data/media/000077500000000000000000000000001506556307300154205ustar00rootroot00000000000000SSH-Studio-1.3.1/data/media/icon.svg000066400000000000000000000652441506556307300171040ustar00rootroot00000000000000 >________ SSH-Studio-1.3.1/data/media/icon_128.png000066400000000000000000000217711506556307300174600ustar00rootroot00000000000000PNG  IHDR>a pHYs'tEXtSoftwarewww.inkscape.org< IDATx{]eu\>$+d *"oko˧*[mk[[^j[VV@E@@@ $&hL&d2۹۳?s9s˜9g_ogJDq0Ӌ9r`cs#,f901GY9r`cs#,uO29F4wEDUPJyqf$V&HUfO)K<c@Y5RW洓=ZIMS';%,R]\JU^ϕi4^=,Z HgB h剣E"~Fn⨸C֝enaJPJYk7MZ M^emXuR/=UK0$o\5qqwum^}LU4h<\m8?܇u>"c ?'PB)@է0ޱnjB*MܮA?,Ϗ! ua{@kkkd @ݥ[64Vd ϫ{6jU|z3wv:6^B9_>DP U@*B]o{hgrj~dH0!(Eo|ゔܨYu4 s ^$zD?>To*0x-Re}俾}5)ǎe'u2V^uUmUFfٶܟ<ZkD<kMapfjı ou-鴰sgXtZ&{a'j̦U.,C}ы*x~vxWDҋuɼՕ^vMxo'sOpu308OUQ8M/m^MkGH}T'.=89kLKK/#St=2p?n$ot8:Ѯ`̜RX/6l}k.:>2 0E;XXw7x#P] w/_]=>~ 'rg @E.pPohXP=cIVb_n9eKYzMtd1 B"!Q!BS,~HGX77/`违Ρj޽deeɻzNMhgԍR«/]-UDݿ7qF 3xOV`f3Ǒbtt"M,\+XjPYX ]Ϟj^[=]v{zP/co@\['O7׷o3]veSp6ns <-8j13,Zb|%i14 F6Ow+R>{ %D -sp݋Eo`臜7qa JGpa=uo]KLF#QU)5(2 RIUJ]Q-V$*}/zPrg8tc,Uܝ-"r0'[X-r4W"zN~~v?+:*Gᰎ;6W$J>Av>?Ug MVD |=*Ž_l1zPa9N9n !Ȣ#"c ky棟_+b_9ݘ?l_dk=6f7Pnoܼڣh2*Z~xE;cq, G ;{`gτ=ոJ E&jOc$7"8A Q|N^(J:z+ Җ%u|5)TNA)?VHX 谌|%+B<3kÀAd{(H~w߇~ =2Yw:8\i6?ol<@_^ *];f7PǎtCEC>jl=,npٵg/W/Ip_y }1k.Cmm /$F0 @%JaQK{z&G76Zʗz0H96pfpl= ĵ>&ќ.h JR?!+` Q]J/e,t>QrI=2PԺ#Bb~JAQGDD }7 :긺`H H'B"ːsxyk-*Hɬ@uFNU%T6tM~/Lw|w3Ca_.k!;5RpX>{ w H12݈^z^Q֩aƚ:̷-{yZiO矕?2/k@KZ c)42ZRFKPՍ2QLK] 9k77b] ܇#Fxf a}—8@Np8]LR 8OBH`@c/(Uj]?@^{Ep?db7 ~>%|d@Raw]5\~ KGu0 ;SSYe,'8cݴ MxϷO3AaT/x^qNQi? +,(e{{[~q$YP'|^i qH 78 @‚s[ 8åcGޱ, #ǧ8~+.샚t)f1'W"n0Yخp?oɢ/EK_ 7vsY&f$(5xhHׁ[ߚoO|-mxNy .x+w_üz!U p ñ(>Y29AujpKᗜ8NضDK$h[Ku Uym#FNz:[ّL,.>#c3rrvAe9/P-噋/U 08UxFP^7&]% EO !ƒ _[PmN{9|K2hD[]P9$ɑ:/|j5׮>DC@m0$T Yf?7W+? qiM0/n-/v(zoiN`xuJO‹=^8fJ8I$ʃ.hG!+Р/p* W2ᅬ"=ܗ¹7')he* ܻ,g4An?Ԩ@970\b%>O~?ڊ{i/*Bv(/v#aagYSi%=hT\Ξtw9NlI`?[EwVp^sj=~23o+^9>@tQ:Kqx7sl}<k<6(*PEEqSLf#m-' ;KA nq@!/iT"0/rQœ#@L[p*I)K Ąs-=7;@뉗ŢBTTijZRCN 9Mj'3l,~r> ?dJsa~T~tᔕ kyI&`wNq#y K8t'p/ީ8WP99)+8:* ,_gD$< a*>_L<yE[oqAZLߢRrR8A r|Zj_zصs߸-mtG(bUN^ 4] h7Th~߬a`UW±#'%qGjN.$en`TëJ]|eŢ]2N` uw |XL2/gBwA|0 :4)#7UNo"hs2VTOw_p]#gvoZ}/]~m246Ui#hU*HFur@l hrϖePk=tShҪr"2y ҡPE{omYTh$ EQwB9{Fg[w CBdl 8xi]g j}~0 tHLD4T{$??5u>/ ?"Z@ـKE.9t ͏_zfC(xYuo@o s2BBۿ}Nl3#5?VMۧ{TAv5*VB0P=3]> O:9cҚa`+S0OzA C 2t_x+nteCD4i/vZ‘BvQ$pȣ+ɧ&0.3Vl9͇@ nE+rFw2ԲmP5V ~n5?Qto|'}3xG\L ջFF/"DT-h`ӴG@(.2{+?y楣yNg $Ub~aeΡ8Dk @V3kR<=y:@?ЫzNVJ.v{_IOvj& *p@R|0 un7 Ma*0M# $(?L{*Sx ٮWZ|>L=/51 pJ4Q|PY 8[,r^(j@A1H YT8#= ![~{ֲ,1&)|c\NCG}V:5.;+,"a&v}}k+8?ob;M3ew""|b-.X[@lr}} ,4E\|Ū%&T)"-<Ϯ{:6Xwʟ3# 0e?I: "A\zr//UX.U< h$N:-gq< LY}oDw\s>H!%y#/?`u:1>t8(K?7Z )o=o󥟕 :|xi$ # Цˏo5d46 J,yzأC"+hSw-6$G$q&1Qy|$uznDYIKlG :RϟHćǴ36&e1ع;9;Pu*Tgi}j~dwItej)oCLT0 M}^;ҩNcbkhb/3K@ૉNğ>Rwy͠y̫GH9=M޾pAu*U 4uNT{QD Z4vNwjP8_ˣDfMQ^3!ሡ⭬ !0uF@D)TVB?.[80 oXrhI1z?D/"YR +7w` zhшĘVI$Ԡ|TV3u?@.95{oeD<`x :P/Ѩ+{}D#ʒyƜPTUUy2= ?iAnXcG_$|M8.ӐPAQZ8=|^@]skk%SQ(wOMM(}z.GaI%\ՈOS= #B>#dk|r ' fmE$r"3qk>&0 z~ h1E.,m7oJO9F]&.Z`)| |l?ȸ4|r.xu@F NF)s0OZ| ّ]T}JRmdO#?G y*A,< >Eru`]O>2 { JVEb0?{1~%|iH<Oie}S}vCjmmuW%߶?*|%{l xbf_>TUavRG|q|'}m"r/m*nwAǼ ds%|*lwLLEΖH$Аb`j^̢m yHYlmУXT׬Ynع?|/+j,mW+xjK,.Czvy?;a 6=@,PB6/t akH'<-nzxf_)2aܺ l;H\2 ؤ]GV cxsj- .OmFk^5K"ІKJZLJFX.̯W\~o1rNэ $_ U 6{94T~#<͎ԴG{\n9)%X9-tmhKQ7W5kW:P`흈#tPPoBOxe f$ /\Q7;,LB+ V"Ҩ+1X)/K]4 o{9|_׆zm/q "Z)_Դ5VٙUG o2e9[S~p8ԇ 9T^u̠ϧD{]E0 'q-61=$5 jXQZY:&xN Raː^OiC}eɫNj Vשg= No}?'ez5^zIDATԆ+ø.|8AZә\t+.EmuWOz-Rm>iS h #𾭓ܓؿcO lO]w]h~"1(kP}Ҫ BO}b20F*(VDK-ߎ/ųv:}#JchJ*oΊYGGh,/Ǽx)w VJI Fq2jr]Ű2j{9qz]'Nx::,vDPoM_S|i}i. JD YeY DH l(WHՂ+JB߆ _bFa(x)Ͱ*EѯZQhu4}F~GJCE=:6۪D0܁fmz+d{md]LvQ#ZG9mNEo]]ׅKҶy Ngsdh#1$e avH08J)F}`b{c|QrDGpH(k%}uv8* UnsDvW,q6Ũbfe٬.Ue,~U,Y) %|p6] Z>Ր6T{S~h`f@iv[ewݧ}qNf|vpp],:oe^Ԯ\ɬƳŰY[s X.r.Kְ/&z Bo S4%axD VXXhpS#=^ +hC)S*8n.)׶hO*]V Y%܍==:w.}%d ОA%)Z pQ"yˎd1"E;6@IQOֿPy\f 3ˎh`V[#\6/Jj-r#LsX|ǭ,]z51#mo~GN` Bԕ?TSL^߉]߉>1\uy9NLH ib,!N~~Z=f]qEN<9";g$p /S8U45Q)k/7(˷Ypu/-ba%ȽCXRl5 EA)U]AN.G|rTg&4 "m $w|އދ痰<yXeSGbG\g*uGQQD_E yA/^>͸%y2H/;4V,Dkm(}/1|T% qsP/?u6H}O~ `գ>|1w&3F=r*7DžxZU nVߜw[E˯%vYH;05 {|+"53B *`15{.h"߀E|*ro~~4^P*np&m/:m8#rKvmԞbnq 7o_QڲV/B%o#!6cLA^*=%3 PLIBe][j "s=Uh%; m$'_ӳ Xt/A<-ʝߖ{Z.xU{'`?I;s/g/LP RpoDvQ1Fw_ G>#ÃF3JeHg> #R(+yL'7cvd>ާ ;| a<[v%)RTe " Ԋ<]æX?)FσxAi@i~(w92l̬@eOVùKa5y7^{=WAڳz)~7&lz!*= a&5A~3`M$4SL0 UXmG CCg::0s^xu6-nkrm oMoz I{;!~/ \k34)j}j"tꝗA/0T*HR6s8CWι?O[-xrcMms;Ygo.Z<ÔH/͟H*&0Xomr_ؔl965'y6/2!'C3ڶw22NGeK|[}*uu[1*5ۿ%_¨(^M r29_,7,˖@[n{ɑ6W+\b\![ +a' t҄tM籸K\x}.zqfPE|)(%\=ɠA.UFjdLjS@ i]`6\]q޲/2w= (gd9`ߡɫ1EN][1V,XĹKK,2\C.oBapz(ÉSN38{@JSnnb]3gg Pm:,nmwumRE0}urtr3zA;3>_zQ^8""S=OdOJY-97n\J=XkʹУVJcۻN)Z-?i7rPa=ƪy&}LPOdYqR_sΚF+ 3\A>noݿv.ghdڂJԪRg>ʛp3Gpt1ƿ\)CDY溃}0Z 6=~]Q.u9Gvl/0臰?e:x78&K3ŧy{1?t{7d~(l,o@PEk뙒Dz4Sl=M`s]fկ|O4[FǪ99~/,>t~wrj)T8!ѽGR3]ҋe8NIdMְd]kİpz&*U} 3;q~\+d޿vq6~|+'2:Ovy1@3_Om`+[¶/y@$4<򰸨ޑ>JyRPbe3K*wwwC2ӞgrqF|>x {k2`&fVcx›Y8=lPkrVuP_0~.I&A5J@!k6vz yz]7"- T XF(}ɞRp֮wDVKg=L&cCZVXk:R%\Lkk? zm$ @I(0"0 F'BDh?fh!ܶmK"d;nWT"ǀ/,Y1ƵLn mVSR$ d1x K>)~,2ܷm?czj%"ǂ6j;}_$Te}͘3Ϣ'y_J G>EVd]?=@әTBVijHEG%I@g?v۶O=s {ƹ|8J5D`LC/k-*Sݖ*PD"zzμ@ՈhA4J!yX CVŚl<0'$ܗ0v*CQOa\T5"\fD͹_,=/H,Q"ŘًQ*<P6' !{X=NǍcٯǝ&Z%䱥{L~M!6tl-{QH95XZכhg3 a?? 揟z]J ~+:I1!$Hf4j4wޞWd)LϾo._l%u6U DKkgFo52Su%÷<{rҗ2F؆)/3װ7~=O#6y$E#$I~O.1 {}[0] ًx 1G _̈Z͠ @4~ Hё+@Z*3~? c3$%cQw>/rYR4"1` /CG'*PQ(hT#6$ĶJU7I9m|Kp\z_ԮTieBDqXnDA ج?tpeKT/*J ˰QڄrupOMhip^r* {JDـRbHR R8Ôw "N$)*H]$zd5Xi+I%7%8jZU5p śo-|f{w7ƛr" 2d޴gKuQm-Wg->? kHuA8<4ޠP, E?\JA@)@)E #,Zr@ <<~ESo!o{a'xB<&7A|@:,c"Ԓ_!V= IDATD ~_֥t!pk7 g#d4L7^+YkTRҨ{a;KE&&o>oٞ]pR`DjC X1y],ʭsL*S 6[T!ntTBOS4&*Brr_}o^y6{`@8'po<PkC55%ç?vh/j=\| 7G3FAH:S:P\,_Y3,^oeSE6d$̀(ge|'l#078x4p,m o}~ܼSݓ@cP^\xs2|u*8]/"򵨵c7~tH_ixH%D ,ZWb:߄RclkJ9 owh<$A "h458ƕ뵕#w)^,42WFڡQ{,{'BhG.Q(SĦV@u$ ƹ+M5fP4wS|6nKjsԚUU(ֽq NhK+ȨK(!HS=ĵ8L{u|(2usJ\zhb$`=l814Fٳ 8^K%Ä#Q)J<-UuNTFK@c_=8zM)5Fhclj`2&eK}\L1!ǡ`8Ptjt6 ,g%C5? r:t~`9';_p<4Ln"u +Jx@5 QqhRC}Cm76vבzO@,7׎Rp599'8ʫZss`s[Oj.D.@W/)et!q]@EQNh(/w[8CT\2HhlN5^gPP-֥bW |wpkcI~$he2Hdi&BI8aVq͈ 41Hv<|)Zoʑ>Gx~pfӄ5?ǍQoUM|5mY0f}-;$~.ra6.hLk8~ꌟ4u}qYd O{DilIt<>{Q4^ ޿${z 5Ш s݄9WӀ i$UjkؾYNm H {XO\uzMU CCK C f<ѭLa7%B\k0]G;O4>y`uаayn"UlkxC5/^;zfN5-xO[v*s*5$`D3-QX ,)j-%pjɁXNCe/%e1Z@ѵ_w/ [.;*zv\p^, aOJ=٘^??uk30ˤU`Bjₕ $__m|!4tj64ÆB%=/'x:e|T!!%(rHyHUWJ g<Dӈ5ީكKzf3cS jCRHoHgN^U@_|1RHu~o=$%B2XxY<8o3l^vDa>^?v5rM [)-ziYwaCgD'(75oP+e\$H4Cx̺D|=#'liXd7!Dž&h3N9+*a8?LTrGhcKJO{?JХfxǥL5~u7ϓe*][>E iY0:H 퉣3),Puݏ MW>Ek!VI pz=),@yLJ I(0)fI߀@ VT6>l6C.xó3zwqcks㌶ @6K~p ફf/Ouqǐ%`g$ZH&EjT'cw~&3wop'{ZuTJUV*kV tEc~8ZG9~w- f8o{1ڞ YRo-$  @)põ4 v̮oye7c?^_Jo=D1r>]~f \Yh ~X?_?9%C( @`"߯l#%(7V=<5EL(Q}/Ҽåpr4y omοD@ Tz{Н G&&xoa]%[XvTSБO*?TR9KN ,EnഄKDUjel 4|FCZ`T*)K.WfYb\ƒ={KJe}!_@~`R">%Ώ]?+rUe E L5?{u2``T8ç<U8]ʔىS-EPk{Ez܏|ɜ"%Bn}dUe H 0b@WUZ\6ӤP(S< QWDڀJ}K!lիÒ_ |҇>԰W#pճ❧~Ys:a?EBU ^KuTN/Ke"8=8za&H̀B=Lq=fiHTN/zxΫq.)hYEŸT= '}~нS=eJg9nj/جDϠT {47`~CQ>pDW pۋK-1R8q5C|˱ڟD@GpM/M%G řEROJPaݥܽ9uJg)njOk#)aHPLN1( 6嘮'O|mM)A>>_,TESLSSt搬žtkT7,|ٯ.>pӕ|+IoP'.}FU8bpE <EP?DoLHoEf??v8퉄ਆ˗<(K<8+bA~OWT8Kp㕃|&)q7!1 I&o k38*\سw?8nY_9ESK(p uBH5+SGG?)Bq3@=;ΘTh> ;8zJN/G@l^ze/dxR `櫇&' ؝PɎ& @ 4/cG;5-7qj3ҴT>լ !ԱruH0/ sz!BϠ]8~kbCx`=פAuP 4F:^ XQ io  j 7_=ħI_27},,?e AuQǑ_ 0Jf=?io3gQf˞J `+1uߐz/D$p`19 AP7cLI 18x͢H R)T6s3dcR ң7]NϠc;oM)0B^zD$^˜.y16e R%h2d>F)lƔNlǸg_߻ӅnxH Eq㕃S?&יLEc0O`e2VOVJ^Nr1ɐdmG sNA% 9 +ИB8MҴ?*]p[iJ-DnL &Z ǢZuu+.mP:2@RpˊcAŮ6 p 3ۀN s:R봶19L Կ>&\FևV~"^gBGs;abr=37'FRMԎYePK%XBVJ%{X02DQTdj= VB&Y -YBu˨l7VV0;Ϗږ B?YƫE~K('tGo74WPFVd2xjȞp [.(R&-J)t  +lR& <I X (Vd&+N> :0H EpB?+LN]eTX2K$ D#3XFBy# ?&$ǪʗXgs-jU{Ϝ4|^ڂgZOcVLIp,RJE_} }) {BHj6/*%cI| `^ƒ{|7=͋[y&G2o/bp\ua^rpf ||簧oA !%9”j3gG{1_؊.^|įܣO73nlOpպ~{˳upgA>9hپ^ͮk8>ԅ)I0h8=G=| [vqH f ,ֽm!zش*` +4KlR%,]Ӗ' O5X.7!%YFcLSӻ+ \>KuIOj=D剓saoe]ӕ/Б-љ-ўpUjP]F,#~abFH `1o/#1_܊hmrmmB0ch8Y0?O{s1^0~.Vp~G6C\wIdNL+?ݺ5Tl< ֆ_$'xDS  LݰEo6u%XAE bƴӱw`}^իP. -3? @`4 /˲=~h8| ? TWvJ8}H9/!eEĂqLOdE,kiTuᦫԗ8JĺDdozWI,a_HGZV4FriA *c94"/N Re_p i[+U)W?3 yPBr ?9(mc=x n/ǜԅQ/ee֖ϔo<9`69D8zhq= @퉨mB@`ں,m~ᗦOQ}1?̝)q4n?C-*Oh׵>54dvgSp !@,S An8%'W gb`O3HO5ΟbڊHe4ЄbX`4sr{ǝ|\4tgS4bZ?Vje{Sm+צNzdGj?E !KremPjP)FәY2::l6qAKvubanvhv$GMnt3 lO8:XD~BUKKW{'р$x ?*eb6=xQ='!"0i:"Ќxh!$~rmAXrJQP"EVl.vte <5L wW7PBLt/Kq:!c Ÿ]  \t՗֘AB3( J9oR7cǶ 굛e86[ @[(\\cRl/ZI%+Z@hd_anz8QQ1fxʥOB'[K!Ӟ㑧 i;T?vPb$Uﵒ~8HY*m+[CbZ3fRm;HnjT[ _vA Ӄ,E#BCoY*!1=Z|B5x3"D=Ƌ5?{"˸? UyV ztXÂoʉݩvHaE%Q/ZT67_wFVZ-{V(r@0]XUuA9b kMOG$ P@G z!*¢,RPc׉8zD yLRbH|Aq~ x僟 xiSwHdH2^-_+گԍkvvǃ_ hv>;8ν)"%J$;tb)qDZ5(4ۦE>E-T?)}I-i S8vb8#+bH"KER+\.wg3;K( ΌϹs|v"6{\-ե)8.)oM}p*ȟ́]孂*Ϸ%=]v!ݝs/Q>97Px—^*gXVF3HlyZh#b專3%MFϬpuCݹX3*r$'B`H3gQt J%c8!_asVk%@٦O>1yqS~1Gx#|{K+WCYӐn!'v!@ϳt.wkc'h}>=jQlc;'3Ro=֡,¥p&oA׺Q0b#]x9GZa&,Ê`L7Ñgy(Uxvsqw8>r_sQX7o ߩE>{^suFvũ(s>6 _An3ȟ}~6~,BزIBy|MTOd-~6nO15yN|2e k?e qnn {]fzakiydIiȳas_Ma6'?yſǫ# jyM#{D5 `[?M. aqpns#>&4s͞Iw}Wȫ1t@34l=*`}O0܁Jt-ؾk׃sC%oYĬ2m4'\Z@ѫȿx8:g~b0Xy] '^ZQ7o;Hio> L|di1T0ׁ?<}Eq|~1,4*(UtG7"Є>5X@11J٧c~nU䊈ayx;KoRr+^Ҕ yUEZS _+HC*-:;HDΪB'| 5̖;G3 |f@┡Ҥt^-,6K"CRt@ڴ7ԣC[\_PV s? !,k 旅;A,ߟ瓋b͏Z= }Oz~a3#3sͩNkElN Dz&U6tsѶl^x-|>y0P hyᓻ╏\hho g+ȣۑ'vMW%jK]p$>x#/rҴ7ϱ. Z*n4„TAH(tE!Pai~(ߨ0;!.._+ kw2[DvɟmQ,'+n9p**Jj6 :kx |Y{Gl"DƶþAz > /?sϯx>#6/"SQƘ9-scc-tq/Dƈq>}Tw@ dB < O ɇõ4{G. YB/R kt@Zt9KAl=XY$o%t n@6Fh0\HMH vÃ[+hWhp'B%x ^d#68s&H: Ɩj<%=`O/a%XPyq'u֧#5fp mn-Qp]صV;+]מ޻%|j`4/|ՏU@*jFK_9E⨶W2͟.B Aq[LNnϱ$B DC٠CjŊŨAxj?GW[~4Gx o>>{_$Etʻ{Ѝ.\B3Bc?|e n_l8t)]Z_%kbBEרط8gr2f} )![.4k>I)S!?maD_KZ F[ n~dj{S T.{*9Xt6ȳؓO#`ƯqZ:݈796n5 !bt}Xd+D&ˆŪA*p5Mќ] !|}'ǐޞ ߪM:Iٞ+}~s[XL_muPH@}Sy-s="F*CM&66ڿ6$'&M-k} r'/2ɭc[ŪIxv6U؝ ~v~9'%]#hgMbiصu+e6B'#!~:WwEE~*"Qs:)g*;NnIY^'0 1۰1؆dֈ A&bk,V,R_O#R»L81ܝQ "x~nSSU¤"y+hhs5 0 JE-%|Z|shi:~;_^_kHQLIʉէ#5*Ygc҆t0rT#2aR 4f ?3}7[H^Zᶴ rT}ȩsnA11HM j6) ;=T_-L4(R3+EzY7 ^xy<^ۋ:J_3,| FZ9)ƜTS6ҺF/s-$Oc493ע8LLJ1-Qv|>׈n0v/ݦj3#}wxQ\S;_Is2m-3N- ?#nƻXVda2E9X" pDv*qT眷YOѓ)_m%St p̌FnJ]DIfݡ;Eeg Pa6t }*ڇREc KZlն{ڕo_eV`֬9x~{ i%k\C\B#4A!0/B# *:-F"h>۬V5\;9YRA`f16/6h.ͩCԴJAf T!2|KH-x]StVxs/lZ iJB  !uk*ZTP mFqO 9FÝLwQt rx12%j_-m$ՊSU# Dj2*B9ZJkoU]pф/h3W|"Hp^ FXw^CZǦwvp)޺8FA)>pG>D@̘́i,z3 CjCm\˫^JOxЯcB >>Z*+aT}<fkH_]֔ wYy;|`W^ Bv5 ]kp[H|r܍_N(QbP.![=R(q%J(@0JP=R(q%J(@0JP=R(qXulEIENDB`SSH-Studio-1.3.1/data/media/icon_512.png000066400000000000000000001724401506556307300174550ustar00rootroot00000000000000PNG  IHDRx pHYsodtEXtSoftwarewww.inkscape.org< IDATxyYu;ވ*JRVB4ibc1Y=3̸g7lclx<1ۃ X@o4M/nBTKURD{YYju232%{ι$"x<dzP<?^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx</<ـxx< x6 ^x<dzx< l@x<gx<sO`BDK)Xx ak5Uz]a7`.B\piU!`0gzzG}ǁt[8Bs^?^ ~ וݖ('Ńˉ8qJlUbbX@\Q!f/<ej3Ф-Nsc2F3ņi S0fE/A>ZOիUU+'*dV%^cqLQpbDZFXB@FYcB"x6YjX$V:@D A)ŨKJcIUY!ZҚu56)XM۹Bv[%r!?=fޣY+nbS]-6W!5I&0$a`hm و@EQ!" ]ηlPR$,R"&KLD)KYUqUbegceqEkzXWC#gU.Ys# AWU'LL2K[5BjL:gBq8PEl]A@JdHBjG!C0bň(%J(QD<1@, Q "KK։D:Vc*&ͱ0¨ֱ%⠦cvWMبj<5W*JvEOH7a/V]hj¹06ΛPzhQdEPԠHEPE DB)AČH$BĈRQ1, PD}dzdh24_j]a@,Y0["$f,F"5)M5THMA*TkŕU-aj.AP5ĺq?k9  G[Vc"HE.*XP$'%* Pb)P$"E8""Bh("D(oyM10H6] B1D 5&)"QT!AED`Ԝ0rs =9cCWq5aU3OWQ*e9Ҋ 8,/B`ixoj}\X PQݡAQ.8()%b'r?I!O@I (CI  p/hG.g0lE}3tXBbF PÃA@?SdzTkK7%!I C26j'@g+*0`X ;#Ȝ33B2ƌ(L9b=If9"3$RѦ^CQ<[NjFɃw[?Gl(-oq3=ӟ~#+ZJDˤt?$ Pܠ 0(A4~mmA1ې7 |g2NׁQz<Ӻ#NG,}ۋVޒ1X]Py,?]oNCā!D YD`B4&aBI$LB Q0$sJ$2'm#*!"`!F2sc=ŚH1葢M aP ! h"RPJȀ4|`a,[֮7˞Z7AxxV@vʒ[#|.@eǯ(}l1DT XR8FYL~Afv`a8`ycP4 kQDcF&4vSAQ,sZׂ vx xy?=j\"[2XeP[fưJ0hEiMeD?lP0X| 婷v˸g?v!.CsyϾ]x< qW͈dg~QZ2靴;N`/hD2>fHܧs,q"J(Q!e3J1'8 &IxJ(+|e6\ ]xZ n^#}a7R֒~5 B<$arFyRרF(e`Bﶡ/[=!=/`dImU!`br>1; ,?&R Dgxidga{"jx멱(h\ P=b [ q$Hj(Eƿ4( N aQPߜNف]'N)"pJ *GSPbdTaGZ<@럫Εp*[  ah$Nrإ6o`?: mAlX '?q86 ⮆\&t+kgx֖f4 `EGT*{A=A{e:-O=$mn\D\`17߀><'98D)H QDcJۉ0JfT\"  cА>LZClݰr4BD;.0RP[ n~A5(g. {f9'(}| 37L-D }Gh #SG>O"?,xR.t_+|גei0n<ڷ91H7r .YTska'PA'! N 2Zc;L4 Tf$8|[&`|q>*T\QIPºa4v.q1! `UB۱W4]\$ UNBܝ!kiGXeI!x.nÎmZ D Ah(umYz%&A~rI  9O(9ȨRe7FZMRQMIsIPE}jǏo!^,J[z@ 2CIVvAdr[+ӫM4 q{[s}?ߕ/OfܝO}(Ӑص<7k(h=ƂǍ% Zv_#=OVVE:h&;(2swlԺer˛CiWcC<`g!qhuB'@4 g b;SaƆa%TjƝSo!pU N㿷'TJH $.0VN&Ƅ0Ƥ2l %\8Bϲ%w˼}Fwv [, /gjgqr6[N썻Yu(dטFrA-LܒO@Z;*"J^z^\e೼y {fTuF: 7 E@t\pB Ph.hmc\ WE>_R_XCN pn MC!=\\s [~:o}a&A nzm[av$o=y4Mzn m= Kf5g@p= k~(@ScU;- Oc08N e,v)#s&bm$p i߹p )LY *gHNdq[Du aL4 =ؑ;JotAGa? dt.g&`pøgf,x3mjevx<3DŽBt)dTXET1c{kL=}>EU%3stPQPV: .b ^Rjk~&\wBtoO5<Գ™o=j[^>.hs5}~j{H[EA616  F7>e;Ө&: |צpF:+Qr P'ܘبVjIm#F,0LB0]6q2¼/Pa1!0ԇ_;˅cY6 ?wwÝM~MeoF }.RCa|LAh 2L:TPmAp4XD8@>vYXI[-&:JF cB8Ȏi 3wfn#M4ه(*U[yP%<;ek>)+a#?"v-{~ ;w_> 7g20?Q ި{<|(9Mo _ (D~n-u?'gB9 h.Cs-x~=gQ?=XkۧnͩBLZ&ب`iQxHQheg4\9 &KM_KFdb'NnD;!0Fa?<}0}k|(m&! f]p$Hx5@^dрz:zn\4 b90 @/&2yh(Z"2z)"CqV"78v EZ].0*}Dz{`:Q!T~߇`/R>o=gtO/Er"<ﻚ1w<]kxfcxVbB gs,ړ7[gonKf47>s:Ш`D%RG8Lp0)5W͂fWȵ(z]I *e.IJW7g+ GpD> Fgw~Au/Y-kx[I~yx<ʹ/ӛ@=赻Nژ\9yN" sv=* Q68F]'(M cqxV.5Wi?ܵJޱ?u`Rm]/*@AA+4q-{@v!a@M]saiR IDATyW=] խcfP{`ʅ0L"y LУuc[#lA#/kvM¾?msBf&6c.~55i(ʀɩ=²K;Q̃brbRD3Ƅ(U1pN7>ΝtaQjD$eq2DFaYޣ1&& C5pcOF?1Sm Rd~ ޝ9_:| (y;AQC/˱<sq1+<<Q@ԿJVTP|s !"{ Nq< UplfF_F8V-`by٭ږW @_s7 ,?<'tDjb_sAaV_M nvnqKׯbyޥ.:ԍL. @u?Y]4lVwlubȣ@>6:!ݦ`c( W V0Y)dפQWt/ߡp옩L֑(W"e!5Gd;ӌPА_ u{}ߗ!g+p[_c/0cc.#p%_ v,jg }m:.z<%,Y%eQ88V|[z @]o9 `|5: CF+EjYaF ׍ċ7a9߁v?7QkV矒u+ĥw(Bxy2KC < 2e0Ϟ~)c\@~M/}6ᨑNb eebDuLWz`OPhbR+Qb,4(簓U 6ڼ} H~ޖHkv)t^섖o/'ii/2 _7>eFƓ}s5 R !:^hDv d' A0@; r0X;|X3&zð"E~bPIdwJCJihR8}[PT#hȚr#/_ōfgrCː9fKPǶC[bl1o=B_/03Tz Vs!Mp|!F#LͭiXSfYo!W,)>0Sa*R %4!(Nt1VYX+?+ NkvrZ_#*u0QtԘ,-kL~%< ^wtn >I`AOAp9iܼoϻA0iVe2ƭ;7h2j_怣Gux!>ڋoQ36Y ]$'%QR6,2,#SH^$ V^?2 BZMR^:Dhk~x {Ҙ_N~ɶ?yĺ˳q n7{$^=lhy˙jΨr>x͋נ `8 >{W<8gAu(y#sOT2]]P_B3VɰԤ2As|o [ʊ{S J0@5Rb!2jHP#r=H|k8i% y^?MߛTVq&rg5F #[G?/~ntt {|}plLp3WNE? v;+Yb6/Hlp j]8/[g{q׃8T iZt?:Cp r7Fp a0 €rO$EKHBA>:XST<XJ̮80CJV!HFi `QGьN@>s<y6<ex= eqB*W޽IvjpN@߶sRp*xۦ!RrY5ic_=`coqc13ν7:a! >zv`S9P~}AmȰ Pv%M(j+BڸpExz Ç1a$Jb.2 (C`Yi T0_/XĹKc:WӤo`->1A+S|g7&g:w|y Ǡ3 ׮rFNU>?fl⭯^2RYgk`-!~7ñe_J~U㓄Vjk0?]P'U_kǂ!'wA);oW0y$~,P.bi Ks\꾗HqEO>wqlX.?6:'/_8JOE DT `ʤD!$AWr"jIp.T-PJp(C0DH@S>!`G"lz泰o2cmgB_{R%6w#:6/;o1>cXęUP)|C~$?tѝkl>~ϳ1(-Oǟ>>K>zannu1#{1ORy9AKAxׯj|=4 XHGZ] ꙮe.{ R4 ,% DNЅ f'ogm!4K~ ЀV'Q jj! "p5i \?OVM+I+s /6cz#G_,GV8?Cߎf>󣨿a-Y7V_|$~|~!cmwYm)^!:˅pOL| o|q- %Z>=r<0R: (G l!`] ǡZKԕ2 @FC?h"T@$CS7BQ`  z|+ Fwzmsֿ0@cX 1?ni3<|85NXհK:Oo(`n" 0߿ TGH>$ܿ>jӗ 9uK}Yy"2:Ǎ|xk;nwH Mh'")~(R u茫oRWM THUcHEpR~0Q6 _߃ Me4Cr Y[ {<+k?y$KCq:^qb^"@q~ 0/oUu,\.@ qdYd(Bņ0VZM㝿Ώ}?%9֙-y 96nepi&6J PE\.Yu% ]-6XER$~(Wʴ&E~Soϝh{%: 7;6Q  R b_`?F&`!n ݶqmzޢ%\Np`\_~oy j) \\t5vg!W @H"eR(PԤBGq*rtv[\2.NBWTNJL\S9 h%_ƩRnMk!I wf3?zZzx:$}ǁg.R J>ڍ}MDRC7 Z{NJc5|<됞W-AMh D :/2_/~l_DE7 ",RAH1z.g?U?p7('GUH`?UGRG"Ogc?kx)J ?_Hc0 RC# wyn]cոHϥm'W}= ,Il Ւ Uݸ8 4Bv( PKI5:/J$ E(!!<լEB s8˽5Ȍd7? X8JP~@ֳ mt]ANԬ[_@Ξ`fmDJ* S!cx f[+␌KlD)2P":~v!cheDo{<A㝧ѿzz.vbv * >w3kaKe Ҋ='hUdCW9D@+޿2Ѓ'p˴,u[Eiņ3+|;wJc%*d"  Bw#3ikS9rxV7>}ҽ }|?~e ^~7Ӡ^IR yA`qibh du\D t!HkZ\M5/ ?QcRc.\]?UVT0BJ\O^D?![oڢ(v`#qRH1[V "$Ȍ#wdzy+<\{)5W!ğqKdt0#\\  21 A'"a {:m0Dx/i/ƶP.ʜߏftcjaj ! "F$H9sF"[ǑaF,\HQ !Rky IDAT$w cidCgB$/} spbbB }? ga3h lܭ4]Ђ:8nLZ!!Ͽԅg@堵5R$ P(Tl00v]bQ@`=QĠ'/*<< #}!/F+ᲥԔv3̜BJ {vS,~T N<ɐ0UCtM RmD |j{,^8v!q# G+.$,h $Y ÛVYł*h-KJ{AI]d+O?\m(a 3q^|kGOq=bmOI^,N{} \k[M!AH@/}m8}1뱂@-P)ce$ɢy-{5+(AmzR܂V J( -/VzNZb\6/=x~@4a@OQl_|YIi~čLV?Y,;GW M XvgB|[AX ~nu= :*S\: KZ֢HUО5tI? `lLI B+- uB@IlB?gv?a Y,D?[W*OUhR3;<cGNoD|$!XI Z_SS((@ZT$%O5:JEIW .@֢a\.|i K&Z?eMW0` `3>oV~B]HR)0mi7sIiyimsgACa>c-5UOECgGC]I-tFԞ}!5Y^ȒBV)y?@ 5ԡ1h0@ֵ`n?*_J {dqn4ю0K9*!Im'e>?Dg4=#$~Ӌ("1*нjZ%(H RG"), ~Q]H-Qզ8&O(&}eK(xa6o7a3xB AB|]> kXokTSdgyl{:`HDNAA("cJۈk4T?C@_P )R+%Y-W%ͪ8^`$TUj)Ԣ@j! @/vpܧ@-I 8>/czք?I?V*Bs*q ycP e~89|[1;d;`#4i+Rwdn3pw"ooG]$kec`d)OQ@Nڳ^-@8X׎ѾxKfˉf Q:ƚW*/N'ݎ(v?@oU)eVҼqհD?o{B@zI 8MP׬n}iX#ՔL5s@S\@a%z. o>.oox.K4ҔV[&?T%@!eceDĔ_Ӄwl RϮ/)Rgkάp t =y67$۴Wm=8EY*y؊ѯ:V(e1+L?04Q0S?t%wh{f!'MYHRl B@7KH @_9"V.(j>EcWzxޖkng:SIW(anq4ޭ=M M]jD8F@@vMDŽC'1 ~^h 4 `~P`;`xp_%uv/׈?`,g RU)H+]lE A2ؕvuO`=QIXae%2 ӝ\xIϚ9},R*i4ӻFQp_y3(V8{VI xX-*QGJMցO![!𕀋/=ٛa:_߄@:"Y3,ށf" ("}G,h`aٿ IsO{\m,"N[G 4&9_?l^]La:ƫ/{[i&*F@iSrYy27ZCV% ; _ü_|OKY\`S + tU&?LOH:8S?¿Ӥ y^3;UAy<4PF\Sz\6霬P+ ôRQ=Ÿf QAfܩsU:4REHh9`AeOڽV0 o}@½3,N0lm¿u<n<l&TDP/OEhRFyAIk81~V5]=# +&?WW඾Maare&>/-Ϻ.ˑ } ,턺*( `R^ԙu#0 0G4+4ORIuN(IJOA%Ŀڱt[5:}ɷ켱ӟ 0mXRm0g(I#?Lc៏$KF xo+F 0Ь 87|҇aRFO߿Ba֩>W7ӛtVDHgvӽ$׿Mؿ_;Oc+ۆ\@?cnkO~$ *J,,NUþ2@50?Aeh]ה<$&eɵkqw]Qs1zfNs맞0ڒ$c}?%y1? Ƥ *5F6y*l :)G hH[ _&O` &>F>o?f^Bvn{:hT{:8֞]cۏ}U +fcK\Q$u܏3Y.$Oqu@+xum{ڶHT,Һ ( `0a%*dD-qUx"4+׵\䍗޼w৿IJ:R#0]O_/| L*7q"ɓN8,jڕ!F?JFZ!ECG(0+pJZ'm=4~(Z5-ل'``Som:C3L^r7H:D?<\,IV&5KSaC!+V=ESSy'hs`4˔4Z ao@3J)'BWD&ܭc^յ +Yc;%Hrs?) x{%Ӭ@BI~CI*h*D%@}!;~W M U ,5}LE %N_\~9z`2` n$Z7Av)>Xz d)oߊ|~c\ώ-)\%nE,~L,yvP>Fh*)CJ`B0vHA[p(@T#:[ q 8\Y˰]{Q~Q-z)<{)z>{z>Tȵ03s֎+g/@D?Rѵ ^ w@j?)3EGڬ]O4<G߳ #@vs?,¿ ~xGO;@վ,~G(HRFBSXChXc-~b`BĮt0$=R1RU 9 DBr~yyo{6xQj "p~(2YI V.3Q1jPeG [z~X|WKzT'T(\X>ok9XTb"iJRF =pVp @˾j "UTԗYFH*snۛiV}ꗞݝ(\ KV=NAEr쉆 !Gsm Dk4?]D5 }MzRxp [%]c 0<\c#=,!.> 4ѳА qXS- P| "WyWMpu8r()g@w]p? ߌa` *޸wʕ(\ l@3{(7ncE68y~h_!:8ZJzwaF&4}]C+qtbJ)G<<n{Éax?B'>,@*! h0d' &% BԽ_@ua<vE8 \y[g>.+Ӏzv?E+Pxjʩ_Ċ3m\mO"Gr zlV۟wuLN-pxD[W'OpUI*Ǘ`%sK-:>^rN ŘCa+ ! ᑿQijkDP`}EG5p~?*+ӈ޾{P)(\bEX`yq?Yҡ2zz; C2Qܳ4胮SCZģV_x^yZȝ M"\)m QX*Btep 뤷.3ey\\5W^}Xw@VfU  xUmP0AasVbNkLy[!O}Q9z~{w O=#xɅXE@RjHj}|@ +E?F=laxjB{fPSH_ @!W\TCK LBixwAg.F5'C>a0PPx{fQbpOM#+P]|C;\O`~mCC`x-7]//顸C~iT.YFr10)k$iǛ,]|7NJrÍf"P9>4ʀ~z{&JYy f^z=? lfvuv/ő|ƿn=}F ࿷w޴lrCIhj6f )Qucv΂Q@j3DpZ4pKz̸ܰ`CM yI(@$·I -jB_#k`CfXMnfߧ?GQFQߏc@E(^v Vt+N7RN o[U܏٭Gwyꖧ]kg'ƪ%s0R֡/# 4.6JYKQ&'g7kyn/x5oqwoEC p,*ojt%ot/*2XO߲]/l}]|ඓ,~QZW;N{o1S@naUC>_@Po'QE^ ㇦2# &Vy,C*[޴OLuĝ&5[ FNf?v[Z{TC *>r)848xoC~`&<_QT+گb{ҷe5_*=O70  K(j PSb&yjXYSJWWO]:Gjd?<Ó 8E8:j Q[G0e"ܻICE%B} A}r "1-t$g p+V9oID͸x@?D$o[$Or~Zt IDAT\UѠw{ODq )・~8 0&?,y:U5¥{E%Oɀu:djɋ U$#Ϛ g8'B#᜿4ʨ޸7vxL7bi .A8pNʿ,#deO\; &P?mbAi+:Jc'EOѯ'+Dڮǩ֭u.{0C+Qx(hЛ8=?Zax3#gj'%^`*3O6F&4>7r>{e/mYkU?gKR (NOf*+ 'hV|Fۉ<w_5:XI$yr8;U8 6 wf}̜Axh6Af,Sֿ/ҵ?1pp$;xbǖ`2%0M𬳇/]GBU?G҉ZLw:r+GGGXtpX]~[O~ N"¯>7]ѱyO0uDOg 3T7\I,L1e(\>= G{|>2⍯`0枴/o>!lM#t?{*3|B'V:+cxM8} (#KZ^[@=Ю*Ȃ!OY$P:"% %X]U(l /^KCpzxV0yf,HW]Eصtִv٤ZJ|(O>u1 v_:V@ֿ? 0 Oy /C7(=V:I+I} ;&}3L\:*?5aCkA?:ԣ&Dt):j#{wz^O(@)pl| N`$ ~[`ϳ|S0Մ`Jp^WXn:.a)e>!::v#e?P@8dJ2Gzxh-tv_W=q;evQ¹/" IB8k]TD+I}Pꡣ~Y 4@J7\2p_F%Tpr]c<ѻҬi^ .V[ _V!3r@WJ\GM+-RR=~ }8a~ mc3f< KI2W[@XwUx`OGw  .m>Ɉ#/l+X; P t,N>Q+DuU4((@ϒ`1O ۵r)n;*&?'IT\>7}KqhXhEX k;!tVNіv!F}(guooMџjӫ?c| ;roT$%(nӘ)ɛ<lصk&ί>`tPQ[Ϛluvֶ9$ YswA0L"/p٧E`:o]??Rd 'nwq`d~'Vؕb*Ѡ `dS+Eydt7%+N2T(ef6BhyQ1){[Z70,Ix*ro= R3A2+u\@4"Ep_/&WS{.c\ ݏ[Y3>P0eT?y#?;#V&]{ׄjX )錭?O⼳\sȢ'v46̬s)?*m:Q&@@MI FfE57ؓO<v'.-<]q!.=/Wfߏ]G0 ^;Qʳ_A SyvH -uQSDŽ!c+A+='$ 0XJ6T'h]z -/,=A5-3,9s/,*V) U﹧aD$ 5}n /bo@a3צumsŎ c[, 82P0xcpָp9GabVD?yҠ2iQDԑE K9VIS$a)+ Y+ByFqFH:4J<z#hHfa@͉K?udy E B yz_,qJ"ەwD[?oC׳,(i2t)+ 3Ǚ?PES~|5Zo:א)Io3^8޲]$ V!)5A^v٬R?K0g`5tk91`Jhj@)%'<r峊 +8(ܵN/Ky\V@,|+ 3Y0Pih&4WP|q" 0DM!x\_? f$,b1Ey6IX'0L,d{uk'>&@%L:ŃIpށU^35K?u g\:poBnMŷyJ ώп v_f, 2PjאF(/mv AtbXBA!2?)UhM؅jnj]J0}֑a#?`pl#=)XHO xҔ!hJ.L7—< }ӽD \m4\{{mLNF:% &L急t @j!I\mxקO)k߹k.hgiB4 {f9)yMy $iOvSJ;y8}Y l;0hq}n1/޶:+w2?D0TS'OUv(HGBayg =%0sRN$С@B@P%w׋_RXGkr֨o(Oh渨`$p4 A<ŁATNQ }ν :Pn;e=+}F/"ɫ1஢9OW2? P`$pB•M~0+Mcډ @ {K^] ?~<8Ƶ܅KA]t$9Ae'!Oֱ087*kKefSi`8"|%@@."|ΰ`[?)FLFŸe2k$'7ftfXtPܸelYO RJ:8uP~km_!X{qQ5{H駏? Ķ]k# V(]ͅ (Cyct/!  =̀*5 D}@;`[&qgo<Gz:Zڈhx5e Tq4@|à4HV4hm٧1 VUѤ%v`MZG+h`k֋{xG_Pyr|#rMvkR_RߧdiFkG[TT׼bAv0gt59)'zƷC.q ?22-8G/Oy trs=6;w2L$=o⟇+`ƃiE=Rүr7ba8#c%4nR+D#ʷ1%E?eE|'?<<}֬(݌ 3_ߋƉ~t;GVjEӒ~y=P=sf3445SER>@ߍ8|mmzX^Kpٯ-ĆѾLqV߯Fq_Hkanc[~k$rwda8+,rI" з",2ɡl_|e`b'q_m)2mc =zot? }Q۶$ٽfר590" + 39>4̭2Jr"A_""P"k?̚s5}kF Ox.~8Џ}{phO&`5'q \Q\ ` 6n4#-ɏsr7DUJ0L>GK:FNAt3FJ}DPAdWWHę^qf 16(h G4\XXX9A'K}Iڗ&Ҭ{@й>0sr, rb`o@IS\e훹#ɢO8^Sbk5lo @8?N׿MgQ*a&YLQHm"bY7q}}=L@boĈ+`Ƴ-/ J*:/X S#`7q0@4CPE4% i4OL[_jo>?Ít9A 0,;Ŝ1sOL<$6mY? Ѩd4s1Fo){>'=}mU~F%S0 Piw7;:d.>H濵=c6tp?1&Ey "ߎ{VLE6QnoVFIA@OKjs5븀3)iG IflSO wPt"MT @@ P`&?j4gF098?L&KNw4J$HqJBt_n zm`vҺ0I~* @a&N=H?K؛ <H1 F.۔:d$@ hyi$,tǒfR``*y3΄a u6O2Є²<~wQ(XiB>-?+=M͈"?`J%0_p)=)Yh>piL2>>U !ehpFN¿MVyØa؝$Š¥/0gz$)@w}qJDoGI~F 4֚4WUWٟ)0{\@x|qTjzC#PdvϺ]сP%K_Y:rI/vUbOۢUrG䪬H { rb"`<(^^k,ݯ#@#IJ"{?뷻JsAmd>a e/Xu>=+ XO-t)I@<  ‚ٵ,p`]NUFa#ƍ]S,Xk͂KE&:%W=>=+ Dl҄]–?`w A )ÂnCTt5oC<,@'VekhB5J} Kgnuk>@#>lCfƓ ؍ Su3CC-C"k&oC!g~sDpsϱVZ`ۧ{ vDAٗynNV|½V =g $j BZ$}#0 $; C݅- ;wNls56J6b2'`I&:tC:,|f&+ 3y!|9;r1L6@{ g.O Ȩze0s rA3F?r6:p0=f9hBJ)kva  ږ (eۤ6:1 hGDMֿPQoXXIw=@;כ]+ 3Ǹ|?Ql0Lc('/P:*"4[˛] fqaMgMk.Lk1q+柤~QnYau(n0>4KtE@՚0Y?ԆV\|{v_J1¿p\0!b?A5/ SE ,r~7|'ZgǚW:0DI[ڶ IDATvZ !EOȨ¸Q+@ X۠Q^FC~r_'\러* y<0ۡy@u ^.h+a.%pE'o_t/# NQMgS Ĩ<閲Y`.Ygↆ߶c}{[/?!/QkcCֿ7|%qtWW#Mx?Ÿaߊ%0^lǕ,0Lq#w&IۢŸ\wW dQW~=6<c-'!0]ygKߣ&? }w}OgPPWY! z@{\+-7A T*-ʅOmT] a? |v3L>?m 'FĐRoXh^uyӒU L"^au6?S1<@ ŧݷEq#X+ 39a|``0`' |~MŐXӮ0׎?؏lŸH)?,9Jf5?&O SyÃ=ݮ%0֎wCT(q>`B.X3L+?MA /+q,Wxx=Ufܶ~q$U Te8gYZn<*v.Eۇv='X@xJ03LsYB?U\ڸJn0.$B~ۮ `9ekS''[CkRY`Y7"օ~~_}+`aZ!]K$g}&& mic 26\K߱T.:Y, "p`W0 8_X1}5 ؑuv/'w$%IaOg`od%'׿}DjN-TpmcĒY`Y CԬu;-Ika+irlZ' `"YˮqN-98oI81F "u8 Rc{|_CjU@N?yO6~kG V+}`>I8WH  P.Oh)&߮w54 e&LU^ױuz%l|a]K@v2 &?~-j~C5cb$]IL|+ t;-? OzF;J/d^ X 0v0L2Q_[۽hO~x_^wr0LuBuz @a&L4Χ+i6Wa TPl }X`.dˮ+ p Rf#‚] ka'.5k:Q_3'OT3>ܱ0]ʓo_ 줬Q/0 %0 ߎ&Okvd9^TE*~ t1wo]Tl%  F\?~/`QR(W5~)Vs7J/G0a;O Jf̯mMpvf0ldW?|u:D y2 $B@ #Q[6=کGaay}8Vo ɃX=i;uOĸ5GQ~P(c돁2nX[B?(>T0s{"g %}Tzu?n0]D__\`a+_XѮ%"{ײml%e@K`S~5S/cO= %8o8wmnGհB[۩LЁ%0L#׿gZ_qL@V'? at#~8w}e+_Jjd۱]~?A_JZbXdqǣ_۳x+ 3Sx֍9d و,pO]oL+L_4~TոD?_2~;ư0~}F="rrC:;0D\;MwSe?/c~}N,=+0,end F\<^X2<+6츿#'aBdWy*'Jj|Ww:0#+ 3 Y8olβ-$_|з6'~ifdQxnMDoۣg5_Ldw}I`ڻ0{nuD}tiIoh`porVf[ᖸaf (!a !N  Ɲځ%8o 8%'6~ы]$  Ng_$]w]mFzI_i_*XpWG0,ԣOkR_LR&pS@UXr3!oM$PO-fΈ-03 ؂lrvlPpL>滤ᄔEf6M˿ jz@p>倇ѻoL̤\0 f᠇ iUǸQZ%'D\TWM̿jړ_Oh|{*N{_Vf`="n_A$? dP8 2 VkobÞ;O`BgIV6Ĥe+pt30!r lLr ny|)2X``=7oNmF?`NkCY `f9;*vgg.>{RxjX`Do†[7p7)>$c@601&Z[z1\Q?9 3cp߮Տ9\Lv} CNd103G~Ο3 &ُPaK E> 13Ӎ/Hh?Z܊[1f-'btVfWmYK:-/"'LC'_k!.ln%<6j00P%1є:Y=EcS04p=)k$/Iկ /کY:y?R5 b"GxJS&/1@u૞ |2.EZ.+ 3MoQxs -E E 2AӤ%\^UCPT=,<|W“+ 3 pouM KlHfC#>xبOP_!plMvߺ.k}zT4e2?[(ibk[ YT{A$q&˟)+\; xjs.L/sT_ L!VSY"R)\0 RrX `{f7 }zH@ք-Tŋ.G4;?U 3e8Cbo//IPzAʹ yo6%׎CغsarTi6I־>*LPJ͂)|NOͱ80LV6v"zGk%f80iJAЧяhg g(IG$־ #G$0  }W+ *x=xDaar2د J<X1_zAH) c!QD",h[h=hо3M)S|`>-(I(k)݂0]pg?rƫwo`arP,*lu͐ p@FFUTǍOni- ^Ӆi< 9@pF;vږ;D%§5Y;L<c6GQw?ێ-V&#%An-"/{H')@%At<=~Խ2 ,B--5Ч/Rb켂hNAZa;H@y,oI{J~׸vWBU9C0:S7k^l4'Or,e : }$uD<=\g}$kJ0-F`!<Ti@ϋ-lOJI|DTG i1!Q LHcO"U׾eN}{OP]mw 4`}U?U?O3 hPUr~V-Qys&K[#]s򾠵-W72ۤ~g̓$eU50$q 2 Z˖VjCˊ:e(֚^aCAY+kEI4EKkB$E @sa0 z>Q]tw1骬̬i0⍄&` *#@51 %zlTByz.9/Uen=Y~\h?lXM_1~1A#JDuP Z`:!j\F(P:C0i`>,saX|;\(!#^ <>yc S &q׺4d{L]'5ŃJjV,8o^L,6F-Ev҈HzNiܞwrǙ{ }PD -6i%~Z^U/6-l-]́ZR(-a7ڢ\8%`,DR]FG~wZ`~/Fk ɢ؜@'yt oK #P=)k [MLq/5W34/?( %Z0g}οuS>} K6qNv%:OĐʸa9GMU]oPT}y 3:WMҷLfA֒n> d$ 9@Nƀȉ N Y/IB-~^-1/6We6rQ%JPto&]& B h*V $!CIy QH  ei[=53b g՜.2ghNV0P}̟ hsy$O%8>U[ m4JHPo7cI?m;-$ȿS꼈pE- DF i`XXg97 =2j%doI=6g)Miu6P˷-EsγsC䰠4J`MQ '9&ϩ;K:ݫW׷xqR<`AIaT`|JǾzW&F};(qcku 67:V(^׎-E]@U/&0XCeP])iTO9>|nţ( 4V~ȿu:?di6ΚŴm=*z)[ϴ~ӎDC,3w:_>w攲ۅSs|r?r5]ByKܳz+þ'nm-igu+Jn4Jܓ^M9 ?0w|"o&O>;.(#Cp\!04_IehSJc|tbQ^;1 @{e.)!wnO>|'ِ’q븿N0SaIno-g#›]Vv#WߜzaV{}(qO!W9?\Z߹,eʇo/U%F?[[EWnz ~\~|2T]cSfw_4J3V/~/:XbәiO4>g?o;=#VW~nk;M1g;ǃw8lw<ੇ+W~ChTk(Wn[.iy>ϝ`bi VKAA@i'04|S..N黟!6ڴ}4S~_ ܼH%Cj Opc87"8 IDAT1Cwxpx'} ?Ns;hZ%a>m5xuRx72<+@4Jz Y+$NE!_aMߺy[!Q~_֍;/|dv7uQG3p9{X_Ѿý+ T T+džBUx#!FдЈmİTJDKrٕAծ4Jj[uo;Ol&fGп:?77_7PoVk2ءQ[@)qhqwsbmIO ['ɹQ~_kw uR%#JġȠwGߋR$*ܧn|4߸9/3|&?%Jf@C>˗>yy盩v'?.Ʀ 9XXV/K(  iݮf_|S M~v cG9Y:`eU{x&?%J6@Cު߾towǺKW-?Gs˯;W /ɿĽ ġٽ6?&?JW-w>#C5.|p׿D(q౵:޷K]$kӖ~tW?`bvٲĽ2P@ct-P? uοDw(q`:Fݬ_6K(u@mNoɦidi(Qbk(5%z,_tJ&-Y7%](+Q(  QSL_0>jxb#^bC [ ?m(Y:c#LD3q aS-/MZ~/q?}`y-s^rĽW>K-mIyDpŋ0)0e4gB|DVs9!οGߓb%@3BI ȣseA)8KjxZO_xlF` `m@B4- QHK!DP␠g//W8 Y;c2OTx/_0)0-k3&!~ + rb ɍ *3 ɺ,M!6p-I GVf#yGFƒ_T:<1&#UM2' Y (˝KDor_ ^5AhWt,e}twg>y2_PCўrQCt$Q#и=WX)PГO{$TDR@_߽=7kQw۞;y5H]= )?_I%T@-gG:~9Ѕ=>vX_h%laMo:ONg 힕t<[Wvο_|N?\P7}d$W,ęO{XS/;5i4Sj4vwy2鑴bh9.+m#C|K9<AKG:V)ˊ,XڿLR_6)q?mo-`s 9& v?("ʲ$ojd3A?PFxh7KofJ_u Xca?ee{!*Rm∞XL$O]5$;H4R|&+=;$ (-!˟Easf `9|?$←Ҥg?p/qA:{\k{w @e1L#JQ̟(?hpn8ME-J 8:*d|7=vu_%?tʄP$riykφW&:r:@v<46y ż7'{2@qS'-8%4iw@xhql}0%6^dz]74." M?ONۮWYZ]޹DClѰ$^DH_d,_@=N:VAq㞨CqF;"kSD. Pb0gy^ά~>>/pM~wGKے&u](qO" fUko==nݼCnuTΊ8q,!l;nȿqJ 8ՠOɉ7B%ɽ8Oߓ5~K{AsM~/OZ>b5ۣ5˗oxr]W=v"Ű=6I8^+WYE!V4#W7uĈuN\H\=n[JMh?#_Z{P)_ko4dۿ[f7B*P$݅ftmBs?jVgu5cS ~U!{)fӉ'8} 0VYz87T!H@,- 7>wW|Ŭ%(etg{>8Y9Ұi\" Q14DўY3+V#NL3+4Pá8U?s7}t# HM{(iY< Elf!׫jK( 5{^pPU%F4| Yg;-`"'J,N4H#b"@xSq{ 2Zɿx8wi]7io俕:@I{V>%J+)%Ƚo̸͐C Q8 y+@L9Y qi M( &fQE? /jRM~wo!K/Qbp+E$/ TΘu657v|77NppFʼn1Ya"5u'='p.ݤȻOvtl )qDgP翗_"Ô@/Qhׄsfyߺ:_O$?1uQisK8\q :>3UT᪓(rVb(cx# 0*8&7cfqNߔRFv]WT&?ja L,Ȕ`f R{ ?:ifN#`>n4mB]Tjh1181T*H1TTk1X"0J\ݩįr듨M"HārvεL*ZS债cV3TW&[U(YG;Џ;?:m*KEe[jJ"HM uA0ȸ<3{TF\]1uj5N9Tp߻XPbz!` :ddGKQKݨD_PcNo L 2i(%NC/.oo+u 5i4$2{bxIJ.UcWцMC; Rk=Q.l&pEh96`|f<-utο+lLJNn ӴT(B8ν<ҋ<ٹ7xQB$!Ѹ)BMUjsqX =iID]l*44:"5]r@eeO9BS4tLD 'O@b,@9VfbԹ sU|Ԡ]ǑLki-T_\KeD. Sלȕ[@\5БT PS6L5L%jL۫@- _w76Q%nԍ?N} ܜCh7 |7Z5ڵ ./}KuabN¯&h<`xD-XS 10Wn%J|)Ww#/ͧJ~KK, Z&f9~xnإ6L7T\%'*)WH騿:9CF3HԡFV_%xk'[7b2w#i{S`wA/ ;eL LF s\(QbŹ|}'긹˷N=@A& Qݨ6j36p@e`_TbGaUjNm͈,kO5b@iCiںIp; 2bߴu?"g٣I6ͮ%KGx'RVu4yظLmP؎D;_ ϕ T;Xwom>EBxe5rQ=݊\Hf\Ǧ uc%||ߟˎ' 0:MXmZ_R6@߹]iS.G^M"aot =I?ƿEc[I(>?a2WZ~_%Ja_g) s<<}UD@]4=.tE=hK8Td%T|s ba/d]3(`SP%ڥڠdbdܧ#]nz:p;Gqny^䗒=i@/CO%BSsBAbü1 NivƀMb$˳N*Q /Ks/[]Ifҭh"ryDYЊZ7J ,v8q|#mTFnEj[CjwOP+G_r4¤)P:9sH_s޺[Abݕ|?l6!vu!/y۶bl qRQ ;U}Y|j'9P:!2f`UMɚ>U~ˍ=%J¿V?sм= ]M |T̃.dJ,}v(l\$kdİ ̪S.M*T1VYaaCp IB* Hi$ɿOlϷ@34 ?m;OXY *U5J~KJHlB3R*ܭ1xg"Z{(QbКHy,wnyYd@rF@Myԣ k?f mTP\׈`=2>2"Vmpɏ<y%f|:S4Nѷ-9R02:[_oF6'x_9 b5}>ߗ[h4!'d\ NRe捁l!ƌ[M%J= |GGmʱ 66Ǎ: Fd)ŸD+.g ljC N-k҄O-)j&<7|CZSoKqۜ6g}ll[fY)XpG`t~%r%䧘pM{ 5&474Y\t)4ݏ/QbQ_Cj8a8~?+&acfW#KVG,@mE7Xuo.G֌%T sb@EUG핛I7 ad8Gd 돧)_x|DN5Uy@kJV@rhYr t)O!`(Abh?NiDdKP'e>Z˸D;6% w٥_VO Um 0輪[RJkT]4Tշ8qLOkZG͒3fAil}Cr~M x1Kz ̏?J'|^3*.%:y[ Z>C1~B{ה d{ض%5G7\f!W[M&(1z \ڠ(IW"eWDС#/w3%jl6$ZDK0 `j{:ԉ%73:&΀i`UPQz&-sb !(|YgW猁5A2|K;y[O^oDw61o;/; Ƌ;[ -aT f%2D SAI%JZWlΓ Eu7zkWdo><,$0 ?Zo,=Ů글+S}q$Vjͣ: ,Ys&e_4o̚ojUמ@+#5-{/~n6>$[ q7@>ܟgH |Up87%I_A;/ECjmOꌥvf/Q@]?%Gºa`UO]F2OΒ!tDdV IDATìYPg"C=վ9lj{tg49qEw\F=25"$K$"2b&̋)‰NBu Gz8j ZȾ`~=GW翍]2e•@kњO=w^A[agEz'l)540B$RmTVx?* ,.XYQw 81ZD˨(8;4Qy“[G17*ڢMAedu@`[kms?o][!iD~@ATO55y[Gb8c'#ʌ 3@i7^ܧ.~K?^6ewG𺛮ߘbKOœ̼1ƍ#SvPU'"ԠB\1\3s dVLsuW0Ɛ8P^*!~{jT%q@x]E0V$t =Y {a61lNןi5 [gK=~iBع<­ܚ:ʅY.GqՇ8-A(KdR#FtA#Tqպjcl>B"Wr##bjTqvƝ2&=a"YEĐcO7}Y$vFwga^O'ŨFSpXךh <#_V 7-xv8b|3Gc#5M}BuwBڴg?0K/Q БyOjk η8dE120)0jq1JB19ŀՊԫhi: YVDo)NE.dsER O,5N!d'da*۴??E_vz{Wq~ﹷ~g /lHZtX~K/Q޳edK#bG7te&\Z?]cQa qW#KQË$ 订 7ZWb^jX\Ug%I1c4jQ\z‘#`穿1d?H0 @z^톗^'DF׻'B#vHI8ӹ_x 3OO}D˖E]ii&F%JsoN/g1?u\gw~ui?ꢈ!:0hEf9HK6.\`lU 47"Ff8"c]6! G`!H@S1~r ᡴDС"Je#!mĩ!'6?Nwn[޸^Xu0sg=v>S_n͋JѝiK/QBDw F~q锟hN/}zZ/}j2c*.U*7fGk.8w.ūˋ [10/Fg z [N~_A\COzGiKj[{khA2n(5Xxw2(.Wf򺒜 )fGgf8BW6x]VcE#|+O?׏OwoݹN[N3Y=E_gď#ȏmL W0bON_ C"ӊW"Ԍ?)ۍS]AQGh4jje^IwpS-:g%Z`i.&ݦ?XRycrNB4>"W/dXGD=I0'@/p?o vF#oXk:y?~io<_c7cV*hKĩ/Q"6/?v{bs Ø3."DfVC. Wū˶*Q-6jGSћYHNK՝]@ ?5ï9|b* m\$֡=O·ߗFH@15p0SqJEAU3ɿߥjΙO:u?|w/>獁 |s—u%JxaY~r9kfk/)(5mঊat޹hYOGc?{eykD$lBBp*$8c,{@z><8~*& 5&ğ$ 8 搳duA0:5 ECC4hR\I=$8jxm6wxs7ܜ?cN-޳OɨequO*;(n+\T Boyc>hVGZ!-sdKV*~F%$=T|r5틌 91cE b8ũxOpה,=EAHh?w :5JMBNfɿ]vX!ILx# ᫤!Q߾sm6Vޘ;sˎFyשhD&??L9:Z?y^j!e~6Sh!?h6CpSU'%2Ө[F\lS1n[؛@nƹ uSH"u#Q8b5>.*qsb0j HW^rq/+ *!;3ѯMe/vo`(Bmټ2K QxGi-wD18p;}Xkr=>S'xֹRWF'u  T7x:?CW7 9zStҡjqovk=Z,o r4Zw=vHf՘I!᜵~`a@'O|/p,|Y4$~,ɿĽTFY3ӱY |ȏ>M[m+1Ё s,\ǸnIYV↩U{?艋njyЗ.V̴DrK#k,&!;*.d9TBUG]H*\ķێ.|IJ Eȥk‚(w:V79J?B*-Q k0޺6M}=1?N䉣ڍN_*7:ͩբS1gdtZǍZ-ܹ}5z@:S3+WL^Y^Q5qTځ1Pq>( LsJ 凉"/PV%_},r `WfߺMO\4 n.E9hY.p=ڞ=s](k?] 1wD10x׻[D-D8<]^,k `< _ƛG/Q]l'GgqέF" Z,Z_bc1o_+VpISoVkFMHfϩʐCv4 H$zܯ*Ynw^Aa6(Ѣ11QۨDgV % @+/=4 B@k_r^LW(jmI'#|@%J܃h!³uH?x.GgNo'g_w1Mm~jyv1s]W#ӪBo]u>m{ne'.cJc5kƬV*>W4}8g)HULR(`D|@R4hb Ue7ǘ}mc?kI6 "ϞA>9C"Ljԛ@R4t1%(x&?wyAL&%F@9];3 _Ǟ).x\<{GTظG>g(}"n6\0`\5*}I=oYxyhf1+[< k/| Iܲ|N(| zo[a6_YC5gB4 4]m-+{Z PAٷ|(oPoSp~;_m-ל^񱋌ٵ'\(FpC Ic*WbӨo4Gcp'@pSnoT7&j,[hu=V3ǹbx#J~# F@ƽ筎7/O2uv0\N "q›ȕ䂖"G4Z0 9VTG,!f BH;FA<:};%Jc(E%y!kw{2Ao]o_Èk5I?zM\qgV 6WꤪDO /=@^q5STpqTڣJQz}oC#& /\2&si[0=hɹ!̹`zmxi6[`8?j5 Ů.%@'rv@ 63" D߂1۶dFu$v(q/#{ϴuŵ EƢ;}[_XgS_gj{:אqjA!rS#s̼,V69 VR#N\$U]2t "zQzI҇F|\X$!BjUo:_cϜ`ݛl?w}i6 7ȷ / [K:(-q !0u'Y_N\ }V ϧ ?&0TV(55m} ~Ƿ$cW+t/? rnGh[E$nfdf{Lsl󶃗c_)rzc Q\1uc⊘JEqFb3 TzpFH $7dUH ~vŗ OPox@OLWf`roͬ5lSizo{DpGi?A4f6@ESc7߲C)QB[Y^{*uۏ#8]4>s[&Aȿ5;g51\Rc:MW1FlKq &y /Ǿ3 zgg\1GzzզQ8SQ`"PQ#9gp$ɉaĥF uzOF~[1}խȉlFzzcM[>~ػ􄐽OV.~b2oTy_ėB1 ia%Jn_3!0<< /i#f/2eFmZ]!=7.95T1i*[X6Slp/ `(h^SZc3CqDOE`L6kO:KITԔ?Zsg4Q8?|'0 +d ^A\gg.nMfw|ՒmM(Kʴ7 ٓ`%JLlrl1Gz}p~|(v,|_Ȳ[_[PimSY78PTIJGS˱osaMľ5 ' xꛫ/aV(Bo:U}u!B 4X|ǀ HtQI%r}UG 4o`cz"5GzH/ؑ\ab|@_wO7u! ƁkcSC4BU`lBI[ (QbGeŌ۴uU7K5 1 >? ,UVIUXU6uZE% >Pр ,\(̉KD7Mafʻ=GZcȕ)t3dbp 99NC(d{3&"i{`-&Ͽe>UDAv ܈h$t$Ú0oNZTx#D!-5fLVb@{#ࡾ9j]!"qSEaTZwނQ5>hHfJ P%C)ĖT0+%E.As jg"ǎ3)dFoԈ#(xa`͓qêiH0Op(QbKKuj]eU1-B5rWDᚈ\wFn-MuӊYpK=8F֪ؔ=ήX5s*1FRGRsNU?IJ \(F4$V@dbܕ&ff_YQMpi>;AdCB[-]om@y2t'ݲ<ک @$DYIDATaI+& m },QD;*+W'X6Aߘg&Vj/c=OT'Su DpucƘI,j}YjMp h1H`&؉D1bZG%qd}TѪJ& 4 M L~9]&9I }1}nb娡y`O{}G=C&?y}” v@!gH\vhJ(-xs՛3ӎʜOw 6 k%cn[KmpnJ㑉MBgj}68~G ɗ²;VFRXP1 ;DzϹJ2"1 B ֽe@lH|(,6X F,f!($@+A`hLBkҩ)ng޳]]UQI*\{ɦ" `lL_":UEـyưh'T j{'s9#?Blq[q[` "ހnue@; "f!&?lv2y}Vi] Y6Y4%{oe8γ`LI8Gzir$!c"<6h:( psDg |NK`V F;}*<螩&Ƒm$I',Ⱦ 8-Yw1+֥iX)|{QY0il1fH%ԌU28)ScRg 7*@Uv^17TAd)Lx\oMkc@ź.`:4:A$'bSu$Ɓ yw i*TL*>$(e&@ofzLS 8Ua!u4J@[F8`a.jzS i!Jg"~!a0'.ӆIVRȿ+`}< G=Q3!"`,NZil|u5Q~p<ffuH`std,1)fHQe}Fۅq`-* uF@j`c^7NgO$ dn͞%ȿ}و- }/"5_~;[;Sg"y-]׫_ݮ寷4W} }E}`ᐂegQ ݩTl_/_BgZ`)ݿsGnw ZgkD BF4 8b$o')PQX%;PIi9o">;_yCϸHgXDbB睶W?7O֗]maRTzdDFnaGRE{V!r aml01ano`GkVY`_ cq"yԩ_J7L8ʾx`J$ǁ:M[SC̻WtOUko܁3^c'/Hn!L5q")!Uvx57 h7mivUNxdžZusU@USKRZDBX CMˑ5.Z;<|M_ ~eh 2 0B qDT&f٤ty0}wm+ZU#w玡ߏi?EiB> 6ՄIJi,YJD`C5&57^5@ـH]((e4 Ҷ_6R-{]ܷTϲ% k>궻EEmhO͓9\4a#\ŧ8'"qJ//=" $E0$8bС($GTrcZ$if)OcyGe+Za4y?v1)j#`!La #1$3^Av؁v;술%!Q,,Iy,AQGf_؍HufBVi R5^`__e4x{q/?~<-_[\K>*DAd"#C(FʘY*$62݂۩ؘch3*tyXK,eJ#>l`'ω-G&fYf[B"[&BvWH\!xlج3uVd1Uų)ǯWd_NHY)8Y|3P ]>YzaW>ޣ 8t@JĿdp *cJAdLcIPdA'Lba +qcs͈Mam @r#pͮnmiRH7Aj3'آ-Qn"%>h]]]Pd`vZc: Qo`HE:igq( Z~Ob1q q< DPP$̡20dJ`""&LLe L'TȔ)UȣXh7caݸ4<14?8@!tFVe$T U ]BУHOh=reL1QMCWmPAdf$3( z-X_7i ClYcg!c$| saG{ƲD#E ) r99(rSSLY\E&qH…pp ł(g NDi6Q1;."ZSem+c@WES"mvahCkAb"- Ȩ̔ADIfJ ( 8)U٭漱ΫF/7aGsF+n&@`bb"MhBFQ)"R,!ea@ B. +kbb;6rûZ](nrx=6th3c+*V)qh'C)MvJji3I[U2T`@% J%u2VD40W/$|_`}qVm9LidFD4U%YH(fT ! !f#Yv8ǝVY-u4 hgxػ{G(pt77Y:~C 1 aЉ%*C2a2d@HȐQąJ$ꦡ 87X%s#br~AŸCSyinrH 3Tf$X%hIb A,jb;J8؊NB4Gn!  9VQw'P^Fʖ)aB7b4K1e2l/hfm*V Rs}I?wh"0 b-BMplVc+J-3f|dn2-`/_EX*;y?tz9@  8NЦ>Vs[tr"Bw.C7#Ҍؘ*C<'|[W7/5<r "iNՅyn*B|+/K~)Gr#  main_window.ui host_editor.ui host_list.ui preferences_dialog.ui test_connection_dialog.ui ssh_key_manager_dialog.ui generate_key_dialog.ui key_picker_dialog.ui keyboard_shortcuts_dialog.ui welcome_view.ui unsaved_changes_dialog.ui ssh-studio.css icon_256.png icon_256.png SSH-Studio-1.3.1/data/ssh-studio.in000066400000000000000000000000711506556307300167710ustar00rootroot00000000000000#!/usr/bin/env bash exec python3 -m ssh_studio.main "$@" SSH-Studio-1.3.1/data/ui/000077500000000000000000000000001506556307300147565ustar00rootroot00000000000000SSH-Studio-1.3.1/data/ui/generate_key_dialog.blp000066400000000000000000000027271506556307300214460ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $GenerateKeyDialog: Adw.Dialog { Adw.ToastOverlay toast_overlay { Box { orientation: vertical; width-request: 520; height-request: 420; [header] Adw.HeaderBar { [title] Label { label: _("Generate SSH Key"); css-classes: ["title"]; } } Adw.Clamp { maximum-size: 760; child: Adw.PreferencesPage { Adw.PreferencesGroup { title: _("Key Options"); Adw.ComboRow type_row { title: _("Type"); subtitle: _("ed25519 recommended"); } Adw.ComboRow size_row { title: _("RSA Size"); subtitle: _("Only for RSA"); visible: false; } Adw.EntryRow name_row { title: _("File name"); text: "id_ed25519"; } Adw.EntryRow comment_row { title: _("Comment"); text: "ssh-studio"; } Adw.PasswordEntryRow pass_row { title: _("Passphrase"); } } }; } Box { orientation: horizontal; halign: end; spacing: 6; margin-start: 12; margin-end: 12; margin-top: 12; margin-bottom: 12; Button cancel_btn { label: _("Cancel"); } Button generate_btn { label: _("Generate"); css-classes: ["suggested-action"]; } } } } } SSH-Studio-1.3.1/data/ui/host_editor.blp000066400000000000000000000267161506556307300200140ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $HostEditor: Gtk.Box { orientation: vertical; vexpand: true; hexpand: true; styles [ "editor-pane", "host-editor-pane", ] [start] Button add_button { icon-name: "list-add-symbolic"; tooltip-text: _("Add Host"); visible: false; styles [ "suggested-action", ] } [start] Button duplicate_button { icon-name: "edit-copy-symbolic"; tooltip-text: _("Duplicate Host"); visible: false; styles [ "flat", ] } [start] Button delete_button { icon-name: "edit-delete-symbolic"; tooltip-text: _("Delete Host"); visible: false; styles [ "destructive-action", ] } Box { orientation: vertical; vexpand: true; hexpand: true; WindowHandle { Adw.HeaderBar { show-back-button: false; show-title: false; [center] Adw.ViewSwitcher viewswitcher { stack: viewstack; margin-end: 20; policy: wide; valign: center; halign: center; hexpand: true; styles [ "view-switcher", ] } Gtk.WindowControls window_controls { margin-start: 20; } } } Overlay banner_overlay { child: Adw.Banner unsaved_banner { revealed: false; title: _("You have unsaved changes."); button-label: _("Save"); use-markup: false; styles [ "banner", ] }; } Adw.ViewStack viewstack { vexpand: true; margin-start: 4; margin-end: 4; styles [ "host-editor", ] Adw.ViewStackPage { title: _("Settings"); icon-name: "preferences-system-symbolic"; name: "settings"; child: Adw.PreferencesPage { Adw.PreferencesGroup { title: _("Connection"); Adw.EntryRow patterns_entry { title: _("Host pattern"); } Label patterns_error_label { xalign: 0; visible: false; styles [ "error-label", "status-error", "dim-label", ] } Adw.EntryRow hostname_entry { title: _("Host name"); } Adw.EntryRow user_entry { title: _("Username"); } Adw.SpinRow port_entry { title: _("Port"); numeric: true; adjustment: Gtk.Adjustment { value: 22; lower: 1; upper: 65535; step-increment: 1; page-increment: 10; }; } Label port_error_label { xalign: 0; visible: false; styles [ "error-label", "status-error", "dim-label", ] } } Adw.PreferencesGroup { title: _("Authentication"); Adw.EntryRow identity_entry { title: _("Identity file"); [suffix] Button identity_button { margin-top: 8; margin-bottom: 8; icon-name: "document-open-symbolic"; tooltip-text: _("Choose Identity File"); styles [ "flat", ] } [suffix] Button identity_pick_button { margin-top: 8; margin-bottom: 8; icon-name: "dialog-password-symbolic"; tooltip-text: _("Pick from existing SSH keys"); styles [ "flat", ] } } Adw.ActionRow { title: _("Forward agent"); activatable-widget: forward_agent_switch; [suffix] Switch forward_agent_switch { valign: center; } } } Adw.PreferencesGroup { title: _("Actions"); Box { orientation: horizontal; spacing: 12; margin-top: 12; margin-bottom: 12; Button copy_button { label: _("Copy SSH command"); hexpand: true; styles [ "pill", "suggested-action", ] } Button test_button { label: _("Test connection"); hexpand: true; styles [ "pill", ] } } } }; } Adw.ViewStackPage { title: _("Networking"); icon-name: "network-wired-symbolic"; name: "networking"; child: Adw.PreferencesPage { Adw.PreferencesGroup { title: _("Networking"); Adw.EntryRow proxy_jump_entry { title: _("ProxyJump"); } Adw.EntryRow proxy_cmd_entry { title: _("ProxyCommand"); } Adw.EntryRow local_forward_entry { title: _("Local Forward"); } Adw.EntryRow remote_forward_entry { title: _("Remote Forward"); } } }; } Adw.ViewStackPage { title: _("Advanced"); icon-name: "applications-system-symbolic"; name: "advanced"; child: Adw.PreferencesPage { Adw.PreferencesGroup { title: _("Advanced Options"); description: _("Common SSH options not covered in other tabs"); Adw.ActionRow { title: _("Compression"); subtitle: _("Enable compression for slow connections"); activatable-widget: compression_switch; [suffix] Switch compression_switch { valign: center; } } Adw.SpinRow serveralive_interval_entry { title: _("ServerAliveInterval (s)"); numeric: true; adjustment: Gtk.Adjustment { value: 0; lower: 0; upper: 300; step-increment: 1; page-increment: 10; }; } Adw.SpinRow serveralive_count_entry { title: _("ServerAliveCountMax"); numeric: true; adjustment: Gtk.Adjustment { value: 3; lower: 1; upper: 100; step-increment: 1; page-increment: 5; }; } Adw.ActionRow { title: _("TCPKeepAlive"); subtitle: _("Enable TCP keepalive messages"); activatable-widget: tcp_keepalive_switch; [suffix] Switch tcp_keepalive_switch { valign: center; } } Adw.ComboRow strict_host_key_row { title: _("StrictHostKeyChecking"); subtitle: _("How to handle unknown host keys"); model: Gtk.StringList { strings: ["ask", "yes", "no"]; }; selected: 0; } } Adw.PreferencesGroup { title: _("Authentication and Keys"); description: _("Tune authentication behavior"); Adw.ActionRow { title: _("PubkeyAuthentication"); subtitle: _("Use public key authentication"); activatable-widget: pubkey_auth_switch; [suffix] Switch pubkey_auth_switch { valign: center; } } Adw.ActionRow { title: _("PasswordAuthentication"); subtitle: _("Allow password authentication"); activatable-widget: password_auth_switch; [suffix] Switch password_auth_switch { valign: center; } } Adw.ActionRow { title: _("KbdInteractiveAuthentication"); subtitle: _("Allow keyboard-interactive auth"); activatable-widget: kbd_interactive_auth_switch; [suffix] Switch kbd_interactive_auth_switch { valign: center; } } Adw.ActionRow { title: _("GSSAPIAuthentication"); subtitle: _("Enable Kerberos/GSSAPI auth"); activatable-widget: gssapi_auth_switch; [suffix] Switch gssapi_auth_switch { valign: center; } } Adw.ComboRow add_keys_to_agent_row { title: _("AddKeysToAgent"); subtitle: _("Add identities to ssh-agent"); model: Gtk.StringList { strings: ["no", "yes", "ask", "confirm"]; }; selected: 0; } Adw.EntryRow preferred_authentications_entry { title: _("PreferredAuthentications"); } Adw.EntryRow identity_agent_entry { title: _("IdentityAgent"); } } Adw.PreferencesGroup { title: _("Connection Behavior"); description: _("Connection timeouts, logging, host key verification"); Adw.EntryRow connect_timeout_entry { title: _("ConnectTimeout (s)"); show-apply-button: false; input-purpose: number; } Adw.ComboRow request_tty_row { title: _("RequestTTY"); subtitle: _("Request a TTY when connecting"); model: Gtk.StringList { strings: ["auto", "no", "yes", "force"]; }; selected: 0; } Adw.ComboRow log_level_row { title: _("LogLevel"); model: Gtk.StringList { strings: ["QUIET", "FATAL", "ERROR", "INFO", "VERBOSE", "DEBUG", "DEBUG1", "DEBUG2", "DEBUG3"]; }; selected: 3; } Adw.ActionRow { title: _("VerifyHostKeyDNS"); subtitle: _("Use SSHFP DNS records"); activatable-widget: verify_host_key_dns_switch; [suffix] Switch verify_host_key_dns_switch { valign: center; } } Adw.ComboRow canonicalize_hostname_row { title: _("CanonicalizeHostname"); model: Gtk.StringList { strings: ["no", "yes", "always"]; }; selected: 0; } Adw.EntryRow canonical_domains_entry { title: _("CanonicalDomains"); } } Adw.PreferencesGroup { title: _("Connection Multiplexing"); description: _("ControlMaster/ControlPersist options"); Adw.ComboRow control_master_row { title: _("ControlMaster"); model: Gtk.StringList { strings: ["no", "yes", "ask", "auto", "autoask"]; }; selected: 0; } Adw.EntryRow control_persist_entry { title: _("ControlPersist"); } Adw.EntryRow control_path_entry { title: _("ControlPath"); } } }; } Adw.ViewStackPage { title: _("Raw/Diff"); icon-name: "text-x-generic-symbolic"; name: "raw"; child: Adw.PreferencesPage { Adw.PreferencesGroup { title: _("Raw Configuration"); description: _("Edit the raw SSH configuration and see changes highlighted"); ScrolledWindow { hexpand: true; vexpand: true; TextView raw_text_view { monospace: true; wrap-mode: none; editable: true; hexpand: true; vexpand: true; left-margin: 8; right-margin: 8; top-margin: 6; bottom-margin: 6; styles [ "raw-editor", ] } } } }; } } } } SSH-Studio-1.3.1/data/ui/host_list.blp000066400000000000000000000054271506556307300174750ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $HostList: Box { orientation: vertical; spacing: 0; vexpand: true; styles [ "host-list", "navigation-sidebar", ] Box { orientation: vertical; spacing: 0; WindowHandle{ Box { orientation: horizontal; spacing: 12; margin-bottom: 8; Button search_button { icon-name: "system-search-symbolic"; tooltip-text: _("Search"); styles [ "flat", ] } Button undo_button { icon-name: "edit-undo-symbolic"; tooltip-text: _("Undo last change"); sensitive: false; styles [ "flat", ] } Label { label: _("SSH Studio"); halign: center; hexpand: true; styles [ "title-4", ] } Button add_bottom_button { icon-name: "list-add-symbolic"; tooltip-text: _("Add Host"); styles [ "flat", ] } MenuButton { icon-name: "open-menu-symbolic"; menu-model: main_menu; valign: center; styles [ "flat", ] } } } Box search_bar { orientation: horizontal; spacing: 0; hexpand: true; visible: false; SearchEntry search_entry { placeholder-text: _("Search hosts, hostnames, users, keys..."); tooltip-text: _("Search across all SSH host configurations"); hexpand: true; halign: center; margin-start: 20; margin-end: 20; margin-bottom: 12; styles [ "pill", ] } } ScrolledWindow { hexpand: true; vexpand: true; min-content-width: 350; styles [ "host-list-scroll", ] Stack host_stack { hexpand: true; vexpand: true; ListBox list_box { selection-mode: single; hexpand: true; vexpand: true; margin-bottom: 12; styles [ "navigation-sidebar", ] } Adw.StatusPage empty_page { visible: false; icon-name: "computer-symbolic"; title: _("No hosts yet"); description: _("Click the + button to add your first host"); } } } } } menu main_menu { section { item { label: _("Open Config"); action: "app.open-config"; } item { label: _("Reload"); action: "app.reload"; } item { label: _("Preferences"); action: "app.preferences"; } item { label: _("Keyboard Shortcuts"); action: "app.keyboard-shortcuts"; } } section { item { label: _("Manage SSH Keys…"); action: "app.manage-keys"; } } section { item { label: _("About"); action: "app.about"; } } } SSH-Studio-1.3.1/data/ui/key_picker_dialog.blp000066400000000000000000000021151506556307300211200ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $KeyPickerDialog: Adw.Dialog { Adw.ToastOverlay toast_overlay { Adw.ToolbarView { width-request: 760; height-request: 520; [top] Adw.HeaderBar { [title] Label { label: _("Pick SSH Key"); css-classes: ["title"]; } [end] Button generate_btn { label: _("Generate…"); css-classes: ["suggested-action"]; } } content: Adw.Clamp { maximum-size: 900; tightening-threshold: 600; margin-top: 12; margin-bottom: 12; child: ScrolledWindow { vexpand: true; hexpand: true; has-frame: false; ListBox public_list { selection-mode: single; vexpand: true; hexpand: true; css-classes: ["boxed-list"]; } }; }; [bottom] Box { orientation: horizontal; halign: end; spacing: 6; margin-start: 12; margin-end: 12; margin-top: 12; margin-bottom: 12; Button cancel_btn { label: _("Cancel"); } Button use_btn { label: _("Use Key"); css-classes: ["suggested-action"]; sensitive: false; } } } } } SSH-Studio-1.3.1/data/ui/keyboard_shortcuts_dialog.blp000066400000000000000000000137341506556307300227220ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $KeyboardShortcutsDialog: Adw.Dialog { Adw.ToastOverlay toast_overlay { Adw.ToolbarView { width-request: 680; height-request: 250; vexpand: false; valign: start; [top] Adw.HeaderBar { [title] Adw.WindowTitle { title: _("Keyboard Shortcuts"); } } content: Adw.Clamp { margin-top: 24; margin-bottom: 24; margin-start: 24; margin-end: 24; vexpand: false; valign: start; Adw.PreferencesPage { Adw.PreferencesGroup { title: _("Global Shortcuts"); description: _("Application-wide keyboard shortcuts"); Adw.ActionRow { title: _("Open SSH Config File"); subtitle: _("Open a different SSH configuration file"); [suffix] Label { label: "Ctrl+O"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Save Configuration"); subtitle: _("Save current changes to SSH config"); [suffix] Label { label: "Ctrl+S"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Reload Configuration"); subtitle: _("Reload SSH config from disk"); [suffix] Label { label: "Ctrl+R"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Search"); subtitle: _("Focus the search bar"); [suffix] Label { label: "Ctrl+F"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Toggle Sidebar"); subtitle: _("Show or hide the host editor panel"); [suffix] Label { label: "F9"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Preferences"); subtitle: _("Open application preferences"); [suffix] Label { label: "Ctrl+,"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("SSH Key Manager"); subtitle: _("Open SSH key management dialog"); [suffix] Label { label: "Ctrl+K"; css-classes: ["keyboard-shortcut"]; } } } Adw.PreferencesGroup { title: _("Host Management"); description: _("Shortcuts for managing SSH hosts"); Adw.ActionRow { title: _("Add New Host"); subtitle: _("Create a new SSH host configuration"); [suffix] Label { label: "Ctrl+N"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Duplicate Host"); subtitle: _("Create a copy of the selected host"); [suffix] Label { label: "Ctrl+D"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Delete Host"); subtitle: _("Remove the selected host"); [suffix] Label { label: "Ctrl+Delete"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Edit Host"); subtitle: _("Edit the selected host configuration"); [suffix] Label { label: "Enter, F2"; css-classes: ["keyboard-shortcut"]; } } } Adw.PreferencesGroup { title: _("Navigation"); description: _("Shortcuts for navigating the interface"); Adw.ActionRow { title: _("Navigate Host List"); subtitle: _("Move up and down through hosts"); [suffix] Label { label: "↑, ↓"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Jump to First Host"); subtitle: _("Select the first host in the list"); [suffix] Label { label: "Home"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Jump to Last Host"); subtitle: _("Select the last host in the list"); [suffix] Label { label: "End"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Page Navigation"); subtitle: _("Navigate by 10 hosts at a time"); [suffix] Label { label: "Page Up, Page Down"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Form Navigation"); subtitle: _("Move between form fields"); [suffix] Label { label: "Tab, Shift+Tab"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Clear Focus"); subtitle: _("Remove focus from current field"); [suffix] Label { label: "Escape"; css-classes: ["keyboard-shortcut"]; } } } Adw.PreferencesGroup { title: _("Dialog Shortcuts"); description: _("Shortcuts available in dialogs"); Adw.ActionRow { title: _("Close Dialog"); subtitle: _("Close any open dialog"); [suffix] Label { label: "Escape"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Generate Key"); subtitle: _("Create new SSH key (in Key Manager)"); [suffix] Label { label: "Ctrl+N"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Import Key"); subtitle: _("Import existing SSH key (in Key Manager)"); [suffix] Label { label: "Ctrl+I"; css-classes: ["keyboard-shortcut"]; } } Adw.ActionRow { title: _("Delete Key"); subtitle: _("Delete selected key (in Key Manager)"); [suffix] Label { label: "Delete"; css-classes: ["keyboard-shortcut"]; } } } } }; } } }SSH-Studio-1.3.1/data/ui/main_window.blp000066400000000000000000000022501506556307300177670ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $MainWindow: Adw.ApplicationWindow { default-width: 1300; default-height: 720; Adw.ToastOverlay toast_overlay { Adw.ToolbarView main_box { hexpand: true; vexpand: true; Adw.NavigationSplitView split_view { sidebar: Adw.NavigationPage { title: _("SSH Hosts"); child: Adw.NavigationView sidebar_nav { Adw.NavigationPage { title: _("SSH Hosts"); child: Box { orientation: vertical; margin-start: 8; margin-end: 8; $HostList host_list {} } ;} }; }; content: Adw.NavigationPage { title: _("Host Editor"); child: Adw.NavigationView content_nav { Adw.NavigationPage { title: _("Welcome"); tag: "welcome"; child: $WelcomeView welcome_view {}; } Adw.NavigationPage { title: _("Host Editor"); tag: "host-editor"; child: $HostEditor host_editor {}; } }; }; } } } }SSH-Studio-1.3.1/data/ui/preferences_dialog.blp000066400000000000000000000037201506556307300212770ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; Adjustment editor_font_adjustment { lower: 9; upper: 24; step-increment: 1; page-increment: 2; value: 12; } template $SSHStudioPreferencesDialog: Adw.PreferencesDialog { title: _("Preferences"); Adw.PreferencesPage { title: _("General"); icon-name: "preferences-system-symbolic"; Adw.PreferencesGroup { title: _("Configuration"); description: _("SSH configuration file and backup settings"); Adw.EntryRow config_path_entry { title: _("SSH Config Path"); [suffix] Button config_path_button { icon-name: "folder-open-symbolic"; tooltip-text: _("Choose Config File"); styles [ "flat", ] } } Adw.EntryRow backup_dir_entry { title: _("Backup Directory"); [suffix] Button backup_dir_button { icon-name: "folder-open-symbolic"; tooltip-text: _("Choose Backup Directory"); styles [ "flat", ] } } Adw.SwitchRow auto_backup_switch { title: _("Enable Auto-Backup"); subtitle: _("Automatically create backups before saving changes"); active: true; } } Adw.PreferencesGroup { title: _("Editor"); description: _("Text editor appearance and behavior"); Adw.SpinRow editor_font_spin { title: _("Font Size"); adjustment: editor_font_adjustment; digits: 0; } Adw.SwitchRow raw_wrap_switch { title: _("Wrap Long Lines in Raw Editor"); subtitle: _("Wrap long lines in the raw configuration editor"); active: false; } } Adw.PreferencesGroup { title: _("Appearance"); description: _("Visual appearance and theme settings"); Adw.SwitchRow dark_theme_switch { title: _("Prefer Dark Theme"); subtitle: _("Use dark theme when available"); active: false; } } } } SSH-Studio-1.3.1/data/ui/ssh_key_manager_dialog.blp000066400000000000000000000037521506556307300221420ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $SSHKeyManagerDialog: Adw.Dialog { Adw.ToastOverlay toast_overlay { Adw.ToolbarView { width-request: 860; height-request: 620; [top] Adw.HeaderBar { [title] Adw.ViewSwitcher { policy: wide; stack: stack; } [end] Box { spacing: 6; Button generate_button { label: _("Generate…"); css-classes: ["suggested-action"]; } Button import_button { label: _("Import…"); } } } content: Box { orientation: vertical; margin-start: 12; margin-end: 12; margin-top: 12; margin-bottom: 12; spacing: 12; Adw.ViewStack stack { vexpand: true; Adw.ViewStackPage { name: "private"; title: _("Private Keys"); icon-name: "dialog-password-symbolic"; child: Adw.Clamp { maximum-size: 900; tightening-threshold: 600; child: ScrolledWindow { vexpand: true; hexpand: true; has-frame: false; ListBox private_list { selection-mode: single; vexpand: true; hexpand: true; css-classes: ["boxed-list"]; } }; }; } Adw.ViewStackPage { name: "public"; title: _("Public Keys"); icon-name: "user-info-symbolic"; child: Adw.Clamp { maximum-size: 900; tightening-threshold: 600; child: ScrolledWindow { vexpand: true; hexpand: true; has-frame: false; ListBox public_list { selection-mode: single; vexpand: true; hexpand: true; css-classes: ["boxed-list"]; } }; }; } } Box { orientation: horizontal; halign: end; spacing: 6; Button copy_pub_button { label: _("Copy Public Key"); } Button reveal_button { label: _("Reveal in Files"); } Button delete_button { label: _("Delete"); css-classes: ["destructive-action"]; } } }; } } } SSH-Studio-1.3.1/data/ui/test_connection_dialog.blp000066400000000000000000000031671506556307300222010ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $TestConnectionDialog: Adw.Window { title: _("Test Connection"); modal: true; resizable: true; default-width: 800; default-height: 600; Box main_box { orientation: vertical; [header] Adw.HeaderBar { [title] Label { label: _("Test Connection"); css-classes: ["title"]; } } Adw.ViewStack stack { vexpand: true; Adw.ViewStackPage { name: "loading"; child: Adw.StatusPage loading_page { title: _("Testing Connection"); description: _("Running SSH command..."); icon-name: "network-workgroup-symbolic"; Spinner spinner { spinning: true; margin-top: 12; } }; } Adw.ViewStackPage { name: "results"; child: Box { orientation: vertical; margin-start: 24; margin-end: 24; margin-top: 24; margin-bottom: 24; spacing: 24; Label status_title { css-classes: ["title-1"]; halign: start; } Label status_description { css-classes: ["body"]; halign: start; wrap: true; selectable: true; } ScrolledWindow output_scrolled { vexpand: true; hexpand: true; css-classes: ["frame"]; TextView output_text { monospace: true; editable: false; wrap-mode: word_char; css-classes: ["code"]; } } }; } } } } SSH-Studio-1.3.1/data/ui/unsaved_changes_dialog.blp000066400000000000000000000003531506556307300221320ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; Adw.AlertDialog unsaved_changes_dialog { heading: _("Unsaved Changes"); body: _("You have unsaved changes that will be lost if you quit.\n\nWhat would you like to do?"); close-response: "cancel"; } SSH-Studio-1.3.1/data/ui/welcome_view.blp000066400000000000000000000076131506556307300201510ustar00rootroot00000000000000using Gtk 4.0; using Adw 1; template $WelcomeView: Gtk.Box { orientation: vertical; vexpand: true; hexpand: true; styles [ "welcome-view", ] Adw.HeaderBar { show-title: false; [end] Gtk.WindowControls window_controls {} } Box { orientation: vertical; vexpand: true; hexpand: true; valign: center; halign: center; spacing: 24; margin-start: 40; margin-end: 40; margin-top: 40; margin-bottom: 40; Box { orientation: vertical; spacing: 16; valign: center; halign: center; Image { icon-name: "io.github.BuddySirJava.SSH-Studio"; pixel-size: 256; } Label { label: _("Welcome to SSH Studio"); halign: center; styles [ "title-1", ] } Label { label: _("Select a host from the host list to start editing, or create a new one to get started."); halign: center; wrap: true; max-width-chars: 50; styles [ "dim-label", ] } } Box { orientation: vertical; spacing: 12; valign: center; halign: center; Label { label: _("Keyboard Shortcuts"); halign: center; styles [ "title-3", ] } Box { orientation: vertical; spacing: 8; valign: center; halign: center; Box { orientation: horizontal; spacing: 12; valign: center; Label { label: _("Ctrl+N"); halign: start; width-chars: 8; styles [ "monospace", "dim-label", ] } Label { label: _("Create new host"); halign: start; styles [ "dim-label", ] } } Box { orientation: horizontal; spacing: 12; valign: center; Label { label: _("Ctrl+D"); halign: start; width-chars: 8; styles [ "monospace", "dim-label", ] } Label { label: _("Duplicate selected host"); halign: start; styles [ "dim-label", ] } } Box { orientation: horizontal; spacing: 12; valign: center; Label { label: _("Delete"); halign: start; width-chars: 8; styles [ "monospace", "dim-label", ] } Label { label: _("Delete selected host"); halign: start; styles [ "dim-label", ] } } Box { orientation: horizontal; spacing: 12; valign: center; Label { label: _("Ctrl+F"); halign: start; width-chars: 8; styles [ "monospace", "dim-label", ] } Label { label: _("Search hosts"); halign: start; styles [ "dim-label", ] } } Box { orientation: horizontal; spacing: 12; valign: center; Label { label: _("Ctrl+S"); halign: start; width-chars: 8; styles [ "monospace", "dim-label", ] } Label { label: _("Save configuration"); halign: start; styles [ "dim-label", ] } } Box { orientation: horizontal; spacing: 12; valign: center; Label { label: _("Ctrl+Z"); halign: start; width-chars: 8; styles [ "monospace", "dim-label", ] } Label { label: _("Revert changes"); halign: start; styles [ "dim-label", ] } } } } } } SSH-Studio-1.3.1/installer/000077500000000000000000000000001506556307300154255ustar00rootroot00000000000000SSH-Studio-1.3.1/installer/ssh-studio.iss000066400000000000000000000012061506556307300202460ustar00rootroot00000000000000[Setup] AppName={#AppName} AppVersion={#Version} DefaultDirName={autopf}\{#AppName} DefaultGroupName={#AppName} OutputDir=installer\out OutputBaseFilename=SSH-Studio-{#Version}-Setup Compression=lzma SolidCompression=yes ArchitecturesInstallIn64BitMode=x64 [Files] Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: "{group}\{#AppName}"; Filename: "{app}\SSH-Studio.bat" Name: "{autodesktop}\{#AppName}"; Filename: "{app}\SSH-Studio.bat"; Tasks: desktopicon [Run] Filename: "{app}\SSH-Studio.bat"; Description: "{cm:LaunchProgram,{#AppName}}"; Flags: nowait postinstall skipifsilent SSH-Studio-1.3.1/io.github.BuddySirJava.SSH-Studio.json000066400000000000000000000037551506556307300224540ustar00rootroot00000000000000{ "app-id" : "io.github.BuddySirJava.SSH-Studio", "runtime" : "org.gnome.Platform", "runtime-version" : "49", "sdk" : "org.gnome.Sdk", "command" : "ssh-studio", "finish-args" : [ "--share=ipc", "--socket=fallback-x11", "--socket=wayland", "--share=network", "--filesystem=~/.ssh:rw", "--socket=ssh-auth", "--talk-name=org.freedesktop.Flatpak", "--socket=session-bus", "--device=dri", "--share=ipc", "filesystem=home" ], "cleanup" : [ "/include", "/lib/pkgconfig", "/share/man", "/share/pkgconfig", "/share/gtk-doc", "/share/doc", "/share/devhelp", "*.la", "*.a" ], "modules" : [ { "name" : "blueprint-compiler", "buildsystem" : "meson", "cleanup" : [ "*" ], "sources" : [ { "type" : "git", "url" : "https://gitlab.gnome.org/GNOME/blueprint-compiler.git", "tag" : "v0.18.0", "x-checker-data" : { "type" : "git", "tag-pattern" : "^v([\\d.]+)$" } } ] }, { "name" : "ssh-studio", "buildsystem" : "meson", "sources" : [ { "type" : "git", "url" : "https://github.com/BuddySirJava/SSH-Studio.git", "tag" : "1.3.1", "commit" : "528e5c0e207c63b9ead2adf3f169316950ee81af", "x-checker-data" : { "type" : "git", "tag-pattern" : "^v([\\d.]+)$" } } ], "config-opts" : [ "--libdir=lib" ] } ], "build-options" : { "env" : { } } } SSH-Studio-1.3.1/io.github.BuddySirJava.SSH-Studio.json~000066400000000000000000000032231506556307300226400ustar00rootroot00000000000000{ "app-id": "io.github.BuddySirJava.SSH-Studio", "runtime": "org.gnome.Platform", "runtime-version": "49", "sdk": "org.gnome.Sdk", "command": "ssh-studio", "finish-args": [ "--share=ipc", "--socket=fallback-x11", "--socket=wayland", "--share=network", "--filesystem=~/.ssh:rw", "--socket=ssh-auth" ], "cleanup": [ "/include", "/lib/pkgconfig", "/share/man", "/share/pkgconfig", "/share/gtk-doc", "/share/doc", "/share/devhelp", "*.la", "*.a" ], "modules": [ { "name": "blueprint-compiler", "buildsystem": "meson", "cleanup": ["*"], "sources": [ { "type": "git", "url": "https://gitlab.gnome.org/GNOME/blueprint-compiler.git", "tag": "v0.18.0", "x-checker-data": { "type": "git", "tag-pattern": "^v([\\d.]+)$" } } ] }, { "name": "ssh-studio", "buildsystem": "meson", "sources": [ { "type": "git", "url": "https://github.com/BuddySirJava/SSH-Studio.git", "tag": "1.3.1", "commit": "528e5c0e207c63b9ead2adf3f169316950ee81af", "x-checker-data": { "type": "git", "tag-pattern": "^v([\\d.]+)$" } } ] } ] }SSH-Studio-1.3.1/meson.build000066400000000000000000000034041506556307300155730ustar00rootroot00000000000000project('ssh-studio', 'c', version: '1.3.1', license: 'GPL-3.0-or-later', meson_version: '>= 0.60.0', default_options: ['warning_level=2', 'buildtype=debugoptimized']) python = import('python') python_installation = python.find_installation() gnome = import('gnome') gtksource_dep = dependency('gtksourceview-5', version: '>= 5.0.0') application_id = 'io.github.BuddySirJava.SSH-Studio' i18n = import('i18n') subdir('po') subdir('src') subdir('data') install_data(join_paths(meson.current_source_dir(), 'data', application_id + '.desktop'), install_dir: join_paths(get_option('datadir'), 'applications')) install_data(join_paths(meson.current_source_dir(), 'data', 'ssh-studio.in'), rename: 'ssh-studio', install_mode: 'rwxr-xr-x', install_dir: join_paths(get_option('bindir'))) install_data(join_paths(meson.current_source_dir(), 'data', 'media', 'icon_256.png'), rename: application_id + '.png', install_dir: join_paths(get_option('datadir'), 'icons', 'hicolor', '256x256', 'apps')) install_data(join_paths(meson.current_source_dir(), 'data', 'media', 'icon_128.png'), rename: application_id + '.png', install_dir: join_paths(get_option('datadir'), 'icons', 'hicolor', '128x128', 'apps')) install_data(join_paths(meson.current_source_dir(), 'data', 'media', 'icon_512.png'), rename: application_id + '.png', install_dir: join_paths(get_option('datadir'), 'icons', 'hicolor', '512x512', 'apps')) install_data(join_paths(meson.current_source_dir(), 'data', 'media', 'icon.svg'), rename: application_id + '.svg', install_dir: join_paths(get_option('datadir'), 'icons', 'hicolor', 'scalable', 'apps')) SSH-Studio-1.3.1/po/000077500000000000000000000000001506556307300140465ustar00rootroot00000000000000SSH-Studio-1.3.1/po/LINGUAS000066400000000000000000000000031506556307300150640ustar00rootroot00000000000000en SSH-Studio-1.3.1/po/POTFILES000066400000000000000000000003471506556307300152220ustar00rootroot00000000000000src/main.py src/ssh_config_parser.py src/ui/host_editor.py src/ui/host_list.py src/ui/main_window.py src/ui/preferences_dialog.py data/ui/host_editor.blp data/ui/host_list.blp data/ui/main_window.blp data/ui/preferences_dialog.blp SSH-Studio-1.3.1/po/en.po000066400000000000000000000175711506556307300150230ustar00rootroot00000000000000msgid "" msgstr "" "Project-Id-Version: ssh-studio 1.3.1\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-05 00:28+0330\n" "PO-Revision-Date: 2025-01-01 00:00+0000\n" "Last-Translator: \n" "Language-Team: \n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #: src/main.py:99 msgid "Failed to load SSH config" msgstr "" #: src/main.py:195 msgid "Error" msgstr "" #: src/main.py:206 msgid "Info" msgstr "" #: src/ui/host_editor.py:426 data/ui/host_editor.blp:132 msgid "Choose Identity File" msgstr "" #: src/ui/host_editor.py:431 src/ui/main_window.py:328 #: src/ui/preferences_dialog.py:41 src/ui/preferences_dialog.py:58 msgid "Cancel" msgstr "" #: src/ui/host_editor.py:432 src/ui/main_window.py:327 #: src/ui/preferences_dialog.py:42 msgid "Open" msgstr "" #: src/ui/host_editor.py:435 msgid "SSH Keys" msgstr "" #: src/ui/host_editor.py:466 msgid "No host selected" msgstr "" #: src/ui/host_editor.py:499 msgid "No hostname or pattern available" msgstr "" #: src/ui/host_editor.py:508 msgid "Failed to access display" msgstr "" #: src/ui/host_editor.py:599 msgid "Host name (patterns) is required." msgstr "" #: src/ui/host_editor.py:606 msgid "Port must be between 1 and 65535." msgstr "" #: src/ui/host_editor.py:608 msgid "Port must be numeric." msgstr "" #: src/ui/host_editor.py:619 msgid "Custom option key cannot be empty." msgstr "" #: src/ui/host_editor.py:722 msgid "Test Connection" msgstr "" #: src/ui/host_editor.py:727 msgid "Close" msgstr "" #: src/ui/host_editor.py:738 msgid "Running SSH command..." msgstr "" #: src/ui/host_editor.py:761 msgid "Error: No hostname or pattern available to test." msgstr "" #: src/ui/host_editor.py:818 msgid "Connection OK" msgstr "" #: src/ui/host_editor.py:833 msgid "Connection timed out" msgstr "" #: src/ui/host_editor.py:834 msgid " " msgstr "" #: src/ui/host_list.py:53 msgid "Host" msgstr "" #: src/ui/host_list.py:54 msgid "HostName" msgstr "" #: src/ui/host_list.py:55 msgid "User" msgstr "" #: src/ui/host_list.py:56 data/ui/host_editor.blp:109 msgid "Port" msgstr "" #: src/ui/host_list.py:57 msgid "Identity" msgstr "" #: src/ui/host_list.py:172 msgid ", " msgstr "" #: src/ui/host_list.py:175 msgid "No" msgstr "" #: src/ui/host_list.py:176 msgid "Yes" msgstr "" #: src/ui/main_window.py:249 msgid "Configuration saved successfully" msgstr "" #: src/ui/main_window.py:279 msgid "Host added" msgstr "" #: src/ui/main_window.py:289 msgid "Host deleted" msgstr "" #: src/ui/main_window.py:324 msgid "Open SSH Config File" msgstr "" #: src/ui/main_window.py:399 msgid "Preferences saved" msgstr "" #: src/ui/main_window.py:409 data/ui/main_window.blp:5 #: data/ui/main_window.blp:17 #, fuzzy msgid "SSH-Studio" msgstr "SSH-Studio" #: src/ui/main_window.py:412 msgid "Made with ❤️ by Mahyar Darvishi" msgstr "" #: src/ui/main_window.py:416 msgid "© 2025 Mahyar Darvishi" msgstr "" #: src/ui/main_window.py:418 msgid "A native Python + GTK application for managing SSH configuration files" msgstr "" #: src/ui/preferences_dialog.py:26 data/ui/main_window.blp:125 #: data/ui/preferences_dialog.blp:13 msgid "Preferences" msgstr "" #: src/ui/preferences_dialog.py:37 msgid "Choose SSH Config File" msgstr "" #: src/ui/preferences_dialog.py:54 data/ui/preferences_dialog.blp:68 msgid "Choose Backup Directory" msgstr "" #: src/ui/preferences_dialog.py:59 msgid "Select" msgstr "" #: data/ui/host_editor.blp:46 msgid "Revert" msgstr "" #: data/ui/host_editor.blp:55 msgid "Save" msgstr "" #: data/ui/host_editor.blp:77 msgid "Settings" msgstr "" #: data/ui/host_editor.blp:84 msgid "Connection" msgstr "" #: data/ui/host_editor.blp:87 msgid "Host pattern" msgstr "" #: data/ui/host_editor.blp:101 msgid "Host name" msgstr "" #: data/ui/host_editor.blp:105 msgid "Username" msgstr "" #: data/ui/host_editor.blp:124 msgid "Authentication" msgstr "" #: data/ui/host_editor.blp:127 msgid "Identity file" msgstr "" #: data/ui/host_editor.blp:137 msgid "Forward agent" msgstr "" #: data/ui/host_editor.blp:148 msgid "Actions" msgstr "" #: data/ui/host_editor.blp:151 msgid "Copy SSH command" msgstr "" #: data/ui/host_editor.blp:153 msgid "Copies the resolved SSH command to the clipboard" msgstr "" #: data/ui/host_editor.blp:157 msgid "Test connection" msgstr "" #: data/ui/host_editor.blp:159 msgid "Runs a quick non-interactive SSH check" msgstr "" #: data/ui/host_editor.blp:167 data/ui/host_editor.blp:174 msgid "Networking" msgstr "" #: data/ui/host_editor.blp:177 msgid "ProxyJump" msgstr "" #: data/ui/host_editor.blp:181 msgid "ProxyCommand" msgstr "" #: data/ui/host_editor.blp:185 msgid "Local Forward" msgstr "" #: data/ui/host_editor.blp:189 msgid "Remote Forward" msgstr "" #: data/ui/host_editor.blp:197 data/ui/host_editor.blp:204 msgid "Advanced" msgstr "" #: data/ui/host_editor.blp:205 msgid "" "Add custom SSH configuration options not available in the standard fields" msgstr "" #: data/ui/host_editor.blp:208 msgid "Custom Options" msgstr "" #: data/ui/host_editor.blp:212 msgid "Add Custom Option" msgstr "" #: data/ui/host_editor.blp:229 msgid "Raw/Diff" msgstr "" #: data/ui/host_editor.blp:236 #, fuzzy msgid "Raw Configuration" msgstr "SSH-Studio" #: data/ui/host_editor.blp:237 msgid "Edit the raw SSH configuration and see changes highlighted" msgstr "" #: data/ui/host_list.blp:25 data/ui/main_window.blp:84 #: data/ui/main_window.blp:88 msgid "SSH Hosts" msgstr "" #: data/ui/host_list.blp:34 msgid "0 hosts" msgstr "" #: data/ui/main_window.blp:27 msgid "Search (Ctrl+F)" msgstr "" #: data/ui/main_window.blp:37 msgid "Add Host" msgstr "" #: data/ui/main_window.blp:47 msgid "Duplicate Host" msgstr "" #: data/ui/main_window.blp:57 msgid "Delete Host" msgstr "" #: data/ui/main_window.blp:96 data/ui/main_window.blp:100 msgid "Host Editor" msgstr "" #: data/ui/main_window.blp:115 msgid "Open Config" msgstr "" #: data/ui/main_window.blp:120 msgid "Reload" msgstr "" #: data/ui/main_window.blp:132 msgid "About" msgstr "" #: data/ui/preferences_dialog.blp:20 msgid "General" msgstr "" #: data/ui/preferences_dialog.blp:24 #, fuzzy msgid "Configuration" msgstr "SSH-Studio" #: data/ui/preferences_dialog.blp:25 msgid "SSH configuration file and backup settings" msgstr "" #: data/ui/preferences_dialog.blp:28 #, fuzzy msgid "SSH Config Path" msgstr "SSH-Studio" #: data/ui/preferences_dialog.blp:29 msgid "Path to your SSH configuration file" msgstr "" #: data/ui/preferences_dialog.blp:37 msgid "~/.ssh/config" msgstr "" #: data/ui/preferences_dialog.blp:43 msgid "Choose Config File" msgstr "" #: data/ui/preferences_dialog.blp:53 msgid "Backup Directory" msgstr "" #: data/ui/preferences_dialog.blp:54 msgid "Directory where backups are stored" msgstr "" #: data/ui/preferences_dialog.blp:62 msgid "~/.ssh" msgstr "" #: data/ui/preferences_dialog.blp:78 msgid "Enable Auto-Backup" msgstr "" #: data/ui/preferences_dialog.blp:79 msgid "Automatically create backups before saving changes" msgstr "" #: data/ui/preferences_dialog.blp:91 msgid "Editor" msgstr "" #: data/ui/preferences_dialog.blp:92 msgid "Text editor appearance and behavior" msgstr "" #: data/ui/preferences_dialog.blp:95 msgid "Font Size" msgstr "" #: data/ui/preferences_dialog.blp:96 msgid "Size of text in the editor" msgstr "" #: data/ui/preferences_dialog.blp:106 msgid "Wrap Long Lines in Raw Editor" msgstr "" #: data/ui/preferences_dialog.blp:107 msgid "Wrap long lines in the raw configuration editor" msgstr "" #: data/ui/preferences_dialog.blp:119 msgid "Appearance" msgstr "" #: data/ui/preferences_dialog.blp:120 msgid "Visual appearance and theme settings" msgstr "" #: data/ui/preferences_dialog.blp:123 msgid "Prefer Dark Theme" msgstr "" #: data/ui/preferences_dialog.blp:124 msgid "Use dark theme when available" msgstr "" #: data/ui/host_list.blp:24 msgid "Search hosts, hostnames, users, keys..." msgstr "" #: data/ui/host_list.blp:25 msgid "Search across all SSH host configurations" msgstr "" SSH-Studio-1.3.1/po/meson.build000066400000000000000000000000771506556307300162140ustar00rootroot00000000000000i18n.gettext('ssh-studio', preset: 'glib', install: true) SSH-Studio-1.3.1/po/ssh-studio.pot000066400000000000000000000200361506556307300166750ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the ssh-studio package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: ssh-studio\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-05 00:28+0330\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #: src/main.py:99 msgid "Failed to load SSH config" msgstr "" #: src/main.py:195 msgid "Error" msgstr "" #: src/main.py:206 msgid "Info" msgstr "" #: src/ui/host_editor.py:426 data/ui/host_editor.blp:132 msgid "Choose Identity File" msgstr "" #: src/ui/host_editor.py:431 src/ui/main_window.py:328 #: src/ui/preferences_dialog.py:41 src/ui/preferences_dialog.py:58 msgid "Cancel" msgstr "" #: src/ui/host_editor.py:432 src/ui/main_window.py:327 #: src/ui/preferences_dialog.py:42 msgid "Open" msgstr "" #: src/ui/host_editor.py:435 msgid "SSH Keys" msgstr "" #: src/ui/host_editor.py:466 msgid "No host selected" msgstr "" #: src/ui/host_editor.py:499 msgid "No hostname or pattern available" msgstr "" #: src/ui/host_editor.py:508 msgid "Failed to access display" msgstr "" #: src/ui/host_editor.py:599 msgid "Host name (patterns) is required." msgstr "" #: src/ui/host_editor.py:606 msgid "Port must be between 1 and 65535." msgstr "" #: src/ui/host_editor.py:608 msgid "Port must be numeric." msgstr "" #: src/ui/host_editor.py:619 msgid "Custom option key cannot be empty." msgstr "" #: src/ui/host_editor.py:722 msgid "Test Connection" msgstr "" #: src/ui/host_editor.py:727 msgid "Close" msgstr "" #: src/ui/host_editor.py:738 msgid "Running SSH command..." msgstr "" #: src/ui/host_editor.py:761 msgid "Error: No hostname or pattern available to test." msgstr "" #: src/ui/host_editor.py:818 msgid "Connection OK" msgstr "" #: src/ui/host_editor.py:833 msgid "Connection timed out" msgstr "" #: src/ui/host_editor.py:834 msgid " " msgstr "" #: src/ui/host_list.py:53 msgid "Host" msgstr "" #: src/ui/host_list.py:54 msgid "HostName" msgstr "" #: src/ui/host_list.py:55 msgid "User" msgstr "" #: src/ui/host_list.py:56 data/ui/host_editor.blp:109 msgid "Port" msgstr "" #: src/ui/host_list.py:57 msgid "Identity" msgstr "" #: src/ui/host_list.py:172 msgid ", " msgstr "" #: src/ui/host_list.py:175 msgid "No" msgstr "" #: src/ui/host_list.py:176 msgid "Yes" msgstr "" #: src/ui/main_window.py:249 msgid "Configuration saved successfully" msgstr "" #: src/ui/main_window.py:279 msgid "Host added" msgstr "" #: src/ui/main_window.py:289 msgid "Host deleted" msgstr "" #: src/ui/main_window.py:324 msgid "Open SSH Config File" msgstr "" #: src/ui/main_window.py:399 msgid "Preferences saved" msgstr "" #: src/ui/main_window.py:409 data/ui/main_window.blp:5 #: data/ui/main_window.blp:17 msgid "SSH-Studio" msgstr "" #: src/ui/main_window.py:412 msgid "Made with ❤️ by Mahyar Darvishi" msgstr "" #: src/ui/main_window.py:416 msgid "© 2025 Mahyar Darvishi" msgstr "" #: src/ui/main_window.py:418 msgid "A native Python + GTK application for managing SSH configuration files" msgstr "" #: src/ui/preferences_dialog.py:26 data/ui/main_window.blp:125 #: data/ui/preferences_dialog.blp:13 msgid "Preferences" msgstr "" #: src/ui/preferences_dialog.py:37 msgid "Choose SSH Config File" msgstr "" #: src/ui/preferences_dialog.py:54 data/ui/preferences_dialog.blp:68 msgid "Choose Backup Directory" msgstr "" #: src/ui/preferences_dialog.py:59 msgid "Select" msgstr "" #: data/ui/host_editor.blp:46 msgid "Revert" msgstr "" #: data/ui/host_editor.blp:55 msgid "Save" msgstr "" #: data/ui/host_editor.blp:77 msgid "Settings" msgstr "" #: data/ui/host_editor.blp:84 msgid "Connection" msgstr "" #: data/ui/host_editor.blp:87 msgid "Host pattern" msgstr "" #: data/ui/host_editor.blp:101 msgid "Host name" msgstr "" #: data/ui/host_editor.blp:105 msgid "Username" msgstr "" #: data/ui/host_editor.blp:124 msgid "Authentication" msgstr "" #: data/ui/host_editor.blp:127 msgid "Identity file" msgstr "" #: data/ui/host_editor.blp:137 msgid "Forward agent" msgstr "" #: data/ui/host_editor.blp:148 msgid "Actions" msgstr "" #: data/ui/host_editor.blp:151 msgid "Copy SSH command" msgstr "" #: data/ui/host_editor.blp:153 msgid "Copies the resolved SSH command to the clipboard" msgstr "" #: data/ui/host_editor.blp:157 msgid "Test connection" msgstr "" #: data/ui/host_editor.blp:159 msgid "Runs a quick non-interactive SSH check" msgstr "" #: data/ui/host_editor.blp:167 data/ui/host_editor.blp:174 msgid "Networking" msgstr "" #: data/ui/host_editor.blp:177 msgid "ProxyJump" msgstr "" #: data/ui/host_editor.blp:181 msgid "ProxyCommand" msgstr "" #: data/ui/host_editor.blp:185 msgid "Local Forward" msgstr "" #: data/ui/host_editor.blp:189 msgid "Remote Forward" msgstr "" #: data/ui/host_editor.blp:197 data/ui/host_editor.blp:204 msgid "Advanced" msgstr "" #: data/ui/host_editor.blp:205 msgid "" "Add custom SSH configuration options not available in the standard fields" msgstr "" #: data/ui/host_editor.blp:208 msgid "Custom Options" msgstr "" #: data/ui/host_editor.blp:212 msgid "Add Custom Option" msgstr "" #: data/ui/host_editor.blp:229 msgid "Raw/Diff" msgstr "" #: data/ui/host_editor.blp:236 msgid "Raw Configuration" msgstr "" #: data/ui/host_editor.blp:237 msgid "Edit the raw SSH configuration and see changes highlighted" msgstr "" #: data/ui/host_list.blp:25 data/ui/main_window.blp:84 #: data/ui/main_window.blp:88 msgid "SSH Hosts" msgstr "" #: data/ui/host_list.blp:34 msgid "0 hosts" msgstr "" #: data/ui/main_window.blp:27 msgid "Search (Ctrl+F)" msgstr "" #: data/ui/main_window.blp:37 msgid "Add Host" msgstr "" #: data/ui/main_window.blp:47 msgid "Duplicate Host" msgstr "" #: data/ui/main_window.blp:57 msgid "Delete Host" msgstr "" #: data/ui/main_window.blp:96 data/ui/main_window.blp:100 msgid "Host Editor" msgstr "" #: data/ui/main_window.blp:115 msgid "Open Config" msgstr "" #: data/ui/main_window.blp:120 msgid "Reload" msgstr "" #: data/ui/main_window.blp:132 msgid "About" msgstr "" #: data/ui/preferences_dialog.blp:20 msgid "General" msgstr "" #: data/ui/preferences_dialog.blp:24 msgid "Configuration" msgstr "" #: data/ui/preferences_dialog.blp:25 msgid "SSH configuration file and backup settings" msgstr "" #: data/ui/preferences_dialog.blp:28 msgid "SSH Config Path" msgstr "" #: data/ui/preferences_dialog.blp:29 msgid "Path to your SSH configuration file" msgstr "" #: data/ui/preferences_dialog.blp:37 msgid "~/.ssh/config" msgstr "" #: data/ui/preferences_dialog.blp:43 msgid "Choose Config File" msgstr "" #: data/ui/preferences_dialog.blp:53 msgid "Backup Directory" msgstr "" #: data/ui/preferences_dialog.blp:54 msgid "Directory where backups are stored" msgstr "" #: data/ui/preferences_dialog.blp:62 msgid "~/.ssh" msgstr "" #: data/ui/preferences_dialog.blp:78 msgid "Enable Auto-Backup" msgstr "" #: data/ui/preferences_dialog.blp:79 msgid "Automatically create backups before saving changes" msgstr "" #: data/ui/preferences_dialog.blp:91 msgid "Editor" msgstr "" #: data/ui/preferences_dialog.blp:92 msgid "Text editor appearance and behavior" msgstr "" #: data/ui/preferences_dialog.blp:95 msgid "Font Size" msgstr "" #: data/ui/preferences_dialog.blp:96 msgid "Size of text in the editor" msgstr "" #: data/ui/preferences_dialog.blp:106 msgid "Wrap Long Lines in Raw Editor" msgstr "" #: data/ui/preferences_dialog.blp:107 msgid "Wrap long lines in the raw configuration editor" msgstr "" #: data/ui/preferences_dialog.blp:119 msgid "Appearance" msgstr "" #: data/ui/preferences_dialog.blp:120 msgid "Visual appearance and theme settings" msgstr "" #: data/ui/preferences_dialog.blp:123 msgid "Prefer Dark Theme" msgstr "" #: data/ui/preferences_dialog.blp:124 msgid "Use dark theme when available" msgstr "" #: data/ui/host_list.blp:24 msgid "Search hosts, hostnames, users, keys..." msgstr "" #: data/ui/host_list.blp:25 msgid "Search across all SSH host configurations" msgstr "" SSH-Studio-1.3.1/src/000077500000000000000000000000001506556307300142175ustar00rootroot00000000000000SSH-Studio-1.3.1/src/__init__.py000066400000000000000000000000001506556307300163160ustar00rootroot00000000000000SSH-Studio-1.3.1/src/main.py000066400000000000000000000301771506556307300155250ustar00rootroot00000000000000#!/usr/bin/env python3 """SSH-Studio: Main Application Entry Point.""" import sys import gi import logging from gettext import gettext as _ import gettext import os import threading gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Gio, GLib, Gdk, Adw def _ensure_utf8_locale(): import locale try: current = locale.setlocale(locale.LC_ALL, "") if current is None or ("UTF-8" not in current and "utf8" not in current): os.environ.setdefault("LC_ALL", "C.UTF-8") os.environ.setdefault("LANG", "C.UTF-8") try: locale.setlocale(locale.LC_ALL, "C.UTF-8") except Exception: pass except Exception: pass def _configure_renderer_for_x11(): if os.getenv("GSK_RENDERER") or os.getenv("SSH_STUDIO_FORCE_GPU") == "1": return is_x11 = os.getenv("DISPLAY") and not os.getenv("WAYLAND_DISPLAY") if not is_x11: return os.environ.setdefault("GDK_BACKEND", "x11") dri_path = "/dev/dri" try: has_dri = os.path.exists(dri_path) and os.access(dri_path, os.R_OK) except Exception: has_dri = False if not has_dri and not os.getenv("LIBGL_ALWAYS_SOFTWARE"): os.environ["GSK_RENDERER"] = "cairo" logging.info( "GSK_RENDERER=cairo set for X11 without DRM; forcing software rendering" ) try: from ssh_studio.ssh_config_parser import SSHConfigParser except ImportError: from ssh_config_parser import SSHConfigParser logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) if os.getenv("FLATPAK_ID"): logging.getLogger().setLevel(logging.INFO) class SSHConfigStudioApp(Adw.Application): def __init__(self): super().__init__( application_id="io.github.BuddySirJava.SSH-Studio", flags=Gio.ApplicationFlags.FLAGS_NONE, ) self.parser = None self.main_window = None def do_activate(self): try: from ssh_studio.ui.main_window import MainWindow except ImportError: from ui.main_window import MainWindow if not self.main_window: self.main_window = MainWindow(self) self.main_window.present() else: self.main_window.present() def do_startup(self): Adw.Application.do_startup(self) try: system_locale_dir = ( os.path.join(get_option := getattr(GLib, "get_user_data_dir"), "locale") if False else "/app/share/locale" ) gettext.bindtextdomain("ssh-studio", system_locale_dir) gettext.textdomain("ssh-studio") except Exception: try: locale_dir = os.path.join(GLib.get_user_data_dir(), "locale") gettext.bindtextdomain("ssh-studio", locale_dir) gettext.textdomain("ssh-studio") except Exception: pass if os.getenv("FLATPAK_ID"): try: resource = Gio.Resource.load( "/app/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource" ) Gio.resources_register(resource) logging.info("Registered GResource from Flatpak install directory") except Exception: pass try: icon_theme = Gtk.IconTheme.get_for_display(Gtk.Display.get_default()) icon_theme.add_search_path("/app/share/icons") icon_theme.add_search_path("/usr/share/icons") icon_theme.set_theme_name("Adwaita") except Exception: pass try: settings = Gtk.Settings.get_default() if settings is not None: settings.set_property("gtk-icon-theme-name", "Adwaita") except Exception: pass else: resource_candidates = [ os.path.join( GLib.get_user_data_dir(), "io.github.BuddySirJava.SSH-Studio", "ssh-studio-resources.gresource", ), os.path.join( GLib.get_user_data_dir(), "ssh-studio-resources.gresource" ), "/app/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource", "/app/share/ssh-studio-resources.gresource", os.path.join( GLib.get_home_dir(), ".local", "share", "io.github.BuddySirJava.SSH-Studio", "ssh-studio-resources.gresource", ), "/opt/homebrew/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource", "/usr/local/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource", "/usr/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource", "data/ssh-studio-resources.gresource", ] for candidate in resource_candidates: try: if os.path.exists(candidate): resource = Gio.Resource.load(candidate) Gio.resources_register(resource) logging.info(f"Registered GResource from: {candidate}") break except Exception: continue self._load_css_styles() try: Gtk.IconTheme.get_for_display(Gtk.Display.get_default()).add_resource_path( "/io/github/BuddySirJava/SSH-Studio/icons" ) except Exception: pass self._add_actions() self.parser = SSHConfigParser() GLib.idle_add(self._parse_config_async) def _parse_config_async(self): def worker(): try: if self.parser is not None: self.parser.parse() def update_ui(): try: if self.main_window and getattr( self.main_window, "host_list", None ): self.main_window.host_list.load_hosts( self.parser.config.hosts ) except Exception: pass return False GLib.idle_add(update_ui) except Exception as e: logging.error(f"Failed to initialize SSH config parser: {e}") GLib.idle_add( lambda: ( self._show_error_dialog(_("Failed to load SSH config"), str(e)), False, )[1] ) t = threading.Thread(target=worker, daemon=True) t.start() return False def _add_actions(self): search_action = Gio.SimpleAction.new("search", None) search_action.connect("activate", self._on_search_action) self.add_action(search_action) add_host_action = Gio.SimpleAction.new("add-host", None) add_host_action.connect("activate", self._on_add_host_action) self.add_action(add_host_action) reload_action = Gio.SimpleAction.new("reload", None) reload_action.connect("activate", self._on_reload_action) self.add_action(reload_action) def _on_search_action(self, action, param): if self.main_window: self.main_window._toggle_search() def _on_add_host_action(self, action, param): if self.main_window and self.main_window.host_list: self.main_window.host_list.add_host() def _on_reload_action(self, action, param): if self.main_window: self.main_window.reload_config() def _load_css_styles(self): try: if os.getenv("FLATPAK_ID"): css_provider = Gtk.CssProvider() css_provider.load_from_resource( "/io/github/BuddySirJava/SSH-Studio/ssh-studio.css" ) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) logging.info("Loaded CSS styles from GResource bundle (Flatpak)") return else: try: css_provider = Gtk.CssProvider() css_provider.load_from_resource( "/io/github/BuddySirJava/SSH-Studio/ssh-studio.css" ) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) logging.info("Loaded CSS styles from GResource bundle") return except Exception as e: logging.warning(f"Failed to load CSS from GResource: {e}") css_candidates = [ os.path.join( GLib.get_user_data_dir(), "io.github.BuddySirJava.SSH-Studio", "ssh-studio.css", ), os.path.join(GLib.get_user_data_dir(), "ssh-studio.css"), "/app/share/io.github.BuddySirJava.SSH-Studio/ssh-studio.css", "/app/share/ssh-studio.css", os.path.join( GLib.get_home_dir(), ".local", "share", "io.github.BuddySirJava.SSH-Studio", "ssh-studio.css", ), "/opt/homebrew/share/io.github.BuddySirJava.SSH-Studio/ssh-studio.css", "/usr/local/share/io.github.BuddySirJava.SSH-Studio/ssh-studio.css", "data/ssh-studio.css", ] for candidate in css_candidates: if os.path.exists(candidate): try: css_provider = Gtk.CssProvider() css_provider.load_from_path(candidate) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) logging.info(f"Loaded CSS styles from: {candidate}") break except Exception as e: logging.warning(f"Failed to load CSS from {candidate}: {e}") continue except Exception as e: logging.warning(f"Failed to load CSS styles: {e}") def _show_error_dialog(self, title: str, message: str): dialog = Gtk.MessageDialog( transient_for=self.main_window, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=title, secondary_text=message, ) dialog.connect("response", lambda d, r: d.destroy()) dialog.present() def _show_error(self, message: str): logging.error(f"Application Error: {message}") self._show_error_dialog(_("Error"), message) def _show_toast(self, message: str): logging.info(f"Toast: {message}") if self.main_window and hasattr(self.main_window, "show_toast"): try: self.main_window.show_toast(message) return except Exception: pass self._show_error_dialog(_("Info"), message) def main(): _ensure_utf8_locale() _configure_renderer_for_x11() app = SSHConfigStudioApp() try: app.set_default_icon_name("io.github.BuddySirJava.SSH-Studio") except Exception: pass return app.run(sys.argv) if __name__ == "__main__": sys.exit(main()) SSH-Studio-1.3.1/src/meson.build000066400000000000000000000015711506556307300163650ustar00rootroot00000000000000python_sources = [ 'main.py', 'ssh_config_parser.py', 'ui/host_editor.py', 'ui/host_list.py', 'ui/main_window.py', 'ui/preferences_dialog.py', 'ui/test_connection_dialog.py', 'ui/ssh_key_manager_dialog.py', 'ui/generate_key_dialog.py', 'ui/key_picker_dialog.py', 'ui/keyboard_shortcuts_dialog.py', 'ui/welcome_view.py', ] python_installation.install_sources( ['ssh_config_parser.py', 'main.py', '__init__.py'], subdir: 'ssh_studio' ) python_installation.install_sources( ['ui/host_editor.py', 'ui/host_list.py', 'ui/main_window.py', 'ui/preferences_dialog.py', 'ui/test_connection_dialog.py', 'ui/ssh_key_manager_dialog.py', 'ui/generate_key_dialog.py', 'ui/key_picker_dialog.py', 'ui/keyboard_shortcuts_dialog.py', 'ui/welcome_view.py', 'ui/__init__.py'], subdir: 'ssh_studio/ui' ) python_installation.install_sources( [], subdir: 'ssh_studio/ui' ) SSH-Studio-1.3.1/src/ssh_config_parser.py000066400000000000000000000306741506556307300203010ustar00rootroot00000000000000"""Parses, validates, and writes SSH configuration files.""" from __future__ import annotations import fnmatch import glob import logging import os import re import shutil import stat import tempfile from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import List, Optional, Dict logger = logging.getLogger(__name__) @dataclass class SSHOption: key: str value: str indentation: str = " " def __str__(self) -> str: return f"{self.indentation}{self.key} {self.value}".rstrip() @dataclass class SSHHost: patterns: List[str] = field(default_factory=list) options: List[SSHOption] = field(default_factory=list) start_line: int = -1 end_line: int = -1 raw_lines: List[str] = field(default_factory=list) @classmethod def from_raw_lines(cls, lines: List[str]) -> "SSHHost": host = cls() found_host_line = False for line in lines: stripped = line.strip() if not stripped or stripped.startswith("#"): host.raw_lines.append(line) continue if stripped.lower().startswith("host ") and not found_host_line: patterns = stripped.split(None, 1)[1].split() host.patterns = patterns host.raw_lines.append(line) found_host_line = True continue elif stripped.lower().startswith("host ") and found_host_line: raise ValueError( "Multiple Host declarations found within a single raw host block." ) m = re.match(r"^(\S+)\s+(.+)$", stripped) if m: key, value = m.group(1), m.group(2) indentation = line[: len(line) - len(line.lstrip())] host.options.append( SSHOption(key=key, value=value, indentation=indentation) ) host.raw_lines.append(line) else: host.raw_lines.append(line) if not found_host_line: raise ValueError("No Host declaration found in raw host block.") return host def get_option(self, key: str) -> Optional[str]: for opt in self.options: if opt.key.lower() == key.lower(): return opt.value return None def set_option(self, key: str, value: str) -> None: for opt in self.options: if opt.key.lower() == key.lower(): opt.value = value return self.options.append(SSHOption(key=key, value=value)) def remove_option(self, key: str) -> bool: for i, opt in enumerate(self.options): if opt.key.lower() == key.lower(): del self.options[i] return True return False @dataclass class SSHConfig: file_path: Path hosts: List[SSHHost] = field(default_factory=list) global_options: List[SSHOption] = field(default_factory=list) include_directives: List[str] = field(default_factory=list) includes_resolved: Dict[Path, List[str]] = field(default_factory=dict) original_lines: List[str] = field(default_factory=list) def is_dirty(self) -> bool: current_content_lines = [] for opt in self.global_options: current_content_lines.append(str(opt)) if self.global_options and ( not current_content_lines or current_content_lines[-1].strip() != "" ): current_content_lines.append("") for host in self.hosts: current_content_lines.append(f"Host {' '.join(host.patterns)}") for opt in host.options: current_content_lines.append(str(opt)) current_content_lines.append("") while current_content_lines and current_content_lines[-1] == "": current_content_lines.pop() for inc in self.include_directives: current_content_lines.append(f"Include {inc}") original_clean_lines = [line.rstrip("\n") for line in self.original_lines] while original_clean_lines and original_clean_lines[-1] == "": original_clean_lines.pop() return current_content_lines != original_clean_lines def get_host(self, alias: str) -> Optional[SSHHost]: for h in self.hosts: if alias in h.patterns: return h return None def add_host(self, host: SSHHost) -> None: self.hosts.append(host) def remove_host(self, host: SSHHost) -> bool: try: self.hosts.remove(host) return True except ValueError: return False class SSHConfigParser: def __init__(self, config_path: Optional[Path] = None) -> None: self.config_path: Path = config_path or Path.home() / ".ssh" / "config" self.config: SSHConfig = SSHConfig(file_path=self.config_path) self._have_backed_up_this_session: bool = False self.auto_backup_enabled: bool = True self.backup_dir: Optional[Path] = None def parse(self) -> SSHConfig: if not self.config_path.exists(): logger.warning("SSH config file not found: %s", self.config_path) return self.config with self.config_path.open("r", encoding="utf-8") as f: lines = f.readlines() self.config.original_lines = [l.rstrip("\n") for l in lines] self._parse_main_lines(self.config.original_lines) self._resolve_includes() return self.config def write(self, backup: bool = True) -> None: content = self._generate_content() if self.config_path.exists(): try: with self.config_path.open("r", encoding="utf-8") as f: current = f.read() if current == content: return except Exception: pass effective_backup = ( backup and self.auto_backup_enabled and self.config_path.exists() ) if effective_backup and not self._have_backed_up_this_session: self._backup_file() self._have_backed_up_this_session = True self._atomic_write(content) def validate(self) -> List[str]: errors: List[str] = [] seen: Dict[str, SSHHost] = {} for host in self.config.hosts: for pat in host.patterns: if pat in seen: errors.append(f"Duplicate host alias: {pat}") else: seen[pat] = host for host in self.config.hosts: port = host.get_option("Port") if port: try: p = int(port) if p < 1 or p > 65535: errors.append( f"Invalid port for host {host.patterns[0]}: {port}" ) except ValueError: errors.append( f"Port is not an integer for host {host.patterns[0]}: {port}" ) for host in self.config.hosts: ident = host.get_option("IdentityFile") if ident: path = Path(ident).expanduser() if not path.is_absolute(): path = Path.home() / ".ssh" / ident if not path.exists(): errors.append( f"IdentityFile not found for host {host.patterns[0]}: {ident}" ) return errors def _parse_main_lines(self, lines: List[str]) -> None: self.config.hosts.clear() self.config.global_options.clear() self.config.include_directives.clear() current_host: Optional[SSHHost] = None in_host = False for idx, line in enumerate(lines): stripped = line.strip() if not stripped or stripped.startswith("#"): if in_host and current_host is not None: current_host.raw_lines.append(line) continue if stripped.lower().startswith("include "): include_arg = stripped.split(None, 1)[1] self.config.include_directives.append(include_arg) continue if stripped.lower().startswith("host "): if current_host is not None: current_host.end_line = idx - 1 self.config.hosts.append(current_host) patterns = stripped.split(None, 1)[1].split() current_host = SSHHost( patterns=patterns, start_line=idx, raw_lines=[line] ) in_host = True continue m = re.match(r"^(\S+)\s+(.+)$", stripped) if m: key, value = m.group(1), m.group(2) indentation = line[: len(line) - len(line.lstrip())] opt = SSHOption(key=key, value=value, indentation=indentation) if in_host and current_host is not None: current_host.options.append(opt) current_host.raw_lines.append(line) else: self.config.global_options.append(opt) continue if in_host and current_host is not None: current_host.raw_lines.append(line) if current_host is not None: current_host.end_line = len(lines) - 1 self.config.hosts.append(current_host) def _resolve_includes(self) -> None: resolved: Dict[Path, List[str]] = {} base_dir = self.config_path.parent for pattern in self.config.include_directives: expanded = os.path.expanduser(pattern) if not os.path.isabs(expanded): expanded = str(base_dir / expanded) try: matches = glob.glob(expanded, recursive=False) if not matches and "**" in expanded: matches = glob.glob(expanded, recursive=True) except Exception: matches = [] for path_str in matches: p = Path(path_str) try: with p.open("r", encoding="utf-8") as f: resolved[p] = f.readlines() except Exception: continue self.config.includes_resolved = resolved def _backup_file(self) -> None: ts = datetime.now().strftime("%Y%m%d-%H%M%S") if self.backup_dir: target_dir = Path(self.backup_dir).expanduser() else: target_dir = self.config_path.parent try: target_dir.mkdir(parents=True, exist_ok=True) except Exception: target_dir = self.config_path.parent backup = (target_dir / self.config_path.name).with_suffix(f".{ts}.bak") try: shutil.copy2(self.config_path, backup) logger.info("Backup created: %s", backup) except Exception as e: logger.warning("Failed to create backup: %s", e) def _generate_content(self) -> str: lines: List[str] = [] for opt in self.config.global_options: lines.append(str(opt)) if self.config.global_options and (not lines or lines[-1] != ""): lines.append("") for host in self.config.hosts: lines.append(f"Host {' '.join(host.patterns)}") for opt in host.options: lines.append(str(opt)) lines.append("") while lines and lines[-1] == "": lines.pop() for inc in self.config.include_directives: lines.append(f"Include {inc}") return "\n".join(lines) + "\n" def _atomic_write(self, content: str) -> None: tmp = tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=str(self.config_path.parent), delete=False, ) tmp_path = Path(tmp.name) try: tmp.write(content) tmp.flush() os.fsync(tmp.fileno()) tmp.close() if self.config_path.exists(): st = self.config_path.stat() os.chmod(tmp_path, stat.S_IMODE(st.st_mode)) else: os.chmod(tmp_path, 0o600) os.replace(tmp_path, self.config_path) except Exception: try: tmp.close() except Exception: pass try: tmp_path.unlink(missing_ok=True) except Exception: pass raise SSH-Studio-1.3.1/src/ui/000077500000000000000000000000001506556307300146345ustar00rootroot00000000000000SSH-Studio-1.3.1/src/ui/__init__.py000066400000000000000000000000001506556307300167330ustar00rootroot00000000000000SSH-Studio-1.3.1/src/ui/generate_key_dialog.py000066400000000000000000000047051506556307300211750ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Adw from gettext import gettext as _ @Gtk.Template( resource_path="/io/github/BuddySirJava/SSH-Studio/ui/generate_key_dialog.ui" ) class GenerateKeyDialog(Adw.Dialog): __gtype_name__ = "GenerateKeyDialog" toast_overlay = Gtk.Template.Child() type_row = Gtk.Template.Child() size_row = Gtk.Template.Child() name_row = Gtk.Template.Child() comment_row = Gtk.Template.Child() pass_row = Gtk.Template.Child() cancel_btn = Gtk.Template.Child() generate_btn = Gtk.Template.Child() def __init__(self, parent): super().__init__() self.cancel_btn.connect("clicked", lambda *_: self.close()) self._populate_types() self._populate_sizes() self._sync_size_visibility() self.type_row.connect("notify::selected-item", self._on_type_changed) def _populate_types(self): store = Gtk.StringList.new(["ed25519", "rsa", "ecdsa"]) try: self.type_row.set_model(store) self.type_row.set_selected(0) except Exception: pass def _populate_sizes(self): store = Gtk.StringList.new(["1024", "2048", "3072", "4096", "8192"]) try: self.size_row.set_model(store) self.size_row.set_selected(1) except Exception: pass def _on_type_changed(self, *_): self._sync_size_visibility() def _sync_size_visibility(self): try: item = self.type_row.get_selected_item() key_type = item.get_string() if item else "ed25519" self.size_row.set_visible(key_type == "rsa") except Exception: pass def get_options(self): key_type = ( self.type_row.get_selected_item().get_string() if self.type_row.get_selected_item() else "ed25519" ) size_item = self.size_row.get_selected_item() size = ( int(size_item.get_string()) if size_item and self.size_row.get_visible() else 2048 ) name = self.name_row.get_text() or "id_ed25519" comment = self.comment_row.get_text() or "ssh-studio" passphrase = self.pass_row.get_text() or "" return { "type": key_type, "size": size, "name": name, "comment": comment, "passphrase": passphrase, } SSH-Studio-1.3.1/src/ui/host_editor.py000066400000000000000000002331711506556307300175400ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("GtkSource", "5") from gi.repository import Gtk, GObject, Gdk, GLib, Adw, GtkSource try: from ssh_studio.ssh_config_parser import SSHHost, SSHOption from ssh_studio.ui.test_connection_dialog import TestConnectionDialog except ImportError: from ssh_config_parser import SSHHost, SSHOption from ui.test_connection_dialog import TestConnectionDialog import difflib import copy from gettext import gettext as _ import os @Gtk.Template(resource_path="/io/github/BuddySirJava/SSH-Studio/ui/host_editor.ui") class HostEditor(Gtk.Box): __gtype_name__ = "HostEditor" add_button = Gtk.Template.Child() duplicate_button = Gtk.Template.Child() delete_button = Gtk.Template.Child() viewstack = Gtk.Template.Child() patterns_entry = Gtk.Template.Child() patterns_error_label = Gtk.Template.Child() hostname_entry = Gtk.Template.Child() user_entry = Gtk.Template.Child() port_entry = Gtk.Template.Child() port_error_label = Gtk.Template.Child() identity_entry = Gtk.Template.Child() identity_button = Gtk.Template.Child() identity_pick_button = Gtk.Template.Child() forward_agent_switch = Gtk.Template.Child() proxy_jump_entry = Gtk.Template.Child() proxy_cmd_entry = Gtk.Template.Child() local_forward_entry = Gtk.Template.Child() remote_forward_entry = Gtk.Template.Child() compression_switch = Gtk.Template.Child() serveralive_interval_entry = Gtk.Template.Child() serveralive_count_entry = Gtk.Template.Child() tcp_keepalive_switch = Gtk.Template.Child() strict_host_key_row = Gtk.Template.Child() pubkey_auth_switch = Gtk.Template.Child() password_auth_switch = Gtk.Template.Child() kbd_interactive_auth_switch = Gtk.Template.Child() gssapi_auth_switch = Gtk.Template.Child() add_keys_to_agent_row = Gtk.Template.Child() preferred_authentications_entry = Gtk.Template.Child() identity_agent_entry = Gtk.Template.Child() connect_timeout_entry = Gtk.Template.Child() request_tty_row = Gtk.Template.Child() log_level_row = Gtk.Template.Child() verify_host_key_dns_switch = Gtk.Template.Child() canonicalize_hostname_row = Gtk.Template.Child() canonical_domains_entry = Gtk.Template.Child() control_master_row = Gtk.Template.Child() control_persist_entry = Gtk.Template.Child() control_path_entry = Gtk.Template.Child() raw_text_view = Gtk.Template.Child() copy_button = Gtk.Template.Child() test_button = Gtk.Template.Child() unsaved_banner = Gtk.Template.Child() __gsignals__ = { "host-changed": (GObject.SignalFlags.RUN_LAST, None, (object,)), "editor-validity-changed": (GObject.SignalFlags.RUN_LAST, None, (bool,)), "host-save": (GObject.SignalFlags.RUN_LAST, None, (object,)), "show-toast": (GObject.SignalFlags.RUN_LAST, None, (str,)), } def __init__(self): super().__init__() self.set_visible(False) self.app = None self.current_host = None self.is_loading = False self._programmatic_raw_update = False self._editor_valid = True self._touched_options: set[str] = set() self._wired_global_buttons = False try: css = Gtk.CssProvider() css.load_from_data( b""" .error-label { color: #e01b24; } .entry-error { border-color: #e01b24; } """ ) Gtk.StyleContext.add_provider_for_display( Gtk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) except Exception: pass self.buffer = None self._replace_textview_with_sourceview() self._setup_syntax_highlighting() self._connect_signals() self._ensure_buffer_initialized() if self.buffer is not None: self._create_diff_tags() self._show_helpful_placeholder() try: if getattr(self, "unsaved_banner", None): self.unsaved_banner.set_revealed(False) self.unsaved_banner.set_sensitive(False) except Exception: pass try: key_ctrl = Gtk.EventControllerKey.new() key_ctrl.connect("key-pressed", self._on_key_pressed) self.add_controller(key_ctrl) except Exception: pass def set_app(self, app): self.app = app def _wire_global_buttons(self): self._wired_global_buttons = True return False def _on_key_pressed(self, controller, keyval, keycode, state): if keyval == Gdk.KEY_s and (state & Gdk.ModifierType.CONTROL_MASK): self._on_save_clicked(None) return True if keyval == Gdk.KEY_z and (state & Gdk.ModifierType.CONTROL_MASK): # TODO: Implement undo functionality (Im bored) return False if keyval == Gdk.KEY_Escape: try: root = self.get_root() if root and hasattr(root, "get_focus"): focused_widget = root.get_focus() if focused_widget: root.set_focus(None) return True except Exception: pass return False if keyval == Gdk.KEY_Tab: return self._handle_tab_navigation(state & Gdk.ModifierType.SHIFT_MASK) return False def _handle_tab_navigation(self, shift_pressed: bool): """Handle Tab/Shift+Tab navigation between form fields.""" focusable_widgets = [ self.patterns_entry, self.hostname_entry, self.user_entry, self.port_entry, self.identity_entry, self.identity_button, self.identity_pick_button, self.forward_agent_switch, self.proxy_jump_entry, self.proxy_cmd_entry, self.local_forward_entry, self.remote_forward_entry, self.compression_switch, self.serveralive_interval_entry, self.serveralive_count_entry, self.tcp_keepalive_switch, self.pubkey_auth_switch, self.password_auth_switch, self.kbd_interactive_auth_switch, self.gssapi_auth_switch, self.preferred_authentications_entry, self.identity_agent_entry, self.connect_timeout_entry, ] focusable_widgets = [w for w in focusable_widgets if w is not None] if not focusable_widgets: return False current_focus = None try: root = self.get_root() if root and hasattr(root, "get_focus"): current_focus = root.get_focus() except Exception: pass current_index = -1 for i, widget in enumerate(focusable_widgets): if widget == current_focus: current_index = i break if current_index == -1: for i, widget in enumerate(focusable_widgets): if widget.has_focus(): current_index = i break if current_index == -1: return False if shift_pressed: next_index = (current_index - 1) % len(focusable_widgets) else: next_index = (current_index + 1) % len(focusable_widgets) focusable_widgets[next_index].grab_focus() return True def _show_message(self, message: str): """Show a message using toast by emitting a signal.""" self.emit("show-toast", message) def _connect_signals(self): def connect_touch(widget, signal_name: str, option_key: str): if not widget: return def handler(*args): if self.is_loading: return self._touched_options.add(option_key) self._on_field_changed(widget) widget.connect(signal_name, handler) def connect_entry_row_text(widget, option_key: str): if not widget: return def on_notify_text(*_args): if self.is_loading: return self._touched_options.add(option_key) self._on_field_changed(widget) widget.connect("notify::text", on_notify_text) connect_entry_row_text(self.patterns_entry, "__patterns__") connect_entry_row_text(self.hostname_entry, "HostName") connect_entry_row_text(self.user_entry, "User") connect_touch(self.port_entry, "notify::value", "Port") connect_entry_row_text(self.identity_entry, "IdentityFile") connect_touch(self.forward_agent_switch, "state-set", "ForwardAgent") connect_entry_row_text(self.proxy_jump_entry, "ProxyJump") connect_entry_row_text(self.proxy_cmd_entry, "ProxyCommand") connect_entry_row_text(self.local_forward_entry, "LocalForward") connect_entry_row_text(self.remote_forward_entry, "RemoteForward") connect_touch(self.compression_switch, "state-set", "Compression") connect_touch(self.serveralive_interval_entry, "notify::value", "ServerAliveInterval") connect_touch(self.serveralive_count_entry, "notify::value", "ServerAliveCountMax") connect_touch(self.tcp_keepalive_switch, "state-set", "TCPKeepAlive") if hasattr(self, "strict_host_key_row") and self.strict_host_key_row: self.strict_host_key_row.connect( "notify::selected", lambda *args: ( None if self.is_loading else ( self._touched_options.add("StrictHostKeyChecking"), self._on_field_changed(self.strict_host_key_row), ) ), ) if hasattr(self, "add_keys_to_agent_row") and self.add_keys_to_agent_row: self.add_keys_to_agent_row.connect( "notify::selected", lambda *args: ( None if self.is_loading else ( self._touched_options.add("AddKeysToAgent"), self._on_field_changed(self.add_keys_to_agent_row), ) ), ) connect_touch( getattr(self, "pubkey_auth_switch", None), "state-set", "PubkeyAuthentication", ) connect_touch( getattr(self, "password_auth_switch", None), "state-set", "PasswordAuthentication", ) connect_touch( getattr(self, "kbd_interactive_auth_switch", None), "state-set", "KbdInteractiveAuthentication", ) connect_touch( getattr(self, "gssapi_auth_switch", None), "state-set", "GSSAPIAuthentication", ) connect_entry_row_text( getattr(self, "preferred_authentications_entry", None), "PreferredAuthentications", ) connect_entry_row_text( getattr(self, "identity_agent_entry", None), "IdentityAgent" ) connect_entry_row_text( getattr(self, "connect_timeout_entry", None), "ConnectTimeout" ) if hasattr(self, "request_tty_row") and self.request_tty_row: self.request_tty_row.connect( "notify::selected", lambda *args: ( None if self.is_loading else ( self._touched_options.add("RequestTTY"), self._on_field_changed(self.request_tty_row), ) ), ) if hasattr(self, "log_level_row") and self.log_level_row: self.log_level_row.connect( "notify::selected", lambda *args: ( None if self.is_loading else ( self._touched_options.add("LogLevel"), self._on_field_changed(self.log_level_row), ) ), ) connect_touch( getattr(self, "verify_host_key_dns_switch", None), "state-set", "VerifyHostKeyDNS", ) if ( hasattr(self, "canonicalize_hostname_row") and self.canonicalize_hostname_row ): self.canonicalize_hostname_row.connect( "notify::selected", lambda *args: ( None if self.is_loading else ( self._touched_options.add("CanonicalizeHostname"), self._on_field_changed(self.canonicalize_hostname_row), ) ), ) connect_entry_row_text( getattr(self, "canonical_domains_entry", None), "CanonicalDomains" ) if hasattr(self, "control_master_row") and self.control_master_row: self.control_master_row.connect( "notify::selected", lambda *args: ( None if self.is_loading else ( self._touched_options.add("ControlMaster"), self._on_field_changed(self.control_master_row), ) ), ) connect_entry_row_text( getattr(self, "control_persist_entry", None), "ControlPersist" ) connect_entry_row_text(getattr(self, "control_path_entry", None), "ControlPath") if self.buffer: self._raw_changed_handler_id = self.buffer.connect( "changed", self._on_raw_text_changed ) self._connect_buttons() self._connect_header_buttons() def _connect_buttons(self): if hasattr(self, "identity_button") and self.identity_button: self.identity_button.connect("clicked", self._on_identity_file_clicked) if hasattr(self, "identity_pick_button") and self.identity_pick_button: self.identity_pick_button.connect("clicked", self._on_identity_pick_clicked) if hasattr(self, "copy_button") and self.copy_button: self.copy_button.connect("clicked", self._on_copy_ssh_command) if hasattr(self, "test_button") and self.test_button: self.test_button.connect("clicked", self._on_test_connection) try: if getattr(self, "unsaved_banner", None) is not None: self.unsaved_banner.connect( "button-clicked", lambda *_: self._on_save_clicked(None) ) except Exception: pass def _connect_header_buttons(self): """Connect header bar button signals.""" try: if hasattr(self, "add_button") and self.add_button: self.add_button.connect("clicked", self._on_add_clicked) except Exception: pass try: if hasattr(self, "duplicate_button") and self.duplicate_button: self.duplicate_button.connect("clicked", self._on_duplicate_clicked) except Exception: pass try: if hasattr(self, "delete_button") and self.delete_button: self.delete_button.connect("clicked", self._on_delete_clicked) except Exception: pass def _on_add_clicked(self, button): """Handle add host button click.""" try: main_window = self.get_root() if hasattr(main_window, "host_list") and main_window.host_list: main_window.host_list.add_host() except Exception: pass def _on_duplicate_clicked(self, button): """Handle duplicate host button click.""" try: main_window = self.get_root() if hasattr(main_window, "host_list") and main_window.host_list: main_window.host_list.duplicate_host() except Exception: pass def _on_delete_clicked(self, button): """Handle delete host button click.""" try: main_window = self.get_root() if hasattr(main_window, "host_list") and main_window.host_list: main_window.host_list.delete_host() except Exception: pass def load_host(self, host: SSHHost): self.is_loading = True self._touched_options.clear() self.current_host = host self.original_host_state = copy.deepcopy(host) if not host: self._clear_all_fields() self.is_loading = False return def _safe_set_entry_text(row, text): try: if row is not None: row.set_text(text) except Exception: pass _safe_set_entry_text( getattr(self, "patterns_entry", None), " ".join(host.patterns) ) _safe_set_entry_text( getattr(self, "hostname_entry", None), host.get_option("HostName") or "" ) _safe_set_entry_text( getattr(self, "user_entry", None), host.get_option("User") or "" ) port_value = host.get_option("Port") or "22" try: port_int = int(port_value) if port_value.isdigit() else 22 if hasattr(self, "port_entry") and self.port_entry: self.port_entry.set_value(port_int) except (ValueError, AttributeError): if hasattr(self, "port_entry") and self.port_entry: self.port_entry.set_value(22) _safe_set_entry_text( getattr(self, "identity_entry", None), host.get_option("IdentityFile") or "" ) forward_agent = host.get_option("ForwardAgent") try: if getattr(self, "forward_agent_switch", None) is not None: self.forward_agent_switch.set_active(forward_agent == "yes") except Exception: pass _safe_set_entry_text( getattr(self, "proxy_jump_entry", None), host.get_option("ProxyJump") or "" ) _safe_set_entry_text( getattr(self, "proxy_cmd_entry", None), host.get_option("ProxyCommand") or "", ) _safe_set_entry_text( getattr(self, "local_forward_entry", None), host.get_option("LocalForward") or "", ) _safe_set_entry_text( getattr(self, "remote_forward_entry", None), host.get_option("RemoteForward") or "", ) compression = (host.get_option("Compression") or "no").lower() == "yes" try: if getattr(self, "compression_switch", None) is not None: self.compression_switch.set_active(compression) except Exception: pass interval_value = host.get_option("ServerAliveInterval") or "0" try: interval_int = int(interval_value) if interval_value.isdigit() else 0 if hasattr(self, "serveralive_interval_entry") and self.serveralive_interval_entry: self.serveralive_interval_entry.set_value(interval_int) except (ValueError, AttributeError): if hasattr(self, "serveralive_interval_entry") and self.serveralive_interval_entry: self.serveralive_interval_entry.set_value(0) count_value = host.get_option("ServerAliveCountMax") or "3" try: count_int = int(count_value) if count_value.isdigit() else 3 if hasattr(self, "serveralive_count_entry") and self.serveralive_count_entry: self.serveralive_count_entry.set_value(count_int) except (ValueError, AttributeError): if hasattr(self, "serveralive_count_entry") and self.serveralive_count_entry: self.serveralive_count_entry.set_value(3) tcp_keepalive = (host.get_option("TCPKeepAlive") or "yes").lower() == "yes" self.tcp_keepalive_switch.set_active(tcp_keepalive) shk = (host.get_option("StrictHostKeyChecking") or "ask").lower() mapping = {"ask": 0, "yes": 1, "no": 2} self.strict_host_key_row.set_selected(mapping.get(shk, 0)) # Authentication and keys self.pubkey_auth_switch.set_active( ((host.get_option("PubkeyAuthentication") or "yes").lower()) == "yes" ) self.password_auth_switch.set_active( ((host.get_option("PasswordAuthentication") or "no").lower()) == "yes" ) self.kbd_interactive_auth_switch.set_active( ((host.get_option("KbdInteractiveAuthentication") or "no").lower()) == "yes" ) self.gssapi_auth_switch.set_active( ((host.get_option("GSSAPIAuthentication") or "no").lower()) == "yes" ) aka = (host.get_option("AddKeysToAgent") or "no").lower() self._combo_select( self.add_keys_to_agent_row, ["no", "yes", "ask", "confirm"], aka ) self.preferred_authentications_entry.set_text( host.get_option("PreferredAuthentications") or "" ) self.identity_agent_entry.set_text(host.get_option("IdentityAgent") or "") # Connection behavior self.connect_timeout_entry.set_text(host.get_option("ConnectTimeout") or "8") self._combo_select( self.request_tty_row, ["auto", "no", "yes", "force"], (host.get_option("RequestTTY") or "auto").lower(), ) self._combo_select( self.log_level_row, [ "quiet", "fatal", "error", "info", "verbose", "debug", "debug1", "debug2", "debug3", ], (host.get_option("LogLevel") or "info").lower(), ) self.verify_host_key_dns_switch.set_active( ((host.get_option("VerifyHostKeyDNS") or "no").lower()) == "yes" ) self._combo_select( self.canonicalize_hostname_row, ["no", "yes", "always"], (host.get_option("CanonicalizeHostname") or "no").lower(), ) self.canonical_domains_entry.set_text(host.get_option("CanonicalDomains") or "") # Multiplexing self._combo_select( self.control_master_row, ["no", "yes", "ask", "auto", "autoask"], (host.get_option("ControlMaster") or "no").lower(), ) self.control_persist_entry.set_text(host.get_option("ControlPersist") or "") self.control_path_entry.set_text(host.get_option("ControlPath") or "") self.raw_text_view.get_buffer().set_text("\n".join(host.raw_lines)) self.original_raw_content = "\n".join(host.raw_lines) self.is_loading = False try: self._update_button_sensitivity() except Exception: pass # Avoid triggering heavy diff/parse immediately on first load; defer to idle def deferred_update(): self._programmatic_raw_update = True try: self._on_raw_text_changed(self.raw_text_view.get_buffer()) finally: self._programmatic_raw_update = False return False GLib.idle_add(deferred_update) def _clear_all_fields(self): """Clears all input fields and custom options.""" self.patterns_entry.set_text("") self.hostname_entry.set_text("") self.user_entry.set_text("") self.port_entry.set_value(22) self.identity_entry.set_text("") self.forward_agent_switch.set_active(False) self.proxy_jump_entry.set_text("") self.proxy_cmd_entry.set_text("") self.local_forward_entry.set_text("") self.remote_forward_entry.set_text("") if hasattr(self, "compression_switch"): self.compression_switch.set_active(False) if hasattr(self, "serveralive_interval_entry"): self.serveralive_interval_entry.set_value(0) if hasattr(self, "serveralive_count_entry"): self.serveralive_count_entry.set_value(3) if hasattr(self, "tcp_keepalive_switch"): self.tcp_keepalive_switch.set_active(True) if hasattr(self, "strict_host_key_row"): self.strict_host_key_row.set_selected(0) def _load_custom_options(self, host: SSHHost): """Loads custom SSH options into the custom options list.""" if not hasattr(self, "custom_options_list") or not self.custom_options_list: return self._clear_custom_options() common_options = { "HostName", "User", "Port", "IdentityFile", "ForwardAgent", "ProxyJump", "ProxyCommand", "LocalForward", "RemoteForward", } for option in host.options: if option.key not in common_options: self._add_custom_option_row(option.key, option.value) def _clear_custom_options(self): """Clears all custom option rows from the list.""" if not hasattr(self, "custom_options_list") or not self.custom_options_list: return child = self.custom_options_list.get_first_child() while child: self.custom_options_list.remove(child) child = self.custom_options_list.get_first_child() def _add_custom_option_row(self, key: str = "", value: str = ""): """Adds a new row for a custom option to the list.""" action_row = Adw.ActionRow() action_row.set_title(key if key else _("New Custom Option")) action_row.set_subtitle(value if value else _("Enter option name and value")) action_row.set_activatable(False) action_row.add_css_class("custom-option-row") entry_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) entry_container.set_spacing(12) entry_container.set_hexpand(True) entry_container.set_margin_start(12) entry_container.set_margin_end(12) key_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) key_box.set_spacing(4) key_label = Gtk.Label(label=_("Option Name")) key_label.set_xalign(0) key_label.add_css_class("dim-label") key_label.add_css_class("caption") key_box.append(key_label) key_entry = Gtk.Entry() key_entry.set_text(key) key_entry.set_placeholder_text(_("e.g., Compression")) key_entry.set_size_request(160, -1) key_entry.add_css_class("custom-option-key") key_entry.connect("changed", self._on_custom_option_key_changed, action_row) key_box.append(key_entry) value_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) value_box.set_spacing(4) value_box.set_hexpand(True) value_label = Gtk.Label(label=_("Value")) value_label.set_xalign(0) value_label.add_css_class("dim-label") value_label.add_css_class("caption") value_box.append(value_label) value_entry = Gtk.Entry() value_entry.set_text(value) value_entry.set_placeholder_text(_("e.g., yes")) value_entry.set_hexpand(True) value_entry.add_css_class("custom-option-value") value_entry.connect("changed", self._on_custom_option_value_changed, action_row) value_box.append(value_entry) entry_container.append(key_box) entry_container.append(value_box) remove_button = Gtk.Button() remove_button.set_icon_name("user-trash-symbolic") remove_button.add_css_class("flat") remove_button.add_css_class("destructive-action") remove_button.set_tooltip_text(_("Remove this custom option")) remove_button.set_valign(Gtk.Align.CENTER) remove_button.connect("clicked", self._on_remove_custom_option, action_row) action_row.add_suffix(entry_container) action_row.add_suffix(remove_button) action_row.key_entry = key_entry action_row.value_entry = value_entry if hasattr(self, "custom_options_list") and self.custom_options_list: self.custom_options_list.append(action_row) key_entry.connect("changed", self._on_custom_option_changed) value_entry.connect("changed", self._on_custom_option_changed) if hasattr(self, "custom_options_expander") and self.custom_options_expander: if not self.custom_options_expander.get_expanded(): self.custom_options_expander.set_expanded(True) def _on_field_changed(self, widget, *args): """Handle changes in basic and networking fields to update host and dirty state.""" if self.is_loading or not self.current_host: return self._update_button_sensitivity() self._validate_and_update_host() def _on_custom_option_changed(self, widget, *args): """Handle changes in custom option fields to update host and dirty state.""" if self.is_loading or not self.current_host: return self._update_button_sensitivity() self._validate_and_update_host() def _on_custom_option_key_changed(self, widget, action_row): """Handle changes in custom option key field to update the row title.""" key = widget.get_text().strip() if key: action_row.set_title(key) else: action_row.set_title(_("New Custom Option")) def _on_custom_option_value_changed(self, widget, action_row): """Handle changes in custom option value field to update the row subtitle.""" value = widget.get_text().strip() if value: action_row.set_subtitle(value) else: action_row.set_subtitle(_("Enter option name and value")) def _update_raw_text_from_host(self): """Updates the raw text view based on the current host's structured data.""" if not self.current_host: return self.is_loading = True generated_raw_lines = self._generate_raw_lines_from_host() buffer = self.raw_text_view.get_buffer() if hasattr(self, "_raw_changed_handler_id"): buffer.handler_block(self._raw_changed_handler_id) buffer.set_text("\n".join(generated_raw_lines)) if hasattr(self, "_raw_changed_handler_id"): buffer.handler_unblock(self._raw_changed_handler_id) self.is_loading = False self._programmatic_raw_update = True self._on_raw_text_changed(self.raw_text_view.get_buffer()) self._programmatic_raw_update = False def _generate_raw_lines_from_host(self) -> list[str]: """Generates raw lines for the current host based on its structured data.""" lines = [] if self.current_host: if self.current_host.patterns: lines.append(f"Host {' '.join(self.current_host.patterns)}") for opt in self.current_host.options: lines.append(str(opt)) if self.current_host.options and lines[-1].strip() != "": lines.append("") return lines def _on_raw_text_changed(self, buffer): """Handle changes in the raw text view, parse, validate, and apply diff highlighting.""" if self.is_loading or not self.current_host: return current_text = buffer.get_text( buffer.get_start_iter(), buffer.get_end_iter(), False ) current_lines = current_text.splitlines() original_lines = self.original_raw_content.splitlines() self._ensure_buffer_initialized() if self.buffer is None: return if self._is_source_view(): self._apply_subtle_diff_highlighting(current_lines, original_lines) else: self.buffer.remove_all_tags( self.buffer.get_start_iter(), self.buffer.get_end_iter() ) self._apply_full_diff_highlighting(current_lines, original_lines) if not self._programmatic_raw_update: self._parse_and_validate_raw_text(current_lines) self._update_button_sensitivity() def _parse_and_validate_raw_text(self, current_lines: list[str]): """Parses raw lines and updates current_host and UI fields if valid.""" try: temp_host = SSHHost.from_raw_lines(current_lines) self.current_host.patterns = temp_host.patterns self.current_host.options = temp_host.options self.current_host.raw_lines = current_lines self.emit("host-changed", self.current_host) self._sync_fields_from_host() self._update_button_sensitivity() except ValueError as e: error_msg = str(e) if "No Host declaration found" in error_msg: if any( line.strip() and not line.strip().startswith("#") for line in current_lines ): self.app._show_error( "SSH host configuration must start with 'Host' declaration" ) else: self.app._show_error(f"Invalid raw host configuration: {e}") except Exception as e: self.app._show_error(f"Error parsing raw host config: {e}") def _update_host_from_fields(self): """Updates the current host object based on GUI field values. Only updates options the user interacted with (touched). Defaults are not written. """ if not self.current_host: return if "__patterns__" in self._touched_options: patterns_text = self.patterns_entry.get_text().strip() self.current_host.patterns = ( [p.strip() for p in patterns_text.split()] if patterns_text else [] ) def update_if_touched( key: str, value: str | None, default_absent_values: list[str] | None = None ): if key not in self._touched_options: return v = (value or "").strip() if default_absent_values and v.lower() in [ d.lower() for d in default_absent_values ]: self.current_host.remove_option(key) elif v == "": self.current_host.remove_option(key) else: self.current_host.set_option(key, v) update_if_touched("HostName", self.hostname_entry.get_text()) update_if_touched("User", self.user_entry.get_text()) port_value = str(int(self.port_entry.get_value())) if self.port_entry.get_value() != 22 else "" update_if_touched("Port", port_value, default_absent_values=["22"]) update_if_touched("IdentityFile", self.identity_entry.get_text()) if "ForwardAgent" in self._touched_options: fa = "yes" if self.forward_agent_switch.get_active() else "no" update_if_touched("ForwardAgent", fa, default_absent_values=["no"]) update_if_touched("ProxyJump", self.proxy_jump_entry.get_text()) update_if_touched("ProxyCommand", self.proxy_cmd_entry.get_text()) update_if_touched("LocalForward", self.local_forward_entry.get_text()) update_if_touched("RemoteForward", self.remote_forward_entry.get_text()) if "Compression" in self._touched_options: comp = ( "yes" if (self.compression_switch and self.compression_switch.get_active()) else "no" ) update_if_touched("Compression", comp, default_absent_values=["no"]) if "ServerAliveInterval" in self._touched_options: interval_value = str(int(self.serveralive_interval_entry.get_value())) if self.serveralive_interval_entry.get_value() != 0 else "" update_if_touched("ServerAliveInterval", interval_value, default_absent_values=["0"]) if "ServerAliveCountMax" in self._touched_options: count_value = str(int(self.serveralive_count_entry.get_value())) if self.serveralive_count_entry.get_value() != 3 else "" update_if_touched("ServerAliveCountMax", count_value, default_absent_values=["3"]) if "TCPKeepAlive" in self._touched_options: tka = ( "yes" if ( self.tcp_keepalive_switch and self.tcp_keepalive_switch.get_active() ) else "no" ) update_if_touched("TCPKeepAlive", tka, default_absent_values=["yes"]) if ( "StrictHostKeyChecking" in self._touched_options and self.strict_host_key_row ): idx = self.strict_host_key_row.get_selected() mapping = ["ask", "yes", "no"] val = mapping[idx] if 0 <= idx < len(mapping) else "ask" update_if_touched( "StrictHostKeyChecking", val, default_absent_values=["ask"] ) if "PubkeyAuthentication" in self._touched_options: update_if_touched( "PubkeyAuthentication", ( "yes" if ( self.pubkey_auth_switch and self.pubkey_auth_switch.get_active() ) else "no" ), default_absent_values=["yes"], ) if "PasswordAuthentication" in self._touched_options: update_if_touched( "PasswordAuthentication", ( "yes" if ( self.password_auth_switch and self.password_auth_switch.get_active() ) else "no" ), default_absent_values=["yes"], ) if "KbdInteractiveAuthentication" in self._touched_options: update_if_touched( "KbdInteractiveAuthentication", ( "yes" if ( self.kbd_interactive_auth_switch and self.kbd_interactive_auth_switch.get_active() ) else "no" ), default_absent_values=["yes"], ) if "GSSAPIAuthentication" in self._touched_options: update_if_touched( "GSSAPIAuthentication", ( "yes" if ( self.gssapi_auth_switch and self.gssapi_auth_switch.get_active() ) else "no" ), default_absent_values=["no"], ) update_if_touched( "PreferredAuthentications", ( getattr(self, "preferred_authentications_entry", None).get_text() if hasattr(self, "preferred_authentications_entry") and self.preferred_authentications_entry else "" ), ) update_if_touched( "IdentityAgent", ( getattr(self, "identity_agent_entry", None).get_text() if hasattr(self, "identity_agent_entry") and self.identity_agent_entry else "" ), ) if "AddKeysToAgent" in self._touched_options and self.add_keys_to_agent_row: aka_idx = self.add_keys_to_agent_row.get_selected() aka_map = ["no", "yes", "ask", "confirm"] val = aka_map[aka_idx] if 0 <= aka_idx < len(aka_map) else "no" update_if_touched("AddKeysToAgent", val, default_absent_values=["no"]) if "ConnectTimeout" in self._touched_options: ct = ( self.connect_timeout_entry.get_text().strip() if self.connect_timeout_entry else "" ) if ct == "0": ct = "" update_if_touched("ConnectTimeout", ct) if "RequestTTY" in self._touched_options and self.request_tty_row: idx = self.request_tty_row.get_selected() rtty_map = ["auto", "no", "yes", "force"] val = rtty_map[idx] if 0 <= idx < len(rtty_map) else "auto" update_if_touched("RequestTTY", val, default_absent_values=["auto"]) if "LogLevel" in self._touched_options and self.log_level_row: idx = self.log_level_row.get_selected() lvl_map = [ "QUIET", "FATAL", "ERROR", "INFO", "VERBOSE", "DEBUG", "DEBUG1", "DEBUG2", "DEBUG3", ] val = lvl_map[idx] if 0 <= idx < len(lvl_map) else "INFO" update_if_touched("LogLevel", val, default_absent_values=["INFO"]) if "VerifyHostKeyDNS" in self._touched_options: vhk = ( "yes" if ( self.verify_host_key_dns_switch and self.verify_host_key_dns_switch.get_active() ) else "no" ) update_if_touched("VerifyHostKeyDNS", vhk, default_absent_values=["no"]) if ( "CanonicalizeHostname" in self._touched_options and self.canonicalize_hostname_row ): idx = self.canonicalize_hostname_row.get_selected() can_map = ["no", "yes", "always"] val = can_map[idx] if 0 <= idx < len(can_map) else "no" update_if_touched("CanonicalizeHostname", val, default_absent_values=["no"]) update_if_touched( "CanonicalDomains", ( getattr(self, "canonical_domains_entry", None).get_text() if hasattr(self, "canonical_domains_entry") and self.canonical_domains_entry else "" ), ) if "ControlMaster" in self._touched_options and self.control_master_row: idx = self.control_master_row.get_selected() cm_map = ["no", "yes", "ask", "auto", "autoask"] val = cm_map[idx] if 0 <= idx < len(cm_map) else "no" update_if_touched("ControlMaster", val, default_absent_values=["no"]) update_if_touched( "ControlPersist", ( getattr(self, "control_persist_entry", None).get_text() if hasattr(self, "control_persist_entry") and self.control_persist_entry else "" ), ) update_if_touched( "ControlPath", ( getattr(self, "control_path_entry", None).get_text() if hasattr(self, "control_path_entry") and self.control_path_entry else "" ), ) def _update_host_option(self, key: str, value: str): """Helper to update or remove a single SSH option on the current host.""" if value.strip(): self.current_host.set_option(key, value.strip()) else: self.current_host.remove_option(key) def _update_custom_options(self): """Updates custom options on the current host based on the listbox content.""" common_options = { "HostName", "User", "Port", "IdentityFile", "ForwardAgent", "ProxyJump", "ProxyCommand", "LocalForward", "RemoteForward", "Compression", "ServerAliveInterval", "ServerAliveCountMax", "TCPKeepAlive", "StrictHostKeyChecking", "PubkeyAuthentication", "PasswordAuthentication", "KbdInteractiveAuthentication", "GSSAPIAuthentication", "AddKeysToAgent", "PreferredAuthentications", "IdentityAgent", "ConnectTimeout", "RequestTTY", "LogLevel", "VerifyHostKeyDNS", "CanonicalizeHostname", "CanonicalDomains", "ControlMaster", "ControlPersist", "ControlPath", } self.current_host.options = [ opt for opt in self.current_host.options if opt.key in common_options ] if hasattr(self, "custom_options_list") and self.custom_options_list: for action_row in self.custom_options_list: if hasattr(action_row, "key_entry") and hasattr( action_row, "value_entry" ): key_entry = action_row.key_entry value_entry = action_row.value_entry if key_entry and value_entry: key = key_entry.get_text().strip() value = value_entry.get_text().strip() if key and value: self.current_host.set_option(key, value) def _on_identity_file_clicked(self, button): dialog = Gtk.FileChooserDialog( title=_("Choose Identity File"), transient_for=self.get_root(), action=Gtk.FileChooserAction.OPEN, ) dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(_("Open"), Gtk.ResponseType.OK) filter_text = Gtk.FileFilter() filter_text.set_name(_("SSH Keys")) filter_text.add_pattern("*.pem") filter_text.add_pattern("id_*") dialog.add_filter(filter_text) dialog.connect("response", self._on_identity_file_response) dialog.present() def _on_identity_pick_clicked(self, button): from .key_picker_dialog import KeyPickerDialog dlg = KeyPickerDialog(self) def on_key_selected(dlg_obj, private_path: str): if private_path: self.identity_entry.set_text(private_path) dlg.connect("key-selected", on_key_selected) def on_generate(*_): from .generate_key_dialog import GenerateKeyDialog gen = GenerateKeyDialog(self) def after_gen(*__): opts = gen.get_options() gen.close() try: import subprocess from pathlib import Path ssh_dir = Path.home() / ".ssh" ssh_dir.mkdir(parents=True, exist_ok=True) name = opts.get("name") or "id_ed25519" base = name i = 0 while (ssh_dir / name).exists(): i += 1 name = f"{base}_{i}" key_path = ssh_dir / name key_type = (opts.get("type") or "ed25519").lower() comment = opts.get("comment") or "ssh-studio" passphrase = opts.get("passphrase") or "" if key_type == "rsa": size = int(opts.get("size") or 2048) cmd = [ "ssh-keygen", "-t", "rsa", "-b", str(size), "-f", str(key_path), "-N", passphrase, "-C", comment, ] elif key_type == "ecdsa": cmd = [ "ssh-keygen", "-t", "ecdsa", "-f", str(key_path), "-N", passphrase, "-C", comment, ] else: cmd = [ "ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", passphrase, "-C", comment, ] subprocess.run(cmd, check=True) try: dlg._load_keys() except Exception: pass self.identity_entry.set_text(str(key_path)) except Exception: pass gen.generate_btn.connect("clicked", after_gen) gen.present(self.get_root()) dlg.generate_btn.connect("clicked", on_generate) dlg.present(self.get_root()) def _on_identity_file_response(self, dialog, response_id): try: if response_id == Gtk.ResponseType.OK: file = dialog.get_file() if file: self.identity_entry.set_text(file.get_path()) finally: dialog.destroy() def _on_add_custom_option(self, button): self._add_custom_option_row() def _on_remove_custom_option(self, button, action_row): """Handle remove custom option button click.""" if hasattr(self, "custom_options_list") and self.custom_options_list: self.custom_options_list.remove(action_row) self._update_host_from_fields() self.emit("host-changed", self.current_host) self._show_message(_("Custom option removed")) def _on_copy_ssh_command(self, button): """Copy the generated SSH command to the clipboard and show a toast.""" if not self.current_host: self._show_message(_("No host selected")) return try: hostname = self.hostname_entry.get_text().strip() if not hostname and self.current_host.patterns: hostname = self.current_host.patterns[0] if not hostname: self._show_message(_("No hostname or pattern available")) return command = f"ssh {hostname}" if not self._copy_text_to_clipboard(command): raise RuntimeError("clipboard backends unavailable") self._show_message(_(f"SSH command copied: {command}")) except Exception as e: self._show_message(_(f"Failed to copy command: {str(e)}")) def _copy_text_to_clipboard(self, text: str) -> bool: try: display = Gdk.Display.get_default() if not display: raise RuntimeError("no display") clipboard = display.get_clipboard() bytes_utf8 = GLib.Bytes.new(text.encode("utf-8")) providers = [ Gdk.ContentProvider.new_for_bytes( "text/plain;charset=utf-8", bytes_utf8 ), Gdk.ContentProvider.new_for_bytes("text/plain", bytes_utf8), ] provider = ( Gdk.ContentProvider.new_union(providers) if hasattr(Gdk.ContentProvider, "new_union") else providers[0] ) self._last_clip_provider = provider if hasattr(clipboard, "set_content"): clipboard.set_content(provider) elif hasattr(clipboard, "set"): clipboard.set(provider) elif hasattr(clipboard, "set_text"): clipboard.set_text(text) else: raise RuntimeError("unsupported clipboard api") try: primary = display.get_primary_clipboard() if primary: if hasattr(primary, "set_content"): primary.set_content(self._last_clip_provider) elif hasattr(primary, "set"): primary.set(self._last_clip_provider) elif hasattr(primary, "set_text"): primary.set_text(text) except Exception: pass return True except Exception: pass try: import subprocess as _sub for cmd in [ ["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"], ]: try: res = _sub.run(cmd, input=text, text=True, capture_output=True) if res.returncode == 0: return True except Exception: continue except Exception: pass return False def set_wrap_mode(self, wrap: bool): """Set the wrap mode for the raw text view based on preferences.""" try: if wrap: self.raw_text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) else: self.raw_text_view.set_wrap_mode(Gtk.WrapMode.NONE) except Exception: pass def is_host_dirty(self) -> bool: """Checks if the current host has unsaved changes compared to its original loaded state.""" if not self.current_host or not self.original_host_state: return False if sorted(self.current_host.patterns) != sorted( self.original_host_state.patterns ): return True if len(self.current_host.options) != len(self.original_host_state.options): return True current_options_dict = { opt.key.lower(): opt.value for opt in self.current_host.options } original_options_dict = { opt.key.lower(): opt.value for opt in self.original_host_state.options } if current_options_dict != original_options_dict: return True current_raw_clean = [line.rstrip("\n") for line in self.current_host.raw_lines] original_raw_clean = [ line.rstrip("\n") for line in self.original_host_state.raw_lines ] return current_raw_clean != original_raw_clean def _collect_field_errors(self) -> dict: errors: dict[str, str] = {} self._clear_field_errors() patterns_text = self.patterns_entry.get_text().strip() if not patterns_text: errors["patterns"] = _("Host name (patterns) is required.") port_value = self.port_entry.get_value() if port_value and not (1 <= port_value <= 65535): errors["port"] = _("Port must be between 1 and 65535.") if "patterns" in errors: self.patterns_error_label.set_text(errors["patterns"]) self.patterns_error_label.set_visible(True) self.patterns_entry.add_css_class("entry-error") else: self.patterns_entry.remove_css_class("entry-error") if "port" in errors: self.port_error_label.set_text(errors["port"]) self.port_error_label.set_visible(True) else: self.port_error_label.set_visible(False) interval_value = self.serveralive_interval_entry.get_value() if interval_value and interval_value < 0: errors["sai"] = _("ServerAliveInterval must be >= 0.") count_value = self.serveralive_count_entry.get_value() if count_value and count_value < 1: errors["sacm"] = _("ServerAliveCountMax must be >= 1.") try: if self.connect_timeout_entry: ct_text = self.connect_timeout_entry.get_text().strip() if ct_text: ct_val = int(ct_text) if ct_val < 1: errors["ct"] = _("ConnectTimeout must be >= 1.") except ValueError: errors["ct"] = _("ConnectTimeout must be numeric.") if "ct" in errors and self.connect_timeout_entry: self.connect_timeout_entry.add_css_class("entry-error") else: if self.connect_timeout_entry: self.connect_timeout_entry.remove_css_class("entry-error") return errors def _clear_field_errors(self): if hasattr(self, "patterns_error_label"): self.patterns_error_label.set_visible(False) if hasattr(self, "port_error_label"): self.port_error_label.set_visible(False) if hasattr(self, "patterns_entry"): self.patterns_entry.remove_css_class("entry-error") if hasattr(self, "connect_timeout_entry") and self.connect_timeout_entry: self.connect_timeout_entry.remove_css_class("entry-error") def _combo_select(self, combo_row, values: list[str], value: str): try: lower_values = [v.lower() for v in values] idx = ( lower_values.index(value.lower()) if value.lower() in lower_values else 0 ) combo_row.set_selected(idx) except Exception: try: combo_row.set_selected(0) except Exception: pass def _validate_and_update_host(self): field_errors = self._collect_field_errors() if field_errors: self._editor_valid = False self.emit("editor-validity-changed", False) self._update_button_sensitivity() return else: self._editor_valid = True self.emit("editor-validity-changed", True) self._update_host_from_fields() self.emit("host-changed", self.current_host) GLib.idle_add(lambda: (self._update_raw_text_from_host(), False)[1]) self._update_button_sensitivity() def _on_save_clicked(self, button): """Handle save button click.""" if not self.current_host: return try: field_errors = self._collect_field_errors() if field_errors: try: self._show_message(_("Fix validation errors before saving")) except Exception: pass return except Exception: pass try: self._update_host_from_fields() self._update_raw_text_from_host() except Exception: pass try: buffer = self.raw_text_view.get_buffer() current_text = buffer.get_text( buffer.get_start_iter(), buffer.get_end_iter(), False ) current_lines = current_text.splitlines() temp_host = SSHHost.from_raw_lines(current_lines) self.current_host.patterns = temp_host.patterns self.current_host.options = temp_host.options self.current_host.raw_lines = current_lines except Exception as e: try: self._show_message( _(f"Failed to parse current host before saving: {e}") ) except Exception: pass return try: main_window = self.app or self.get_root() parser = getattr(main_window, "parser", None) if parser is not None: try: cfg = parser.config target_index = None for idx, h in enumerate(getattr(cfg, "hosts", []) or []): if h is self.current_host: target_index = idx break try: if set(h.patterns) == set( self.original_host_state.patterns ): target_index = idx break except Exception: pass if target_index is None: for idx, h in enumerate(getattr(cfg, "hosts", []) or []): try: if set(h.patterns) == set(self.current_host.patterns): target_index = idx break except Exception: pass if target_index is not None: cfg.hosts[target_index].patterns = list( self.current_host.patterns ) cfg.hosts[target_index].options = list( self.current_host.options ) cfg.hosts[target_index].raw_lines = list( self.current_host.raw_lines ) else: try: cfg.hosts.append(self.current_host) except Exception: pass except Exception: pass try: warnings = parser.validate() if warnings: try: self._show_message(_(f"Validation: {warnings[0]}")) except Exception: pass except Exception: pass try: parser.write(backup=True) except Exception as e: try: self.app._show_error( _(f"Failed to save {parser.config_path}: {e}") ) except Exception: pass return try: expected_content = parser._generate_content() try: with open(parser.config_path, "r", encoding="utf-8") as f: on_disk = f.read() except FileNotFoundError: on_disk = "" if on_disk != expected_content: parser._atomic_write(expected_content) try: with open(parser.config_path, "r", encoding="utf-8") as f: verify = f.read() except Exception: verify = None if verify != expected_content: try: self.app._show_error( _( f"Failed to persist changes to {parser.config_path}. Check permissions / sandbox." ) ) except Exception: pass except Exception: pass self.original_host_state = copy.deepcopy(self.current_host) self.original_raw_content = "\n".join(self.current_host.raw_lines) self._ensure_buffer_initialized() if self.buffer is not None: try: self.buffer.remove_all_tags( self.buffer.get_start_iter(), self.buffer.get_end_iter() ) except Exception: pass try: self._update_button_sensitivity() except Exception: pass try: self._show_message(_(f"Configuration saved → {parser.config_path}")) except Exception: pass try: if hasattr(main_window, "_write_and_reload"): main_window._write_and_reload(show_status=False) except Exception: pass else: self.emit("host-save", self.current_host) if main_window and hasattr(main_window, "_write_and_reload"): main_window._write_and_reload(show_status=True) elif main_window and hasattr(main_window, "_on_save_clicked"): main_window._on_save_clicked(None) finally: try: self._touched_options.clear() self._update_button_sensitivity() except Exception: pass def _update_button_sensitivity(self): """Updates the sensitivity of banner based on global dirty state and validity.""" is_dirty = False try: main = self.app or self.get_root() parser = getattr(main, "parser", None) if parser is not None and getattr(parser, "config", None) is not None: is_dirty = bool(parser.config.is_dirty()) else: is_dirty = self.is_host_dirty() except Exception: is_dirty = self.is_host_dirty() field_errors = self._collect_field_errors() is_valid = not bool(field_errors) try: if getattr(self, "unsaved_banner", None): self.unsaved_banner.set_revealed(is_dirty) self.unsaved_banner.set_sensitive(is_dirty and is_valid) except Exception: pass def _on_test_connection(self, button): if not self.current_host: return dialog = TestConnectionDialog(parent=self.get_root()) hostname = self.hostname_entry.get_text().strip() if not hostname and self.current_host.patterns: hostname = self.current_host.patterns[0] ssh_exec = ["ssh"] try: if os.environ.get("FLATPAK_ID"): ssh_exec = ["flatpak-spawn", "--host", "ssh"] except Exception: pass command = [ *ssh_exec, "-q", "-T", "-o", "BatchMode=yes", "-o", "NumberOfPasswordPrompts=0", ] user_val = self.user_entry.get_text().strip() port_val = str(int(self.port_entry.get_value())) if self.port_entry.get_value() != 22 else "" ident_val = self.identity_entry.get_text().strip() proxy_jump_val = self.proxy_jump_entry.get_text().strip() if user_val: command += ["-l", user_val] if port_val: command += ["-p", port_val] if ident_val: command += ["-i", ident_val] if proxy_jump_val: command += ["-J", proxy_jump_val] special_keys = {"Host", "HostName", "User", "Port", "IdentityFile", "ProxyJump"} options_dict = {} try: for opt in self.current_host.options: options_dict[opt.key] = opt.value except Exception: pass def maybe_add_default(key: str, value: str): if key not in options_dict or not (options_dict.get(key) or "").strip(): command.extend(["-o", f"{key}={value}"]) for key, value in options_dict.items(): if key in special_keys: continue if (value or "").strip(): command.extend(["-o", f"{key}={value}"]) maybe_add_default("ConnectTimeout", "8") maybe_add_default("StrictHostKeyChecking", "accept-new") maybe_add_default("ControlMaster", "no") maybe_add_default("ControlPath", "none") maybe_add_default("ControlPersist", "no") command += [hostname, "exit"] dialog.start_test(command, hostname) dialog.present() def _sync_fields_from_host(self): if not self.current_host: return self.is_loading = True self.patterns_entry.set_text(" ".join(self.current_host.patterns)) self.hostname_entry.set_text(self.current_host.get_option("HostName") or "") self.user_entry.set_text(self.current_host.get_option("User") or "") port_value = self.current_host.get_option("Port") or "22" try: port_int = int(port_value) if port_value.isdigit() else 22 self.port_entry.set_value(port_int) except (ValueError, AttributeError): self.port_entry.set_value(22) self.identity_entry.set_text(self.current_host.get_option("IdentityFile") or "") self.forward_agent_switch.set_active( (self.current_host.get_option("ForwardAgent") or "").lower() == "yes" ) self.proxy_jump_entry.set_text(self.current_host.get_option("ProxyJump") or "") self.proxy_cmd_entry.set_text( self.current_host.get_option("ProxyCommand") or "" ) self.local_forward_entry.set_text( self.current_host.get_option("LocalForward") or "" ) self.remote_forward_entry.set_text( self.current_host.get_option("RemoteForward") or "" ) self.compression_switch.set_active( (self.current_host.get_option("Compression") or "no").lower() == "yes" ) interval_value = self.current_host.get_option("ServerAliveInterval") or "0" try: interval_int = int(interval_value) if interval_value.isdigit() else 0 self.serveralive_interval_entry.set_value(interval_int) except (ValueError, AttributeError): self.serveralive_interval_entry.set_value(0) count_value = self.current_host.get_option("ServerAliveCountMax") or "3" try: count_int = int(count_value) if count_value.isdigit() else 3 self.serveralive_count_entry.set_value(count_int) except (ValueError, AttributeError): self.serveralive_count_entry.set_value(3) self.tcp_keepalive_switch.set_active( (self.current_host.get_option("TCPKeepAlive") or "yes").lower() == "yes" ) shk2 = (self.current_host.get_option("StrictHostKeyChecking") or "ask").lower() mapping2 = {"ask": 0, "yes": 1, "no": 2} self.strict_host_key_row.set_selected(mapping2.get(shk2, 0)) self._load_custom_options(self.current_host) self.is_loading = False def _replace_textview_with_sourceview(self): """Replace the regular TextView with GtkSourceView for syntax highlighting.""" if not self.raw_text_view: return try: parent = self.raw_text_view.get_parent() if not parent: return source_view = GtkSource.View() source_view.set_monospace(True) source_view.set_wrap_mode(Gtk.WrapMode.NONE) source_view.set_editable(True) source_view.set_hexpand(True) source_view.set_vexpand(True) source_view.set_left_margin(8) source_view.set_right_margin(8) source_view.set_top_margin(6) source_view.set_bottom_margin(6) source_view.get_style_context().add_class("raw-editor") source_view.set_show_line_numbers(True) source_view.set_highlight_current_line(True) source_view.set_auto_indent(True) source_view.set_indent_on_tab(True) source_view.set_tab_width(4) source_view.set_insert_spaces_instead_of_tabs(True) parent.set_child(None) parent.set_child(source_view) self.raw_text_view = source_view except Exception as e: print(f"Warning: Could not replace TextView with GtkSourceView: {e}") pass def _show_helpful_placeholder(self): """Show helpful placeholder text when the raw editor is empty or invalid.""" if not self.raw_text_view or not self.buffer: return current_text = self.buffer.get_text( self.buffer.get_start_iter(), self.buffer.get_end_iter(), False ).strip() if not current_text or not current_text.lower().startswith("host "): placeholder_text = """# SSH Host Configuration # Start with a Host declaration, for example: Host myserver HostName example.com User myuser Port 22 IdentityFile ~/.ssh/id_rsa # Add any other SSH options as needed""" if not current_text: self.buffer.set_text(placeholder_text) start = self.buffer.get_start_iter() end = self.buffer.get_end_iter() self.buffer.select_range(start, end) def _ensure_buffer_initialized(self): """Ensure the text buffer is initialized.""" if not self.buffer and self.raw_text_view: try: self.buffer = self.raw_text_view.get_buffer() if self.buffer and not hasattr(self, "_raw_changed_handler_id"): self._raw_changed_handler_id = self.buffer.connect( "changed", self._on_raw_text_changed ) except Exception: pass def _is_source_view(self): """Check if we're using GtkSourceView.""" return ( self.raw_text_view and hasattr(self.raw_text_view, "get_buffer") and isinstance(self.raw_text_view.get_buffer(), GtkSource.Buffer) ) def _create_diff_tags(self): """Create diff highlighting tags with appropriate colors based on editor type.""" if self._is_source_view(): self.tag_add = self.buffer.create_tag( "added", background_rgba=Gdk.RGBA(0.2, 0.4, 0.2, 0.3) ) self.tag_removed = self.buffer.create_tag( "removed", background_rgba=Gdk.RGBA(0.4, 0.2, 0.2, 0.3) ) self.tag_changed = self.buffer.create_tag( "changed", background_rgba=Gdk.RGBA(0.4, 0.4, 0.2, 0.3) ) else: self.tag_add = self.buffer.create_tag( "added", background="#aaffaa", foreground="black" ) self.tag_removed = self.buffer.create_tag( "removed", background="#ffaaaa", foreground="black" ) self.tag_changed = self.buffer.create_tag( "changed", background="#ffffaa", foreground="black" ) def _apply_full_diff_highlighting(self, current_lines, original_lines): """Apply full diff highlighting for regular TextView.""" s = difflib.SequenceMatcher(None, original_lines, current_lines) for opcode, i1, i2, j1, j2 in s.get_opcodes(): if opcode == "equal": pass elif opcode == "insert": for line_idx in range(j1, j2): if line_idx >= len(current_lines): continue success, start_iter = self.buffer.get_iter_at_line(line_idx) if not success: continue end_iter = start_iter.copy() end_iter.forward_to_line_end() self.buffer.apply_tag(self.tag_add, start_iter, end_iter) elif opcode == "delete": pass elif opcode == "replace": for line_idx in range(j1, j2): if line_idx >= len(current_lines): continue success, start_iter = self.buffer.get_iter_at_line(line_idx) if not success: continue end_iter = start_iter.copy() end_iter.forward_to_line_end() self.buffer.apply_tag(self.tag_changed, start_iter, end_iter) def _apply_subtle_diff_highlighting(self, current_lines, original_lines): """Apply subtle diff highlighting for GtkSourceView that doesn't conflict with syntax highlighting.""" s = difflib.SequenceMatcher(None, original_lines, current_lines) for opcode, i1, i2, j1, j2 in s.get_opcodes(): if opcode == "equal": pass elif opcode == "insert": for line_idx in range(j1, j2): if line_idx >= len(current_lines): continue success, start_iter = self.buffer.get_iter_at_line(line_idx) if not success: continue end_iter = start_iter.copy() end_iter.forward_to_line_end() self.buffer.apply_tag(self.tag_add, start_iter, end_iter) elif opcode == "delete": pass elif opcode == "replace": for line_idx in range(j1, j2): if line_idx >= len(current_lines): continue success, start_iter = self.buffer.get_iter_at_line(line_idx) if not success: continue end_iter = start_iter.copy() end_iter.forward_to_line_end() self.buffer.apply_tag(self.tag_changed, start_iter, end_iter) def _setup_syntax_highlighting(self): """Setup syntax highlighting for the raw text editor.""" if not self.raw_text_view: return try: source_buffer = self.raw_text_view.get_buffer() if not source_buffer: return self.buffer = source_buffer if not isinstance(source_buffer, GtkSource.Buffer): return language_manager = GtkSource.LanguageManager.get_default() ssh_language_ids = [ "ssh-config", "ssh_config", "sshconfig", "ssh", "config", "ini", ] language = None for lang_id in ssh_language_ids: language = language_manager.get_language(lang_id) if language: break if not language: language = language_manager.get_language( "ini" ) or language_manager.get_language("config") if language: source_buffer.set_language(language) style_manager = GtkSource.StyleSchemeManager.get_default() style_scheme = ( style_manager.get_scheme("dark") or style_manager.get_scheme("Adwaita-dark") or style_manager.get_scheme("default") ) if style_scheme: source_buffer.set_style_scheme(style_scheme) except Exception as e: print(f"Warning: Could not setup syntax highlighting: {e}") pass SSH-Studio-1.3.1/src/ui/host_list.py000066400000000000000000000651071506556307300172270ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, GObject, Adw, Gdk, GLib from gettext import gettext as _ try: from ssh_studio.ssh_config_parser import SSHHost, SSHOption except ImportError: from ssh_config_parser import SSHHost, SSHOption @Gtk.Template(resource_path="/io/github/BuddySirJava/SSH-Studio/ui/host_list.ui") class HostList(Gtk.Box): __gtype_name__ = "HostList" list_box = Gtk.Template.Child() host_stack = Gtk.Template.Child() empty_page = Gtk.Template.Child() add_bottom_button = Gtk.Template.Child() search_button = Gtk.Template.Child() undo_button = Gtk.Template.Child() search_bar = Gtk.Template.Child() search_entry = Gtk.Template.Child() __gsignals__ = { "host-selected": (GObject.SignalFlags.RUN_LAST, None, (object,)), "host-added": (GObject.SignalFlags.RUN_LAST, None, (object,)), "host-deleted": (GObject.SignalFlags.RUN_LAST, None, (object,)), "hosts-reordered": (GObject.SignalFlags.RUN_LAST, None, (object,)), "undo-clicked": (GObject.SignalFlags.RUN_LAST, None, ()), } def __init__(self): super().__init__() self.hosts = [] self.filtered_hosts = [] self.current_filter = "" self._selected_host = None self._dragging_host = None self._order_before_drag = None self._dnd_hover_row = None self._connect_signals() self.list_store = Gtk.ListStore(str, str, str, str, str, object) if hasattr(self, "tree_view") and self.tree_view is not None: self.tree_view.set_model(self.list_store) self._setup_columns() self._rebuild_listbox_rows() self._update_bottom_toolbar_sensitivity() def _setup_columns(self): def add_text_column( title: str, col_index: int, expand: bool = False, min_width: int | None = None, ): renderer = Gtk.CellRendererText() renderer.set_property("ypad", 6) renderer.set_property("xpad", 8) column = Gtk.TreeViewColumn(title, renderer, text=col_index) if expand: column.set_expand(True) if min_width is not None: column.set_min_width(min_width) self.tree_view.append_column(column) add_text_column(_("Host"), 0, True, 120) add_text_column(_("HostName"), 1, True, 150) add_text_column(_("User"), 2, False, 80) add_text_column(_("Port"), 3, False, 60) add_text_column(_("Identity"), 4, True, 120) def _connect_signals(self): if hasattr(self, "tree_view") and self.tree_view is not None: selection = self.tree_view.get_selection() selection.connect("changed", self._on_selection_changed) if hasattr(self, "list_box") and self.list_box is not None: self.list_box.connect("row-selected", self._on_row_selected) try: drop_target = Gtk.DropTarget.new( GObject.TYPE_STRING, Gdk.DragAction.MOVE ) drop_target.connect("drop", self._on_listbox_drop) try: drop_target.connect("motion", self._on_listbox_motion) except Exception: pass try: drop_target.connect("accept", lambda *args: True) except Exception: pass self.list_box.add_controller(drop_target) except Exception: pass try: if self.add_bottom_button: self.add_bottom_button.connect("clicked", lambda b: self.add_host()) except Exception: pass try: if self.search_button: self.search_button.connect("clicked", self._on_search_button_clicked) except Exception: pass try: if self.undo_button: self.undo_button.connect( "clicked", lambda *_: self.emit("undo-clicked") ) self.undo_button.set_sensitive(False) except Exception: pass try: if self.search_entry: self.search_entry.connect("search-changed", self._on_search_changed) except Exception: pass def load_hosts(self, hosts: list): self.hosts = hosts self.filtered_hosts = hosts.copy() self._refresh_view() self._update_empty_state() def filter_hosts(self, query: str): self.current_filter = query.lower() if not query: self.filtered_hosts = self.hosts.copy() else: self.filtered_hosts = [] for host in self.hosts: searchable_text = ( " ".join(host.patterns) + " " + (host.get_option("HostName") or "") + " " + (host.get_option("User") or "") + " " + (host.get_option("IdentityFile") or "") ).lower() if self.current_filter in searchable_text: self.filtered_hosts.append(host) self._refresh_view() self._update_empty_state() def _refresh_view(self): if hasattr(self, "tree_view") and self.tree_view is not None: selection = self.tree_view.get_selection() model, selected_iter = selection.get_selected() else: model = self.list_store selected_iter = None previously_selected_host = None if selected_iter: previously_selected_host = model.get_value(selected_iter, 5) self.list_store.clear() for host in self.filtered_hosts: host_patterns = ", ".join(host.patterns) hostname = host.get_option("HostName") or "" user = host.get_option("User") or "" port = host.get_option("Port") or "" identity_file = host.get_option("IdentityFile") or "" self.list_store.append( [host_patterns, hostname, user, port, identity_file, host] ) if hasattr(self, "list_box") and self.list_box is not None: self._rebuild_listbox_rows() if previously_selected_host: self.select_host(previously_selected_host) def _update_empty_state(self): try: has_hosts = bool(self.hosts) if self.empty_page: self.empty_page.set_visible(not has_hosts) if self.list_box: self.list_box.set_visible(has_hosts) except Exception: pass def _on_selection_changed(self, selection): model, tree_iter = selection.get_selected() if tree_iter: host = model.get_value(tree_iter, 5) self.emit("host-selected", host) self._update_bottom_toolbar_sensitivity() def _on_duplicate_host_clicked(self, button, host): """Handle duplicate host button click from an ActionRow.""" self.duplicate_host(host) def _on_delete_host_clicked(self, button, host): """Handle delete host button click from an ActionRow.""" self.delete_host(host) def add_host(self): """Add a new host.""" new_host = SSHHost(patterns=["new-host"]) self.emit("host-added", new_host) self.filter_hosts(self.current_filter) self.select_host(new_host) def duplicate_host(self, original_host: SSHHost = None): """Duplicate the selected host.""" if original_host is None: original_host = self._get_selected_host() if original_host is not None: duplicated_host = self._duplicate_host(original_host) self.emit("host-added", duplicated_host) self.filter_hosts(self.current_filter) self.select_host(duplicated_host) def delete_host(self, host_to_delete: SSHHost = None): """Delete the selected host.""" if host_to_delete is None: host_to_delete = self._get_selected_host() if host_to_delete is not None: title = _("Delete host?") body = _(f"Delete host '{', '.join(host_to_delete.patterns)}'?") dialog = Adw.AlertDialog.new(title, body) dialog.add_response("cancel", _("Cancel")) dialog.add_response("delete", _("Delete")) try: dialog.set_response_appearance( "delete", Adw.ResponseAppearance.DESTRUCTIVE ) dialog.set_default_response("cancel") dialog.set_close_response("cancel") except Exception: pass def on_response(dlg, response): if response == "delete": self.emit("host-deleted", host_to_delete) if host_to_delete in self.hosts: self.hosts.remove(host_to_delete) if host_to_delete in self.filtered_hosts: self.filtered_hosts.remove(host_to_delete) self._refresh_view() dialog.connect("response", on_response) try: dialog.present(self.get_root()) except Exception: dialog.present(None) def _duplicate_host(self, original_host: SSHHost) -> SSHHost: duplicated_host = SSHHost() duplicated_host.patterns = [ f"{pattern}-copy" for pattern in original_host.patterns ] for option in original_host.options: duplicated_option = SSHOption( key=option.key, value=option.value, indentation=option.indentation ) duplicated_host.options.append(duplicated_option) return duplicated_host def select_host(self, host: SSHHost): for index, row in enumerate(self.list_store): if row[5] == host: if hasattr(self, "tree_view") and self.tree_view is not None: tree_iter = self.list_store.iter_nth_child(None, index) if tree_iter is None: return selection = self.tree_view.get_selection() selection.select_iter(tree_iter) path = self.list_store.get_path(tree_iter) if path is not None: self.tree_view.scroll_to_cell(path, None, False, 0, 0) elif hasattr(self, "list_box") and self.list_box is not None: row_widget = self.list_box.get_row_at_index(index) if row_widget is not None: self.list_box.select_row(row_widget) try: row_widget.grab_focus() except Exception: pass break def get_selected_host(self) -> SSHHost | None: """Get the currently selected host.""" if hasattr(self, "tree_view") and self.tree_view is not None: selection = self.tree_view.get_selection() model, tree_iter = selection.get_selected() if tree_iter is not None: return model[tree_iter][5] elif hasattr(self, "list_box") and self.list_box is not None: selected_row = self.list_box.get_selected_row() if selected_row is not None and hasattr(selected_row, "_host_ref"): return selected_row._host_ref return None def navigate_with_key(self, keyval, state): """Handle keyboard navigation in the host list.""" if not self.filtered_hosts: return False current_index = self._get_current_selection_index() if current_index is None: current_index = 0 new_index = current_index if keyval == Gdk.KEY_Up: new_index = max(0, current_index - 1) elif keyval == Gdk.KEY_Down: new_index = min(len(self.filtered_hosts) - 1, current_index + 1) elif keyval == Gdk.KEY_Home: new_index = 0 elif keyval == Gdk.KEY_End: new_index = len(self.filtered_hosts) - 1 elif keyval == Gdk.KEY_Page_Up: new_index = max(0, current_index - 10) elif keyval == Gdk.KEY_Page_Down: new_index = min(len(self.filtered_hosts) - 1, current_index + 10) else: return False if new_index != current_index and new_index < len(self.filtered_hosts): host = self.filtered_hosts[new_index] self.select_host(host) return True return False def _get_current_selection_index(self) -> int | None: """Get the index of the currently selected host in filtered_hosts.""" selected_host = self.get_selected_host() if selected_host is None: return None try: return self.filtered_hosts.index(selected_host) except ValueError: return None def _rebuild_listbox_rows(self): if not hasattr(self, "list_box") or self.list_box is None: return while (child := self.list_box.get_first_child()) is not None: self.list_box.remove(child) for row in self.list_store: host = row[5] patterns = row[0] hostname = row[1] user = row[2] action_row = Adw.ActionRow() action_row.set_title(patterns) secondary = ( f"{user}@{hostname}" if (hostname or user) else ("") ) action_row.set_subtitle(secondary) action_row.set_selectable(True) action_row.set_activatable(True) action_row._host_ref = host try: button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) except Exception: button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) grip_button = Gtk.Button() try: grip_button.set_icon_name("list-drag-handle-symbolic") except Exception: grip_button.set_icon_name("open-menu-symbolic") grip_button.set_tooltip_text(_("Drag to reorder")) grip_button.set_margin_top(8) grip_button.set_margin_bottom(8) grip_button.add_css_class("flat") try: grip_button.set_visible(False) except Exception: pass try: drag_source = Gtk.DragSource() drag_source.set_actions(Gdk.DragAction.MOVE) def _on_drag_begin( src, drag, host_ref=host, patterns_text=patterns, secondary_text=secondary, ): self._dragging_host = host_ref self._order_before_drag = list(self.hosts) try: icon = Gtk.DragIcon.get_for_drag(drag) preview = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=2 ) lbl_title = Gtk.Label(label=patterns_text) lbl_title.set_xalign(0) try: lbl_title.add_css_class("title-4") except Exception: pass lbl_sub = Gtk.Label(label=secondary_text) lbl_sub.set_xalign(0) try: lbl_sub.add_css_class("dim-label") except Exception: pass preview.append(lbl_title) preview.append(lbl_sub) preview.set_margin_start(8) preview.set_margin_end(8) preview.set_margin_top(6) preview.set_margin_bottom(6) try: preview.add_css_class("card") except Exception: pass icon.set_child(preview) try: icon.set_hotspot(8, 8) except Exception: pass except Exception: pass def _on_drag_end(src, drag, delete_data): self._dragging_host = None def _on_prepare(src, x, y, host_ref=host): try: alias = ", ".join(host_ref.patterns) or "host" return Gdk.ContentProvider.new_for_value(alias) except Exception: try: bytes_utf8 = GLib.Bytes.new( (", ".join(host_ref.patterns) or "host").encode("utf-8") ) return Gdk.ContentProvider.new_for_bytes( "text/plain;charset=utf-8", bytes_utf8 ) except Exception: return None drag_source.connect("drag-begin", _on_drag_begin) drag_source.connect("drag-end", _on_drag_end) drag_source.connect("prepare", _on_prepare) grip_button.add_controller(drag_source) except Exception: pass try: action_row.add_prefix(grip_button) except Exception: pass action_row._grip_button = grip_button duplicate_button = Gtk.Button() duplicate_button.set_icon_name("edit-copy-symbolic") duplicate_button.set_tooltip_text(_("Duplicate Host")) duplicate_button.add_css_class("flat") duplicate_button.set_margin_top(8) duplicate_button.set_margin_bottom(8) duplicate_button.set_visible(False) duplicate_button.connect("clicked", self._on_duplicate_host_clicked, host) delete_button = Gtk.Button() delete_button.set_icon_name("edit-delete-symbolic") delete_button.set_tooltip_text(_("Delete Host")) delete_button.add_css_class("flat") delete_button.add_css_class("destructive-action") delete_button.set_margin_top(8) delete_button.set_margin_bottom(8) delete_button.set_visible(False) delete_button.connect("clicked", self._on_delete_host_clicked, host) button_box.append(duplicate_button) button_box.append(delete_button) action_row._duplicate_button = duplicate_button action_row._delete_button = delete_button action_row.add_suffix(button_box) self.list_box.append(action_row) def _on_listbox_drop(self, drop_target, value, x, y): source_host = self._dragging_host if source_host is None: return False try: y_int = int(y) target_row = None try: target_row = self.list_box.get_row_at_y(y_int) except Exception: target_row = None if target_row is not None: row_idx = self._get_row_index_from_widget(target_row) alloc = target_row.get_allocation() row_top = getattr(alloc, "y", 0) row_height = getattr(alloc, "height", 0) after = (y_int - row_top) >= int(row_height * 0.55) dest_index_filtered = row_idx + (1 if after else 0) else: dest_index_filtered = self._get_insert_index_from_y(y_int) if dest_index_filtered >= len(self.filtered_hosts): dest_index_base = len(self.hosts) else: dest_host_at_pos = self.filtered_hosts[dest_index_filtered] dest_index_base = self.hosts.index(dest_host_at_pos) if source_host not in self.hosts: return False source_index_base = self.hosts.index(source_host) if ( dest_index_base == source_index_base or dest_index_base == source_index_base + 1 ): return True host_obj = self.hosts.pop(source_index_base) if dest_index_base > source_index_base: dest_index_base -= 1 self.hosts.insert(dest_index_base, host_obj) self.filter_hosts(self.current_filter) try: self.select_host(source_host) except Exception: pass prev = self._order_before_drag or [] self._order_before_drag = None self.emit("hosts-reordered", prev) return True except Exception: return False def _get_row_index_from_widget(self, row_widget) -> int: try: idx = 0 child = self.list_box.get_first_child() while child is not None: if child is row_widget: return idx idx += 1 child = child.get_next_sibling() return idx except Exception: return 0 def _on_listbox_motion(self, drop_target, x, y): try: scroller = self._find_scroller() if not scroller: return Gdk.DragAction.MOVE vadj = scroller.get_vadjustment() if not vadj: return Gdk.DragAction.MOVE height = scroller.get_allocated_height() edge = 28 step = max( 12, ( int(vadj.get_page_increment() * 0.15) if hasattr(vadj, "get_page_increment") else 20 ), ) y_int = int(y) new_val = vadj.get_value() if y_int < edge: new_val = max(vadj.get_lower(), vadj.get_value() - step) elif y_int > height - edge: upper = vadj.get_upper() - vadj.get_page_size() new_val = min(upper, vadj.get_value() + step) if new_val != vadj.get_value(): vadj.set_value(new_val) except Exception: pass return Gdk.DragAction.MOVE def _find_scroller(self): try: w = self.list_box while w is not None and not isinstance(w, Gtk.ScrolledWindow): w = w.get_parent() return w except Exception: return None def _get_insert_index_from_y(self, y: int) -> int: """Return the filtered index at which to insert based on y position. Inserts before the row whose midpoint is below y; append at end otherwise. """ try: index = 0 child = self.list_box.get_first_child() while child is not None: alloc = child.get_allocation() row_top = getattr(alloc, "y", 0) row_height = getattr(alloc, "height", 0) row_mid = row_top + (row_height // 2) if y < row_mid: return index index += 1 child = child.get_next_sibling() return index except Exception: return len(self.filtered_hosts) def _on_row_selected(self, listbox, row): self._hide_all_row_buttons() if row is None: self._update_bottom_toolbar_sensitivity() return host = getattr(row, "_host_ref", None) if host is not None: self._selected_host = host self.emit("host-selected", host) self._show_row_buttons(row) self._update_bottom_toolbar_sensitivity() def _hide_all_row_buttons(self): """Hide buttons on all ActionRows.""" if not hasattr(self, "list_box") or self.list_box is None: return child = self.list_box.get_first_child() while child is not None: if hasattr(child, "_duplicate_button") and hasattr(child, "_delete_button"): child._duplicate_button.set_visible(False) child._delete_button.set_visible(False) if hasattr(child, "_grip_button"): try: child._grip_button.set_visible(False) except Exception: pass child = child.get_next_sibling() def _show_row_buttons(self, row): """Show buttons on the specified ActionRow.""" if hasattr(row, "_duplicate_button") and hasattr(row, "_delete_button"): row._duplicate_button.set_visible(True) row._delete_button.set_visible(True) if hasattr(row, "_grip_button"): try: row._grip_button.set_visible(True) except Exception: pass def _get_selected_host(self): if hasattr(self, "list_box") and self.list_box is not None: try: row = self.list_box.get_selected_row() except Exception: row = None if row is not None: host = getattr(row, "_host_ref", None) if host is not None: return host if hasattr(self, "tree_view") and self.tree_view is not None: selection = self.tree_view.get_selection() model, tree_iter = selection.get_selected() if tree_iter: return model.get_value(tree_iter, 5) return self._selected_host def _update_bottom_toolbar_sensitivity(self): pass def set_undo_enabled(self, enabled: bool): """Enable or disable the header undo button.""" try: if self.undo_button: self.undo_button.set_sensitive(bool(enabled)) except Exception: pass def _on_search_button_clicked(self, button): """Toggle search bar visibility when search button is clicked.""" if self.search_bar: is_visible = self.search_bar.get_visible() self.search_bar.set_visible(not is_visible) if not is_visible: if self.search_entry: self.search_entry.grab_focus() else: if self.search_entry: self.search_entry.set_text("") self.filter_hosts("") def _on_search_changed(self, entry): """Handle search query changes.""" try: text = entry.get_text() except Exception: text = "" self.filter_hosts(text) SSH-Studio-1.3.1/src/ui/key_picker_dialog.py000066400000000000000000000051171506556307300206560ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GObject from gettext import gettext as _ from pathlib import Path @Gtk.Template( resource_path="/io/github/BuddySirJava/SSH-Studio/ui/key_picker_dialog.ui" ) class KeyPickerDialog(Adw.Dialog): __gtype_name__ = "KeyPickerDialog" __gsignals__ = { "key-selected": (GObject.SignalFlags.RUN_LAST, None, (str,)), } toast_overlay = Gtk.Template.Child() public_list = Gtk.Template.Child() cancel_btn = Gtk.Template.Child() use_btn = Gtk.Template.Child() generate_btn = Gtk.Template.Child() def __init__(self, parent): super().__init__() self.cancel_btn.connect("clicked", lambda *_: self.close()) self.use_btn.connect("clicked", self._on_use) self.public_list.connect("row-selected", self._on_selection_changed) self._load_keys() def _load_keys(self): ssh_dir = Path.home() / ".ssh" for lst in (self.public_list,): row = lst.get_first_child() while row is not None: nxt = row.get_next_sibling() lst.remove(row) row = nxt if ssh_dir.exists(): for path in sorted(ssh_dir.iterdir()): if not path.is_file(): continue name = path.name if name in {"config", "known_hosts", "authorized_keys"}: continue if name.endswith(".pub"): self.public_list.append(self._row_for(path)) self._update_use_sensitivity() def _row_for(self, path: Path): row = Gtk.ListBoxRow() action = Adw.ActionRow() action.set_title(path.name) action.set_subtitle(str(path)) action.set_activatable(True) row.set_child(action) row.path_value = str(path) return row def _on_selection_changed(self, *_): self._update_use_sensitivity() def _update_use_sensitivity(self): has_sel = bool(self.public_list.get_selected_row()) try: self.use_btn.set_sensitive(has_sel) except Exception: pass def _on_use(self, *_): row = self.public_list.get_selected_row() if row and hasattr(row, "path_value"): path = Path(row.path_value) private = path.with_suffix("") if path.suffix == ".pub" else path self.selected_path = str(private) try: self.emit("key-selected", self.selected_path) finally: self.close() SSH-Studio-1.3.1/src/ui/keyboard_shortcuts_dialog.py000066400000000000000000000027401506556307300224460ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, Gdk from gettext import gettext as _ @Gtk.Template( resource_path="/io/github/BuddySirJava/SSH-Studio/ui/keyboard_shortcuts_dialog.ui" ) class KeyboardShortcutsDialog(Adw.Dialog): """Dialog showing keyboard shortcuts for SSH Studio.""" __gtype_name__ = "KeyboardShortcutsDialog" def __init__(self, parent=None): super().__init__() self._parent = parent try: self.set_title(_("Keyboard Shortcuts")) except Exception: pass try: self.set_default_size(680, 250) except Exception: pass try: if hasattr(self, "set_content_width"): self.set_content_width(680) if hasattr(self, "set_content_height"): self.set_content_height(540) except Exception: pass self._setup_keyboard_shortcuts() def _setup_keyboard_shortcuts(self): """Setup keyboard shortcuts for the shortcuts dialog.""" key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) def _on_key_pressed(self, controller, keyval, keycode, state): """Handle key presses in the shortcuts dialog.""" if keyval == Gdk.KEY_Escape: self.close() return True return False SSH-Studio-1.3.1/src/ui/main_window.py000066400000000000000000001013551506556307300175260ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Gio, Gdk, Adw, GLib from pathlib import Path from gettext import gettext as _ import sys from .host_list import HostList from .host_editor import HostEditor from .welcome_view import WelcomeView from gi.repository import Gio as _Gio @Gtk.Template(resource_path="/io/github/BuddySirJava/SSH-Studio/ui/main_window.ui") class MainWindow(Adw.ApplicationWindow): """Main application window for SSH-Studio.""" __gtype_name__ = "MainWindow" main_box = Gtk.Template.Child() toast_overlay = Gtk.Template.Child() split_view = Gtk.Template.Child() content_nav = Gtk.Template.Child() host_list = Gtk.Template.Child() host_editor = Gtk.Template.Child() welcome_view = Gtk.Template.Child() def __init__(self, app): super().__init__( application=app, ) self.app = app self.parser = app.parser self.is_dirty = False self._raw_wrap_lines = False self._original_width = -1 self._original_height = -1 self._last_reorder_previous = None try: if hasattr(self, "host_editor") and self.host_editor is not None: self.host_editor.set_app(self) except Exception: pass self._connect_signals() self._load_preferences() try: if hasattr(self.app, "_parse_config_async"): self.app._parse_config_async() except Exception: pass self.connect("notify::has-focus", self._on_window_focus_changed) self.connect("close-request", self._on_close_request) self._show_welcome_view() try: key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) except Exception: pass def _set_host_editor_visible(self, visible): if visible: if self._original_width == -1: self._original_width = self.get_width() self._original_height = self.get_height() self.set_default_size(1300, self._original_height) elif self._original_width != -1: self.set_default_size(self._original_width, self._original_height) self._original_width = -1 self._original_height = -1 self.host_editor.set_visible(visible) if not visible: try: self._show_welcome_view() except Exception: pass def _load_preferences(self): """Load preferences from the saved file and apply them to the window.""" try: from .preferences_dialog import PreferencesDialog temp_dialog = PreferencesDialog(self) prefs = temp_dialog.get_preferences() temp_dialog.destroy() if prefs.get("editor_font_size"): self._editor_font_size = int(prefs["editor_font_size"]) if "prefer_dark_theme" in prefs: self._prefer_dark_theme = bool(prefs["prefer_dark_theme"]) if "raw_wrap_lines" in prefs: self._raw_wrap_lines = bool(prefs["raw_wrap_lines"]) if hasattr(self, "_prefer_dark_theme") and self._prefer_dark_theme: try: from gi.repository import Adw style_manager = Adw.StyleManager.get_default() style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK) except Exception: pass except Exception: self._editor_font_size = 12 self._prefer_dark_theme = False self._raw_wrap_lines = False def show_toast(self, message: str): """Show a transient toast using Adw.ToastOverlay.""" try: toast = Adw.Toast.new(message) try: toast.set_timeout(3) except Exception: pass if hasattr(self, "toast_overlay") and self.toast_overlay is not None: self.toast_overlay.add_toast(toast) except Exception: pass def _show_undo_toast(self, message: str, on_undo): """Show a toast with an Undo action; executes on_undo when clicked.""" try: toast = Adw.Toast.new(message) if hasattr(toast, "set_button_label"): try: toast.set_button_label(_("Undo")) except Exception: pass if hasattr(toast, "connect"): try: toast.connect("button-clicked", lambda t: on_undo()) except Exception: pass if hasattr(self, "toast_overlay") and self.toast_overlay is not None: self.toast_overlay.add_toast(toast) else: self.show_toast(message) except Exception: self.show_toast(message) def _setup_split_view(self): """Set up the split view between host list and editor.""" self.host_list = HostList() self.host_editor = HostEditor() try: self.host_editor.set_app(self.app) except Exception: return paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) paned.set_start_child(self.host_list) paned.set_end_child(self.host_editor) paned.set_position(400) self.main_box.append(paned) def _connect_signals(self): """Connect all the signal handlers.""" self.host_list.connect("host-selected", self._on_host_selected) self.host_list.connect("host-added", self._on_host_added) self.host_list.connect("host-deleted", self._on_host_deleted) self.host_list.connect("hosts-reordered", self._on_hosts_reordered) self.host_list.connect("undo-clicked", self._on_undo_clicked) self.host_editor.connect("host-changed", self._on_host_changed) self.host_editor.connect("host-save", self._on_host_save) self.host_editor.connect( "editor-validity-changed", self._on_editor_validity_changed ) self.host_editor.connect("show-toast", self._on_show_toast) self.host_list.search_entry.connect("search-changed", self._on_search_changed) self._setup_actions() def _setup_actions(self): actions = Gio.SimpleActionGroup() open_action = Gio.SimpleAction.new("open-config", None) open_action.connect("activate", self._on_open_config) actions.add_action(open_action) save_action = Gio.SimpleAction.new("save", None) save_action.connect("activate", self._on_save_clicked) actions.add_action(save_action) reload_action = Gio.SimpleAction.new("reload", None) reload_action.connect("activate", self._on_reload) actions.add_action(reload_action) add_host_action = Gio.SimpleAction.new("add-host", None) add_host_action.connect( "activate", lambda a, p: ( self.host_editor._on_add_clicked(None) if hasattr(self.host_editor, "_on_add_clicked") else None ), ) actions.add_action(add_host_action) duplicate_host_action = Gio.SimpleAction.new("duplicate-host", None) duplicate_host_action.connect( "activate", lambda a, p: ( self.host_editor._on_duplicate_clicked(None) if hasattr(self.host_editor, "_on_duplicate_clicked") else None ), ) actions.add_action(duplicate_host_action) delete_host_action = Gio.SimpleAction.new("delete-host", None) delete_host_action.connect( "activate", lambda a, p: ( self.host_editor._on_delete_clicked(None) if hasattr(self.host_editor, "_on_delete_clicked") else None ), ) actions.add_action(delete_host_action) search_action = Gio.SimpleAction.new("search", None) search_action.connect("activate", self._on_search_action) actions.add_action(search_action) prefs_action = Gio.SimpleAction.new("preferences", None) prefs_action.connect("activate", self._on_preferences) actions.add_action(prefs_action) manage_keys_action = Gio.SimpleAction.new("manage-keys", None) manage_keys_action.connect("activate", self._on_manage_keys) actions.add_action(manage_keys_action) about_action = Gio.SimpleAction.new("about", None) about_action.connect("activate", self._on_about) actions.add_action(about_action) keyboard_shortcuts_action = Gio.SimpleAction.new("keyboard-shortcuts", None) keyboard_shortcuts_action.connect("activate", self._on_keyboard_shortcuts) actions.add_action(keyboard_shortcuts_action) self.insert_action_group("app", actions) def _on_search_action(self, action, param): """Handle search action.""" self._toggle_search() def _on_key_pressed(self, controller, keyval, keycode, state): ctrl_pressed = bool(state & Gdk.ModifierType.CONTROL_MASK) if ctrl_pressed: if keyval == Gdk.KEY_o: self._on_open_config(None, None) return True elif keyval == Gdk.KEY_s: self._on_save_clicked(None) return True elif keyval == Gdk.KEY_r: self._on_reload(None, None) return True elif keyval == Gdk.KEY_n: if hasattr(self.host_editor, "_on_add_clicked"): self.host_editor._on_add_clicked(None) return True elif keyval == Gdk.KEY_d: if hasattr(self.host_editor, "_on_duplicate_clicked"): self.host_editor._on_duplicate_clicked(None) return True elif keyval == Gdk.KEY_f: self._toggle_search() return True elif keyval == Gdk.KEY_comma: self._on_preferences(None, None) return True elif keyval == Gdk.KEY_k: self._on_manage_keys(None, None) return True if ctrl_pressed and keyval == Gdk.KEY_Delete: if hasattr(self.host_editor, "_on_delete_clicked"): self.host_editor._on_delete_clicked(None) return True if keyval == Gdk.KEY_Escape: if self.host_list.search_bar.get_visible(): try: focus_widget = None try: focus_widget = self.get_focus() except Exception: focus_widget = None def _is_descendant(widget, ancestor): try: while widget is not None: if widget == ancestor: return True widget = widget.get_parent() except Exception: pass return False if not _is_descendant(focus_widget, self.host_list.search_bar): return False self.host_list.search_bar.set_visible(False) except Exception: self.host_list.search_bar.set_visible(False) self.host_list.search_entry.set_text("") self.host_list.filter_hosts("") return True return False if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter: if hasattr(self.host_list, "get_selected_host"): selected_host = self.host_list.get_selected_host() if selected_host: self.host_editor.load_host(selected_host) self._set_host_editor_visible(True) return True return False if keyval == Gdk.KEY_F2: if hasattr(self.host_list, "get_selected_host"): selected_host = self.host_list.get_selected_host() if selected_host: self.host_editor.load_host(selected_host) self._set_host_editor_visible(True) return True return False if keyval in [ Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Home, Gdk.KEY_End, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down, ]: if hasattr(self.host_list, "navigate_with_key"): return self.host_list.navigate_with_key(keyval, state) return False return False def _on_escape_pressed(self, shortcut): """Handle Escape key press - close search bar if visible.""" if self.host_list.search_bar.get_visible(): self.host_list.search_entry.set_text("") self.host_list.search_bar.set_visible(False) self.host_list.filter_hosts("") def _load_config(self): """Trigger async config parsing via the application to keep UI responsive.""" try: if hasattr(self.app, "_parse_config_async"): self.app._parse_config_async() except Exception as e: self._show_error(f"Failed to trigger config reload: {e}") def _reselect_current_host(self): """Reselect and reload the previously selected host after model changes.""" try: target_host = None try: target_host = self.host_list.get_selected_host() except Exception: target_host = None if not target_host and hasattr(self.host_editor, "current_host"): target_host = self.host_editor.current_host target_alias = None try: if target_host and getattr(target_host, "patterns", None): if len(target_host.patterns) > 0: target_alias = target_host.patterns[0] except Exception: target_alias = None selected = None if self.parser and self.parser.config and self.parser.config.hosts: if target_alias: for h in self.parser.config.hosts: try: if target_alias in h.patterns: selected = h break except Exception: continue if selected is None: selected = self.parser.config.hosts[0] self.host_list.select_host(selected) self.host_editor.load_host(selected) self._set_host_editor_visible(True) except Exception: pass def _toggle_search(self, force=None): try: make_visible = True if force is None else bool(force) self.host_list.search_bar.set_visible(make_visible) if make_visible: self.host_list.search_entry.grab_focus() else: self.host_list.search_entry.set_text("") self.host_list.filter_hosts("") except Exception: pass def _on_host_save(self, editor, host): """Handle host save signal from editor.""" self._on_save_clicked(None) def _on_window_focus_changed(self, window, param): """Hide search bar if window loses focus.""" if not self.get_has_focus() and self.host_list.search_bar.get_visible(): self.host_list.search_entry.set_text("") self.host_list.search_bar.set_visible(False) self.host_list.filter_hosts("") def _on_close_request(self, window): """Handle window close request - check for unsaved changes.""" if ( hasattr(self.host_editor, "is_host_dirty") and self.host_editor.is_host_dirty() ): return self._show_unsaved_changes_dialog() return False def _show_unsaved_changes_dialog(self): """Show alert dialog asking user what to do with unsaved changes.""" builder = Gtk.Builder.new_from_resource( "/io/github/BuddySirJava/SSH-Studio/ui/unsaved_changes_dialog.ui" ) dialog = builder.get_object("unsaved_changes_dialog") dialog.set_close_response("cancel") dialog.add_response("discard", _("Discard Changes")) dialog.add_response("cancel", _("Cancel")) dialog.add_response("save", _("Save & Quit")) dialog.set_response_appearance("discard", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) dialog.set_default_response("save") def on_response(dialog, response): if response == "save": try: if ( hasattr(self.host_editor, "unsaved_banner") and self.host_editor.unsaved_banner ): self.host_editor._on_save_clicked(None) dialog.close() GLib.timeout_add(200, self._delayed_close) except Exception as e: self.show_toast(_(f"Failed to save: {e}")) elif response == "discard": dialog.close() self.destroy() else: dialog.close() return True dialog.connect("response", on_response) dialog.present(self) return True def _delayed_close(self): """Close the window after a short delay to allow save to complete.""" self.destroy() return False def on_status_bar_close_clicked(self, button): pass def _on_save_clicked(self, button): if not self.parser: return try: errors = self.parser.validate() if errors: dialog = Gtk.MessageDialog( transient_for=self, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, text="Validation warnings", secondary_text="\n".join(errors), ) dialog.connect("response", lambda d, r: d.destroy()) dialog.present() self.parser.write(backup=True) self.parser.parse() self.host_list.load_hosts(self.parser.config.hosts) self.is_dirty = False try: self.host_list.set_undo_enabled(False) except Exception: pass self._update_status(_("Configuration saved successfully")) except Exception as e: self._show_error(f"Failed to save configuration: {e}") def _write_and_reload(self, show_status: bool = False): """Write the config to disk and reload UI without showing validation dialogs.""" if not self.parser: return try: self.parser.write(backup=True) self.parser.parse() self.host_list.load_hosts(self.parser.config.hosts) self.is_dirty = False try: self.host_list.set_undo_enabled(False) except Exception: pass if show_status: self._update_status(_("Configuration saved")) except Exception as e: self._show_error(f"Failed to save configuration: {e}") def _on_host_selected(self, host_list, host): """Handle host selection from the list.""" self.host_editor.load_host(host) self._set_host_editor_visible(True) try: self.host_editor._update_button_sensitivity() except Exception: pass if hasattr(self, "content_nav") and self.content_nav: try: pages = self.content_nav.get_pages() for page in pages: if hasattr(page, "get_tag") and page.get_tag() == "host-editor": self.content_nav.pop_to_page(page) return except Exception: pass self.content_nav.push_by_tag("host-editor") def _show_welcome_view(self): """Show the welcome view when no host is selected.""" if hasattr(self, "content_nav") and self.content_nav: try: page = None if hasattr(self.content_nav, "find_page"): try: page = self.content_nav.find_page("welcome") except Exception: page = None if page is None: try: pages = self.content_nav.get_pages() except Exception: pages = [] for p in pages: try: tag = p.get_tag() if hasattr(p, "get_tag") else getattr(p, "tag", None) except Exception: tag = None if tag == "welcome": page = p break if page is not None: self.content_nav.pop_to_page(page) else: self.content_nav.push_by_tag("welcome") except Exception: try: self.content_nav.push_by_tag("welcome") except Exception: pass def _on_host_added(self, host_list, host): if self.parser: base_pattern = "new-host" i = 0 new_pattern = base_pattern existing_patterns = { p for h in self.parser.config.hosts for p in h.patterns } while new_pattern in existing_patterns: i += 1 new_pattern = f"{base_pattern}-{i}" host.patterns = [new_pattern] host.raw_lines = [f"Host {new_pattern}"] self.parser.config.add_host(host) self.is_dirty = True def undo_add(): try: if host in self.parser.config.hosts: self.parser.config.remove_host(host) self.is_dirty = self.parser.config.is_dirty() self.host_list.load_hosts(self.parser.config.hosts) try: if not self.parser.config.hosts: self._set_host_editor_visible(False) except Exception: pass except Exception: pass self._show_undo_toast(_("Host added"), undo_add) self._set_host_editor_visible(True) self.host_editor.load_host(host) try: self.host_editor._update_button_sensitivity() except Exception: pass def _on_host_deleted(self, host_list, host): """Handle host deletion.""" if self.parser: try: original_index = self.parser.config.hosts.index(host) except ValueError: original_index = None self.parser.config.remove_host(host) self._write_and_reload(show_status=False) def undo_delete(): try: if original_index is None: self.parser.config.add_host(host) else: self.parser.config.hosts.insert(original_index, host) self._write_and_reload(show_status=False) try: self.host_list.select_host(host) self._set_host_editor_visible(True) self.host_editor.load_host(host) except Exception: pass except Exception: pass self._show_undo_toast(_("Host deleted"), undo_delete) if not self.parser.config.hosts: self.host_editor.current_host = None self.host_editor._clear_all_fields() self._set_host_editor_visible(False) self.is_dirty = False try: self.host_editor._update_button_sensitivity() except Exception: pass try: self._show_welcome_view() except Exception: pass else: self.host_list.select_host(self.parser.config.hosts[0]) def _on_host_changed(self, editor, host): self.is_dirty = self.parser.config.is_dirty() try: self.host_editor._update_button_sensitivity() except Exception: pass try: self.host_list.set_undo_enabled(True) except Exception: pass def _on_editor_validity_changed(self, editor, is_valid: bool): pass def _on_hosts_reordered(self, host_list, previous_order): """Handle drag-and-drop reordering from the host list.""" if not self.parser: return try: self.is_dirty = self.parser.config.is_dirty() self._last_reorder_previous = ( list(previous_order) if previous_order else None ) try: self.host_list.set_undo_enabled(True) except Exception: pass except Exception: pass def _on_undo_clicked(self, *_): """Undo button in header clicked: revert to last saved state.""" try: if self._last_reorder_previous is not None: self.parser.config.hosts = list(self._last_reorder_previous) self._last_reorder_previous = None self.host_list.load_hosts(self.parser.config.hosts) try: self._reselect_current_host() except Exception: pass elif self.parser: self.parser.parse() self.host_list.load_hosts(self.parser.config.hosts) try: self._reselect_current_host() except Exception: pass self.host_list.set_undo_enabled(False) except Exception: pass def _on_show_toast(self, editor, message: str): """Handle show-toast signal from host editor.""" self.show_toast(message) def _on_search_changed(self, entry): """Handle search query changes.""" try: text = entry.get_text() except Exception: text = "" self.host_list.filter_hosts(text) def _on_open_config(self, action, param): """Handle open config action.""" dialog = Gtk.FileChooserNative.new( title=_("Open SSH Config File"), parent=self, action=Gtk.FileChooserAction.OPEN, accept_label=_("Open"), cancel_label=_("Cancel"), ) def on_file_chooser_response(dlg, response_id): if response_id == Gtk.ResponseType.ACCEPT: file = dlg.get_file() if file: self.parser.config_path = Path(file.get_path()) self._load_config() dlg.destroy() dialog.connect("response", on_file_chooser_response) dialog.show() def _on_reload(self, action, param): """Handle reload action.""" self._load_config() def _on_manage_keys(self, action, param): """Open the SSH Key Manager dialog.""" from .ssh_key_manager_dialog import SSHKeyManagerDialog dialog = SSHKeyManagerDialog(self) dialog.present(self) def _on_preferences(self, action, param): """Handle preferences action.""" from .preferences_dialog import PreferencesDialog dialog = PreferencesDialog(self) current_prefs = { "config_path": str(self.parser.config_path) if self.parser else "", "backup_dir": str(getattr(self.parser, "backup_dir", "") or ""), "auto_backup": bool(getattr(self.parser, "auto_backup_enabled", True)), "editor_font_size": getattr(self, "_editor_font_size", 12), "prefer_dark_theme": getattr(self, "_prefer_dark_theme", False), "raw_wrap_lines": getattr(self, "_raw_wrap_lines", False), } dialog.set_preferences(current_prefs) def on_close_request(dlg): prefs = dlg.get_preferences() if self.parser: if prefs.get("config_path"): self.parser.config_path = Path(prefs["config_path"]) self.parser.auto_backup_enabled = bool(prefs.get("auto_backup", True)) backup_dir_val = prefs.get("backup_dir") or None self.parser.backup_dir = ( Path(backup_dir_val).expanduser() if backup_dir_val else None ) font_size = int(prefs.get("editor_font_size") or 12) self._editor_font_size = font_size try: provider = Gtk.CssProvider() provider.load_from_data( f".editor-pane textview {{font-size: {font_size}pt;}}".encode() ) Gtk.StyleContext.add_provider_for_display( Gtk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) except Exception: pass prefer_dark = bool(prefs.get("prefer_dark_theme", False)) self._prefer_dark_theme = prefer_dark try: style_manager = Adw.StyleManager.get_default() if style_manager is not None: style_manager.set_color_scheme( Adw.ColorScheme.PREFER_DARK if prefer_dark else Adw.ColorScheme.DEFAULT ) except Exception: pass raw_wrap = bool(prefs.get("raw_wrap_lines", False)) self._raw_wrap_lines = raw_wrap try: self.host_editor.set_wrap_mode(raw_wrap) except Exception: pass if self.parser: self._load_config() self._update_status(_("Preferences saved")) return False dialog.connect("close-attempt", on_close_request) dialog.present(self) def _on_keyboard_shortcuts(self, action, param): """Open the keyboard shortcuts dialog.""" from .keyboard_shortcuts_dialog import KeyboardShortcutsDialog dialog = KeyboardShortcutsDialog(self) dialog.present() def _on_about(self, action, param): """Show the about dialog using Adwaita's AboutWindow.""" about_window = Adw.AboutWindow( transient_for=self, application_name=_("SSH-Studio"), application_icon="io.github.BuddySirJava.SSH-Studio", version="1.3.1", developer_name=_("Made with ❤️ by Mahyar Darvishi"), website="https://github.com/BuddySirJava/ssh-studio", issue_url="https://github.com/BuddySirJava/ssh-studio/issues", developers=["Mahyar Darvishi"], copyright=_("© 2025 Mahyar Darvishi"), license_type=Gtk.License.MIT_X11, comments=_( "A native Python + GTK application for managing SSH configuration files" ), ) try: texture = Gdk.Texture.new_from_resource( "/io/github/BuddySirJava/SSH-Studio/media/icon_256.png" ) about_window.set_logo(texture) except Exception: pass about_window.set_debug_info( f""" SSH-Studio {about_window.get_version()} GTK {Gtk.get_major_version()}.{Gtk.get_minor_version()}.{Gtk.get_micro_version()} Adwaita {Adw.get_major_version()}.{Adw.get_minor_version()}.{Adw.get_micro_version()} Python {sys.version} """.strip() ) about_window.present() def _update_status(self, message: str): """Update the status bar with a message.""" self.show_toast(message) def _hide_status(self): """Hide the status bar.""" return False def _show_error(self, message: str): """Show an error message in the status bar.""" self.show_toast(message) def _show_warning(self, title: str, message: str): """Show a warning dialog.""" dialog = Gtk.MessageDialog( transient_for=self, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, text=title, secondary_text=message, ) dialog.present() SSH-Studio-1.3.1/src/ui/preferences_dialog.py000066400000000000000000000174441506556307300210400ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, Adw, GLib, Gdk from gettext import gettext as _ import os import json @Gtk.Template( resource_path="/io/github/BuddySirJava/SSH-Studio/ui/preferences_dialog.ui" ) class PreferencesDialog(Adw.PreferencesDialog): """Application preferences dialog using Adwaita components.""" __gtype_name__ = "SSHStudioPreferencesDialog" config_path_entry = Gtk.Template.Child() config_path_button = Gtk.Template.Child() backup_dir_entry = Gtk.Template.Child() backup_dir_button = Gtk.Template.Child() auto_backup_switch = Gtk.Template.Child() editor_font_spin = Gtk.Template.Child() dark_theme_switch = Gtk.Template.Child() raw_wrap_switch = Gtk.Template.Child() def __init__(self, parent): super().__init__() self._parent = parent try: self.set_title(_("Preferences")) except Exception: pass try: self.set_default_size(600, 500) except Exception: pass GLib.idle_add(self._connect_signals) GLib.idle_add(self._load_preferences_safely) def _connect_signals(self): self.config_path_button.connect("clicked", self._on_config_path_clicked) self.backup_dir_button.connect("clicked", self._on_backup_dir_clicked) self.connect("close-attempt", self._on_close_attempt) self.config_path_entry.connect("changed", self._on_entry_changed) self.backup_dir_entry.connect("changed", self._on_entry_changed) self.auto_backup_switch.connect("notify::active", self._on_switch_toggled) self.dark_theme_switch.connect("notify::active", self._on_switch_toggled) self.raw_wrap_switch.connect("notify::active", self._on_switch_toggled) self.editor_font_spin.connect("notify::value", self._on_spin_changed) self.editor_font_spin.get_adjustment().connect( "value-changed", self._on_spin_changed ) self._setup_keyboard_shortcuts() def _setup_keyboard_shortcuts(self): """Setup keyboard shortcuts for the preferences dialog.""" key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) def _on_key_pressed(self, controller, keyval, keycode, state): """Handle key presses in the preferences dialog.""" if keyval == Gdk.KEY_Escape: self.close() return True return False def _on_config_path_clicked(self, button): dialog = Gtk.FileChooserDialog( title=_("Choose SSH Config File"), action=Gtk.FileChooserAction.OPEN, ) dialog.set_transient_for(self) dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(_("Open"), Gtk.ResponseType.OK) dialog.connect("response", self._on_file_chooser_response) dialog.present() def _on_file_chooser_response(self, dialog, response_id): if response_id == Gtk.ResponseType.OK: filename = dialog.get_file().get_path() self.config_path_entry.set_text(filename) dialog.destroy() def _on_backup_dir_clicked(self, button): dialog = Gtk.FileChooserDialog( title=_("Choose Backup Directory"), action=Gtk.FileChooserAction.SELECT_FOLDER, ) dialog.set_transient_for(self) dialog.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) dialog.add_button(_("Select"), Gtk.ResponseType.OK) dialog.connect("response", self._on_backup_dir_response) dialog.present() def _on_backup_dir_response(self, dialog, response_id): if response_id == Gtk.ResponseType.OK: folder = dialog.get_file() if folder: self.backup_dir_entry.set_text(folder.get_path()) dialog.destroy() def _get_config_dir(self) -> str: base_dir = GLib.get_user_config_dir() return os.path.join(base_dir, "ssh-studio") def _get_prefs_path(self) -> str: return os.path.join(self._get_config_dir(), "preferences.json") def _ensure_config_dir(self) -> None: os.makedirs(self._get_config_dir(), exist_ok=True) def _set_default_preferences(self) -> None: """Set default preference values.""" import os if not hasattr(self, "config_path_entry") or self.config_path_entry is None: return default_ssh_config = os.path.join(self._get_config_dir(), "ssh_config") self.config_path_entry.set_text(default_ssh_config) default_backup = os.path.join(self._get_config_dir(), "backups") self.backup_dir_entry.set_text(default_backup) self.auto_backup_switch.set_active(True) self.dark_theme_switch.set_active(False) self.raw_wrap_switch.set_active(True) self.editor_font_spin.set_value(12.0) def _load_preferences_safely(self) -> None: if not hasattr(self, "config_path_entry") or self.config_path_entry is None: GLib.idle_add(self._load_preferences_safely) return try: path = self._get_prefs_path() if os.path.exists(path) and os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): self.set_preferences(data) else: self._set_default_preferences() else: self._set_default_preferences() except Exception: self._set_default_preferences() def _save_preferences_safely(self) -> None: try: self._ensure_config_dir() prefs = self.get_preferences() target_path = self._get_prefs_path() tmp_path = target_path + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(prefs, f, ensure_ascii=False, indent=2) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, target_path) except Exception: pass def _on_entry_changed(self, entry): self._save_preferences_safely() def _on_switch_toggled(self, switch, pspec): self._save_preferences_safely() def _on_spin_changed(self, spin, pspec=None): self._save_preferences_safely() def _on_close_attempt(self, dialog): self._save_preferences_safely() return False def get_preferences(self) -> dict: if not hasattr(self, "config_path_entry") or self.config_path_entry is None: return {} return { "config_path": self.config_path_entry.get_text(), "backup_dir": self.backup_dir_entry.get_text(), "auto_backup": self.auto_backup_switch.get_active(), "editor_font_size": int(self.editor_font_spin.get_value()), "prefer_dark_theme": self.dark_theme_switch.get_active(), "raw_wrap_lines": self.raw_wrap_switch.get_active(), } def set_preferences(self, prefs: dict): if not hasattr(self, "config_path_entry") or self.config_path_entry is None: return if "config_path" in prefs: self.config_path_entry.set_text(prefs["config_path"]) if "backup_dir" in prefs: self.backup_dir_entry.set_text(prefs["backup_dir"]) if "auto_backup" in prefs: self.auto_backup_switch.set_active(bool(prefs["auto_backup"])) if "editor_font_size" in prefs: self.editor_font_spin.set_value(float(prefs["editor_font_size"])) if "prefer_dark_theme" in prefs: self.dark_theme_switch.set_active(bool(prefs["prefer_dark_theme"])) if "raw_wrap_lines" in prefs: self.raw_wrap_switch.set_active(bool(prefs["raw_wrap_lines"])) SSH-Studio-1.3.1/src/ui/ssh_key_manager_dialog.py000066400000000000000000000325771506556307300217020ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Gio, Adw, Gdk, GLib from gettext import gettext as _ from pathlib import Path import subprocess @Gtk.Template( resource_path="/io/github/BuddySirJava/SSH-Studio/ui/ssh_key_manager_dialog.ui" ) class SSHKeyManagerDialog(Adw.Dialog): __gtype_name__ = "SSHKeyManagerDialog" toast_overlay = Gtk.Template.Child() private_list = Gtk.Template.Child() public_list = Gtk.Template.Child() generate_button = Gtk.Template.Child() import_button = Gtk.Template.Child() copy_pub_button = Gtk.Template.Child() reveal_button = Gtk.Template.Child() delete_button = Gtk.Template.Child() def __init__(self, parent): super().__init__() self._home_ssh = Path.home() / ".ssh" self._connect_signals() self._load_keys() def _connect_signals(self): self.generate_button.connect("clicked", self._on_generate_clicked) self.import_button.connect("clicked", self._on_import_clicked) self.copy_pub_button.connect("clicked", self._on_copy_public_clicked) self.reveal_button.connect("clicked", self._on_reveal_clicked) self.delete_button.connect("clicked", self._on_delete_clicked) self.private_list.connect("row-selected", self._on_row_selected) self.public_list.connect("row-selected", self._on_row_selected) self._setup_keyboard_shortcuts() def _setup_keyboard_shortcuts(self): """Setup keyboard shortcuts for the SSH key manager dialog.""" key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) def _on_key_pressed(self, controller, keyval, keycode, state): """Handle key presses in the SSH key manager dialog.""" if keyval == Gdk.KEY_Escape: self.close() return True elif keyval == Gdk.KEY_n and (state & Gdk.ModifierType.CONTROL_MASK): self._on_generate_clicked(None) return True elif keyval == Gdk.KEY_i and (state & Gdk.ModifierType.CONTROL_MASK): self._on_import_clicked(None) return True elif keyval == Gdk.KEY_Delete: self._on_delete_clicked(None) return True return False def _load_keys(self): for lst in (self.private_list, self.public_list): row = lst.get_first_child() while row is not None: next_row = row.get_next_sibling() lst.remove(row) row = next_row priv_keys, pub_keys = self._discover_keys_split() for key in priv_keys: row = self._create_row_for_key(key) self.private_list.append(row) for key in pub_keys: row = self._create_row_for_key(key) self.public_list.append(row) self._update_buttons_sensitivity() def _discover_keys_split(self): private_keys = [] public_keys = [] try: if self._home_ssh.exists(): for path in sorted(self._home_ssh.iterdir()): if not path.is_file(): continue name = path.name if name in {"known_hosts", "config", "authorized_keys"}: continue if name.endswith(".pub"): public_keys.append( { "name": name, "path": str(path), "pub": None, } ) else: if path.suffix == "": pub_path = path.with_name(name + ".pub") private_keys.append( { "name": name, "path": str(path), "pub": str(pub_path) if pub_path.exists() else None, } ) except Exception: pass return private_keys, public_keys def _create_row_for_key(self, key_info): box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=12, margin_start=12, margin_end=12, margin_top=8, margin_bottom=8, ) label = Gtk.Label(label=key_info["name"], halign=Gtk.Align.START, hexpand=True) sub = Gtk.Label(label=key_info["path"], halign=Gtk.Align.END) sub.get_style_context().add_class("dim-label") box.append(label) box.append(sub) row = Gtk.ListBoxRow() row.set_child(box) row.key_info = key_info return row def _get_selected_key(self): row = ( self.private_list.get_selected_row() or self.public_list.get_selected_row() ) return getattr(row, "key_info", None) if row else None def _on_row_selected(self, listbox, row): self._update_buttons_sensitivity() def _update_buttons_sensitivity(self): has_sel = self._get_selected_key() is not None for btn in (self.copy_pub_button, self.reveal_button, self.delete_button): try: btn.set_sensitive(has_sel) except Exception: pass def _on_generate_clicked(self, button): from .generate_key_dialog import GenerateKeyDialog dlg = GenerateKeyDialog(self) def on_generate(*_): opts = dlg.get_options() dlg.close() self._generate_key_with_options(opts) dlg.generate_btn.connect("clicked", on_generate) dlg.present(self) def _on_import_clicked(self, button): dialog = Gtk.FileChooserNative.new( title=_("Import Private Key"), parent=self.get_root(), action=Gtk.FileChooserAction.OPEN, accept_label=_("Import"), cancel_label=_("Cancel"), ) def on_response(dlg, response_id): if response_id == Gtk.ResponseType.ACCEPT: file = dlg.get_file() if file: src = Path(file.get_path()) try: dst = self._home_ssh / src.name self._home_ssh.mkdir(parents=True, exist_ok=True) data = src.read_bytes() dst.write_bytes(data) try: dst.chmod(0o600) except Exception: pass self._show_toast(_("Key imported")) self._load_keys() except Exception as e: self._show_toast(_(f"Failed to import key: {e}")) dlg.destroy() dialog.connect("response", on_response) dialog.show() def _on_copy_public_clicked(self, button): key = self._get_selected_key() if not key: return pub_path = Path(key.get("pub") or "") if not pub_path.exists(): self._show_toast(_("No public key found; generate with ssh-keygen -y")) return try: text = pub_path.read_text() if self._copy_text_to_clipboard(text): self._show_toast(_("Public key copied")) else: raise RuntimeError("clipboard backends unavailable") except Exception as e: self._show_toast(_(f"Failed to copy: {e}")) def _on_reveal_clicked(self, button): key = self._get_selected_key() if not key: return try: Gio.AppInfo.launch_default_for_uri(f"file://{key['path']}") except Exception as e: self._show_toast(_(f"Failed to reveal: {e}")) def _on_delete_clicked(self, button): key = self._get_selected_key() if not key: return dialog = Gtk.MessageDialog( transient_for=self.get_root(), message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.OK_CANCEL, text=_("Delete key?"), secondary_text=_( "This will permanently delete the selected private key (and public key if present)." ), ) def on_resp(dlg, resp): if resp == Gtk.ResponseType.OK: try: Path(key["path"]).unlink(missing_ok=True) if key.get("pub"): Path(key["pub"]).unlink(missing_ok=True) self._show_toast(_("Key deleted")) self._load_keys() except Exception as e: self._show_toast(_(f"Failed to delete: {e}")) dlg.destroy() dialog.connect("response", on_resp) dialog.present() def _generate_key_with_options(self, opts: dict): try: self._home_ssh.mkdir(parents=True, exist_ok=True) name = opts.get("name") or "id_ed25519" base_name = name j = 0 while (self._home_ssh / name).exists(): j += 1 name = f"{base_name}_{j}" key_path = self._home_ssh / name key_type = (opts.get("type") or "ed25519").lower() comment = opts.get("comment") or "ssh-studio" passphrase = opts.get("passphrase") or "" if key_type == "rsa": size = int(opts.get("size") or 2048) cmd = [ "ssh-keygen", "-t", "rsa", "-b", str(size), "-f", str(key_path), "-N", passphrase, "-C", comment, ] elif key_type == "ecdsa": cmd = [ "ssh-keygen", "-t", "ecdsa", "-f", str(key_path), "-N", passphrase, "-C", comment, ] else: cmd = [ "ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", passphrase, "-C", comment, ] subprocess.run(cmd, check=True) self._show_toast(_("Key generated")) self._load_keys() except FileNotFoundError: self._show_toast(_("ssh-keygen not found")) except subprocess.CalledProcessError as e: self._show_toast(_(f"Keygen failed: {e}")) except Exception as e: self._show_toast(_(f"Failed to generate key: {e}")) def _show_toast(self, message: str): try: toast = Adw.Toast.new(message) self.toast_overlay.add_toast(toast) except Exception: pass def _copy_text_to_clipboard(self, text: str) -> bool: try: display = Gdk.Display.get_default() if not display: raise RuntimeError("no display") clipboard = display.get_clipboard() bytes_utf8 = GLib.Bytes.new(text.encode("utf-8")) providers = [ Gdk.ContentProvider.new_for_bytes( "text/plain;charset=utf-8", bytes_utf8 ), Gdk.ContentProvider.new_for_bytes("text/plain", bytes_utf8), ] provider = ( Gdk.ContentProvider.new_union(providers) if hasattr(Gdk.ContentProvider, "new_union") else providers[0] ) # Keep a reference to avoid premature GC self._last_clip_provider = provider if hasattr(clipboard, "set_content"): clipboard.set_content(provider) elif hasattr(clipboard, "set"): clipboard.set(provider) elif hasattr(clipboard, "set_text"): clipboard.set_text(text) else: raise RuntimeError("unsupported clipboard api") try: primary = display.get_primary_clipboard() if primary: if hasattr(primary, "set_content"): primary.set_content(self._last_clip_provider) elif hasattr(primary, "set"): primary.set(self._last_clip_provider) elif hasattr(primary, "set_text"): primary.set_text(text) except Exception: pass return True except Exception: pass try: import subprocess as _sub for cmd in [ ["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"], ]: try: res = _sub.run(cmd, input=text, text=True, capture_output=True) if res.returncode == 0: return True except Exception: continue except Exception: pass return False SSH-Studio-1.3.1/src/ui/test_connection_dialog.py000066400000000000000000000122151506556307300217240ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, GLib, Adw, Gdk import subprocess import threading from gettext import gettext as _ @Gtk.Template( resource_path="/io/github/BuddySirJava/SSH-Studio/ui/test_connection_dialog.ui" ) class TestConnectionDialog(Adw.Window): __gtype_name__ = "TestConnectionDialog" stack = Gtk.Template.Child() loading_page = Gtk.Template.Child() status_title = Gtk.Template.Child() status_description = Gtk.Template.Child() output_text = Gtk.Template.Child() def __init__(self, parent=None, **kwargs): super().__init__(**kwargs) self.set_transient_for(parent) self._setup_keyboard_shortcuts() def _setup_keyboard_shortcuts(self): """Setup keyboard shortcuts for the test connection dialog.""" key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) def _on_key_pressed(self, controller, keyval, keycode, state): """Handle key presses in the test connection dialog.""" if keyval == Gdk.KEY_Escape: self.close() return True return False def start_test(self, command, hostname): """Start the SSH connection test with the given command and hostname.""" if not hostname: self._show_error(_("No hostname or pattern available to test.")) return self.stack.set_visible_child_name("loading") self.loading_page.set_title(_("Testing Connection")) self.loading_page.set_description(_("Running SSH command...")) self.loading_page.set_icon_name("network-workgroup-symbolic") def run_test(): try: result = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=20, ) rc = result.returncode stdout_text = (result.stdout or "").strip() stderr_text = (result.stderr or "").strip() def update_ui(): self._show_results(rc, stdout_text, stderr_text, command) return False GLib.idle_add(update_ui) except subprocess.TimeoutExpired: def update_timeout(): self._show_timeout(command) return False GLib.idle_add(update_timeout) except Exception as e: def update_error(): self._show_exception(e) return False GLib.idle_add(update_error) threading.Thread(target=run_test, daemon=True).start() def _show_error(self, message): """Show error state.""" self.stack.set_visible_child_name("loading") self.loading_page.set_title(_("Error")) self.loading_page.set_description(message) self.loading_page.set_icon_name("dialog-error-symbolic") def _show_results(self, return_code, stdout_text, stderr_text, command): """Show test results.""" self.stack.set_visible_child_name("results") if return_code == 0: self.status_title.set_text(_("Connection Successful")) self.status_description.set_text( _("SSH connection test completed successfully") ) else: self.status_title.set_text(_("Connection Failed")) self.status_description.set_text( _(f"SSH connection failed with exit code {return_code}") ) output_lines = [] output_lines.append(f"Command: {' '.join(command)}") output_lines.append("") if stdout_text: output_lines.append("STDOUT:") output_lines.append(stdout_text) output_lines.append("") if stderr_text: output_lines.append("STDERR:") output_lines.append(stderr_text) output_lines.append("") output_lines.append(f"Exit code: {return_code}") self.output_text.get_buffer().set_text("\n".join(output_lines)) def _show_timeout(self, command): """Show timeout state.""" self.stack.set_visible_child_name("results") self.status_title.set_text(_("Connection Timed Out")) self.status_description.set_text( _("SSH connection test timed out after 20 seconds") ) output_lines = [] output_lines.append(f"Command: {' '.join(command)}") output_lines.append("") output_lines.append("Timed out after 20 seconds") self.output_text.get_buffer().set_text("\n".join(output_lines)) def _show_exception(self, exception): """Show exception state.""" self.stack.set_visible_child_name("results") self.status_title.set_text(_("Error")) self.status_description.set_text(_(f"An error occurred: {exception}")) output_lines = [] output_lines.append("Error Details:") output_lines.append(str(exception)) self.output_text.get_buffer().set_text("\n".join(output_lines)) SSH-Studio-1.3.1/src/ui/welcome_view.py000066400000000000000000000005541506556307300176770ustar00rootroot00000000000000import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Adw from gettext import gettext as _ @Gtk.Template(resource_path="/io/github/BuddySirJava/SSH-Studio/ui/welcome_view.ui") class WelcomeView(Gtk.Box): __gtype_name__ = "WelcomeView" def __init__(self, **kwargs): super().__init__(**kwargs)