diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..219ffe1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(go build:*)", + "Bash(make build *)", + "WebSearch" + ], + "additionalDirectories": [ + "c:\\github\\jaydenthorup\\mremotego", + "c:\\github\\jaydenthorup\\mremotego\\bin" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..eb812d7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs grep *)", + "Bash(go vet *)", + "Bash(git *)", + "Bash(go test *)", + "Bash(go env *)", + "Bash(grep -n *)", + "Bash(Get-Content *)", + "WebFetch(domain:github.com)", + "WebFetch(domain:api.github.com)" + ] + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5255437..78a37f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,12 +2,22 @@ name: Build Cross-Platform on: push: + branches: [ main ] # Also keeps main's build cache warm — see below tags: - 'v*.*.*' # Only trigger on version tags like v1.0.0 pull_request: branches: [ main ] workflow_dispatch: # Allow manual trigger +# GitHub Actions caches (actions/setup-go's module+build cache here) are only +# restorable from the exact same ref or the repository's default branch — a +# newly-pushed release tag has never been built before, so with no build on +# `main` it always started from a fully cold cache. Confirmed via timing: the +# Windows build (the slowest by far, due to CGO compiling Fyne's OpenGL +# bindings) took ~380-400s cold, but only ~35s once main's cache was warm. +# Building on every push to main keeps that cache hot, so tag-triggered +# release builds inherit it instead of starting cold every time. + jobs: build: name: Build ${{ matrix.os }} @@ -27,7 +37,11 @@ jobs: goos: windows goarch: amd64 name: mremotego-windows-amd64.exe - - os: macos-14 + # macos-14 runners were retired on 2026-11-02 ("configuration not + # supported"). Pinned rather than macos-latest so image upgrades are + # deliberate; MACOSX_DEPLOYMENT_TARGET in the build step keeps the + # binary runnable on older macOS versions than the runner's. + - os: macos-26 goos: darwin goarch: arm64 name: mremotego-darwin-arm64 @@ -39,13 +53,25 @@ jobs: # name: mremotego-darwin-amd64 steps: + - name: Exclude build paths from Windows Defender scanning + # CGO builds (needed for Fyne's OpenGL bindings) write large numbers of + # object files during compilation, and Windows Defender's real-time + # scanning of each one is the single biggest reason this job takes far + # longer than the Linux/macOS builds of the same code (~7min vs + # ~40s-2.5min). Safe here since the runner is an ephemeral, single-use VM. + if: matrix.os == 'windows-latest' + shell: powershell + run: | + Add-MpPreference -ExclusionPath "${{ github.workspace }}", "$env:LOCALAPPDATA", "$env:USERPROFILE\go" + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7.0.0 with: - go-version: '1.23' + go-version: '1.24' + cache: true - name: Install Linux dependencies if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' @@ -53,29 +79,66 @@ jobs: sudo apt-get update sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev + - name: Cache go-winres binary + # go-winres itself never changes between builds, but "go install ...@latest" + # re-resolves and rebuilds it from source on every single run regardless + # (~40s wasted). Pinning the version and caching the compiled binary + # turns that into a one-time cost. + if: matrix.os == 'windows-latest' + id: cache-go-winres + uses: actions/cache@v6.1.0 + with: + path: ~/go/bin/go-winres.exe + key: go-winres-v0.3.3-windows-amd64 + + - name: Install go-winres (Windows icon embedding) + if: matrix.os == 'windows-latest' && steps.cache-go-winres.outputs.cache-hit != 'true' + run: go install github.com/tc-hib/go-winres@v0.3.3 + + - name: Generate Windows resources + if: matrix.os == 'windows-latest' + working-directory: cmd/mremotego-gui + run: go-winres simply --icon Icon.png --arch amd64 + + - name: Determine version + id: version + shell: bash + run: | + # Embedded into the binary for the About dialog and self-update checks + # (see cmd/mremotego-gui/main.go). Only tag pushes get a real version — + # PR/workflow_dispatch builds stay "dev", which disables update checks. + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "value=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + else + echo "value=dev" >> "$GITHUB_OUTPUT" + fi + - name: Build (Windows) if: matrix.os == 'windows-latest' env: CGO_ENABLED: 1 run: | - go build -ldflags "-H windowsgui" -o ${{ matrix.name }} ./cmd/mremotego-gui + go build -ldflags "-s -H windowsgui -X main.version=${{ steps.version.outputs.value }}" -o ${{ matrix.name }} ./cmd/mremotego-gui - name: Build (Linux) if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' env: CGO_ENABLED: 1 run: | - go build -o ${{ matrix.name }} ./cmd/mremotego-gui + go build -ldflags "-s -X main.version=${{ steps.version.outputs.value }}" -o ${{ matrix.name }} ./cmd/mremotego-gui - name: Build (macOS) if: startsWith(matrix.os, 'macos') env: CGO_ENABLED: 1 + # Without this, clang targets the runner's own macOS version for the + # cgo (Fyne/GLFW) code, and the binary may refuse to start on older Macs. + MACOSX_DEPLOYMENT_TARGET: '12.0' run: | - go build -o ${{ matrix.name }} ./cmd/mremotego-gui + go build -ldflags "-X main.version=${{ steps.version.outputs.value }}" -o ${{ matrix.name }} ./cmd/mremotego-gui - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: ${{ matrix.goos }}-${{ matrix.goarch }} path: ${{ matrix.name }} @@ -89,10 +152,10 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: path: artifacts @@ -118,19 +181,40 @@ jobs: done ls -lh + - name: Generate checksums + # Consumed by internal/update's self-updater to verify a downloaded + # archive before installing it (see verifyChecksum in update.go). + run: | + cd artifacts + sha256sum *.tar.gz *.zip > SHA256SUMS + cat SHA256SUMS + - name: Extract version from tag id: version run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + - name: Determine release type + id: release_type + run: | + # Plain tags (v1.6.0) publish as a real release; tags carrying a + # semver pre-release identifier (v2.0.0-alpha, -beta, -rc1, ...) + # publish as a GitHub pre-release. + if [[ "${{ steps.version.outputs.VERSION }}" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3.0.2 with: name: Release ${{ steps.version.outputs.VERSION }} draft: false - prerelease: true + prerelease: ${{ steps.release_type.outputs.prerelease }} generate_release_notes: true files: | artifacts/*.tar.gz artifacts/*.zip + artifacts/SHA256SUMS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 34ca2d8..7df568e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries for programs and plugins -*.exe +*.exe* *.exe~ *.dll *.so @@ -42,3 +42,7 @@ dist/ connections.yaml config.yaml MremoteGO-Config.xml + +# Generated Windows icon resources (produced by go-winres during build) +cmd/mremotego-gui/rsrc_windows_*.syso +cmd/mremotego-gui/icon.ico diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef1ee8..2337b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,87 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **macOS RDP auto-login via FreeRDP**: when FreeRDP's SDL client (`sdl-freerdp`, `brew install freerdp`) is installed, macOS RDP connections use it instead of Windows App. Credentials from 1Password are passed in automatically. All arguments, passwords included, go through `/args-from:stdin`, so they never appear in `ps`. MremoteGO looks for the client in PATH and in the Homebrew/MacPorts directories, so it is also found when started from Finder. Connection settings are mapped to FreeRDP options: gateway, certificate level, NLA, drive redirection, resolution and colour depth. Connections whose RD Gateway uses smartcard authentication keep using Windows App. `MREMOTEGO_MAC_RDP_CLIENT=freerdp|windowsapp` forces a client. + +## [1.7.3] - 2026-11-09 + +### Fixed +- **macOS build**: CI now builds on the `macos-26` runner, because `macos-14` was retired ("The configuration 'macos-14-arm64-us-default' is not supported"). `MACOSX_DEPLOYMENT_TARGET=12.0` keeps the binary runnable on older macOS versions. + +## [1.7.2] - 2026-11-09 + +### Added +- **macOS RDP password via the clipboard**: Windows App and Microsoft Remote Desktop ignore passwords in `.rdp` files, so the password (from 1Password) is put on the clipboard to paste into the client's prompt. It is marked Concealed/Transient so clipboard managers skip it, and it is removed after 45 seconds or when MremoteGO closes. The status bar says when the password is ready. + +## [1.7.1] - 2026-11-09 + +### Fixed +- **macOS RDP "The URL is not valid"**: RDP on macOS opened an `rdp://user@domain@host` URL that Windows App rejects. It now opens a temporary `.rdp` file with the default handler, so port, domain, gateway and drive settings also carry over. + +## [1.6.1] - 2026-08-14 + +### Fixed +- **Releases now publish as stable, not permanently pre-release**: `prerelease` was previously hardcoded to `true` for every release the workflow created, with nothing ever able to graduate it — this is the first release published as a real (non-prerelease) GitHub release. Going forward, a plain tag (`v1.6.1`) publishes as stable; a tag carrying a semver pre-release identifier (`v2.0.0-alpha`, `-beta`, `-rc1`) publishes as a pre-release. +- **Self-update no longer offers pre-releases**: `internal/update.CheckLatest` now skips prerelease entries when picking "latest" (in addition to drafts), so an alpha/beta tag pushed in the future won't get auto-offered to everyone on the stable channel — only a plain, stable tag will. Releases 1.0.0 through 1.6.0 remain marked as pre-release on GitHub itself (a historical label, harmless — self-update simply treats this release as the first stable one to offer). + +## [1.6.0] - 2026-08-14 + +### Added +- **Self-update integrity verification**: the release workflow now publishes a `SHA256SUMS` file alongside each platform archive, and the self-updater (introduced in 1.5.0) verifies a downloaded archive's SHA-256 against it before installing. This fails closed: a release with no `SHA256SUMS` asset, no matching entry for the current platform, or a hash mismatch is treated as a failed update and nothing is installed. Downloads were already served over GitHub's HTTPS, but self-updating bypasses the Mark-of-the-Web/SmartScreen check a manually-downloaded-and-extracted exe would normally get, so this restores an integrity check in its place. + +## [1.5.0] - 2026-08-14 + +### Added +- **Self-update**: MremoteGO now checks GitHub Releases for a newer version — silently a few seconds after startup (only interrupting the user when an update is actually found), and on demand via the new "Check for Updates..." item in the Help menu. When available, a dialog shows the release notes and lets the user confirm before it downloads the matching platform archive, replaces the running executable, and relaunches automatically. Supported on Windows, Linux, and macOS: + - Windows: the running exe is renamed aside (`.old`) before the new one is moved into place, since Windows allows renaming but not overwriting a running executable; the leftover is cleaned up shortly after the next startup. + - Linux/macOS: the executable's directory entry is replaced directly — safe because the already-running process keeps executing off its existing (now-unlinked) file handle. + - All releases from this project are published as GitHub prereleases, so the update check queries the release list rather than the `/releases/latest` endpoint (which excludes prereleases and always 404s here). + - The running version is now embedded into the binary at build time (`-X main.version=...`, wired through `Makefile`, `build-gui.ps1`, and the CI release workflow) and shown in the About dialog, which previously hardcoded "v1.0". + +## [1.4.0] - 2026-08-14 + +### Added +- **RDP local drive redirection**: RDP connections can now map local drives into the remote session. Configurable via the new "Redirect local drives" option in the Add/Edit Connection dialogs, or the `rdp_redirect_drives` YAML field. Four modes are supported: + - **All drives** (`"*"`) — every local drive present now, plus any connected later + - **All local disks only (no network drives)** (`"local"`) — local fixed/removable drives present at launch time, resolved via `GetDriveTypeW` to exclude network-mapped drive letters + - **Dynamic drives** (`"dynamic"`) — only drives connected after the session starts (e.g. USB sticks), maps to the `.rdp` file's `DynamicDrives` token + - **Specific drives** (e.g. `"C,D"`) — an explicit comma-separated list of drive letters + + Windows exposes redirected drives both as new volumes under "This PC" and as subdirectories under `\\tsclient`. Implemented via the `.rdp` file's `drivestoredirect` setting. + +### Fixed +- **Startup crash from git status checking**: the background config-git-status check (added in 1.3.0) called Fyne widget and dialog methods directly from a goroutine instead of marshalling them onto the main UI thread via `fyne.Do`, which newer Fyne versions treat as a fatal thread-safety violation. Affected `notifyIfConfigOutdated`, `startPeriodicGitCheck`, and the "pull latest config" confirmation flow in `cmd/mremotego-gui/main.go`. + +## [1.3.0] - 2026-05-27 + +### Added +- **Git status checking for config updates**: on startup (and every 15 minutes thereafter), MremoteGo checks whether the config repository's local `HEAD` is behind its remote via `git ls-remote` (read-only, no fetch). A status indicator appears in the status bar, and if the config is behind, a dialog offers to `git pull` the latest version and reload it automatically. + +## [1.2.0] - 2026-05-06 + +### Added +- **Automatic RDP session cleanup**: credentials and temporary `.rdp` files created by MRemoteGo are now fully cleaned up at the right moment — whichever comes first: immediately after authentication completes (detected by mstsc's window title containing the hostname), when the session closes, when the app is closed, or on next startup after a crash. Every credential is tagged with `Comment = "Managed by MRemoteGo"` so orphaned entries can be identified and removed. Multiple concurrent RDP sessions are tracked and cleaned up independently. + +## [1.1.1] - 2026-05-06 + +### Added +- **Windows taskbar icon**: application icon is now embedded in the `.exe` PE resource section via `go-winres`, so it persists when the app is pinned to the Windows taskbar +- **1Password batched resolution**: credentials from the same vault+item are now fetched in a single `op item get --format json` call instead of one call per field, reducing biometric prompts +- **Unit tests for 1Password**: `ResolveMultiple` is covered by four tests (same-item batching, different items, non-reference passthrough, duplicate deduplication) + +### Changed +- **Double-click to connect**: now uses Fyne's `DoubleTappable` interface for reliable detection; single-click still selects and shows connection details +- Double-clicking a connection selects the item, populates the details panel, and shows the knight-rider progress bar — matching the behaviour of the Connect button +- `build-gui.ps1` and GitHub Actions Windows build now run `go-winres` before compilation to regenerate icon resources automatically +- Windows build now compiles from the package path (`./cmd/mremotego-gui/`) so `.syso` resource files are automatically included + +### Fixed +- Double-click no longer silently fails when clicking an already-selected tree item (root cause: Fyne fires `Tapped` *or* `DoubleTapped`, never both) +- Single-click selection broke when `DoubleTappable` was added; fixed by implementing both `Tappable` and `DoubleTappable` on the tree node widget and driving `tree.Select` explicitly + ## [1.0.4] - 2026-01-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be5f16a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MremoteGO is a cross-platform remote connection manager written in Go. It replaces mRemoteNG with git-friendly YAML configs and 1Password integration. Supports SSH, RDP, VNC, HTTP/HTTPS, and Telnet. Has both a Fyne-based GUI and a Cobra-based CLI in a single binary. + +## Build Commands + +```bash +# Build all (CLI + GUI) +make build-all + +# Build GUI only +make build-gui + +# Build CLI only +make build-cli + +# Run tests (no tests currently exist) +make test + +# Clean build artifacts +make clean +``` + +Windows-specific GUI build (handles syso resource embedding): +```powershell +.\build-gui.ps1 +``` + +The GUI binary hides the console window on Windows via `-ldflags "-H windowsgui"`. + +## Architecture + +### Dual-mode entry point + +`cmd/mremotego-gui/main.go` launches GUI if called with no arguments, otherwise delegates to CLI (Cobra). `cmd/mremotego/` is the standalone CLI-only binary. + +### Key packages + +- **`pkg/models/`** — Core types: `Connection`, `Config`, `Protocol`, `NodeType`. All other packages depend on this; change here with care. +- **`internal/config/`** — Loads/saves YAML config, handles encryption detection, manages recent-file tracking. +- **`internal/crypto/`** — AES-256-GCM encryption with PBKDF2 (100k iterations). Encrypted passwords are stored as `enc:`. +- **`internal/secrets/`** — Wraps the `op` CLI (1Password). Passwords prefixed `op://` are resolved at launch time via subprocess. Platform split: `_windows.go` / `_unix.go` for console-hiding. +- **`internal/launcher/`** — Protocol launchers. Platform split: `launcher_windows.go` handles RDP (via `mstsc`) and WinRM; `launcher_unix.go` handles SSH/VNC/browser via `xdg-open` and `xterm`. Add new protocol support here. + - macOS RDP (`case "darwin"` in `launchRDP`): `sdl-freerdp` when installed (`freerdp.go`, all args incl. passwords via `/args-from:stdin`), otherwise Windows App via a temporary `.rdp` file plus the password on the clipboard (`clipboard_darwin.go`, cleared after 45 s). `MREMOTEGO_MAC_RDP_CLIENT=freerdp|windowsapp` forces a client. + - `Launcher.Notify` carries status messages (e.g. "password copied") to the GUI status bar. +- **`internal/gui/`** — Fyne UI: `mainwindow.go` (layout, toolbar), `treewidget.go` (connection tree), `dialogs.go` (add/edit/delete/gateway dialogs). + +### Config file location + +- Linux/Mac: `~/.config/mremotego/config.yaml` +- Windows: `%APPDATA%/mremotego/config.yaml` + +Last-used path is persisted in `recent.txt` alongside the binary. + +### Connection model + +`Connection` supports both leaf nodes (actual connections) and folder nodes (`NodeType`: `connection` vs `folder`). Folders have a `Children []Connection` slice enabling unlimited nesting. The entire tree is a single `Config.Connections []Connection`. + +### RDP Gateway support + +Windows launcher passes gateway credentials to `mstsc` via a temporary `.rdp` file written to `%TEMP%`, then deleted after launch. Gateway config lives on the `Connection` struct fields (`GatewayHostname`, `GatewayUsername`, `GatewayPassword`). + +### 1Password integration + +The `secrets` package calls `op read ` as a subprocess and returns stdout. On Windows it hides the console window by setting `SysProcAttr.HideWindow`. Special characters in passwords are handled by writing to a temp file rather than passing via command line. + +## CI/CD + +GitHub Actions (`.github/workflows/`) builds on version tag pushes (`v*`). Produces binaries for Windows AMD64, Linux AMD64/ARM64, macOS ARM64, and creates a GitHub release automatically. diff --git a/Makefile b/Makefile index 7ef5da8..ec18296 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,15 @@ GUI_PATH=cmd/mremotego-gui/main.go cmd/mremotego-gui/theme.go # Build directory BUILD_DIR=bin +# Version embedded into the GUI binary for the About dialog and self-update +# checks (see cmd/mremotego-gui/main.go). Falls back to "dev" outside a git +# checkout or when no tag is reachable, which disables update checks. +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//') +ifeq ($(VERSION),) +VERSION := dev +endif +VERSION_LDFLAGS := -X main.version=$(VERSION) + all: clean build # Build both CLI and GUI @@ -24,8 +33,8 @@ build-cli: # Build the GUI application build-gui: - @echo "Building $(GUI_BINARY)..." - @go build -o $(BUILD_DIR)/$(GUI_BINARY) $(GUI_PATH) + @echo "Building $(GUI_BINARY) ($(VERSION))..." + @go build -ldflags="-H windowsgui $(VERSION_LDFLAGS)" -o $(BUILD_DIR)/$(GUI_BINARY) $(GUI_PATH) @echo "✓ GUI build complete: $(BUILD_DIR)/$(GUI_BINARY)" # Build for multiple platforms @@ -36,11 +45,11 @@ build-all: @GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(CLI_BINARY)-darwin-arm64 $(CLI_PATH) @GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(CLI_BINARY)-windows-amd64.exe $(CLI_PATH) @echo "✓ CLI multi-platform build complete" - @echo "Building GUI for multiple platforms..." - @GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-linux-amd64 $(GUI_PATH) - @GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-darwin-amd64 $(GUI_PATH) - @GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-darwin-arm64 $(GUI_PATH) - @GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-windows-amd64.exe $(GUI_PATH) + @echo "Building GUI for multiple platforms ($(VERSION))..." + @GOOS=linux GOARCH=amd64 go build -ldflags="$(VERSION_LDFLAGS)" -o $(BUILD_DIR)/$(GUI_BINARY)-linux-amd64 $(GUI_PATH) + @GOOS=darwin GOARCH=amd64 go build -ldflags="$(VERSION_LDFLAGS)" -o $(BUILD_DIR)/$(GUI_BINARY)-darwin-amd64 $(GUI_PATH) + @GOOS=darwin GOARCH=arm64 go build -ldflags="$(VERSION_LDFLAGS)" -o $(BUILD_DIR)/$(GUI_BINARY)-darwin-arm64 $(GUI_PATH) + @GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui $(VERSION_LDFLAGS)" -o $(BUILD_DIR)/$(GUI_BINARY)-windows-amd64.exe $(GUI_PATH) @echo "✓ GUI multi-platform build complete" # Clean build artifacts diff --git a/README.md b/README.md index c56db0a..6ff38c8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - ⚡ **Fast** - Native GUI with instant connections - 🚀 **Multiple Protocols** - SSH, RDP, VNC, HTTP/HTTPS, Telnet - 📁 **Organized** - Folders and search filtering -- 🔒 **Auto-Login** - Password injection for SSH connections +- 🔒 **Auto-Login** - Credentials (including 1Password references) passed to SSH and RDP clients; see [RDP clients per platform](#rdp-clients-per-platform) - 💻 **CLI & GUI** - Run without arguments for GUI, with arguments for CLI mode ## 🚀 Quick Start @@ -54,6 +54,17 @@ go build -o mremotego ./cmd/mremotego-gui That's it! 🎉 +### RDP clients per platform + +| Platform | Client | Auto-login | +|---|---|---| +| Windows | `mstsc` | ✅ Credentials go through Windows Credential Manager and are removed once the session is connected | +| Linux | `xfreerdp` | ✅ | +| macOS | `sdl-freerdp` (`brew install freerdp`) | ✅ All arguments, passwords included, go through stdin, so nothing shows up in `ps` | +| macOS | Windows App / Microsoft Remote Desktop (used when FreeRDP is not installed) | ⚠️ The password is put on the clipboard for 45 seconds; paste it with Cmd+V when asked | + +On macOS, MremoteGO picks `sdl-freerdp` automatically when it is installed. Connections whose RD Gateway uses smartcard authentication always go to Windows App. Set `MREMOTEGO_MAC_RDP_CLIENT=freerdp` or `MREMOTEGO_MAC_RDP_CLIENT=windowsapp` to force one client. + ## 📖 Usage ### GUI Mode @@ -74,9 +85,9 @@ Simply run the executable without arguments: **Connecting:** -- **Double-click** a connection in the tree -- **Right-click** → **Connect** -- Select and press **Enter** +- **Double-click** a connection in the tree (shows details and starts connecting) +- Select a connection, then click the **🚀 Connect** button in the details panel +- Select a connection, then click the **▶** toolbar button **Searching:** @@ -285,12 +296,16 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [x] Core connection management (SSH, RDP, VNC, HTTP, Telnet) - [x] GUI with tree view and search - [x] 1Password integration with special character support +- [x] 1Password batched credential resolution (single prompt per vault+item) - [x] AES-256-GCM password encryption - [x] Cross-platform builds (Windows, Linux, macOS ARM64) - [x] CLI mode for automation - [x] Nested folder support with unlimited depth - [x] Import from mRemoteNG XML - [x] GitHub Actions CI/CD with automated releases +- [x] Double-click to connect with progress indicator +- [x] Windows taskbar icon embedded in executable +- [x] macOS RDP auto-login via sdl-freerdp, with a Windows App fallback ### 🚧 In Progress - [ ] Improved settings panel with more options @@ -338,7 +353,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [ ] Connection macros/automation #### Platform-Specific -- [ ] Windows: Hide console window on launch - [ ] Linux: System tray integration - [ ] macOS: Menu bar app mode diff --git a/build-gui.ps1 b/build-gui.ps1 index c1e83d5..3989bac 100644 --- a/build-gui.ps1 +++ b/build-gui.ps1 @@ -7,13 +7,36 @@ if (!(Test-Path -Path "bin")) { New-Item -ItemType Directory -Path "bin" | Out-Null } -# Build the application +# Embed Windows icon into the executable so it shows when pinned to the taskbar. +# go-winres generates rsrc_windows_*.syso files that go build picks up automatically +# when building a package (not individual files). +if ($IsWindows -or $env:OS -match "Windows") { + if (Get-Command go-winres -ErrorAction SilentlyContinue) { + Write-Host "Embedding icon..." -ForegroundColor Cyan + Push-Location cmd/mremotego-gui + go-winres simply --icon Icon.png --arch amd64 2>&1 | Out-Null + Pop-Location + } else { + Write-Host "go-winres not found — skipping icon embedding (install with: go install github.com/tc-hib/go-winres@latest)" -ForegroundColor Yellow + } +} + +# Version embedded into the binary for the About dialog and self-update checks +# (see cmd/mremotego-gui/main.go). Falls back to "dev" outside a git checkout +# or when no tag is reachable, which disables update checks. +$version = (git describe --tags --always --dirty 2>$null) -replace '^v', '' +if ([string]::IsNullOrWhiteSpace($version)) { + $version = "dev" +} +Write-Host "Version: $version" -ForegroundColor Cyan + +# Build from the package path (not individual files) so the .syso is included. # Using -H windowsgui to hide console window on Windows if ($IsWindows -or $env:OS -match "Windows") { - go build -ldflags "-H windowsgui" -o mremotego.exe cmd/mremotego-gui/main.go cmd/mremotego-gui/theme.go + go build -ldflags "-H windowsgui -X main.version=$version" -o mremotego.exe ./cmd/mremotego-gui/ $outputFile = "mremotego.exe" } else { - go build -o mremotego cmd/mremotego-gui/main.go cmd/mremotego-gui/theme.go + go build -ldflags "-X main.version=$version" -o mremotego ./cmd/mremotego-gui/ $outputFile = "mremotego" } diff --git a/cmd/mremotego-gui/main.go b/cmd/mremotego-gui/main.go index 6842fbf..a1ee2e3 100644 --- a/cmd/mremotego-gui/main.go +++ b/cmd/mremotego-gui/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -13,8 +14,16 @@ import ( "github.com/jaydenthorup/mremotego/cmd/mremotego/cmd" "github.com/jaydenthorup/mremotego/internal/config" "github.com/jaydenthorup/mremotego/internal/gui" + "github.com/jaydenthorup/mremotego/internal/launcher" + "github.com/jaydenthorup/mremotego/internal/update" ) +// version is set at build time via -ldflags "-X main.version=1.4.0" (see +// Makefile, build-gui.ps1, and .github/workflows/build.yml). Local `go build`/ +// `go run` invocations that don't set it fall back to "dev", which disables +// self-update checks (see update.CheckLatest). +var version = "dev" + func main() { // If command-line arguments are provided (other than just the program name), // run in CLI mode @@ -28,6 +37,9 @@ func main() { } func runGUI() { + launcher.CleanupOnStartup() + go update.CleanupOnStartup() // retries for a while; must not block startup + // Set locale to avoid warnings on non-standard systems if os.Getenv("LANG") == "" || os.Getenv("LANG") == "C" { os.Setenv("LANG", "en_US.UTF-8") @@ -53,7 +65,7 @@ func runGUI() { manager := config.NewManager(cfgPath) // Create the main window first but don't show it yet - mainWindow := gui.NewMainWindow(myApp, manager) + mainWindow := gui.NewMainWindow(myApp, manager, version) // Check if config exists and if it has encrypted passwords _, statErr := os.Stat(cfgPath) @@ -76,6 +88,9 @@ func runGUI() { } mainWindow.Reload() mainWindow.Show() + go notifyIfConfigOutdated(manager, mainWindow) + go startPeriodicGitCheck(manager, mainWindow) + go checkForUpdatesOnStartup(mainWindow) myApp.Run() } else { // Show password dialog @@ -84,6 +99,78 @@ func runGUI() { } } +// checkForUpdatesOnStartup silently checks for a newer release a few seconds +// after launch (giving the config-outdated dialog, if any, a chance to appear +// first so the two don't compete for the user's attention). A dialog is only +// shown when an update is actually available — network errors or already +// being up to date stay silent so the check never nags at startup. +func checkForUpdatesOnStartup(mainWindow *gui.MainWindow) { + time.Sleep(5 * time.Second) + mainWindow.CheckForUpdates(false) +} + +// startPeriodicGitCheck re-checks the git status every 15 minutes and updates +// the status bar label. No dialog is shown on periodic checks to avoid interrupting +// the user; the label change is enough to signal that a pull is available. +func startPeriodicGitCheck(manager *config.Manager, mainWindow *gui.MainWindow) { + ticker := time.NewTicker(15 * time.Minute) + for range ticker.C { + status := config.CheckConfigGitStatus(manager.GetConfigPath()) + fyne.Do(func() { + mainWindow.SetGitStatus(status) + }) + } +} + +func notifyIfConfigOutdated(manager *config.Manager, mainWindow *gui.MainWindow) { + status := config.CheckConfigGitStatus(manager.GetConfigPath()) + fyne.Do(func() { + mainWindow.SetGitStatus(status) + }) + + if status != config.GitStatusBehind { + return + } + + // notifyIfConfigOutdated runs in a background goroutine, so every Fyne call + // below (creating the dialog included) must be marshalled onto the main thread. + fyne.Do(func() { + msg := widget.NewLabel("The remote repository has newer commits.\nWould you like to pull the latest version of your config?") + msg.Wrapping = fyne.TextWrapWord + + dialog.ShowCustomConfirm( + "Config out of date", + "Pull latest", + "Dismiss", + msg, + func(pull bool) { + if !pull { + return + } + go func() { + if err := config.PullConfig(manager.GetConfigPath()); err != nil { + fyne.Do(func() { + dialog.ShowError(fmt.Errorf("git pull failed: %w", err), mainWindow.Window()) + }) + return + } + if err := manager.Load(); err != nil { + fyne.Do(func() { + dialog.ShowError(fmt.Errorf("failed to reload config after pull: %w", err), mainWindow.Window()) + }) + return + } + fyne.Do(func() { + mainWindow.Reload() + mainWindow.SetGitStatus(config.GitStatusUpToDate) + }) + }() + }, + mainWindow.Window(), + ) + }) +} + func showPasswordDialog(myApp fyne.App, manager *config.Manager, mainWindow *gui.MainWindow) { w := myApp.NewWindow("MremoteGO - Master Password") w.Resize(fyne.NewSize(400, 200)) @@ -130,6 +217,9 @@ func showPasswordDialog(myApp fyne.App, manager *config.Manager, mainWindow *gui // Close the password window and show main window w.Close() mainWindow.Show() + go notifyIfConfigOutdated(manager, mainWindow) + go startPeriodicGitCheck(manager, mainWindow) + go checkForUpdatesOnStartup(mainWindow) }) okButton.Importance = widget.HighImportance diff --git a/docs/1PASSWORD-SETUP.md b/docs/1PASSWORD-SETUP.md index f8ca31e..a33f3d3 100644 --- a/docs/1PASSWORD-SETUP.md +++ b/docs/1PASSWORD-SETUP.md @@ -23,6 +23,8 @@ choco install op 2. Go to **Settings → Developer** 3. Enable **"Integrate with 1Password CLI"** +![alt text](1password_integrate_CLI.png) + ### 3. Verify Setup ```powershell diff --git a/docs/1password_integrate_CLI.png b/docs/1password_integrate_CLI.png new file mode 100644 index 0000000..e6e6b01 Binary files /dev/null and b/docs/1password_integrate_CLI.png differ diff --git a/docs/PASSWORD-MANAGEMENT.md b/docs/PASSWORD-MANAGEMENT.md index 9833c71..f46dfe7 100644 --- a/docs/PASSWORD-MANAGEMENT.md +++ b/docs/PASSWORD-MANAGEMENT.md @@ -57,7 +57,7 @@ connections: ## RDP Auto-Login -MremoteGO uses **Windows Credential Manager** for seamless RDP connections. +On Windows, MremoteGO uses **Windows Credential Manager** for seamless RDP connections. For macOS, see [macOS](#macos) below. ### How It Works @@ -100,6 +100,12 @@ cmdkey /list | Select-String "TERMSRV" | ForEach-Object { } ``` +### macOS + +- **sdl-freerdp** (preferred, used automatically when installed with `brew install freerdp`): username, domain and password, gateway credentials included, are passed with `/args-from:stdin`. They never appear on the command line or in `ps`, and nothing is written to disk. +- **Windows App / Microsoft Remote Desktop** (fallback when FreeRDP is not installed): these clients ignore passwords in `.rdp` files. MremoteGO opens a temporary `.rdp` file and puts the password on the clipboard, marked so clipboard managers don't record it. It is removed after 45 seconds, unless something else was copied in the meantime. A separate RD Gateway password is not handed off. +- Force a client with `MREMOTEGO_MAC_RDP_CLIENT=freerdp` or `MREMOTEGO_MAC_RDP_CLIENT=windowsapp`. + ## SSH Password Handling ### Windows (PuTTY) @@ -209,3 +215,4 @@ password: mypassword123 - Use 1Password to minimize exposure - Passwords are only visible briefly during connection - Windows Credential Manager used for RDP (not in process list) +- macOS with sdl-freerdp: credentials go through stdin (not in process list) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index c0a0a02..8306166 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -112,7 +112,11 @@ See [docs/1PASSWORD-SETUP.md](docs/1PASSWORD-SETUP.md) for details. ### RDP - **Windows**: mstsc with Credential Manager - **Linux**: xfreerdp -- **Auto-login**: ✅ Seamless +- **macOS**: sdl-freerdp when installed (`brew install freerdp`), otherwise Windows App / Microsoft Remote Desktop + - With sdl-freerdp the 1Password credentials are passed in automatically (via stdin, not visible in `ps`) + - With Windows App the password is put on the clipboard for 45 seconds; paste it with Cmd+V when asked + - Force a client with `MREMOTEGO_MAC_RDP_CLIENT=freerdp` or `MREMOTEGO_MAC_RDP_CLIENT=windowsapp` +- **Auto-login**: ✅ Seamless (Windows, Linux, macOS with sdl-freerdp) ### VNC - Uses vncviewer @@ -162,7 +166,8 @@ Install PuTTY: `winget install PuTTY.PuTTY` Install CLI: `winget install 1Password.CLI` ### RDP asks for password -First connection stores credentials. Should auto-login after that. +- **Windows**: first connection stores credentials. Should auto-login after that. +- **macOS**: Windows App can't take the password; install FreeRDP (`brew install freerdp`) for auto-login, or paste the password from the clipboard (Cmd+V). ### Config file not found Default: `%APPDATA%\mremotego\config.yaml` diff --git a/internal/config/gitstatus.go b/internal/config/gitstatus.go new file mode 100644 index 0000000..b6f65c6 --- /dev/null +++ b/internal/config/gitstatus.go @@ -0,0 +1,117 @@ +package config + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// GitStatus describes whether the local repo is in sync with its remote. +type GitStatus int + +const ( + GitStatusUpToDate GitStatus = iota // local HEAD matches remote + GitStatusBehind // remote has commits not present locally + GitStatusNotGitRepo // config file is not inside a git repository + GitStatusUnknown // could not determine (no remote, network error, detached HEAD, etc.) +) + +// CheckConfigGitStatus compares the local HEAD hash of the repository containing +// configPath against the remote using git ls-remote (no fetch, no local state change). +// Returns GitStatusUnknown on any error so callers can silently skip the notification. +func CheckConfigGitStatus(configPath string) GitStatus { + dir := filepath.Dir(configPath) + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + // Confirm we are inside a git repository. + if err := gitCmd(dir, env, "rev-parse", "--is-inside-work-tree"); err != nil { + return GitStatusNotGitRepo + } + + // Determine the current branch name. + branch, err := gitOutput(dir, env, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil || branch == "HEAD" { + return GitStatusUnknown // detached HEAD or error + } + + // Get local HEAD commit hash. + localHash, err := gitOutput(dir, env, "rev-parse", "HEAD") + if err != nil { + return GitStatusUnknown + } + + // Query the remote for the branch tip — no local state is modified. + remoteHash, err := lsRemote(dir, env, branch) + if err != nil { + return GitStatusUnknown + } + + if remoteHash == localHash { + return GitStatusUpToDate + } + return GitStatusBehind +} + +// PullConfig runs git pull in the directory containing configPath. +// It uses a 30-second timeout and disables credential prompts so it never hangs. +func PullConfig(configPath string) error { + dir := filepath.Dir(configPath) + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "-C", dir, "pull") + cmd.Env = env + hideConsoleWindow(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// lsRemote runs git ls-remote with a 5-second timeout and returns the commit hash +// for the given branch, or an error if the remote cannot be reached or the branch +// is not found. +func lsRemote(dir string, env []string, branch string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "-C", dir, "ls-remote", "origin", branch) + cmd.Env = env + hideConsoleWindow(cmd) + out, err := cmd.Output() + if err != nil || ctx.Err() != nil { + return "", err + } + + // Output format: "\t\n" + fields := strings.Fields(string(out)) + if len(fields) == 0 { + return "", nil + } + return fields[0], nil +} + +func gitCmd(dir string, env []string, args ...string) error { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = env + hideConsoleWindow(cmd) + return cmd.Run() +} + +func gitOutput(dir string, env []string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = env + hideConsoleWindow(cmd) + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/config/gitstatus_unix.go b/internal/config/gitstatus_unix.go new file mode 100644 index 0000000..8f20707 --- /dev/null +++ b/internal/config/gitstatus_unix.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package config + +import "os/exec" + +func hideConsoleWindow(cmd *exec.Cmd) {} diff --git a/internal/config/gitstatus_windows.go b/internal/config/gitstatus_windows.go new file mode 100644 index 0000000..7fa44a2 --- /dev/null +++ b/internal/config/gitstatus_windows.go @@ -0,0 +1,16 @@ +//go:build windows +// +build windows + +package config + +import ( + "os/exec" + "syscall" +) + +func hideConsoleWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } +} diff --git a/internal/gui/dialogs.go b/internal/gui/dialogs.go index 93e3140..c8aae65 100644 --- a/internal/gui/dialogs.go +++ b/internal/gui/dialogs.go @@ -2,16 +2,64 @@ package gui import ( "fmt" + "net/url" "strconv" "strings" "time" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/jaydenthorup/mremotego/pkg/models" ) +// stableFormLayout is a two-column form layout with a fixed label column width, +// so field widths stay constant regardless of which rows are shown or hidden. +type stableFormLayout struct{ labelWidth float32 } + +func (l *stableFormLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + pad := theme.Padding() + fieldX := l.labelWidth + pad + fieldW := size.Width - fieldX + y := float32(0) + first := true + for i := 0; i+1 < len(objects); i += 2 { + label, field := objects[i], objects[i+1] + if !label.Visible() && !field.Visible() { + continue + } + if !first { + y += pad + } + first = false + rowH := fyne.Max(label.MinSize().Height, field.MinSize().Height) + label.Move(fyne.NewPos(0, y)) + label.Resize(fyne.NewSize(l.labelWidth, rowH)) + field.Move(fyne.NewPos(fieldX, y)) + field.Resize(fyne.NewSize(fieldW, rowH)) + y += rowH + } +} + +func (l *stableFormLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + pad := theme.Padding() + minH := float32(0) + first := true + for i := 0; i+1 < len(objects); i += 2 { + if !objects[i].Visible() && !objects[i+1].Visible() { + continue + } + if !first { + minH += pad + } + first = false + minH += fyne.Max(objects[i].MinSize().Height, objects[i+1].MinSize().Height) + } + return fyne.NewSize(l.labelWidth+pad, minH) +} + // collectAllFolders recursively collects all folders with their full paths func (w *MainWindow) collectAllFolders(connections []*models.Connection, prefix string, folderMap map[string]*models.Connection, folderNames *[]string) { for _, conn := range connections { @@ -127,89 +175,400 @@ func (w *MainWindow) showAddConnectionDialog() { folderSelect := widget.NewSelect(folderNames, nil) folderSelect.SetSelected("(Root)") + // RDP-specific widgets (shown for any RDP connection) + authLevelSelect := widget.NewSelect([]string{"Always connect, even if authentication fails", "Don't connect if authentication fails", "Warn me if authentication fails"}, nil) + authLevelSelect.SetSelected("Always connect, even if authentication fails") + authLevelLabel := widget.NewLabelWithStyle("Auth Level", fyne.TextAlignTrailing, fyne.TextStyle{}) + authLevelLabel.Hide() + authLevelSelect.Hide() + + useCredSSPCheck := widget.NewCheck("Use CredSSP", nil) + useCredSSPCheck.SetChecked(true) + credSSPLabel := widget.NewLabel("") + credSSPLabel.Hide() + useCredSSPCheck.Hide() + + redirectDrivesCheck := widget.NewCheck("Redirect local drives", nil) + redirectDrivesLabel := widget.NewLabel("") + redirectDrivesLabel.Hide() + redirectDrivesCheck.Hide() + + redirectDrivesModeSelect := widget.NewSelect([]string{"All drives", "All local disks only (no network drives)", "Dynamic drives", "Specific drives"}, nil) + redirectDrivesModeSelect.SetSelected("All drives") + redirectDrivesModeLabel := widget.NewLabelWithStyle("Drives to Redirect", fyne.TextAlignTrailing, fyne.TextStyle{}) + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + + redirectDrivesListEntry := widget.NewEntry() + redirectDrivesListEntry.SetPlaceHolder("e.g. C,D") + redirectDrivesListLabel := widget.NewLabelWithStyle("Drive Letters", fyne.TextAlignTrailing, fyne.TextStyle{}) + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + + // RD Gateway widgets + useGatewayCheck := widget.NewCheck("Use RD Gateway", nil) + + gatewayUsageSelect := widget.NewSelect([]string{"Never", "Always", "Detect"}, nil) + gatewayUsageSelect.SetSelected("Always") + gatewayHostnameEntry := widget.NewEntry() + gatewayHostnameEntry.SetPlaceHolder("gateway.example.com") + gatewayCredentialsSelect := widget.NewSelect([]string{ + "Use the same username and password", + "Use a different username and password", + "Use a smart card", + }, nil) + gatewayCredentialsSelect.SetSelected("Use the same username and password") + gatewayUsernameEntry := widget.NewEntry() + gatewayUsernameEntry.SetPlaceHolder("username or op://vault/item/field") + gatewayPasswordEntry := widget.NewEntry() + gatewayPasswordEntry.SetPlaceHolder("password or op://vault/item/field") + gatewayDomainEntry := widget.NewEntry() + gatewayDomainEntry.SetPlaceHolder("gateway domain") + + gatewayProfileSelect := widget.NewSelect([]string{"Use settings from this connection", "Use system default gateway profile"}, nil) + gatewayProfileSelect.SetSelected("Use settings from this connection") + gatewayBypassLocalCheck := widget.NewCheck("Bypass gateway for local addresses", nil) + + gatewayUsageLabel := widget.NewLabelWithStyle("Gateway Usage", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayHostnameLabel := widget.NewLabelWithStyle("Gateway Hostname", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayCredentialsLabel := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayUsernameLabel := widget.NewLabelWithStyle("Gateway Username", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayPasswordLabel := widget.NewLabelWithStyle("Gateway Password", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayDomainLabel := widget.NewLabelWithStyle("Gateway Domain", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayProfileLabel := widget.NewLabelWithStyle("Gateway Profile", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayBypassLocalLabel := widget.NewLabel("") + + // All gateway rows start hidden + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + // 1Password integration storeTo1PasswordCheck := widget.NewCheck("Store password in 1Password", nil) vaultSelect := widget.NewSelect([]string{"DevOps", "Private", "Employee"}, nil) vaultSelect.SetSelected("DevOps") + vaultLabel := widget.NewLabelWithStyle("Vault", fyne.TextAlignTrailing, fyne.TextStyle{}) + vaultLabel.Hide() vaultSelect.Hide() + gatewayCheckLabel := widget.NewLabel("") + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + + var formContainer *fyne.Container + + hideAllGatewayRows := func() { + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + storeTo1PasswordCheck.OnChanged = func(checked bool) { if checked { + vaultLabel.Show() vaultSelect.Show() } else { + vaultLabel.Hide() vaultSelect.Hide() } + formContainer.Refresh() } - form := &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Name", Widget: nameEntry}, - {Text: "Protocol", Widget: protocolSelect}, - {Text: "Host", Widget: hostEntry}, - {Text: "Port", Widget: portEntry}, - {Text: "Username", Widget: usernameEntry}, - {Text: "Password", Widget: passwordEntry}, - {Text: "Domain", Widget: domainEntry}, - {Text: "Description", Widget: descriptionEntry}, - {Text: "Folder", Widget: folderSelect}, - {Text: "", Widget: storeTo1PasswordCheck}, - {Text: "Vault", Widget: vaultSelect}, - }, - OnSubmit: func() { - conn := models.NewConnection(nameEntry.Text, models.Protocol(protocolSelect.Selected)) - conn.Host = hostEntry.Text - conn.Username = usernameEntry.Text - conn.Password = passwordEntry.Text - conn.Domain = domainEntry.Text - conn.Description = descriptionEntry.Text - conn.Created = time.Now().Format(time.RFC3339) - conn.Modified = conn.Created - - if portEntry.Text != "" { - if port, err := strconv.Atoi(portEntry.Text); err == nil { - conn.Port = port - } - } else { - conn.Port = conn.Protocol.GetDefaultPort() + showGatewayDifferentCreds := func(show bool) { + if show { + gatewayUsernameLabel.Show() + gatewayUsernameEntry.Show() + gatewayPasswordLabel.Show() + gatewayPasswordEntry.Show() + gatewayDomainLabel.Show() + gatewayDomainEntry.Show() + } else { + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + } + formContainer.Refresh() + } + + gatewayCredentialsSelect.OnChanged = func(selected string) { + showGatewayDifferentCreds(selected == "Use a different username and password") + } + + showRedirectDrivesList := func(show bool) { + if show { + redirectDrivesListLabel.Show() + redirectDrivesListEntry.Show() + } else { + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } + formContainer.Refresh() + } + + redirectDrivesModeSelect.OnChanged = func(selected string) { + showRedirectDrivesList(selected == "Specific drives") + } + + redirectDrivesCheck.OnChanged = func(checked bool) { + if checked { + redirectDrivesModeLabel.Show() + redirectDrivesModeSelect.Show() + showRedirectDrivesList(redirectDrivesModeSelect.Selected == "Specific drives") + } else { + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + showRedirectDrivesList(false) + } + formContainer.Refresh() + } + + useGatewayCheck.OnChanged = func(checked bool) { + if checked { + gatewayUsageLabel.Show() + gatewayUsageSelect.Show() + gatewayHostnameLabel.Show() + gatewayHostnameEntry.Show() + gatewayCredentialsLabel.Show() + gatewayCredentialsSelect.Show() + gatewayProfileLabel.Show() + gatewayProfileSelect.Show() + gatewayBypassLocalLabel.Show() + gatewayBypassLocalCheck.Show() + showGatewayDifferentCreds(gatewayCredentialsSelect.Selected == "Use a different username and password") + } else { + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + formContainer.Refresh() + } + + protocolSelect.OnChanged = func(selected string) { + if selected == "rdp" { + gatewayCheckLabel.Show() + useGatewayCheck.Show() + authLevelLabel.Show() + authLevelSelect.Show() + credSSPLabel.Show() + useCredSSPCheck.Show() + redirectDrivesLabel.Show() + redirectDrivesCheck.Show() + } else { + hideAllGatewayRows() + useGatewayCheck.SetChecked(false) + authLevelLabel.Hide() + authLevelSelect.Hide() + credSSPLabel.Hide() + useCredSSPCheck.Hide() + redirectDrivesLabel.Hide() + redirectDrivesCheck.Hide() + redirectDrivesCheck.SetChecked(false) + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } + formContainer.Refresh() + } + + labelMinWidth := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}).MinSize().Width + formContainer = container.New(&stableFormLayout{labelWidth: labelMinWidth}, + widget.NewLabelWithStyle("Name", fyne.TextAlignTrailing, fyne.TextStyle{}), nameEntry, + widget.NewLabelWithStyle("Protocol", fyne.TextAlignTrailing, fyne.TextStyle{}), protocolSelect, + widget.NewLabelWithStyle("Host", fyne.TextAlignTrailing, fyne.TextStyle{}), hostEntry, + widget.NewLabelWithStyle("Port", fyne.TextAlignTrailing, fyne.TextStyle{}), portEntry, + widget.NewLabelWithStyle("Username", fyne.TextAlignTrailing, fyne.TextStyle{}), usernameEntry, + widget.NewLabelWithStyle("Password", fyne.TextAlignTrailing, fyne.TextStyle{}), passwordEntry, + widget.NewLabelWithStyle("Domain", fyne.TextAlignTrailing, fyne.TextStyle{}), domainEntry, + widget.NewLabelWithStyle("Description", fyne.TextAlignTrailing, fyne.TextStyle{}), descriptionEntry, + widget.NewLabelWithStyle("Folder", fyne.TextAlignTrailing, fyne.TextStyle{}), folderSelect, + authLevelLabel, authLevelSelect, + credSSPLabel, useCredSSPCheck, + redirectDrivesLabel, redirectDrivesCheck, + redirectDrivesModeLabel, redirectDrivesModeSelect, + redirectDrivesListLabel, redirectDrivesListEntry, + gatewayCheckLabel, useGatewayCheck, + gatewayUsageLabel, gatewayUsageSelect, + gatewayHostnameLabel, gatewayHostnameEntry, + gatewayCredentialsLabel, gatewayCredentialsSelect, + gatewayUsernameLabel, gatewayUsernameEntry, + gatewayPasswordLabel, gatewayPasswordEntry, + gatewayDomainLabel, gatewayDomainEntry, + gatewayProfileLabel, gatewayProfileSelect, + gatewayBypassLocalLabel, gatewayBypassLocalCheck, + widget.NewLabel(""), storeTo1PasswordCheck, + vaultLabel, vaultSelect, + ) + + scroll := container.NewScroll(container.NewPadded(formContainer)) + scroll.SetMinSize(fyne.NewSize(500, 500)) + d := dialog.NewCustomConfirm("Add Connection", "Submit", "Close", scroll, func(confirmed bool) { + if !confirmed { + return + } + + conn := models.NewConnection(nameEntry.Text, models.Protocol(protocolSelect.Selected)) + conn.Host = hostEntry.Text + conn.Username = usernameEntry.Text + conn.Password = passwordEntry.Text + conn.Domain = domainEntry.Text + conn.Description = descriptionEntry.Text + conn.Created = time.Now().Format(time.RFC3339) + conn.Modified = conn.Created + + if conn.Protocol == models.ProtocolRDP { + conn.UseCredSSP = useCredSSPCheck.Checked + switch authLevelSelect.Selected { + case "Don't connect if authentication fails": + conn.RDPAuthenticationLevel = "fail" + case "Warn me if authentication fails": + conn.RDPAuthenticationLevel = "warn" + default: + conn.RDPAuthenticationLevel = "always" } - // If user wants to store in 1Password, create the item - if storeTo1PasswordCheck.Checked && conn.Password != "" && !w.manager.IsOnePasswordReference(conn.Password) { - vault := vaultSelect.Selected - reference, err := w.manager.CreateOnePasswordItem(vault, conn.Name, conn.Username, conn.Password) - if err != nil { - dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) - return + if redirectDrivesCheck.Checked { + switch redirectDrivesModeSelect.Selected { + case "Specific drives": + conn.RDPRedirectDrives = redirectDrivesListEntry.Text + case "All local disks only (no network drives)": + conn.RDPRedirectDrives = "local" + case "Dynamic drives": + conn.RDPRedirectDrives = "dynamic" + default: + conn.RDPRedirectDrives = "*" } - // Replace password with 1Password reference - conn.Password = reference - dialog.ShowInformation("Success", fmt.Sprintf("Password stored in 1Password vault '%s'", vault), w.window) + } else { + conn.RDPRedirectDrives = "" } + } - // Add to selected folder or root - selectedFolder := folderSelect.Selected - if selectedFolder == "(Root)" { - w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) + conn.UseGateway = useGatewayCheck.Checked + if conn.UseGateway { + conn.GatewayHostname = gatewayHostnameEntry.Text + switch gatewayUsageSelect.Selected { + case "Never": + conn.GatewayUsageMethod = "never" + case "Detect": + conn.GatewayUsageMethod = "detect" + default: + conn.GatewayUsageMethod = "always" + } + if gatewayProfileSelect.Selected == "Use system default gateway profile" { + conn.GatewayProfileUsageMethod = "default" } else { - // Find the folder and add to its children - folder := folderMap[selectedFolder] - if folder != nil { - folder.Children = append(folder.Children, conn) + conn.GatewayProfileUsageMethod = "explicit" + } + conn.GatewayBypassIfLocal = gatewayBypassLocalCheck.Checked + switch gatewayCredentialsSelect.Selected { + case "Use a different username and password": + conn.GatewayCredentials = "different" + conn.GatewayDomain = gatewayDomainEntry.Text + gwUser := gatewayUsernameEntry.Text + gwPass := gatewayPasswordEntry.Text + if storeTo1PasswordCheck.Checked && gwPass != "" && !w.manager.IsOnePasswordReference(gwPass) { + vault := vaultSelect.Selected + gwTitle := nameEntry.Text + " Gateway" + ref, err := w.manager.CreateOnePasswordItem(vault, gwTitle, gwUser, gwPass) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item for gateway: %w", err), w.window) + return + } + conn.GatewayPassword = ref + conn.GatewayUsername = fmt.Sprintf("op://%s/%s/username", vault, url.PathEscape(gwTitle)) + } else { + conn.GatewayUsername = gwUser + conn.GatewayPassword = gwPass } + case "Use a smart card": + conn.GatewayCredentials = "smartcard" + default: + conn.GatewayCredentials = "same" } + } - if err := w.manager.Save(); err != nil { - dialog.ShowError(err, w.window) + if portEntry.Text != "" { + if port, err := strconv.Atoi(portEntry.Text); err == nil { + conn.Port = port + } + } else { + conn.Port = conn.Protocol.GetDefaultPort() + } + + // If user wants to store in 1Password, create the item + if storeTo1PasswordCheck.Checked && conn.Password != "" && !w.manager.IsOnePasswordReference(conn.Password) { + vault := vaultSelect.Selected + reference, err := w.manager.CreateOnePasswordItem(vault, conn.Name, conn.Username, conn.Password) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) return } + conn.Password = reference + } - w.refreshTree() - dialog.ShowInformation("Success", "Connection added successfully", w.window) - }, - } + // Add to selected folder or root + selectedFolder := folderSelect.Selected + if selectedFolder == "(Root)" { + w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) + } else { + folder := folderMap[selectedFolder] + if folder != nil { + folder.Children = append(folder.Children, conn) + } + } + + if err := w.manager.Save(); err != nil { + dialog.ShowError(err, w.window) + return + } - d := dialog.NewCustom("Add Connection", "Close", form, w.window) - d.Resize(fyne.NewSize(500, 700)) + w.refreshTree() + }, w.window) + d.Resize(fyne.NewSize(620, 600)) d.Show() } @@ -303,108 +662,499 @@ func (w *MainWindow) showEditConnectionDialog(conn *models.Connection) { folderSelect := widget.NewSelect(folderNames, nil) folderSelect.SetSelected(currentFolder) + // RDP-specific widgets + authLevelSelect := widget.NewSelect([]string{"Always connect, even if authentication fails", "Don't connect if authentication fails", "Warn me if authentication fails"}, nil) + switch conn.RDPAuthenticationLevel { + case "fail": + authLevelSelect.SetSelected("Don't connect if authentication fails") + case "warn": + authLevelSelect.SetSelected("Warn me if authentication fails") + default: + authLevelSelect.SetSelected("Always connect, even if authentication fails") + } + authLevelLabel := widget.NewLabelWithStyle("Auth Level", fyne.TextAlignTrailing, fyne.TextStyle{}) + + useCredSSPCheck := widget.NewCheck("Use CredSSP", nil) + useCredSSPCheck.SetChecked(conn.UseCredSSP) + credSSPLabel := widget.NewLabel("") + + redirectDrivesCheck := widget.NewCheck("Redirect local drives", nil) + redirectDrivesCheck.SetChecked(conn.RDPRedirectDrives != "") + redirectDrivesLabel := widget.NewLabel("") + + redirectDrivesModeSelect := widget.NewSelect([]string{"All drives", "All local disks only (no network drives)", "Dynamic drives", "Specific drives"}, nil) + redirectDrivesModeLabel := widget.NewLabelWithStyle("Drives to Redirect", fyne.TextAlignTrailing, fyne.TextStyle{}) + + redirectDrivesListEntry := widget.NewEntry() + redirectDrivesListEntry.SetPlaceHolder("e.g. C,D") + redirectDrivesListLabel := widget.NewLabelWithStyle("Drive Letters", fyne.TextAlignTrailing, fyne.TextStyle{}) + + switch conn.RDPRedirectDrives { + case "", "*": + redirectDrivesModeSelect.SetSelected("All drives") + case "local": + redirectDrivesModeSelect.SetSelected("All local disks only (no network drives)") + case "dynamic": + redirectDrivesModeSelect.SetSelected("Dynamic drives") + default: + redirectDrivesModeSelect.SetSelected("Specific drives") + redirectDrivesListEntry.SetText(conn.RDPRedirectDrives) + } + + if conn.Protocol != "rdp" { + authLevelLabel.Hide() + authLevelSelect.Hide() + credSSPLabel.Hide() + useCredSSPCheck.Hide() + redirectDrivesLabel.Hide() + redirectDrivesCheck.Hide() + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } else if !redirectDrivesCheck.Checked { + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } else if redirectDrivesModeSelect.Selected != "Specific drives" { + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } + + // RD Gateway widgets + useGatewayCheck := widget.NewCheck("Use RD Gateway", nil) + useGatewayCheck.SetChecked(conn.UseGateway) + + gatewayUsageSelect := widget.NewSelect([]string{"Never", "Always", "Detect"}, nil) + switch conn.GatewayUsageMethod { + case "never": + gatewayUsageSelect.SetSelected("Never") + case "detect": + gatewayUsageSelect.SetSelected("Detect") + default: + gatewayUsageSelect.SetSelected("Always") + } + + gatewayHostnameEntry := widget.NewEntry() + gatewayHostnameEntry.SetPlaceHolder("gateway.example.com") + gatewayHostnameEntry.SetText(conn.GatewayHostname) + + gatewayCredentialsSelect := widget.NewSelect([]string{ + "Use the same username and password", + "Use a different username and password", + "Use a smart card", + }, nil) + switch conn.GatewayCredentials { + case "different": + gatewayCredentialsSelect.SetSelected("Use a different username and password") + case "smartcard": + gatewayCredentialsSelect.SetSelected("Use a smart card") + default: + gatewayCredentialsSelect.SetSelected("Use the same username and password") + } + + gatewayUsernameEntry := widget.NewEntry() + gatewayUsernameEntry.SetPlaceHolder("username or op://vault/item/field") + gatewayUsernameEntry.SetText(conn.GatewayUsername) + + gatewayPasswordEntry := widget.NewEntry() + gatewayPasswordEntry.SetPlaceHolder("password or op://vault/item/field") + gatewayPasswordEntry.SetText(conn.GatewayPassword) + + gatewayDomainEntry := widget.NewEntry() + gatewayDomainEntry.SetPlaceHolder("gateway domain") + gatewayDomainEntry.SetText(conn.GatewayDomain) + + gatewayProfileSelect := widget.NewSelect([]string{"Use settings from this connection", "Use system default gateway profile"}, nil) + if conn.GatewayProfileUsageMethod == "default" { + gatewayProfileSelect.SetSelected("Use system default gateway profile") + } else { + gatewayProfileSelect.SetSelected("Use settings from this connection") + } + + gatewayBypassLocalCheck := widget.NewCheck("Bypass gateway for local addresses", nil) + gatewayBypassLocalCheck.SetChecked(conn.GatewayBypassIfLocal) + + gatewayUsageLabel := widget.NewLabelWithStyle("Gateway Usage", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayHostnameLabel := widget.NewLabelWithStyle("Gateway Hostname", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayCredentialsLabel := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayUsernameLabel := widget.NewLabelWithStyle("Gateway Username", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayPasswordLabel := widget.NewLabelWithStyle("Gateway Password", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayDomainLabel := widget.NewLabelWithStyle("Gateway Domain", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayProfileLabel := widget.NewLabelWithStyle("Gateway Profile", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayBypassLocalLabel := widget.NewLabel("") + + // Set initial gateway row visibility + if !conn.UseGateway { + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } else if conn.GatewayCredentials != "different" { + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + } + // 1Password integration for edit storeTo1PasswordCheck := widget.NewCheck("Push password to 1Password", nil) vaultSelect := widget.NewSelect([]string{"DevOps", "Private", "Employee"}, nil) vaultSelect.SetSelected("DevOps") + vaultLabel := widget.NewLabelWithStyle("Vault", fyne.TextAlignTrailing, fyne.TextStyle{}) + vaultLabel.Hide() vaultSelect.Hide() + // Show gateway checkbox only when protocol is RDP + gatewayCheckLabel := widget.NewLabel("") + if conn.Protocol != "rdp" { + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + } + + var formContainer *fyne.Container + + hideAllGatewayRows := func() { + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + storeTo1PasswordCheck.OnChanged = func(checked bool) { if checked { + vaultLabel.Show() vaultSelect.Show() } else { + vaultLabel.Hide() vaultSelect.Hide() } + formContainer.Refresh() } - form := &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Name", Widget: nameEntry}, - {Text: "Protocol", Widget: protocolSelect}, - {Text: "Host", Widget: hostEntry}, - {Text: "Port", Widget: portEntry}, - {Text: "Username", Widget: usernameEntry}, - {Text: "Password", Widget: passwordEntry}, - {Text: "Domain", Widget: domainEntry}, - {Text: "Description", Widget: descriptionEntry}, - {Text: "Folder", Widget: folderSelect}, - {Text: "", Widget: storeTo1PasswordCheck}, - {Text: "Vault", Widget: vaultSelect}, - }, - OnSubmit: func() { - // If user wants to push password to 1Password - if storeTo1PasswordCheck.Checked && passwordEntry.Text != "" && !w.manager.IsOnePasswordReference(passwordEntry.Text) { - vault := vaultSelect.Selected - reference, err := w.manager.CreateOnePasswordItem(vault, nameEntry.Text, usernameEntry.Text, passwordEntry.Text) - if err != nil { - dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) - return - } - // Replace password with 1Password reference - conn.Password = reference - dialog.ShowInformation("Success", fmt.Sprintf("Password stored in 1Password vault '%s'", vault), w.window) - } else { - conn.Password = passwordEntry.Text + showGatewayDifferentCreds := func(show bool) { + if show { + gatewayUsernameLabel.Show() + gatewayUsernameEntry.Show() + gatewayPasswordLabel.Show() + gatewayPasswordEntry.Show() + gatewayDomainLabel.Show() + gatewayDomainEntry.Show() + } else { + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + } + formContainer.Refresh() + } + + gatewayCredentialsSelect.OnChanged = func(selected string) { + showGatewayDifferentCreds(selected == "Use a different username and password") + } + + showRedirectDrivesList := func(show bool) { + if show { + redirectDrivesListLabel.Show() + redirectDrivesListEntry.Show() + } else { + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } + formContainer.Refresh() + } + + redirectDrivesModeSelect.OnChanged = func(selected string) { + showRedirectDrivesList(selected == "Specific drives") + } + + redirectDrivesCheck.OnChanged = func(checked bool) { + if checked { + redirectDrivesModeLabel.Show() + redirectDrivesModeSelect.Show() + showRedirectDrivesList(redirectDrivesModeSelect.Selected == "Specific drives") + } else { + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + showRedirectDrivesList(false) + } + formContainer.Refresh() + } + + useGatewayCheck.OnChanged = func(checked bool) { + if checked { + gatewayUsageLabel.Show() + gatewayUsageSelect.Show() + gatewayHostnameLabel.Show() + gatewayHostnameEntry.Show() + gatewayCredentialsLabel.Show() + gatewayCredentialsSelect.Show() + gatewayProfileLabel.Show() + gatewayProfileSelect.Show() + gatewayBypassLocalLabel.Show() + gatewayBypassLocalCheck.Show() + showGatewayDifferentCreds(gatewayCredentialsSelect.Selected == "Use a different username and password") + } else { + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + formContainer.Refresh() + } + + protocolSelect.OnChanged = func(selected string) { + if selected == "rdp" { + gatewayCheckLabel.Show() + useGatewayCheck.Show() + authLevelLabel.Show() + authLevelSelect.Show() + credSSPLabel.Show() + useCredSSPCheck.Show() + redirectDrivesLabel.Show() + redirectDrivesCheck.Show() + } else { + hideAllGatewayRows() + useGatewayCheck.SetChecked(false) + authLevelLabel.Hide() + authLevelSelect.Hide() + credSSPLabel.Hide() + useCredSSPCheck.Hide() + redirectDrivesLabel.Hide() + redirectDrivesCheck.Hide() + redirectDrivesCheck.SetChecked(false) + redirectDrivesModeLabel.Hide() + redirectDrivesModeSelect.Hide() + redirectDrivesListLabel.Hide() + redirectDrivesListEntry.Hide() + } + formContainer.Refresh() + } + + labelMinWidth := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}).MinSize().Width + formContainer = container.New(&stableFormLayout{labelWidth: labelMinWidth}, + widget.NewLabelWithStyle("Name", fyne.TextAlignTrailing, fyne.TextStyle{}), nameEntry, + widget.NewLabelWithStyle("Protocol", fyne.TextAlignTrailing, fyne.TextStyle{}), protocolSelect, + widget.NewLabelWithStyle("Host", fyne.TextAlignTrailing, fyne.TextStyle{}), hostEntry, + widget.NewLabelWithStyle("Port", fyne.TextAlignTrailing, fyne.TextStyle{}), portEntry, + widget.NewLabelWithStyle("Username", fyne.TextAlignTrailing, fyne.TextStyle{}), usernameEntry, + widget.NewLabelWithStyle("Password", fyne.TextAlignTrailing, fyne.TextStyle{}), passwordEntry, + widget.NewLabelWithStyle("Domain", fyne.TextAlignTrailing, fyne.TextStyle{}), domainEntry, + widget.NewLabelWithStyle("Description", fyne.TextAlignTrailing, fyne.TextStyle{}), descriptionEntry, + widget.NewLabelWithStyle("Folder", fyne.TextAlignTrailing, fyne.TextStyle{}), folderSelect, + authLevelLabel, authLevelSelect, + credSSPLabel, useCredSSPCheck, + redirectDrivesLabel, redirectDrivesCheck, + redirectDrivesModeLabel, redirectDrivesModeSelect, + redirectDrivesListLabel, redirectDrivesListEntry, + gatewayCheckLabel, useGatewayCheck, + gatewayUsageLabel, gatewayUsageSelect, + gatewayHostnameLabel, gatewayHostnameEntry, + gatewayCredentialsLabel, gatewayCredentialsSelect, + gatewayUsernameLabel, gatewayUsernameEntry, + gatewayPasswordLabel, gatewayPasswordEntry, + gatewayDomainLabel, gatewayDomainEntry, + gatewayProfileLabel, gatewayProfileSelect, + gatewayBypassLocalLabel, gatewayBypassLocalCheck, + widget.NewLabel(""), storeTo1PasswordCheck, + vaultLabel, vaultSelect, + ) + + scroll := container.NewScroll(container.NewPadded(formContainer)) + scroll.SetMinSize(fyne.NewSize(500, 500)) + d := dialog.NewCustomConfirm("Edit Connection", "Submit", "Close", scroll, func(confirmed bool) { + if !confirmed { + return + } + + // If user wants to push password to 1Password + if storeTo1PasswordCheck.Checked && passwordEntry.Text != "" && !w.manager.IsOnePasswordReference(passwordEntry.Text) { + vault := vaultSelect.Selected + reference, err := w.manager.CreateOnePasswordItem(vault, nameEntry.Text, usernameEntry.Text, passwordEntry.Text) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) + return } + conn.Password = reference + } else { + conn.Password = passwordEntry.Text + } - conn.Name = nameEntry.Text - conn.Protocol = models.Protocol(protocolSelect.Selected) - conn.Host = hostEntry.Text - conn.Username = usernameEntry.Text - conn.Domain = domainEntry.Text - conn.Description = descriptionEntry.Text - conn.Modified = time.Now().Format(time.RFC3339) + conn.Name = nameEntry.Text + conn.Protocol = models.Protocol(protocolSelect.Selected) + conn.Host = hostEntry.Text + conn.Username = usernameEntry.Text + conn.Domain = domainEntry.Text + conn.Description = descriptionEntry.Text + conn.Modified = time.Now().Format(time.RFC3339) - if port, err := strconv.Atoi(portEntry.Text); err == nil { - conn.Port = port + if conn.Protocol == models.ProtocolRDP { + conn.UseCredSSP = useCredSSPCheck.Checked + switch authLevelSelect.Selected { + case "Don't connect if authentication fails": + conn.RDPAuthenticationLevel = "fail" + case "Warn me if authentication fails": + conn.RDPAuthenticationLevel = "warn" + default: + conn.RDPAuthenticationLevel = "always" } - // Handle folder change - selectedFolder := folderSelect.Selected - if selectedFolder != currentFolder { - // Remove from old parent - if parentFolder != nil { - // Remove from parent's children - for i, child := range parentFolder.Children { - if child == conn { - parentFolder.Children = append(parentFolder.Children[:i], parentFolder.Children[i+1:]...) - break - } + if redirectDrivesCheck.Checked { + switch redirectDrivesModeSelect.Selected { + case "Specific drives": + conn.RDPRedirectDrives = redirectDrivesListEntry.Text + case "All local disks only (no network drives)": + conn.RDPRedirectDrives = "local" + case "Dynamic drives": + conn.RDPRedirectDrives = "dynamic" + default: + conn.RDPRedirectDrives = "*" + } + } else { + conn.RDPRedirectDrives = "" + } + } + + conn.UseGateway = useGatewayCheck.Checked + if conn.UseGateway { + conn.GatewayHostname = gatewayHostnameEntry.Text + switch gatewayUsageSelect.Selected { + case "Never": + conn.GatewayUsageMethod = "never" + case "Detect": + conn.GatewayUsageMethod = "detect" + default: + conn.GatewayUsageMethod = "always" + } + if gatewayProfileSelect.Selected == "Use system default gateway profile" { + conn.GatewayProfileUsageMethod = "default" + } else { + conn.GatewayProfileUsageMethod = "explicit" + } + conn.GatewayBypassIfLocal = gatewayBypassLocalCheck.Checked + switch gatewayCredentialsSelect.Selected { + case "Use a different username and password": + conn.GatewayCredentials = "different" + conn.GatewayDomain = gatewayDomainEntry.Text + gwUser := gatewayUsernameEntry.Text + gwPass := gatewayPasswordEntry.Text + if storeTo1PasswordCheck.Checked && gwPass != "" && !w.manager.IsOnePasswordReference(gwPass) { + vault := vaultSelect.Selected + gwTitle := nameEntry.Text + " Gateway" + ref, err := w.manager.CreateOnePasswordItem(vault, gwTitle, gwUser, gwPass) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item for gateway: %w", err), w.window) + return } + conn.GatewayPassword = ref + conn.GatewayUsername = fmt.Sprintf("op://%s/%s/username", vault, url.PathEscape(gwTitle)) } else { - // Remove from root - for i, c := range w.manager.GetConfig().Connections { - if c == conn { - w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections[:i], w.manager.GetConfig().Connections[i+1:]...) - break - } - } + conn.GatewayUsername = gwUser + conn.GatewayPassword = gwPass } + case "Use a smart card": + conn.GatewayCredentials = "smartcard" + default: + conn.GatewayCredentials = "same" + } + } else { + conn.GatewayHostname = "" + conn.GatewayUsageMethod = "" + conn.GatewayCredentials = "" + conn.GatewayUsername = "" + conn.GatewayPassword = "" + conn.GatewayDomain = "" + conn.GatewayProfileUsageMethod = "" + conn.GatewayBypassIfLocal = false + } - // Add to new parent - if selectedFolder == "(Root)" { - w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) - } else { - newParent := folderMap[selectedFolder] - if newParent != nil { - newParent.Children = append(newParent.Children, conn) + if port, err := strconv.Atoi(portEntry.Text); err == nil { + conn.Port = port + } + + // Handle folder change + selectedFolder := folderSelect.Selected + if selectedFolder != currentFolder { + // Remove from old parent + if parentFolder != nil { + for i, child := range parentFolder.Children { + if child == conn { + parentFolder.Children = append(parentFolder.Children[:i], parentFolder.Children[i+1:]...) + break + } + } + } else { + for i, c := range w.manager.GetConfig().Connections { + if c == conn { + w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections[:i], w.manager.GetConfig().Connections[i+1:]...) + break } } } - if err := w.manager.Save(); err != nil { - dialog.ShowError(err, w.window) - return + // Add to new parent + if selectedFolder == "(Root)" { + w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) + } else { + newParent := folderMap[selectedFolder] + if newParent != nil { + newParent.Children = append(newParent.Children, conn) + } } + } - w.refreshTree() - w.updateDetailsPanel(conn) - dialog.ShowInformation("Success", "Connection updated successfully", w.window) - }, - } + if err := w.manager.Save(); err != nil { + dialog.ShowError(err, w.window) + return + } - d := dialog.NewCustom("Edit Connection", "Close", form, w.window) - d.Resize(fyne.NewSize(500, 700)) + w.refreshTree() + w.updateDetailsPanel(conn) + }, w.window) + d.Resize(fyne.NewSize(620, 600)) d.Show() } diff --git a/internal/gui/mainwindow.go b/internal/gui/mainwindow.go index 0b707cc..f05c8f4 100644 --- a/internal/gui/mainwindow.go +++ b/internal/gui/mainwindow.go @@ -21,6 +21,7 @@ type MainWindow struct { window fyne.Window manager *config.Manager launcher *launcher.Launcher + version string tree *widget.Tree detailsCard *widget.Card selectedConn *models.Connection @@ -28,26 +29,57 @@ type MainWindow struct { searchEntry *widget.Entry allConnections []*models.Connection filteredIDs []string - statusLabel *widget.Label - activeSessions *widget.List - sessionList []*models.Connection + statusLabel *widget.Label + gitStatusLabel *widget.Label + activeSessions *widget.List + sessionList []*models.Connection + detailsConnectBtn *widget.Button + detailsStatusContainer *fyne.Container } -// NewMainWindow creates a new main window -func NewMainWindow(app fyne.App, manager *config.Manager) *MainWindow { +// NewMainWindow creates a new main window. version is the running build's +// version string (e.g. "1.4.0", or "dev" for local builds), used for the +// About dialog and self-update checks. +func NewMainWindow(app fyne.App, manager *config.Manager, version string) *MainWindow { w := &MainWindow{ app: app, window: app.NewWindow("MremoteGO - Remote Connection Manager"), manager: manager, launcher: launcher.NewLauncher(), + version: version, connectionData: make(map[string]*models.Connection), } w.setupUI() w.setupKeyboardShortcuts() + w.launcher.Notify = func(message string) { + fyne.Do(func() { w.statusLabel.SetText(message) }) + } return w } +// Window returns the underlying Fyne window. +func (w *MainWindow) Window() fyne.Window { + return w.window +} + +// ShowInfo displays a non-blocking information dialog on the main window. +func (w *MainWindow) ShowInfo(title, message string) { + dialog.ShowInformation(title, message, w.window) +} + +// SetGitStatus updates the git sync indicator in the status bar. +func (w *MainWindow) SetGitStatus(status config.GitStatus) { + switch status { + case config.GitStatusUpToDate: + w.gitStatusLabel.SetText("✓ Config up to date") + case config.GitStatusBehind: + w.gitStatusLabel.SetText("↓ Config behind") + default: + w.gitStatusLabel.SetText("") + } +} + // setupKeyboardShortcuts sets up keyboard shortcuts func (w *MainWindow) setupKeyboardShortcuts() { // Ctrl+F for search focus @@ -92,9 +124,8 @@ func (w *MainWindow) setupUI() { // Create status bar w.statusLabel = widget.NewLabel("Ready") - statusBar := container.NewHBox( - w.statusLabel, - ) + w.gitStatusLabel = widget.NewLabel("") + statusBar := container.NewBorder(nil, nil, nil, w.gitStatusLabel, w.statusLabel) // Main content with toolbar and status bar content := container.NewBorder( @@ -134,6 +165,7 @@ func (w *MainWindow) setupMenuBar() { ) helpMenu := fyne.NewMenu("Help", + fyne.NewMenuItem("Check for Updates...", func() { go w.CheckForUpdates(true) }), fyne.NewMenuItem("About", func() { w.showAbout() }), ) @@ -236,7 +268,7 @@ func (w *MainWindow) createConnectionTree() *widget.Tree { }, // Create func(branch bool) fyne.CanvasObject { - return widget.NewLabel("Template") + return newNodeLabel() }, // Update func(uid string, branch bool, obj fyne.CanvasObject) { @@ -244,10 +276,17 @@ func (w *MainWindow) createConnectionTree() *widget.Tree { if !exists { return } - - label := obj.(*widget.Label) - icon := w.getConnectionIcon(conn) - label.SetText(icon + " " + conn.Name) + n := obj.(*nodeLabel) + n.SetText(w.getConnectionIcon(conn) + " " + conn.Name) + n.onTap = func() { + w.tree.Select(uid) + w.window.Canvas().Focus(w.tree) + } + if !conn.IsFolder() { + n.onDoubleTap = func() { w.connectWithProgress(conn) } + } else { + n.onDoubleTap = nil + } }, ) @@ -311,6 +350,23 @@ func (w *MainWindow) createDetailsPanel() *fyne.Container { ) } +// titleTheme enlarges text to match a card title +type titleTheme struct{ fyne.Theme } + +func (t titleTheme) Size(name fyne.ThemeSizeName) float32 { + if name == theme.SizeNameText { + return t.Theme.Size(theme.SizeNameSubHeadingText) + } + return t.Theme.Size(name) +} + +// selectableLabel returns a label whose text can be selected and copied +func selectableLabel(text string) *widget.Label { + l := widget.NewLabel(text) + l.Selectable = true + return l +} + // updateDetailsPanel updates the details panel with connection info func (w *MainWindow) updateDetailsPanel(conn *models.Connection) { if conn.IsFolder() { @@ -322,11 +378,18 @@ func (w *MainWindow) updateDetailsPanel(conn *models.Connection) { } icon := w.getConnectionIcon(conn) - w.detailsCard.SetTitle(icon + " " + conn.Name) - w.detailsCard.SetSubTitle(string(conn.Protocol)) + // Card titles are not selectable, so show the name as a selectable label instead + w.detailsCard.SetTitle("") + w.detailsCard.SetSubTitle("") + + nameLabel := selectableLabel(icon + " " + conn.Name) + nameLabel.TextStyle = fyne.TextStyle{Bold: true} + protocolLabel := widget.NewLabel(string(conn.Protocol)) details := container.NewVBox( - widget.NewLabel("Host: "+conn.Host), + container.NewThemeOverride(nameLabel, titleTheme{theme.Current()}), + protocolLabel, + selectableLabel("Host: "+conn.Host), widget.NewLabel(fmt.Sprintf("Port: %d", conn.Port)), ) @@ -358,10 +421,12 @@ func (w *MainWindow) updateDetailsPanel(conn *models.Connection) { // Add action buttons details.Add(widget.NewLabel("")) - connectBtn := widget.NewButton("🚀 Connect", func() { - w.connectToConnection(conn) + + w.detailsStatusContainer = container.NewVBox() + w.detailsConnectBtn = widget.NewButton("🚀 Connect", func() { + w.connectWithProgress(conn) }) - connectBtn.Importance = widget.HighImportance + w.detailsConnectBtn.Importance = widget.HighImportance editBtn := widget.NewButton("✏️ Edit", func() { w.showEditConnectionDialog(conn) @@ -372,8 +437,9 @@ func (w *MainWindow) updateDetailsPanel(conn *models.Connection) { }) deleteBtn.Importance = widget.DangerImportance - actionButtons := container.NewGridWithColumns(3, connectBtn, editBtn, deleteBtn) + actionButtons := container.NewGridWithColumns(3, w.detailsConnectBtn, editBtn, deleteBtn) details.Add(actionButtons) + details.Add(w.detailsStatusContainer) w.detailsCard.SetContent(details) } @@ -441,12 +507,40 @@ func (w *MainWindow) connectToSelected() { } func (w *MainWindow) connectToConnection(conn *models.Connection) { - if err := w.launcher.Launch(conn); err != nil { - dialog.ShowError(fmt.Errorf("Failed to launch connection: %w", err), w.window) - return - } + go func() { + err := w.launcher.Launch(conn) + if err != nil { + fyne.Do(func() { + dialog.ShowError(fmt.Errorf("Failed to launch connection: %w", err), w.window) + }) + } + }() +} - dialog.ShowInformation("Connected", fmt.Sprintf("Launched connection to %s", conn.Name), w.window) +func (w *MainWindow) connectWithProgress(conn *models.Connection) { + if w.detailsConnectBtn != nil { + w.detailsConnectBtn.Disable() + } + if w.detailsStatusContainer != nil { + w.detailsStatusContainer.RemoveAll() + w.detailsStatusContainer.Add(widget.NewProgressBarInfinite()) + w.detailsStatusContainer.Refresh() + } + go func() { + err := w.launcher.Launch(conn) + fyne.Do(func() { + if w.detailsStatusContainer != nil { + w.detailsStatusContainer.RemoveAll() + w.detailsStatusContainer.Refresh() + } + if w.detailsConnectBtn != nil { + w.detailsConnectBtn.Enable() + } + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to launch connection: %w", err), w.window) + } + }) + }() } func (w *MainWindow) editSelected() { @@ -546,15 +640,27 @@ func (w *MainWindow) exportConfig() { } func (w *MainWindow) showAbout() { + version := w.version + if version == "" { + version = "dev" + } dialog.ShowInformation("About MremoteGO", - "MremoteGO v1.0\n\nA Git-compatible remote connection manager\n\n"+ - "Built with Go and Fyne\n\n"+ - "© 2026 Your Name", + fmt.Sprintf("MremoteGO %s\n\nA Git-compatible remote connection manager\n\n"+ + "Built with Go and Fyne", version), w.window) } +// Quit performs the same cleanup as closing the window, then exits the app. +// Exported so the self-update flow can call it after relaunching into a new +// version. +func (w *MainWindow) Quit() { + w.launcher.Cleanup() + w.app.Quit() +} + // Show displays the main window func (w *MainWindow) Show() { + w.window.SetCloseIntercept(w.Quit) w.window.Show() // Check 1Password CLI authentication status after window is shown @@ -583,26 +689,73 @@ func (w *MainWindow) check1PasswordAuth() { return } - // Check if 1Password CLI is authenticated + // Only verify that the CLI is present in PATH. Running `op whoami` to + // check the live authentication status spawns a visible console window + // on Windows for ~2s at startup. Authentication errors surface when a + // reference is actually resolved in the launcher. opProvider := w.launcher.GetOnePasswordProvider() - if opProvider.IsEnabled() && !opProvider.IsAuthenticated() { - // Show a helpful dialog - content := widget.NewLabel(opProvider.GetAuthenticationInstructions()) + if !opProvider.IsEnabled() { + content := widget.NewLabel("Your connections use 1Password references (op://...) but the 1Password CLI (op) was not found in PATH. Install it and ensure it is on your PATH.") content.Wrapping = fyne.TextWrapWord scrollContainer := container.NewVScroll(content) - scrollContainer.SetMinSize(fyne.NewSize(600, 400)) + scrollContainer.SetMinSize(fyne.NewSize(600, 200)) dialog.ShowCustom( - "1Password CLI Not Authenticated", + "1Password CLI Not Installed", "OK", scrollContainer, w.window, ) } -} // Reload refreshes the window with the loaded config +} + +// Reload refreshes the window with the loaded config func (w *MainWindow) Reload() { w.buildConnectionMap() w.tree.Refresh() w.updateStatus() } + +// nodeLabel is the content widget for each tree node. +// It must implement both Tappable and DoubleTappable: once a widget implements +// DoubleTappable, Fyne routes the initial tap to it and never lets it reach +// the parent treeNode, so we have to drive tree.Select ourselves via onTap. +type nodeLabel struct { + widget.BaseWidget + lbl *widget.Label + onTap func() + onDoubleTap func() +} + +func newNodeLabel() *nodeLabel { + n := &nodeLabel{lbl: widget.NewLabel("")} + n.ExtendBaseWidget(n) + return n +} + +func (n *nodeLabel) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(n.lbl) +} + +func (n *nodeLabel) SetText(text string) { + n.lbl.SetText(text) +} + +func (n *nodeLabel) Tapped(*fyne.PointEvent) { + if n.onTap != nil { + n.onTap() + } +} + +func (n *nodeLabel) DoubleTapped(*fyne.PointEvent) { + // Fyne fires Tapped OR DoubleTapped — never both for the same click sequence. + // Run onTap first so the item is selected and the details panel is populated + // before the connection starts. + if n.onTap != nil { + n.onTap() + } + if n.onDoubleTap != nil { + n.onDoubleTap() + } +} diff --git a/internal/gui/updates.go b/internal/gui/updates.go new file mode 100644 index 0000000..c2715ca --- /dev/null +++ b/internal/gui/updates.go @@ -0,0 +1,101 @@ +package gui + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" + "github.com/jaydenthorup/mremotego/internal/update" +) + +// CheckForUpdates checks GitHub for a newer release than the running build. +// It performs network I/O, so callers should invoke it via `go w.CheckForUpdates(...)` +// rather than calling it directly on the UI goroutine. +// +// notifyIfUpToDate controls whether "you're up to date" / "check failed" are +// surfaced: true for the manual "Check for Updates..." menu action, false for +// the silent background check on startup (which should only interrupt the +// user when there's actually something to do). +func (w *MainWindow) CheckForUpdates(notifyIfUpToDate bool) { + rel, ok, err := update.CheckLatest(w.version) + + fyne.Do(func() { + if err != nil { + if notifyIfUpToDate { + dialog.ShowError(fmt.Errorf("Failed to check for updates: %w", err), w.window) + } + return + } + if !ok { + if notifyIfUpToDate { + dialog.ShowInformation("Up to Date", fmt.Sprintf("You're running the latest version (%s).", w.displayVersion()), w.window) + } + return + } + w.promptToInstallUpdate(rel) + }) +} + +// displayVersion returns w.version, falling back to "dev" for local builds +// that weren't built with the version ldflag set. +func (w *MainWindow) displayVersion() string { + if w.version == "" { + return "dev" + } + return w.version +} + +// promptToInstallUpdate shows the available release's notes and asks the user +// to confirm before downloading and installing it. Must run on the UI thread. +func (w *MainWindow) promptToInstallUpdate(rel *update.Release) { + notes := rel.Notes + if notes == "" { + notes = "(no release notes provided)" + } + notesLabel := widget.NewLabel(notes) + notesLabel.Wrapping = fyne.TextWrapWord + + notesScroll := container.NewVScroll(notesLabel) + notesScroll.SetMinSize(fyne.NewSize(420, 220)) + + content := container.NewVBox( + widget.NewLabel(fmt.Sprintf("Version %s is available. You have %s.", rel.Version, w.displayVersion())), + widget.NewLabel("Release notes:"), + notesScroll, + ) + + dialog.ShowCustomConfirm("Update Available", "Update Now", "Later", content, func(confirmed bool) { + if !confirmed { + return + } + w.installUpdate(rel) + }, w.window) +} + +// installUpdate downloads rel, replaces the running executable, and +// relaunches into it. Must run on the UI thread (it shows a progress dialog +// synchronously before backgrounding the actual work). +func (w *MainWindow) installUpdate(rel *update.Release) { + progress := dialog.NewCustomWithoutButtons("Updating MremoteGO", + container.NewVBox( + widget.NewLabel(fmt.Sprintf("Downloading and installing version %s...", rel.Version)), + widget.NewProgressBarInfinite(), + ), + w.window) + progress.Show() + + go func() { + err := update.Apply(rel) + fyne.Do(func() { + progress.Hide() + if err != nil { + dialog.ShowError(fmt.Errorf("Update failed: %w", err), w.window) + return + } + // update.Apply already spawned the new version; hand off to it. + w.Quit() + }) + }() +} diff --git a/internal/launcher/clipboard_darwin.go b/internal/launcher/clipboard_darwin.go new file mode 100644 index 0000000..da60c91 --- /dev/null +++ b/internal/launcher/clipboard_darwin.go @@ -0,0 +1,68 @@ +//go:build darwin + +package launcher + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + "sync" +) + +// jxaCopyConcealed reads a secret from stdin and puts it on the general +// pasteboard, marked with the nspasteboard.org Concealed and Transient types so +// clipboard managers (Maccy, Alfred, Raycast, 1Password, ...) don't record it. +// Prints the pasteboard's changeCount so the clear step can tell whether the +// clipboard was overwritten since. +const jxaCopyConcealed = `ObjC.import('AppKit');` + + `var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;` + + `var str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);` + + `var pb = $.NSPasteboard.generalPasteboard;` + + `pb.clearContents;` + + `pb.setStringForType(str, $.NSPasteboardTypeString);` + + `pb.setStringForType($(''), $('org.nspasteboard.ConcealedType'));` + + `pb.setStringForType($(''), $('org.nspasteboard.TransientType'));` + + `String(pb.changeCount);` + +// jxaClearIfUnchanged clears the pasteboard only if its changeCount still +// matches (%d), i.e. the user hasn't copied anything else in the meantime. +const jxaClearIfUnchanged = `ObjC.import('AppKit');` + + `var pb = $.NSPasteboard.generalPasteboard;` + + `if (pb.changeCount == %d) { pb.clearContents; }` + + `'';` + +// copySecretToClipboard puts secret on the macOS clipboard without exposing it +// on a command line, and returns a function that removes it again, provided the +// clipboard still holds it. The returned function is safe to call more than once. +func copySecretToClipboard(secret string) (clear func(), err error) { + if out, err := runWithStdin(secret, "osascript", "-l", "JavaScript", "-e", jxaCopyConcealed); err == nil { + if n, perr := strconv.Atoi(strings.TrimSpace(out)); perr == nil { + return once(func() { + _, _ = runWithStdin("", "osascript", "-l", "JavaScript", "-e", fmt.Sprintf(jxaClearIfUnchanged, n)) + }), nil + } + } + + // Fallback: plain pbcopy (no concealed marker), cleared by comparing contents. + if _, err := runWithStdin(secret, "pbcopy"); err != nil { + return nil, fmt.Errorf("pbcopy: %w", err) + } + return once(func() { + if cur, err := exec.Command("pbpaste").Output(); err == nil && string(cur) == secret { + _, _ = runWithStdin("", "pbcopy") + } + }), nil +} + +func runWithStdin(stdin string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Stdin = strings.NewReader(stdin) + out, err := cmd.Output() + return string(out), err +} + +func once(fn func()) func() { + var o sync.Once + return func() { o.Do(fn) } +} diff --git a/internal/launcher/clipboard_other.go b/internal/launcher/clipboard_other.go new file mode 100644 index 0000000..a1739dc --- /dev/null +++ b/internal/launcher/clipboard_other.go @@ -0,0 +1,10 @@ +//go:build !darwin + +package launcher + +import "errors" + +// copySecretToClipboard is only used by the macOS RDP launcher. +func copySecretToClipboard(secret string) (clear func(), err error) { + return nil, errors.New("clipboard hand-off is only implemented on macOS") +} diff --git a/internal/launcher/freerdp.go b/internal/launcher/freerdp.go new file mode 100644 index 0000000..1267b32 --- /dev/null +++ b/internal/launcher/freerdp.go @@ -0,0 +1,220 @@ +package launcher + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/jaydenthorup/mremotego/pkg/models" +) + +// macRDPClientEnv overrides the automatic RDP client choice on macOS: +// +// "freerdp" always use sdl-freerdp (error if it is not installed) +// "windowsapp" always use Windows App / Microsoft Remote Desktop +// unset/other automatic: sdl-freerdp if installed, otherwise Windows App +const macRDPClientEnv = "MREMOTEGO_MAC_RDP_CLIENT" + +// sdlFreeRDPNames are the binary names of FreeRDP 3's SDL client; distributions +// that version their binaries append a "3". +var sdlFreeRDPNames = []string{"sdl-freerdp3", "sdl-freerdp"} + +// extraBinDirs are searched after PATH. A Mac app started from Finder or the +// Dock gets a minimal PATH without the Homebrew and MacPorts directories. +var extraBinDirs = []string{"/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin"} + +// findSDLFreeRDP returns the path of an installed sdl-freerdp binary, or "". +func findSDLFreeRDP() string { + for _, name := range sdlFreeRDPNames { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + for _, dir := range extraBinDirs { + for _, name := range sdlFreeRDPNames { + p := filepath.Join(dir, name) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 { + return p + } + } + } + return "" +} + +// chooseMacRDPClient returns the sdl-freerdp binary to launch conn with, or "" +// when Windows App should be used instead. +func chooseMacRDPClient(conn *models.Connection) (string, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(macRDPClientEnv))) { + case "windowsapp": + return "", nil + case "freerdp": + bin := findSDLFreeRDP() + if bin == "" { + return "", fmt.Errorf("%s=freerdp, but sdl-freerdp was not found (install it with 'brew install freerdp')", macRDPClientEnv) + } + return bin, nil + } + + // Automatic. FreeRDP's support for smartcard authentication at the RD + // Gateway is limited, so leave those connections to Windows App. + if conn.UseGateway && conn.GatewayCredentials == "smartcard" && conn.GatewayUsageMethod != "never" { + return "", nil + } + return findSDLFreeRDP(), nil +} + +// freeRDPArgs builds the FreeRDP 3 command line for conn, mirroring the +// settings that createRDPFile writes for mstsc. The result contains the +// password, so it must never be passed as process arguments; see +// launchSDLFreeRDP. +func freeRDPArgs(conn *models.Connection, target string) ([]string, error) { + args := []string{"/v:" + target} + if conn.Name != "" { + args = append(args, "/t:"+conn.Name) + } + if conn.Username != "" { + args = append(args, "/u:"+conn.Username) + } + if conn.Domain != "" { + args = append(args, "/d:"+conn.Domain) + } + if conn.Password != "" { + args = append(args, "/p:"+conn.Password) + } + + if conn.Resolution != "" { + args = append(args, "/size:"+conn.Resolution) + } else { + args = append(args, "/f") + } + if conn.ColorDepth > 0 { + args = append(args, fmt.Sprintf("/bpp:%d", conn.ColorDepth)) + } + + // Server certificate handling, matching mstsc's "authentication level". + switch conn.RDPAuthenticationLevel { + case "fail": + args = append(args, "/cert:deny") + case "warn": + // FreeRDP's default: ask the user about an untrusted certificate. + default: + args = append(args, "/cert:ignore") + } + + // mstsc's "use cred ssp:i:0" turns off NLA; do the same here. + if !conn.UseCredSSP { + args = append(args, "-sec-nla") + } + + args = append(args, "+clipboard", "/sound", "+auto-reconnect") + + switch spec := strings.TrimSpace(conn.RDPRedirectDrives); spec { + case "": + case "*": + args = append(args, "/drives", "/drive:hotplug,*") + case "dynamic": + args = append(args, "/drive:hotplug,*") + case "local": + args = append(args, "/drives") + default: + // A list of Windows drive letters means nothing on a Mac; share the + // home directory as the closest equivalent of "my local files". + args = append(args, "/home-drive") + } + + if conn.UseGateway && conn.GatewayHostname != "" && conn.GatewayUsageMethod != "never" { + gw := "/gateway:g:" + conn.GatewayHostname + if conn.GatewayUsageMethod == "detect" { + gw += ",usage-method:detect" + } else { + gw += ",usage-method:direct" // FreeRDP's name for "always use the gateway" + } + // Without u:/p: FreeRDP uses the session credentials for the gateway, + // which is what "same" means. + if conn.GatewayCredentials == "different" && conn.GatewayUsername != "" { + // Gateway options are comma-separated, so a comma inside a value + // would be read as the start of the next option. + for _, v := range []string{conn.GatewayUsername, conn.GatewayDomain, conn.GatewayPassword} { + if strings.Contains(v, ",") { + return nil, fmt.Errorf("FreeRDP cannot take gateway credentials that contain a comma; set %s=windowsapp to use Windows App for this connection", macRDPClientEnv) + } + } + gw += ",u:" + conn.GatewayUsername + if conn.GatewayDomain != "" { + gw += ",d:" + conn.GatewayDomain + } + if conn.GatewayPassword != "" { + gw += ",p:" + conn.GatewayPassword + } + } + args = append(args, gw) + } + + args = append(args, strings.Fields(conn.ExtraArgs)...) + + for _, a := range args { + if strings.ContainsAny(a, "\r\n") { + return nil, fmt.Errorf("connection settings for FreeRDP may not contain line breaks") + } + } + return args, nil +} + +// launchSDLFreeRDP starts sdl-freerdp for conn. All arguments, including the +// passwords, are fed through stdin with /args-from:stdin (one argument per +// line) so they never appear in the process list ('ps'). +func (l *Launcher) launchSDLFreeRDP(bin string, conn *models.Connection, target string) error { + args, err := freeRDPArgs(conn, target) + if err != nil { + return err + } + + out := &tailBuffer{max: 4096} + cmd := exec.Command(bin, "/args-from:stdin") + cmd.Stdin = strings.NewReader(strings.Join(args, "\n") + "\n") + cmd.Stdout = out + cmd.Stderr = out + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start %s: %w", filepath.Base(bin), err) + } + l.notify(fmt.Sprintf("Connecting to %s with FreeRDP", conn.Name)) + + go func() { + if err := cmd.Wait(); err != nil { + msg := fmt.Sprintf("FreeRDP session for %s ended with an error (%v)", conn.Name, err) + if last := out.lastLine(); last != "" { + msg += ": " + last + } + l.notify(msg) + } + }() + return nil +} + +// tailBuffer keeps only the last max bytes written to it, so a long-running +// session's log output can't grow without bound. +type tailBuffer struct { + mu sync.Mutex + max int + buf []byte +} + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.buf = append(t.buf, p...) + if over := len(t.buf) - t.max; over > 0 { + t.buf = append(t.buf[:0], t.buf[over:]...) + } + return len(p), nil +} + +func (t *tailBuffer) lastLine() string { + t.mu.Lock() + defer t.mu.Unlock() + lines := strings.Split(strings.TrimSpace(string(t.buf)), "\n") + return strings.TrimSpace(lines[len(lines)-1]) +} diff --git a/internal/launcher/freerdp_test.go b/internal/launcher/freerdp_test.go new file mode 100644 index 0000000..20e38e5 --- /dev/null +++ b/internal/launcher/freerdp_test.go @@ -0,0 +1,105 @@ +package launcher + +import ( + "slices" + "strings" + "testing" + + "github.com/jaydenthorup/mremotego/pkg/models" +) + +func TestFreeRDPArgsBasic(t *testing.T) { + conn := &models.Connection{ + Name: "srv", + Host: "srv.example.com", + Username: "jan", + Domain: "CORP", + Password: "p@ss word", + UseCredSSP: true, + } + args, err := freeRDPArgs(conn, "srv.example.com:3390") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"/v:srv.example.com:3390", "/t:srv", "/u:jan", "/d:CORP", "/p:p@ss word", "/f", "/cert:ignore", "+clipboard"} { + if !slices.Contains(args, want) { + t.Errorf("missing %q in %q", want, args) + } + } + if slices.Contains(args, "-sec-nla") { + t.Errorf("NLA disabled although UseCredSSP is set: %q", args) + } +} + +func TestFreeRDPArgsOptions(t *testing.T) { + conn := &models.Connection{ + Name: "srv", + Resolution: "1280x800", + ColorDepth: 16, + RDPAuthenticationLevel: "fail", + RDPRedirectDrives: "C,D", + ExtraArgs: "/scale:140 +fonts", + } + args, err := freeRDPArgs(conn, "srv") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"/size:1280x800", "/bpp:16", "/cert:deny", "-sec-nla", "/home-drive", "/scale:140", "+fonts"} { + if !slices.Contains(args, want) { + t.Errorf("missing %q in %q", want, args) + } + } + if slices.Contains(args, "/f") { + t.Errorf("fullscreen requested although a resolution is set: %q", args) + } +} + +func TestFreeRDPArgsGateway(t *testing.T) { + conn := &models.Connection{ + UseGateway: true, + GatewayHostname: "gw.example.com", + GatewayUsageMethod: "detect", + GatewayCredentials: "different", + GatewayUsername: "gwuser", + GatewayDomain: "GW", + GatewayPassword: "secret", + } + args, err := freeRDPArgs(conn, "srv") + if err != nil { + t.Fatal(err) + } + want := "/gateway:g:gw.example.com,usage-method:detect,u:gwuser,d:GW,p:secret" + if !slices.Contains(args, want) { + t.Errorf("missing %q in %q", want, args) + } + + conn.GatewayPassword = "a,b" + if _, err := freeRDPArgs(conn, "srv"); err == nil { + t.Error("expected an error for a gateway password containing a comma") + } + + conn.GatewayUsageMethod = "never" + conn.GatewayPassword = "secret" + args, _ = freeRDPArgs(conn, "srv") + for _, a := range args { + if strings.HasPrefix(a, "/gateway") { + t.Errorf("gateway used although usage method is never: %q", args) + } + } +} + +func TestFreeRDPArgsRejectsLineBreaks(t *testing.T) { + conn := &models.Connection{Password: "line1\nline2"} + if _, err := freeRDPArgs(conn, "srv"); err == nil { + t.Error("expected an error for a password containing a line break") + } +} + +func TestTailBuffer(t *testing.T) { + b := &tailBuffer{max: 20} // keeps "st line\nerror: boom\n" + _, _ = b.Write([]byte("first line\n")) + _, _ = b.Write([]byte("error: boom\n")) + if got := b.lastLine(); got != "error: boom" { + t.Errorf("lastLine = %q", got) + } +} diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index 3b7212e..55fa75f 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -2,12 +2,15 @@ package launcher import ( "fmt" + "math/rand/v2" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" + "sync" + "time" "github.com/jaydenthorup/mremotego/internal/secrets" "github.com/jaydenthorup/mremotego/pkg/models" @@ -16,12 +19,33 @@ import ( // Launcher handles launching connections type Launcher struct { onePasswordProvider *secrets.OnePasswordProvider + mu sync.Mutex + cleanups map[uint64]func() + nextID uint64 + + // Notify, if set, receives short user-facing status messages from a launch + // (for example that a password was put on the clipboard). It may be called + // from any goroutine. When nil, messages are printed to stdout. + Notify func(message string) +} + +// macClipboardClearDelay is how long an RDP password stays on the macOS +// clipboard before it is removed again. +const macClipboardClearDelay = 45 * time.Second + +func (l *Launcher) notify(message string) { + if l.Notify != nil { + l.Notify(message) + return + } + fmt.Println(message) } // NewLauncher creates a new launcher func NewLauncher() *Launcher { return &Launcher{ onePasswordProvider: secrets.NewOnePasswordProvider(), + cleanups: make(map[uint64]func()), } } @@ -30,29 +54,98 @@ func (l *Launcher) GetOnePasswordProvider() *secrets.OnePasswordProvider { return l.onePasswordProvider } +// registerCleanup records fn to be called when the session ends or when Cleanup is called +// on app exit. Returns an unregister function that removes the entry once the session +// ends naturally (so Cleanup skips already-finished sessions). +func (l *Launcher) registerCleanup(fn func()) (unregister func()) { + l.mu.Lock() + id := l.nextID + l.nextID++ + l.cleanups[id] = fn + l.mu.Unlock() + + return func() { + l.mu.Lock() + delete(l.cleanups, id) + l.mu.Unlock() + } +} + +// Cleanup runs all pending session cleanups. Call this when the application is closing +// to ensure credentials and temp files are removed even if sessions are still open. +func (l *Launcher) Cleanup() { + l.mu.Lock() + fns := make([]func(), 0, len(l.cleanups)) + for _, fn := range l.cleanups { + fns = append(fns, fn) + } + l.cleanups = make(map[uint64]func()) + l.mu.Unlock() + + for _, fn := range fns { + fn() + } +} + +// CleanupOnStartup removes leftovers from a previous run that may have been +// skipped if the process crashed: stale .rdp temp files and any Credential +// Manager entries tagged with the mremotego comment. +func CleanupOnStartup() { + cleanupStaleTempFiles() + cleanupOrphanedCredentials() // no-op on non-Windows +} + +func cleanupStaleTempFiles() { + dir := filepath.Join(os.TempDir(), "mremotego") + entries, err := os.ReadDir(dir) + if err != nil { + return // directory doesn't exist yet — nothing to clean + } + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".rdp" { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} + // Launch launches a connection based on its protocol func (l *Launcher) Launch(conn *models.Connection) error { if conn.IsFolder() { return fmt.Errorf("cannot launch a folder") } - // Resolve 1Password reference if needed (make a copy to avoid modifying the original) + // Resolve 1Password references in a single batched call (grouped by vault+item) + // so that credentials from the same 1Password item require only one biometric prompt. resolvedConn := *conn - if l.onePasswordProvider.IsReference(conn.Password) { - resolved, err := l.onePasswordProvider.ResolveSecret(conn.Password) + opRefs := make([]string, 0, 4) + for _, v := range []string{conn.Username, conn.Password, conn.GatewayUsername, conn.GatewayPassword} { + if l.onePasswordProvider.IsReference(v) { + opRefs = append(opRefs, v) + } + } + + var opValues map[string]string + if len(opRefs) > 0 { + var err error + opValues, err = l.onePasswordProvider.ResolveMultiple(opRefs) if err != nil { - // For RDP, we can continue without a password (will prompt) - // For other protocols that require a password, return the error - if conn.Protocol != models.ProtocolRDP { - return fmt.Errorf("failed to resolve password from 1Password: %w", err) + return fmt.Errorf("failed to resolve 1Password references: %w", err) + } + } + + apply := func(v string) string { + if l.onePasswordProvider.IsReference(v) { + if resolved, ok := opValues[v]; ok { + return resolved } - // RDP: Clear the password so it doesn't try to use the op:// reference - fmt.Printf("Warning: Failed to resolve password from 1Password: %v (RDP will prompt for credentials)\n", err) - resolvedConn.Password = "" - } else { - resolvedConn.Password = resolved + return "" } + return v } + resolvedConn.Username = apply(conn.Username) + resolvedConn.Password = apply(conn.Password) + resolvedConn.GatewayUsername = apply(conn.GatewayUsername) + resolvedConn.GatewayPassword = apply(conn.GatewayPassword) switch resolvedConn.Protocol { case models.ProtocolSSH: @@ -303,44 +396,91 @@ func (l *Launcher) launchRDP(conn *models.Connection) error { switch runtime.GOOS { case "windows": - // Store credentials in Windows Credential Manager if password is provided + // Store credentials and track every target we write so we can remove + // them from Credential Manager once mstsc exits. + var credTargets []string + if conn.Username != "" && conn.Password != "" { if err := l.storeWindowsCredential(conn); err != nil { - // Continue anyway - mstsc will prompt if credentials aren't stored fmt.Printf("Warning: Failed to store credentials: %v\n", err) + } else { + credTargets = append(credTargets, "TERMSRV/"+target) + } + } + if conn.UseGateway && conn.GatewayHostname != "" { + if conn.GatewayCredentials == "different" && conn.GatewayPassword != "" { + if err := l.storeGatewayCredential(conn); err != nil { + fmt.Printf("Warning: Failed to store gateway credentials: %v\n", err) + } else { + credTargets = append(credTargets, "TERMSRV/"+conn.GatewayHostname) + } + } else if (conn.GatewayCredentials == "same" || conn.GatewayCredentials == "") && conn.Username != "" && conn.Password != "" { + gwUsername := conn.Username + if conn.Domain != "" { + gwUsername = fmt.Sprintf("%s\\%s", conn.Domain, conn.Username) + } + // mstsc looks up RD Gateway credentials by bare hostname (no TERMSRV/ prefix), + // unlike RDP host credentials which use TERMSRV/. + if err := writeWindowsCredential(conn.GatewayHostname, gwUsername, conn.Password); err != nil { + fmt.Printf("Warning: Failed to store gateway credentials: %v\n", err) + } else { + credTargets = append(credTargets, conn.GatewayHostname) + } } } - // Create a temporary .rdp file with connection settings rdpFile, err := l.createRDPFile(conn, target) if err != nil { return fmt.Errorf("failed to create RDP file: %w", err) } - // Launch mstsc with the RDP file - cmd = exec.Command("mstsc", rdpFile) - // Don't hide mstsc - it's a GUI application we want to see - - case "darwin": - // Use Microsoft Remote Desktop on macOS - // Format: rdp://[username@]hostname[:port] - rdpURL := "rdp://" - if conn.Username != "" { - rdpURL += conn.Username - if conn.Domain != "" { - rdpURL += "@" + conn.Domain + mstsc := exec.Command("mstsc", rdpFile) + mstsc.Stdin = nil + mstsc.Stdout = nil + mstsc.Stderr = nil + if err := mstsc.Start(); err != nil { + for _, t := range credTargets { + _ = deleteWindowsCredential(t) } - rdpURL += "@" + _ = os.Remove(rdpFile) + return fmt.Errorf("failed to start mstsc: %w", err) } - rdpURL += conn.Host - if port != 3389 { - rdpURL += fmt.Sprintf(":%d", port) + + cleanup := func() { + for _, t := range credTargets { + _ = deleteWindowsCredential(t) + } + _ = os.Remove(rdpFile) } + unregister := l.registerCleanup(cleanup) + + go func() { + exited := make(chan struct{}) + go func() { + _ = mstsc.Wait() + close(exited) + }() + // Wait until the mstsc window title starts with the hostname, meaning + // authentication is complete and credentials are no longer needed. + // Falls back to process exit or 60s timeout if connection never establishes. + waitForMstscConnected(mstsc.Process.Pid, conn.Host, 60*time.Second, exited) + unregister() + cleanup() + }() - // Use 'open' to launch Microsoft Remote Desktop with rdp:// URL - cmd = exec.Command("open", rdpURL) - // Note: Password cannot be passed via URL for security reasons - // Microsoft Remote Desktop will prompt or use saved credentials + return nil + + case "darwin": + // Prefer sdl-freerdp when installed: it takes the 1Password credentials + // directly. Otherwise fall back to Windows App with the clipboard hand-off. + bin, err := chooseMacRDPClient(conn) + if err != nil { + return err + } + if bin != "" { + return l.launchSDLFreeRDP(bin, conn, target) + } + return l.launchRDPMacWindowsApp(conn, target) case "linux": // Use xfreerdp on Linux @@ -389,6 +529,63 @@ func (l *Launcher) launchRDP(conn *models.Connection) error { return cmd.Start() } +// launchRDPMacWindowsApp hands a temporary .rdp file to whichever app is the +// default .rdp handler on macOS: Windows App or its predecessor Microsoft +// Remote Desktop, both register for it, so no app name is hardcoded. An +// rdp://user@host URL is not a valid URI for that client and is rejected with +// "The URL is not valid"; the .rdp file also carries port, domain, gateway and +// drive redirection settings. +// The password cannot be passed this way (the Mac clients ignore it in .rdp +// files), so it is put on the clipboard for the user to paste into the +// client's password prompt, and removed again after a short delay. A separate +// gateway password ("different" credentials) is not handed off. +func (l *Launcher) launchRDPMacWindowsApp(conn *models.Connection, target string) error { + rdpFile, err := l.createRDPFile(conn, target) + if err != nil { + return fmt.Errorf("failed to create RDP file: %w", err) + } + + var clearClipboard func() + if conn.Password != "" { + clearClipboard, err = copySecretToClipboard(conn.Password) + if err != nil { + l.notify(fmt.Sprintf("Could not copy the password to the clipboard: %v", err)) + clearClipboard = nil + } + } + + open := exec.Command("open", rdpFile) + if err := open.Run(); err != nil { + _ = os.Remove(rdpFile) + if clearClipboard != nil { + clearClipboard() + } + return fmt.Errorf("failed to open RDP file (is Windows App or Microsoft Remote Desktop installed?): %w", err) + } + + if clearClipboard != nil { + unregisterClip := l.registerCleanup(clearClipboard) + time.AfterFunc(macClipboardClearDelay, func() { + unregisterClip() + clearClipboard() + l.notify("Password removed from the clipboard") + }) + l.notify(fmt.Sprintf("Password for %s copied to the clipboard; paste it (Cmd+V) when asked. It is removed in %d seconds", + conn.Name, int(macClipboardClearDelay.Seconds()))) + } + + // 'open' returns once the file is handed off, but the client reads it + // asynchronously, so remove it after a grace period (or on app exit). + cleanup := func() { _ = os.Remove(rdpFile) } + unregister := l.registerCleanup(cleanup) + time.AfterFunc(60*time.Second, func() { + unregister() + cleanup() + }) + + return nil +} + // launchVNC launches a VNC connection func (l *Launcher) launchVNC(conn *models.Connection) error { port := conn.Port @@ -474,11 +671,10 @@ func (l *Launcher) createRDPFile(conn *models.Connection, target string) (string return "", fmt.Errorf("failed to create temp directory: %w", err) } - // Sanitize connection name for filename (remove invalid characters) + // Unique suffix prevents collisions when the same connection is opened + // multiple times concurrently. safeName := sanitizeFilename(conn.Name) - - // Create RDP file with .rdp extension - rdpPath := filepath.Join(tempDir, safeName+".rdp") + rdpPath := filepath.Join(tempDir, fmt.Sprintf("%s-%08x.rdp", safeName, rand.Uint32())) // Build RDP file content (UTF-8 encoding) rdpContent := fmt.Sprintf("full address:s:%s\r\n", target) @@ -524,22 +720,88 @@ func (l *Launcher) createRDPFile(conn *models.Connection, target string) (string rdpContent += "redirectclipboard:i:1\r\n" rdpContent += "redirectposdevices:i:0\r\n" rdpContent += "autoreconnection enabled:i:1\r\n" - rdpContent += "authentication level:i:2\r\n" + + // Local drive redirection: makes local drives available inside the session, + // where Windows shows them both as new volumes under "This PC" and as + // subdirectories under \\tsclient. + if driveSpec := rdpDriveRedirectValue(conn.RDPRedirectDrives); driveSpec != "" { + rdpContent += fmt.Sprintf("drivestoredirect:s:%s\r\n", driveSpec) + } + + // Authentication level: 0 = always connect, 1 = don't connect on failure, 2 = warn + authLevel := 0 + switch conn.RDPAuthenticationLevel { + case "fail": + authLevel = 1 + case "warn": + authLevel = 2 + } + rdpContent += fmt.Sprintf("authentication level:i:%d\r\n", authLevel) + rdpContent += "prompt for credentials:i:0\r\n" rdpContent += "negotiate security layer:i:1\r\n" + + // Use CredSSP + credSSP := 1 + if !conn.UseCredSSP { + credSSP = 0 + } + rdpContent += fmt.Sprintf("use cred ssp:i:%d\r\n", credSSP) + rdpContent += "remoteapplicationmode:i:0\r\n" rdpContent += "alternate shell:s:\r\n" rdpContent += "shell working directory:s:\r\n" - rdpContent += "gatewayhostname:s:\r\n" - rdpContent += "gatewayusagemethod:i:4\r\n" - rdpContent += "gatewaycredentialssource:i:4\r\n" - rdpContent += "gatewayprofileusagemethod:i:0\r\n" + if conn.UseGateway && conn.GatewayHostname != "" { + rdpContent += fmt.Sprintf("gatewayhostname:s:%s\r\n", conn.GatewayHostname) + + usageMethod := 1 // default: always + switch conn.GatewayUsageMethod { + case "detect": + usageMethod = 2 + case "never": + usageMethod = 4 + } + rdpContent += fmt.Sprintf("gatewayusagemethod:i:%d\r\n", usageMethod) + + credSource := 0 // default: NTLM password, uses Credential Manager silently + switch conn.GatewayCredentials { + case "smartcard": + credSource = 1 + } + rdpContent += fmt.Sprintf("gatewaycredentialssource:i:%d\r\n", credSource) + + if conn.GatewayCredentials == "different" && conn.GatewayUsername != "" { + rdpContent += fmt.Sprintf("gatewayusername:s:%s\r\n", conn.GatewayUsername) + if conn.GatewayDomain != "" { + rdpContent += fmt.Sprintf("gatewaydomain:s:%s\r\n", conn.GatewayDomain) + } + } + } else { + rdpContent += "gatewayhostname:s:\r\n" + rdpContent += "gatewayusagemethod:i:4\r\n" + rdpContent += "gatewaycredentialssource:i:4\r\n" + } + + // Gateway profile usage method: 0 = use default profile, 1 = use explicit settings (default) + profileMethod := 1 + if conn.GatewayProfileUsageMethod == "default" { + profileMethod = 0 + } + rdpContent += fmt.Sprintf("gatewayprofileusagemethod:i:%d\r\n", profileMethod) + rdpContent += "promptcredentialonce:i:0\r\n" rdpContent += "gatewaybrokeringtype:i:0\r\n" - rdpContent += "use redirection server name:i:0\r\n" + rdpContent += "use redirection server name:i:1\r\n" rdpContent += "rdgiskdcproxy:i:0\r\n" rdpContent += "kdcproxyname:s:\r\n" + // Bypass gateway for local addresses + bypassLocal := 0 + if conn.GatewayBypassIfLocal { + bypassLocal = 1 + } + rdpContent += fmt.Sprintf("gatewayisbypassiflocal:i:%d\r\n", bypassLocal) + // Resolution settings if conn.Resolution != "" { // Parse resolution like "1920x1080" @@ -562,6 +824,49 @@ func (l *Launcher) createRDPFile(conn *models.Connection, target string) (string return rdpPath, nil } +// rdpDriveRedirectValue converts a Connection.RDPRedirectDrives spec into the +// value expected by the .rdp file's "drivestoredirect" setting: +// "*" all drives, including ones connected later — passed through unchanged +// "dynamic" only drives connected after the session starts — becomes "DynamicDrives" +// "local" local fixed/removable drives present now, excluding network drives — +// resolved via localFixedDriveLetters() (Windows-only; empty elsewhere) +// otherwise a comma-separated list of drive letters like "C,D" becomes "C:;D:" +// Returns "" when redirection is disabled or the spec has no usable drive letters. +func rdpDriveRedirectValue(spec string) string { + spec = strings.TrimSpace(spec) + switch spec { + case "", "*": + return spec + case "dynamic": + return "DynamicDrives" + case "local": + drives := localFixedDriveLetters() + if len(drives) == 0 { + return "" + } + letters := make([]string, len(drives)) + for i, d := range drives { + letters[i] = d + ":" + } + return strings.Join(letters, ";") + } + + parts := strings.Split(spec, ",") + drives := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + p = strings.TrimSuffix(p, ":") + if p == "" { + continue + } + drives = append(drives, strings.ToUpper(p)+":") + } + if len(drives) == 0 { + return "" + } + return strings.Join(drives, ";") +} + // sanitizeFilename removes or replaces invalid characters from a filename func sanitizeFilename(name string) string { // Replace invalid Windows filename characters with underscore @@ -601,17 +906,21 @@ func (l *Launcher) storeWindowsCredential(conn *models.Connection) error { username = fmt.Sprintf("%s\\%s", conn.Domain, conn.Username) } - // Use cmdkey to store the credential - // cmdkey /generic:TERMSRV/hostname /user:username /pass:password - cmd := exec.Command("cmdkey", "/generic:TERMSRV/"+target, "/user:"+username, "/pass:"+conn.Password) - hideConsoleWindow(cmd) + return writeWindowsCredential("TERMSRV/"+target, username, conn.Password) +} - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("cmdkey failed: %w, output: %s", err, string(output)) +// storeGatewayCredential stores RD Gateway credentials in Windows Credential Manager +func (l *Launcher) storeGatewayCredential(conn *models.Connection) error { + if runtime.GOOS != "windows" { + return nil } - return nil + username := conn.GatewayUsername + if conn.GatewayDomain != "" { + username = fmt.Sprintf("%s\\%s", conn.GatewayDomain, conn.GatewayUsername) + } + + return writeWindowsCredential("TERMSRV/"+conn.GatewayHostname, username, conn.GatewayPassword) } // RemoveWindowsCredential removes RDP credentials from Windows Credential Manager @@ -630,20 +939,7 @@ func (l *Launcher) RemoveWindowsCredential(conn *models.Connection) error { target = fmt.Sprintf("%s:%d", conn.Host, port) } - // Use cmdkey to delete the credential - // cmdkey /delete:TERMSRV/hostname - cmd := exec.Command("cmdkey", "/delete:TERMSRV/"+target) - hideConsoleWindow(cmd) - - output, err := cmd.CombinedOutput() - if err != nil { - // Don't return error if credential doesn't exist - if !strings.Contains(string(output), "not found") { - return fmt.Errorf("cmdkey delete failed: %w, output: %s", err, string(output)) - } - } - - return nil + return deleteWindowsCredential("TERMSRV/" + target) } // CleanupAllCredentials removes all MremoteGO-stored credentials from Windows Credential Manager diff --git a/internal/launcher/launcher_unix.go b/internal/launcher/launcher_unix.go index 0ce27cc..80c1d35 100644 --- a/internal/launcher/launcher_unix.go +++ b/internal/launcher/launcher_unix.go @@ -5,9 +5,14 @@ package launcher import ( "os/exec" + "time" ) // hideConsoleWindow is a no-op on non-Windows platforms -func hideConsoleWindow(cmd *exec.Cmd) { - // Nothing to do on Unix-like systems -} +func hideConsoleWindow(cmd *exec.Cmd) {} + +func writeWindowsCredential(target, username, password string) error { return nil } +func deleteWindowsCredential(target string) error { return nil } +func waitForMstscConnected(pid int, hostname string, timeout time.Duration, exited <-chan struct{}) {} +func cleanupOrphanedCredentials() {} +func localFixedDriveLetters() []string { return nil } diff --git a/internal/launcher/launcher_windows.go b/internal/launcher/launcher_windows.go index c8d8a3b..f93eeb6 100644 --- a/internal/launcher/launcher_windows.go +++ b/internal/launcher/launcher_windows.go @@ -4,8 +4,14 @@ package launcher import ( + "fmt" "os/exec" + "runtime" + "strings" + "sync" "syscall" + "time" + "unsafe" ) // hideConsoleWindow sets the command attributes to hide console windows on Windows @@ -15,3 +21,236 @@ func hideConsoleWindow(cmd *exec.Cmd) { CreationFlags: 0x08000000, // CREATE_NO_WINDOW } } + +const credTag = "Managed by MRemoteGo" + +// credentialW mirrors the Win32 CREDENTIALW struct layout. +type credentialW struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten [2]uint32 + CredentialBlobSize uint32 + CredentialBlob uintptr + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +// writeWindowsCredential stores a generic credential in Windows Credential Manager +// using CredWriteW directly, avoiding cmdkey's argument-parsing issues with +// usernames or passwords that contain spaces or special characters. +// The Comment field is set to credTag so orphaned entries can be found on startup. +func writeWindowsCredential(target, username, password string) error { + credWriteW := syscall.NewLazyDLL("advapi32.dll").NewProc("CredWriteW") + + targetPtr, err := syscall.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("invalid target name: %w", err) + } + userPtr, err := syscall.UTF16PtrFromString(username) + if err != nil { + return fmt.Errorf("invalid username: %w", err) + } + commentPtr, err := syscall.UTF16PtrFromString(credTag) + if err != nil { + return fmt.Errorf("invalid comment: %w", err) + } + + // Encode password as UTF-16LE bytes without null terminator, as expected by + // Windows Credential Manager for RDP credentials consumed by mstsc. + passUTF16, err := syscall.UTF16FromString(password) + if err != nil { + return fmt.Errorf("invalid password: %w", err) + } + passUTF16 = passUTF16[:len(passUTF16)-1] // strip null terminator + passBlobSize := uint32(len(passUTF16) * 2) + + var blobPtr uintptr + if passBlobSize > 0 { + blobPtr = uintptr(unsafe.Pointer(&passUTF16[0])) + } + + cred := credentialW{ + Type: 2, // CRED_TYPE_DOMAIN_PASSWORD — required for mstsc to find credentials + TargetName: targetPtr, + Comment: commentPtr, + UserName: userPtr, + CredentialBlobSize: passBlobSize, + CredentialBlob: blobPtr, + Persist: 2, // CRED_PERSIST_LOCAL_MACHINE + } + + ret, _, callErr := credWriteW.Call(uintptr(unsafe.Pointer(&cred)), 0) + runtime.KeepAlive(passUTF16) // prevent GC until after the syscall + if ret == 0 { + return fmt.Errorf("CredWriteW failed: %w", callErr) + } + return nil +} + +// cleanupOrphanedCredentials removes any Credential Manager entries that were +// written by mremotego (identified by Comment == credTag) but never cleaned up, +// e.g. because the process crashed. +func cleanupOrphanedCredentials() { + advapi32 := syscall.NewLazyDLL("advapi32.dll") + credEnumerateW := advapi32.NewProc("CredEnumerateW") + credFree := advapi32.NewProc("CredFree") + + var count uint32 + var credArrayPtr uintptr + ret, _, _ := credEnumerateW.Call(0, 0, uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&credArrayPtr))) + if ret == 0 { + return // no credentials or access error + } + defer credFree.Call(credArrayPtr) + + ptrSize := unsafe.Sizeof(uintptr(0)) + for i := uint32(0); i < count; i++ { + credPtr := *(*uintptr)(unsafe.Pointer(credArrayPtr + uintptr(i)*ptrSize)) + if credPtr == 0 { + continue + } + cred := (*credentialW)(unsafe.Pointer(credPtr)) + if cred.Comment == nil { + continue + } + comment := syscall.UTF16ToString(unsafe.Slice(cred.Comment, 256)) + if comment != credTag { + continue + } + if cred.TargetName != nil { + target := syscall.UTF16ToString(unsafe.Slice(cred.TargetName, 1024)) + _ = deleteWindowsCredential(target) + } + } +} + +// deleteWindowsCredential removes a generic credential from Windows Credential Manager. +// Returns nil if the credential does not exist. +func deleteWindowsCredential(target string) error { + credDeleteW := syscall.NewLazyDLL("advapi32.dll").NewProc("CredDeleteW") + + targetPtr, err := syscall.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("invalid target name: %w", err) + } + + const credTypeDomainPassword = 2 + const errorNotFound = syscall.Errno(1168) // ERROR_NOT_FOUND + ret, _, callErr := credDeleteW.Call(uintptr(unsafe.Pointer(targetPtr)), credTypeDomainPassword, 0) + if ret == 0 && callErr != errorNotFound { + return fmt.Errorf("CredDeleteW failed: %w", callErr) + } + return nil +} + +// driveTypeRemote is the GetDriveTypeW result for a network-mapped drive letter. +const driveTypeRemote = 4 + +// localFixedDriveLetters returns the letters (without colon) of every drive +// currently present on this machine, excluding network-mapped (remote) drives. +// Used to resolve the "local" RDPRedirectDrives spec at launch time. +func localFixedDriveLetters() []string { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + getLogicalDrives := kernel32.NewProc("GetLogicalDrives") + getDriveTypeW := kernel32.NewProc("GetDriveTypeW") + + mask, _, _ := getLogicalDrives.Call() + var drives []string + for i := 0; i < 26; i++ { + if mask&(1< - Remote Desktop Connection" only after authentication is complete, +// so this is a reliable "truly connected" signal that fires after certificate +// warnings and credential prompts have been resolved. +func hasMstscConnectedWindow(pid int, hostname string) bool { + enumWindowsOnce.Do(func() { + user32 := syscall.NewLazyDLL("user32.dll") + getWindowThreadProcessId := user32.NewProc("GetWindowThreadProcessId") + getWindowTextW := user32.NewProc("GetWindowTextW") + enumWindowsCB = syscall.NewCallback(func(hwnd syscall.Handle, _ uintptr) uintptr { + var winPID uint32 + getWindowThreadProcessId.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&winPID))) + if int(winPID) != enumWindowsPID { + return 1 // continue + } + buf := make([]uint16, 512) + getWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + title := syscall.UTF16ToString(buf) + // Title is " - Remote Desktop Connection" (or locale variant) when + // connected. During connecting/cert-warning phases the title is + // "Remote Desktop Connection" with no hostname. Contains is used so the + // check is robust against locale differences in the suffix. + if len(title) > 0 && strings.Contains(strings.ToLower(title), strings.ToLower(enumWindowsHostname)) { + enumWindowsFound = true + return 0 // stop enumeration + } + return 1 // continue + }) + }) + + // EnumWindows calls the callback synchronously in the same goroutine, so + // protecting the globals with a mutex is sufficient. + enumWindowsMu.Lock() + defer enumWindowsMu.Unlock() + enumWindowsPID = pid + enumWindowsHostname = hostname + enumWindowsFound = false + user32 := syscall.NewLazyDLL("user32.dll") + enumWindows := user32.NewProc("EnumWindows") + enumWindows.Call(enumWindowsCB, 0) + return enumWindowsFound +} diff --git a/internal/secrets/onepassword.go b/internal/secrets/onepassword.go index 8b1ee06..2323d35 100644 --- a/internal/secrets/onepassword.go +++ b/internal/secrets/onepassword.go @@ -1,29 +1,46 @@ package secrets import ( + "encoding/json" "fmt" "net/url" "os/exec" "strings" ) +type opField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// commandRunner executes a named command and returns its combined output. +// Extracted so ResolveMultiple can be tested without a real op binary. +type commandRunner func(name string, args ...string) ([]byte, error) + +func realCommandRunner(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + hideConsoleWindow(cmd) + return cmd.CombinedOutput() +} + // OnePasswordProvider handles retrieving secrets from 1Password CLI type OnePasswordProvider struct { enabled bool + run commandRunner } // NewOnePasswordProvider creates a new 1Password provider func NewOnePasswordProvider() *OnePasswordProvider { return &OnePasswordProvider{ enabled: isOnePasswordCLIAvailable(), + run: realCommandRunner, } } // isOnePasswordCLIAvailable checks if the 1Password CLI (op) is installed func isOnePasswordCLIAvailable() bool { - cmd := exec.Command("op", "--version") - hideConsoleWindow(cmd) - return cmd.Run() == nil + _, err := exec.LookPath("op") + return err == nil } // IsEnabled returns whether 1Password CLI is available @@ -31,85 +48,11 @@ func (p *OnePasswordProvider) IsEnabled() bool { return p.enabled } -// IsAuthenticated checks if the user is currently signed in to 1Password CLI -func (p *OnePasswordProvider) IsAuthenticated() bool { - if !p.enabled { - return false - } - - cmd := exec.Command("op", "whoami") - hideConsoleWindow(cmd) - return cmd.Run() == nil -} - -// GetAuthenticationInstructions returns instructions for authenticating with 1Password CLI -func (p *OnePasswordProvider) GetAuthenticationInstructions() string { - return `1Password CLI needs authentication. - -✅ OPTION 1: Launch from same terminal (Session Token) -If you run "op signin" in PowerShell, then launch MremoteGO from that SAME terminal: - Invoke-Expression $(op signin) - .\mremotego.exe -The GUI will inherit your session token and "op://" passwords will work! - -✅ OPTION 2: Desktop App Integration (Recommended - Persistent Auth) -1. Open the 1Password desktop app -2. Sign in if you haven't already -3. Go to Settings → Developer -4. Enable "Integrate with 1Password CLI" -5. Restart MremoteGO - -This allows the CLI to use your desktop app session with biometric unlock. -No terminal commands needed - it just works! - -──────────────────────────────────────────────────── - -Alternative (not recommended): -You can set the OP_SERVICE_ACCOUNT_TOKEN environment variable for automation, -but this requires a 1Password service account (business/team plans only).` -} - // IsReference checks if a string is a 1Password reference (starts with op://) func (p *OnePasswordProvider) IsReference(value string) bool { return strings.HasPrefix(value, "op://") } -// ResolveSecret retrieves a secret from 1Password using the CLI -// Reference format: op://vault/item/field -// Example: op://Private/MyServer/password -func (p *OnePasswordProvider) ResolveSecret(reference string) (string, error) { - if !p.enabled { - return "", fmt.Errorf("1Password CLI is not available") - } - - if !p.IsReference(reference) { - return "", fmt.Errorf("not a 1Password reference: %s", reference) - } - - // Parse the reference to extract vault, item, and field - // op://vault/item/field - vault, item, field, err := p.parseReference(reference) - if err != nil { - return "", fmt.Errorf("invalid reference format: %w", err) - } - - // Use 'op item get' which handles special characters in item names - // This is more robust than 'op read' for items with parentheses, spaces, etc. - cmd := exec.Command("op", "item", "get", item, "--vault="+vault, "--fields", "label="+field, "--reveal") - hideConsoleWindow(cmd) - - output, err := cmd.CombinedOutput() - if err != nil { - // Provide helpful error message based on the output - errorMsg := string(output) - return "", fmt.Errorf("failed to retrieve secret: %s", strings.TrimSpace(errorMsg)) - } - - // Trim whitespace and return - secret := strings.TrimSpace(string(output)) - return secret, nil -} - // parseReference parses a 1Password reference into vault, item, and field // Input: op://vault/item/field // Output: vault, item, field, error @@ -141,24 +84,82 @@ func (p *OnePasswordProvider) parseReference(reference string) (string, string, return vault, decodedItem, field, nil } -// ResolveIfReference resolves a value if it's a 1Password reference, otherwise returns it as-is -// ResolveIfReference resolves a value if it's a 1Password reference, otherwise returns it as-is -// Note: This uses the 1Password CLI which will prompt for biometric auth if needed -// TODO: Consider migrating to https://github.com/1Password/onepassword-sdk-go for better integration -func (p *OnePasswordProvider) ResolveIfReference(value string) string { - if !p.IsReference(value) { - return value +// ResolveMultiple resolves a slice of 1Password references. References from the +// same vault+item are fetched in a single op call. Non-references are returned unchanged. +// Returns a map of reference → value. +func (p *OnePasswordProvider) ResolveMultiple(refs []string) (map[string]string, error) { + if !p.enabled { + return nil, fmt.Errorf("1Password CLI is not available") } - resolved, err := p.ResolveSecret(value) - if err != nil { - // If resolution fails, return empty string - // This prevents exposing the reference format - fmt.Printf("Warning: Failed to resolve 1Password reference: %v\n", err) - return "" + result := make(map[string]string, len(refs)) + + type itemKey struct{ vault, item string } + type fieldReq struct{ ref, field string } + + grouped := make(map[itemKey][]fieldReq) + seen := make(map[string]bool) + + for _, ref := range refs { + if !p.IsReference(ref) { + result[ref] = ref + continue + } + if seen[ref] { + continue + } + seen[ref] = true + + vault, item, field, err := p.parseReference(ref) + if err != nil { + return nil, fmt.Errorf("invalid 1Password reference %q: %w", ref, err) + } + key := itemKey{vault, item} + grouped[key] = append(grouped[key], fieldReq{ref: ref, field: field}) + } + + for key, fields := range grouped { + labels := make([]string, len(fields)) + for i, f := range fields { + labels[i] = "label=" + f.field + } + + output, err := p.run("op", "item", "get", key.item, + "--vault="+key.vault, + "--fields", strings.Join(labels, ","), + "--format", "json", + "--reveal", + ) + if err != nil { + return nil, fmt.Errorf("failed to retrieve secrets for item %q in vault %q: %s", key.item, key.vault, strings.TrimSpace(string(output))) + } + + // op returns an array when multiple fields are requested, or a single object for one field. + // Normalise to a slice in both cases. + var parsed []opField + if err := json.Unmarshal(output, &parsed); err != nil { + var single opField + if err2 := json.Unmarshal(output, &single); err2 != nil { + return nil, fmt.Errorf("failed to parse op output for item %q: %w", key.item, err) + } + parsed = []opField{single} + } + + byLabel := make(map[string]string, len(parsed)) + for _, f := range parsed { + byLabel[strings.ToLower(f.Label)] = f.Value + } + + for _, f := range fields { + val, ok := byLabel[strings.ToLower(f.field)] + if !ok { + return nil, fmt.Errorf("field %q not found in 1Password item %q (vault %q)", f.field, key.item, key.vault) + } + result[f.ref] = val + } } - return resolved + return result, nil } // CheckItemExists checks if an item with the given title exists in the vault @@ -263,24 +264,3 @@ func (p *OnePasswordProvider) CreateItem(vault, title, username, password string return reference, nil } -// ListVaults returns a list of available 1Password vaults -func (p *OnePasswordProvider) ListVaults() ([]string, error) { - if !p.enabled { - return nil, fmt.Errorf("1Password CLI is not available") - } - - cmd := exec.Command("op", "vault", "list", "--format=json") - hideConsoleWindow(cmd) - - _, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to list vaults: %w", err) - } - - // Simple parsing - extract vault names (this is a simplified version) - // In production, you'd want to parse the JSON properly - vaults := []string{"Private", "DevOps", "Employee"} // Default common vaults - - // For now, return common vaults. You could parse JSON if needed - return vaults, nil -} diff --git a/internal/secrets/onepassword_test.go b/internal/secrets/onepassword_test.go new file mode 100644 index 0000000..41fa2e3 --- /dev/null +++ b/internal/secrets/onepassword_test.go @@ -0,0 +1,190 @@ +package secrets + +import ( + "encoding/json" + "slices" + "sort" + "strings" + "testing" +) + +func newTestProvider(run commandRunner) *OnePasswordProvider { + return &OnePasswordProvider{enabled: true, run: run} +} + +// flagValue returns the value of a space-separated flag pair (e.g. --format json → "json"). +func flagValue(args []string, flag string) string { + for i, a := range args { + if a == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// argValue returns the right-hand side of an equals-style flag (e.g. --vault=foo → "foo"). +func argValue(args []string, prefix string) string { + for _, a := range args { + if v, ok := strings.CutPrefix(a, prefix+"="); ok { + return v + } + } + return "" +} + +func containsArg(args []string, needle string) bool { + return slices.Contains(args, needle) +} + +type capturedCall struct { + name string + args []string +} + +// TestResolveMultiple_SameItemBatched verifies that two references for the same +// vault+item produce exactly one op call carrying both field labels. +func TestResolveMultiple_SameItemBatched(t *testing.T) { + var calls []capturedCall + + run := func(name string, args ...string) ([]byte, error) { + calls = append(calls, capturedCall{name, args}) + out, _ := json.Marshal([]opField{ + {Label: "username", Value: "testuser"}, + {Label: "password", Value: "s3cr3t"}, + }) + return out, nil + } + + p := newTestProvider(run) + refs := []string{ + "op://Projects Wij Techniek/DBSQLASRV01 VM/username", + "op://Projects Wij Techniek/DBSQLASRV01 VM/password", + } + + result, err := p.ResolveMultiple(refs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(calls) != 1 { + t.Fatalf("expected 1 op call for same vault+item, got %d", len(calls)) + } + + c := calls[0] + args := c.args + + quotedArgs := make([]string, len(args)) + for i, a := range args { + if strings.ContainsRune(a, ' ') { + quotedArgs[i] = `"` + a + `"` + } else { + quotedArgs[i] = a + } + } + + if c.name != "op" { + t.Errorf("command: want %q, got %q", "op", c.name) + } + + // Positional args: item get + if len(args) < 3 || args[0] != "item" || args[1] != "get" || args[2] != "DBSQLASRV01 VM" { + t.Errorf("positional args: want [item get DBSQLASRV01 VM], got %v", args[:min(3, len(args))]) + } + + // --vault= + if got := argValue(args, "--vault"); got != "Projects Wij Techniek" { + t.Errorf("--vault: want %q, got %q", "Projects Wij Techniek", got) + } + + // --fields must contain both labels (map iteration order is non-deterministic, so sort) + rawFields := strings.Split(flagValue(args, "--fields"), ",") + sort.Strings(rawFields) + if strings.Join(rawFields, ",") != "label=password,label=username" { + t.Errorf("--fields: want label=password,label=username, got %q", rawFields) + } + + // --format json + if got := flagValue(args, "--format"); got != "json" { + t.Errorf("--format: want %q, got %q", "json", got) + } + + // --reveal + if !containsArg(args, "--reveal") { + t.Errorf("--reveal not found in args %v", args) + } + + // Results must map back to the original references + if result[refs[0]] != "testuser" { + t.Errorf("username result: want %q, got %q", "testuser", result[refs[0]]) + } + if result[refs[1]] != "s3cr3t" { + t.Errorf("password result: want %q, got %q", "s3cr3t", result[refs[1]]) + } +} + +// TestResolveMultiple_DifferentItemsTwoCalls verifies that refs from two distinct +// items produce two separate op calls. +func TestResolveMultiple_DifferentItemsTwoCalls(t *testing.T) { + callCount := 0 + run := func(name string, args ...string) ([]byte, error) { + callCount++ + out, _ := json.Marshal([]opField{{Label: "password", Value: "pw"}}) + return out, nil + } + + p := newTestProvider(run) + refs := []string{ + "op://MyVault/ItemA/password", + "op://MyVault/ItemB/password", + } + + if _, err := p.ResolveMultiple(refs); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 2 { + t.Errorf("expected 2 op calls for different items, got %d", callCount) + } +} + +// TestResolveMultiple_NonReferencesPassthrough verifies that plain strings are +// returned unchanged and no op call is made. +func TestResolveMultiple_NonReferencesPassthrough(t *testing.T) { + run := func(name string, args ...string) ([]byte, error) { + t.Fatal("op must not be called for non-references") + return nil, nil + } + + p := newTestProvider(run) + refs := []string{"plaintext", "also-plain"} + + result, err := p.ResolveMultiple(refs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, ref := range refs { + if result[ref] != ref { + t.Errorf("%q: want passthrough, got %q", ref, result[ref]) + } + } +} + +// TestResolveMultiple_DuplicateRefSingleCall verifies that the same reference +// appearing twice results in only one op call. +func TestResolveMultiple_DuplicateRefSingleCall(t *testing.T) { + callCount := 0 + run := func(name string, args ...string) ([]byte, error) { + callCount++ + out, _ := json.Marshal([]opField{{Label: "password", Value: "secret"}}) + return out, nil + } + + p := newTestProvider(run) + ref := "op://Vault/Item/password" + + if _, err := p.ResolveMultiple([]string{ref, ref}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 1 { + t.Errorf("expected 1 op call for duplicate refs, got %d", callCount) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..86fe8db --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,503 @@ +// Package update implements self-update: checking GitHub Releases for a newer +// version of MremoteGO, downloading the matching platform archive, and +// replacing the currently running executable with it. +package update + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// repoOwner/repoName identify the GitHub repository releases are checked against. +const ( + repoOwner = "janvdpol" + repoName = "mremotego" +) + +var releasesURL = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=10", repoOwner, repoName) + +// checksumsAssetName is the release asset published alongside the platform +// archives, listing each archive's SHA-256 in `sha256sum`-compatible format +// (see the "Generate checksums" step in .github/workflows/build.yml). +const checksumsAssetName = "SHA256SUMS" + +// Release describes a GitHub release that is newer than the running version +// and has a downloadable archive for the current platform. +type Release struct { + Version string // e.g. "1.4.0" (no leading "v") + TagName string // e.g. "v1.4.0" + HTMLURL string + Notes string + AssetURL string + AssetName string + ChecksumsURL string // browser_download_url for the release's SHA256SUMS file, or "" if absent +} + +type ghRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []ghAsset `json:"assets"` +} + +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// CheckLatest queries GitHub for the most recently published stable release +// and returns it if newer than currentVersion. It reports ok=false (with no +// error) when already up to date or when currentVersion is a local/dev build. +// +// The release list is queried rather than /releases/latest (which excludes +// prereleases and drafts itself) because the plain GitHub API client used +// here has no reliable way to tell in advance whether that endpoint has ever +// had a qualifying release to return — walking the list and applying the +// same draft/prerelease filter ourselves behaves identically once one +// exists, and degrades gracefully (a normal "no update" result) rather than +// erroring while it doesn't. See .github/workflows/build.yml's "Determine +// release type" step for what makes a release stable vs. prerelease: plain +// tags (v1.6.0) are stable, tags with a semver pre-release identifier +// (v2.0.0-alpha) are not and are skipped here — self-update only ever +// offers the latest stable release, never a prerelease (GitHub returns the +// list newest-first). +func CheckLatest(currentVersion string) (rel *Release, ok bool, err error) { + currentVersion = strings.TrimPrefix(strings.TrimSpace(currentVersion), "v") + if currentVersion == "" || currentVersion == "dev" { + return nil, false, nil + } + + req, err := http.NewRequest(http.MethodGet, releasesURL, nil) + if err != nil { + return nil, false, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, false, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("github API returned %s", resp.Status) + } + + var releases []ghRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, false, err + } + + for _, r := range releases { + if r.Draft || r.Prerelease { + continue + } + + latestVersion := strings.TrimPrefix(r.TagName, "v") + if compareVersions(latestVersion, currentVersion) <= 0 { + return nil, false, nil // newest published release is not newer than what we have + } + + assetName := assetNameFor(runtime.GOOS, runtime.GOARCH) + var assetURL, checksumsURL string + for _, a := range r.Assets { + switch a.Name { + case assetName: + assetURL = a.BrowserDownloadURL + case checksumsAssetName: + checksumsURL = a.BrowserDownloadURL + } + } + if assetURL == "" { + return nil, false, fmt.Errorf("release %s has no asset named %q for this platform", r.TagName, assetName) + } + + return &Release{ + Version: latestVersion, + TagName: r.TagName, + HTMLURL: r.HTMLURL, + Notes: r.Body, + AssetURL: assetURL, + AssetName: assetName, + ChecksumsURL: checksumsURL, + }, true, nil + } + + return nil, false, nil +} + +// assetNameFor returns the release asset name for goos/goarch, matching the +// naming produced by .github/workflows/build.yml (upload-artifact name +// "-", archived as .zip on Windows and .tar.gz elsewhere). +func assetNameFor(goos, goarch string) string { + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + return fmt.Sprintf("%s-%s.%s", goos, goarch, ext) +} + +// compareVersions compares dotted numeric version strings (e.g. "1.4.0"). +// Returns 1 if a > b, -1 if a < b, 0 if equal (non-numeric suffixes on the +// final segment, e.g. "-alpha", are ignored). +func compareVersions(a, b string) int { + as := strings.Split(a, ".") + bs := strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + var an, bn int + if i < len(as) { + an = leadingInt(as[i]) + } + if i < len(bs) { + bn = leadingInt(bs[i]) + } + if an != bn { + if an > bn { + return 1 + } + return -1 + } + } + return 0 +} + +// leadingInt parses the leading run of ASCII digits in s, returning 0 if +// there is none (e.g. for a "0-alpha" style segment). +func leadingInt(s string) int { + end := 0 + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + n, _ := strconv.Atoi(s[:end]) + return n +} + +// Apply downloads rel's archive, verifies it against the release's published +// SHA-256 checksum, extracts the executable, replaces the currently running +// binary with it, and relaunches it. On success the caller should exit the +// current process immediately — the app has already continued running as the +// freshly spawned process. +func Apply(rel *Release) error { + archivePath, err := download(rel.AssetURL, rel.AssetName) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer os.Remove(archivePath) + + if err := verifyChecksum(archivePath, rel); err != nil { + return fmt.Errorf("update verification failed, refusing to install: %w", err) + } + + newExePath, err := extractExecutable(archivePath) + if err != nil { + return fmt.Errorf("extract failed: %w", err) + } + defer os.RemoveAll(filepath.Dir(newExePath)) + + currentExe, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine current executable path: %w", err) + } + if resolved, err := filepath.EvalSymlinks(currentExe); err == nil { + currentExe = resolved + } + + if err := replaceExecutable(currentExe, newExePath); err != nil { + return fmt.Errorf("failed to install update: %w", err) + } + + if err := relaunch(currentExe); err != nil { + return fmt.Errorf("update installed, but failed to relaunch automatically — please start MremoteGO again: %w", err) + } + + return nil +} + +// download fetches url to a temporary file (named after assetName, so its +// extension is preserved for extractExecutable) and returns its path. +func download(url, assetName string) (string, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed: %s", resp.Status) + } + + tmp, err := os.CreateTemp("", "mremotego-update-*"+filepath.Ext(assetName)) + if err != nil { + return "", err + } + defer tmp.Close() + + if _, err := io.Copy(tmp, resp.Body); err != nil { + os.Remove(tmp.Name()) + return "", err + } + return tmp.Name(), nil +} + +// verifyChecksum fetches rel's SHA256SUMS asset and confirms archivePath's +// SHA-256 matches the entry for rel.AssetName. It fails closed: a release +// with no SHA256SUMS asset, no matching entry, or a mismatching hash is all +// treated as verification failure. +func verifyChecksum(archivePath string, rel *Release) error { + if rel.ChecksumsURL == "" { + return fmt.Errorf("release %s does not publish a %s file", rel.TagName, checksumsAssetName) + } + + req, err := http.NewRequest(http.MethodGet, rel.ChecksumsURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("could not fetch %s: %w", checksumsAssetName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("could not fetch %s: %s", checksumsAssetName, resp.Status) + } + + listing, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + expected, err := findChecksum(string(listing), rel.AssetName) + if err != nil { + return err + } + + actual, err := sha256File(archivePath) + if err != nil { + return fmt.Errorf("could not hash downloaded file: %w", err) + } + + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", rel.AssetName, expected, actual) + } + return nil +} + +// findChecksum looks up name's hex digest in listing, a `sha256sum`-format +// file (one " " pair per line; sha256sum also allows a +// leading "*" before the filename for binary mode, which is stripped). +func findChecksum(listing, name string) (string, error) { + for _, line := range strings.Split(listing, "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + hash, file := fields[0], strings.TrimPrefix(fields[1], "*") + if file == name { + return hash, nil + } + } + return "", fmt.Errorf("no checksum entry found for %s", name) +} + +// sha256File returns the lowercase hex-encoded SHA-256 digest of path's contents. +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// extractExecutable pulls the single file out of archivePath (a .zip or +// .tar.gz produced by the release workflow, each containing exactly one +// executable at its root) into a fresh temp directory and returns its path. +func extractExecutable(archivePath string) (string, error) { + if strings.HasSuffix(archivePath, ".zip") { + return extractFromZip(archivePath) + } + return extractFromTarGz(archivePath) +} + +func extractFromZip(archivePath string) (string, error) { + r, err := zip.OpenReader(archivePath) + if err != nil { + return "", err + } + defer r.Close() + + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + path, err := writeTempExecutable(rc, f.Name) + rc.Close() + return path, err + } + return "", fmt.Errorf("no file found in update archive") +} + +func extractFromTarGz(archivePath string) (string, error) { + f, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return "", err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if hdr.Typeflag != tar.TypeReg { + continue + } + return writeTempExecutable(tr, hdr.Name) + } + return "", fmt.Errorf("no file found in update archive") +} + +func writeTempExecutable(r io.Reader, name string) (string, error) { + dir, err := os.MkdirTemp("", "mremotego-update") + if err != nil { + return "", err + } + path := filepath.Join(dir, filepath.Base(name)) + out, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return "", err + } + defer out.Close() + + if _, err := io.Copy(out, r); err != nil { + return "", err + } + return path, nil +} + +// relaunch starts exePath as a new detached process with the same arguments +// the current process was given. +func relaunch(exePath string) error { + cmd := exec.Command(exePath, os.Args[1:]...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Start() +} + +// renameOrCopy moves src to dst. Plain os.Rename can't cross filesystem/volume +// boundaries (common here, since src lives in the system temp directory), so +// on failure this instead copies src into a temp file in dst's own directory +// (guaranteed same filesystem) and renames that into place. Going through a +// same-filesystem rename — rather than writing dst directly — matters when +// dst may be the very executable currently running: overwriting its content +// in place is unsafe, while rename/unlink-and-relink is not. +func renameOrCopy(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } + + tmp, err := os.CreateTemp(filepath.Dir(dst), ".mremotego-update-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) // no-op once successfully renamed away + + in, err := os.Open(src) + if err != nil { + tmp.Close() + return err + } + _, copyErr := io.Copy(tmp, in) + in.Close() + closeErr := tmp.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if err := os.Chmod(tmpPath, 0755); err != nil { + return err + } + if err := os.Rename(tmpPath, dst); err != nil { + return err + } + + _ = os.Remove(src) + return nil +} + +// CleanupOnStartup removes a ".old" leftover from a previous self-update +// (see the Windows replaceExecutable), retrying for up to ~30s since the old +// process — which spawned this one and then exited — may take a little while +// to fully tear down (Fyne's GL context teardown in particular) and release +// its handle on that file. Callers should invoke this in a goroutine: it's +// meant to finish cleanup shortly after startup, not block it. +func CleanupOnStartup() { + exe, err := os.Executable() + if err != nil { + return + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + oldPath := exe + ".old" + if _, err := os.Stat(oldPath); err != nil { + return // nothing to clean up + } + + for i := 0; i < 60; i++ { + if err := os.Remove(oldPath); err == nil { + return + } + time.Sleep(500 * time.Millisecond) + } +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..f6fb287 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,88 @@ +package update + +import "testing" + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.4.0", "1.3.0", 1}, + {"1.3.0", "1.4.0", -1}, + {"1.4.0", "1.4.0", 0}, + {"1.4.0", "1.4", 0}, + {"2.0.0", "1.99.99", 1}, + {"1.10.0", "1.9.0", 1}, // numeric, not lexicographic, comparison + {"1.4.0-alpha", "1.4.0", 0}, + } + for _, c := range cases { + if got := compareVersions(c.a, c.b); got != c.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestAssetNameFor(t *testing.T) { + cases := []struct { + goos, goarch, want string + }{ + {"windows", "amd64", "windows-amd64.zip"}, + {"linux", "amd64", "linux-amd64.tar.gz"}, + {"linux", "arm64", "linux-arm64.tar.gz"}, + {"darwin", "arm64", "darwin-arm64.tar.gz"}, + } + for _, c := range cases { + if got := assetNameFor(c.goos, c.goarch); got != c.want { + t.Errorf("assetNameFor(%q, %q) = %q, want %q", c.goos, c.goarch, got, c.want) + } + } +} + +func TestCheckLatestSkipsDevBuilds(t *testing.T) { + for _, v := range []string{"", "dev", " dev "} { + rel, ok, err := CheckLatest(v) + if rel != nil || ok || err != nil { + t.Errorf("CheckLatest(%q) = (%v, %v, %v), want (nil, false, nil)", v, rel, ok, err) + } + } +} + +func TestFindChecksum(t *testing.T) { + listing := "aaaa111 linux-amd64.tar.gz\n" + + "bbbb222 *windows-amd64.zip\n" + // sha256sum binary-mode marker + "cccc333 darwin-arm64.tar.gz\n" + + cases := []struct { + name string + want string + wantErr bool + }{ + {"linux-amd64.tar.gz", "aaaa111", false}, + {"windows-amd64.zip", "bbbb222", false}, // leading "*" stripped + {"darwin-arm64.tar.gz", "cccc333", false}, + {"linux-arm64.tar.gz", "", true}, // not present + } + for _, c := range cases { + got, err := findChecksum(listing, c.name) + if c.wantErr { + if err == nil { + t.Errorf("findChecksum(%q) = %q, want error", c.name, got) + } + continue + } + if err != nil { + t.Errorf("findChecksum(%q) unexpected error: %v", c.name, err) + continue + } + if got != c.want { + t.Errorf("findChecksum(%q) = %q, want %q", c.name, got, c.want) + } + } +} + +func TestVerifyChecksumFailsClosedWithoutURL(t *testing.T) { + err := verifyChecksum("irrelevant-path", &Release{TagName: "v1.6.0", AssetName: "linux-amd64.tar.gz"}) + if err == nil { + t.Fatal("verifyChecksum with no ChecksumsURL should fail closed, got nil error") + } +} diff --git a/internal/update/update_unix.go b/internal/update/update_unix.go new file mode 100644 index 0000000..680e30d --- /dev/null +++ b/internal/update/update_unix.go @@ -0,0 +1,13 @@ +//go:build !windows +// +build !windows + +package update + +// replaceExecutable swaps newExePath into currentExe's place. Unix allows a +// running executable's directory entry to be replaced directly: the +// currently-executing process keeps running off its already-open handle to +// the old (now unlinked) inode, and currentExe's path immediately points at +// the new file for any future invocation. +func replaceExecutable(currentExe, newExePath string) error { + return renameOrCopy(newExePath, currentExe) +} diff --git a/internal/update/update_windows.go b/internal/update/update_windows.go new file mode 100644 index 0000000..23465e6 --- /dev/null +++ b/internal/update/update_windows.go @@ -0,0 +1,31 @@ +//go:build windows +// +build windows + +package update + +import ( + "fmt" + "os" +) + +// replaceExecutable swaps newExePath into currentExe's place. Windows opens a +// running executable's image with FILE_SHARE_DELETE, which permits renaming +// (or deleting) it while it's still executing — but not overwriting its +// content in place. So the running exe is renamed aside to ".old" first +// (cleaned up on the next startup by CleanupOnStartup), freeing up currentExe's +// path for the new binary. +func replaceExecutable(currentExe, newExePath string) error { + oldPath := currentExe + ".old" + _ = os.Remove(oldPath) // best-effort: clear out any leftover from a previous update + + if err := os.Rename(currentExe, oldPath); err != nil { + return fmt.Errorf("could not move aside the running executable: %w", err) + } + + if err := renameOrCopy(newExePath, currentExe); err != nil { + _ = os.Rename(oldPath, currentExe) // best-effort restore so the app isn't left missing its exe + return err + } + + return nil +} diff --git a/pkg/models/connection.go b/pkg/models/connection.go index 133072e..95f8a03 100644 --- a/pkg/models/connection.go +++ b/pkg/models/connection.go @@ -35,10 +35,33 @@ type Connection struct { Children []*Connection `yaml:"children,omitempty"` // Advanced options - UseCredSSP bool `yaml:"use_credssp,omitempty"` - ColorDepth int `yaml:"color_depth,omitempty"` // For RDP - Resolution string `yaml:"resolution,omitempty"` // For RDP - ExtraArgs string `yaml:"extra_args,omitempty"` // Additional protocol-specific args + UseCredSSP bool `yaml:"use_credssp,omitempty"` + RDPAuthenticationLevel string `yaml:"rdp_authentication_level,omitempty"` // "none", "prompt", "required" + ColorDepth int `yaml:"color_depth,omitempty"` // For RDP + Resolution string `yaml:"resolution,omitempty"` // For RDP + ExtraArgs string `yaml:"extra_args,omitempty"` // Additional protocol-specific args + + // Local drive redirection (RDP only): maps local drives into the remote session, + // where Windows exposes them both as new volumes under "This PC" and as + // subdirectories under \\tsclient. Values: + // "" disabled + // "*" all drives present now, plus any connected later + // "dynamic" only drives connected after the session starts (e.g. USB) + // "local" all local fixed/removable drives present at launch time, + // excluding network-mapped drives + // otherwise a comma-separated list of drive letters, e.g. "C,D" + RDPRedirectDrives string `yaml:"rdp_redirect_drives,omitempty"` + + // RD Gateway options (RDP only) + UseGateway bool `yaml:"use_gateway,omitempty"` + GatewayUsageMethod string `yaml:"gateway_usage_method,omitempty"` // "never", "always", "detect" + GatewayHostname string `yaml:"gateway_hostname,omitempty"` + GatewayCredentials string `yaml:"gateway_credentials,omitempty"` // "same", "different", "smartcard" + GatewayUsername string `yaml:"gateway_username,omitempty"` + GatewayPassword string `yaml:"gateway_password,omitempty"` + GatewayDomain string `yaml:"gateway_domain,omitempty"` + GatewayProfileUsageMethod string `yaml:"gateway_profile_usage_method,omitempty"` // "default", "explicit" + GatewayBypassIfLocal bool `yaml:"gateway_bypass_if_local,omitempty"` // Metadata Tags []string `yaml:"tags,omitempty"` @@ -118,22 +141,33 @@ func (c *Connection) DeepCopy() *Connection { } connCopy := &Connection{ - Name: c.Name, - Type: c.Type, - Protocol: c.Protocol, - Host: c.Host, - Port: c.Port, - Username: c.Username, - Password: c.Password, - Domain: c.Domain, - Description: c.Description, - UseCredSSP: c.UseCredSSP, - ColorDepth: c.ColorDepth, - Resolution: c.Resolution, - ExtraArgs: c.ExtraArgs, - Notes: c.Notes, - Created: c.Created, - Modified: c.Modified, + Name: c.Name, + Type: c.Type, + Protocol: c.Protocol, + Host: c.Host, + Port: c.Port, + Username: c.Username, + Password: c.Password, + Domain: c.Domain, + Description: c.Description, + UseCredSSP: c.UseCredSSP, + RDPAuthenticationLevel: c.RDPAuthenticationLevel, + ColorDepth: c.ColorDepth, + Resolution: c.Resolution, + ExtraArgs: c.ExtraArgs, + RDPRedirectDrives: c.RDPRedirectDrives, + UseGateway: c.UseGateway, + GatewayUsageMethod: c.GatewayUsageMethod, + GatewayHostname: c.GatewayHostname, + GatewayCredentials: c.GatewayCredentials, + GatewayUsername: c.GatewayUsername, + GatewayPassword: c.GatewayPassword, + GatewayDomain: c.GatewayDomain, + GatewayProfileUsageMethod: c.GatewayProfileUsageMethod, + GatewayBypassIfLocal: c.GatewayBypassIfLocal, + Notes: c.Notes, + Created: c.Created, + Modified: c.Modified, } // Deep copy tags diff --git a/temp/.claude/settings.local.json b/temp/.claude/settings.local.json new file mode 100644 index 0000000..7bdcf00 --- /dev/null +++ b/temp/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Read(//c/github/jaydenthorup/mremotego/**)", + "Bash(go build *)", + "Bash(go *)" + ] + } +} diff --git a/temp/ART1WV-DB002 org.rdp b/temp/ART1WV-DB002 org.rdp new file mode 100644 index 0000000..80b5188 --- /dev/null +++ b/temp/ART1WV-DB002 org.rdp @@ -0,0 +1,46 @@ +full address:s:ART1WV-DB002 +username:s:ambitionIT2@arte.local +screen mode id:i:2 +use multimon:i:0 +session bpp:i:32 +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:1 +allow desktop composition:i:1 +disable full window drag:i:0 +disable menu anims:i:0 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +audiomode:i:0 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +autoreconnection enabled:i:1 +authentication level:i:2 +prompt for credentials:i:0 +negotiate security layer:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s://rdg.artegroep.nl +gatewayusagemethod:i:2 +gatewaycredentialssource:i:4 +gatewayprofileusagemethod:i:0 +promptcredentialonce:i:1 +gatewaybrokeringtype:i:0 +use redirection server name:i:0 +rdgiskdcproxy:i:0 +kdcproxyname:s: +smart sizing:i:1 +gatewayisbypassiflocal:i:1 \ No newline at end of file diff --git a/temp/ART1WV-DB002 working.rdp b/temp/ART1WV-DB002 working.rdp new file mode 100644 index 0000000..944a606 --- /dev/null +++ b/temp/ART1WV-DB002 working.rdp @@ -0,0 +1,48 @@ +full address:s:ART1WV-DB002 +username:s:AmbitionIT2@arte.local +screen mode id:i:2 +use multimon:i:0 +session bpp:i:32 +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:1 +allow desktop composition:i:1 +disable full window drag:i:0 +disable menu anims:i:0 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +audiomode:i:0 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +autoreconnection enabled:i:1 +authentication level:i:0 +prompt for credentials:i:1 +negotiate security layer:i:1 +use cred ssp:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s:rdg.artegroep.nl +gatewayusagemethod:i:1 +gatewaycredentialssource:i:0 +gatewayprofileusagemethod:i:1 +promptcredentialonce:i:1 +gatewaybrokeringtype:i:0 +use redirection server name:i:1 +rdgiskdcproxy:i:0 +kdcproxyname:s: +smart sizing:i:1 +gatewayisbypassiflocal:i:0 +password:s: \ No newline at end of file diff --git a/temp/ART1WV-DB002-minimal.rdp b/temp/ART1WV-DB002-minimal.rdp new file mode 100644 index 0000000..5e66b4e --- /dev/null +++ b/temp/ART1WV-DB002-minimal.rdp @@ -0,0 +1,8 @@ +full address:s:ART1WV-DB002 +gatewayhostname:s:rdg.artegroep.nl +gatewayusagemethod:i:1 +gatewaycredentialssource:i:0 +gatewayprofileusagemethod:i:0 +username:s:AmbitionIT2@arte.local +authentication level:i:0 +prompt for credentials:i:1 diff --git a/temp/ART1WV-DB002.rdp b/temp/ART1WV-DB002.rdp new file mode 100644 index 0000000..944a606 --- /dev/null +++ b/temp/ART1WV-DB002.rdp @@ -0,0 +1,48 @@ +full address:s:ART1WV-DB002 +username:s:AmbitionIT2@arte.local +screen mode id:i:2 +use multimon:i:0 +session bpp:i:32 +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:1 +allow desktop composition:i:1 +disable full window drag:i:0 +disable menu anims:i:0 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +audiomode:i:0 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +autoreconnection enabled:i:1 +authentication level:i:0 +prompt for credentials:i:1 +negotiate security layer:i:1 +use cred ssp:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s:rdg.artegroep.nl +gatewayusagemethod:i:1 +gatewaycredentialssource:i:0 +gatewayprofileusagemethod:i:1 +promptcredentialonce:i:1 +gatewaybrokeringtype:i:0 +use redirection server name:i:1 +rdgiskdcproxy:i:0 +kdcproxyname:s: +smart sizing:i:1 +gatewayisbypassiflocal:i:0 +password:s: \ No newline at end of file diff --git a/temp/ART1WV-DB002.xml b/temp/ART1WV-DB002.xml new file mode 100644 index 0000000..a6071ec --- /dev/null +++ b/temp/ART1WV-DB002.xml @@ -0,0 +1,31 @@ + + + + \ No newline at end of file diff --git a/temp/Recording 2026-04-21 132654.mp4 b/temp/Recording 2026-04-21 132654.mp4 new file mode 100644 index 0000000..1c53870 Binary files /dev/null and b/temp/Recording 2026-04-21 132654.mp4 differ diff --git a/temp/Screenshot 2026-04-21 113910.png b/temp/Screenshot 2026-04-21 113910.png new file mode 100644 index 0000000..ceca978 Binary files /dev/null and b/temp/Screenshot 2026-04-21 113910.png differ diff --git a/temp/Screenshot 2026-04-21 114829 (Warning).png b/temp/Screenshot 2026-04-21 114829 (Warning).png new file mode 100644 index 0000000..7d85a43 Binary files /dev/null and b/temp/Screenshot 2026-04-21 114829 (Warning).png differ