diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index f58de8f..e74e721 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -130,9 +130,13 @@ jobs: ) | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append build: - name: Build and test + name: Build and test (PowerShell ${{ matrix.powershell }}) runs-on: windows-latest needs: release-metadata + strategy: + fail-fast: false + matrix: + powershell: ['7.4.6', '7.5.11', '7.6.6'] steps: - name: Checkout uses: actions/checkout@v4 @@ -162,10 +166,12 @@ jobs: - name: Test # Engine tests resolve the separately installed runtime; it is not a DT package payload. - run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 + run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 -Version ${{ matrix.powershell }} - name: Run tests - timeout-minutes: 60 + # Hosted Windows runners can spend several seconds starting each isolated pwsh. + # Keep the per-test hang guard below; budget the complete sequential UI suite separately. + timeout-minutes: 120 run: >- dotnet test Devolutions.Terminal.slnx -c Release --no-build -m:1 -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} @@ -177,7 +183,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: windows-test-diagnostics + name: windows-test-diagnostics-${{ matrix.powershell }} path: artifacts/test-results/windows if-no-files-found: ignore retention-days: 7 @@ -266,20 +272,19 @@ jobs: -c Release -r ${{ matrix.rid }} --self-contained - -p:EnablePowerShellIse=false -p:PublishAot=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} -o artifacts/${{ matrix.rid }} - - name: Check opt-in managed Iseberg desktop + - name: Check terminal-only NativeAOT desktop run: > dotnet publish src/Devolutions.Terminal/Devolutions.Terminal.csproj -c Release -r ${{ matrix.rid }} --self-contained - -p:EnablePowerShellIse=true + -p:EnablePowerShellIse=false -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - -o artifacts/iseberg-validation/${{ matrix.rid }} + -o artifacts/terminal-only-validation/${{ matrix.rid }} - name: Upload NativeAOT desktop uses: actions/upload-artifact@v4 @@ -289,9 +294,13 @@ jobs: if-no-files-found: error linux-managed: - name: Linux managed and metadata tests + name: Linux managed and metadata tests (PowerShell ${{ matrix.powershell }}) runs-on: ubuntu-24.04 needs: release-metadata + strategy: + fail-fast: false + matrix: + powershell: ['7.4.6', '7.5.11', '7.6.6'] steps: - name: Checkout uses: actions/checkout@v4 @@ -316,7 +325,7 @@ jobs: run: dotnet restore Devolutions.Terminal.slnx -p:Configuration=Release -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Run managed tests - run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 + run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 -Version ${{ matrix.powershell }} - name: Test managed projects run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} @@ -325,9 +334,13 @@ jobs: run: bash scripts/Test-LinuxPackagingMetadata.sh macos-managed: - name: macOS managed and metadata tests + name: macOS managed and metadata tests (PowerShell ${{ matrix.powershell }}) runs-on: macos-26 needs: release-metadata + strategy: + fail-fast: false + matrix: + powershell: ['7.4.6', '7.5.11', '7.6.6'] steps: - name: Checkout uses: actions/checkout@v4 @@ -352,7 +365,7 @@ jobs: run: dotnet restore Devolutions.Terminal.slnx -p:Configuration=Release -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} - name: Run managed tests - run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 + run: pwsh -NoLogo -NoProfile -File scripts/Install-IsebergTestPowerShell.ps1 -Version ${{ matrix.powershell }} - name: Test managed projects run: dotnet test Devolutions.Terminal.slnx -c Release --no-restore -p:EnablePowerShellIse=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} @@ -407,7 +420,6 @@ jobs: -c Release -r ${{ matrix.rid }} --self-contained true - -p:EnablePowerShellIse=false -p:PublishAot=true -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} -p:DebugSymbols=false @@ -696,7 +708,6 @@ jobs: "-c", "Release", "-r", "${{ matrix.rid }}", "--self-contained", "true", - "-p:EnablePowerShellIse=false", "-p:PublishAot=true", "-p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }}", "-p:DebugSymbols=false", @@ -1329,8 +1340,8 @@ jobs: env: EXPECTED_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }} run: | - ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-x64 -ExpectedPublisher $env:EXPECTED_PUBLISHER - ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-arm64 -ExpectedPublisher $env:EXPECTED_PUBLISHER + ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-x64 -RequireIsebergModule -ExpectedPublisher $env:EXPECTED_PUBLISHER + ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-arm64 -RequireIsebergModule -ExpectedPublisher $env:EXPECTED_PUBLISHER - name: Upload Windows x64 NuGet payload uses: actions/upload-artifact@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05fbabe..4f05397 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,9 +23,10 @@ The browser never supplies a command line, and the host does not send one back. Elevated profiles are listed and refused. Warnings are errors. Keep the default NativeAOT desktop publish green. -Iseberg is disabled by default; its managed developer build and regression suite -require `-p:EnablePowerShellIse=true`. See [Iseberg](docs/iseberg.md) for the -installed-engine boundary. +Iseberg is enabled by default; its engine and regression suite require separately +installed PowerShell 7.4.6+ within 7.4.x, 7.5.x or 7.6.x with its bundled runtime. +See [Iseberg](docs/iseberg.md) for the +subprocess/module boundary. ## Desktop publish @@ -33,10 +34,11 @@ installed-engine boundary. dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ``` -The default desktop and release packages use NativeAOT and exclude Iseberg. -Add `-p:EnablePowerShellIse=true` only for an optional managed, untrimmed -Iseberg developer build. Use the same flag for restore/build/test/run commands -when working on Iseberg. The CLI remains NativeAOT in both variants. +The default desktop and release packages use NativeAOT and include Iseberg. +Only the private PowerShell bridge is managed: it is imported by the installed +`pwsh` subprocess, never loaded into DT. Use `-p:EnablePowerShellIse=false` +consistently across build/test/publish commands for a terminal-only configuration. +The CLI remains NativeAOT in both variants. Linux packages: diff --git a/Devolutions.Terminal.slnx b/Devolutions.Terminal.slnx index 1faefa3..1e87ed0 100644 --- a/Devolutions.Terminal.slnx +++ b/Devolutions.Terminal.slnx @@ -17,8 +17,10 @@ + + diff --git a/Directory.Build.props b/Directory.Build.props index d020f39..68f1417 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ false true direct - false + true $(MSBuildThisFileDirectory)assets\icons\terminal.ico diff --git a/Directory.Packages.props b/Directory.Packages.props index cb7b136..b33f6f9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + diff --git a/README.md b/README.md index 6e2835d..13b6c9f 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ as a self-contained **NativeAOT** desktop app, and rendered with **Avalonia 12** - Azure Cloud Shell for remote Azure sessions - selectable built-in or **Ghostty** VT engine - Windows Terminal-compatible `settings.json`, actions, keybindings, and `dt` CLI -- optional source-integrated **Iseberg** PowerShell ISE profiles in managed - developer builds only (disabled in default NativeAOT builds and release packages) +- source-integrated **Iseberg** PowerShell ISE profiles, with execution in an + owned PowerShell subprocess and a NativeAOT desktop This is the [Devolutions Terminal](https://github.com/Devolutions/devolutions-terminal) source tree. Projects, namespaces, and the GUI host use `Devolutions.Terminal.*`. @@ -40,11 +40,11 @@ dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained The executable is written to `src/Devolutions.Terminal/bin/Release/net10.0/win-x64/publish/Devolutions.Terminal.exe`. -The default desktop and `dt` CLI are NativeAOT. Release packages exclude Iseberg. -An explicitly opted-in `-p:EnablePowerShellIse=true` developer build uses a -managed, untrimmed desktop and separately installed compatible PowerShell; see -[docs/iseberg.md](docs/iseberg.md). Saved Iseberg profiles remain intact, but -cannot launch in the default build. +The default desktop and `dt` CLI are NativeAOT. Desktop release packages include +Iseberg and its private managed bridge module; PowerShell itself must be installed +separately and is required only when opening an Iseberg profile. See +[docs/iseberg.md](docs/iseberg.md). Use `-p:EnablePowerShellIse=false` for an explicit +terminal-only build; saved Iseberg profiles remain intact but cannot launch there. macOS app bundles (Darwin only): diff --git a/docs/iseberg-parity.md b/docs/iseberg-parity.md index f5c1b8e..72367fd 100644 --- a/docs/iseberg-parity.md +++ b/docs/iseberg-parity.md @@ -1,8 +1,8 @@ # Original Windows PowerShell ISE feature comparison -DT's source-integrated Iseberg in an explicitly enabled managed developer build -already covers the main edit/run/debug workflow. Default NativeAOT builds and -release packages disable Iseberg; saved profile settings are preserved. +DT's source-integrated Iseberg covers the main edit/run/debug workflow in the +NativeAOT desktop, with PowerShell execution isolated in an owned `pwsh` +subprocess. Default desktop releases include the workbench and its private bridge. The integration now matches additional portable ISE features, but this is **not a drop-in replacement for the original `$psISE` object model, WPF add-ons, or Windows PowerShell 5.1**. Several features exist in the imported workbench but @@ -78,14 +78,14 @@ Evidence codes in the matrices refer to these concrete implementation surfaces: | Code | DT / imported source | | --- | --- | | `L` | [DT tab creation and closing](../src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs), [tab host options](../src/Devolutions.Terminal.App/Views/PowerShellIseTab.cs) | -| `R` | `src\Devolutions.Terminal.Ise\Iseberg.Core\PowerShellSession*.cs`, `WorkbenchHost.cs` | +| `R` | [parent IPC session](../src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs), [child execution/custom host](../src/Devolutions.Terminal.Ise/Iseberg.PowerShell) | | `C` | [native console adapter](../src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs), [console control bridge](../src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs) | -| `E` | [editor control](../src/Devolutions.Terminal.Ise/Iseberg.Editor/PowerShellEditorControl.cs), [analysis](../src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs), [rendering](../src/Devolutions.Terminal.Ise/Iseberg/EditorRendering.cs) | +| `E` | [editor control](../src/Devolutions.Terminal.Ise/Iseberg.Editor/PowerShellEditorControl.cs), [analysis contracts/helpers](../src/Devolutions.Terminal.Ise/Iseberg.Contracts), [child parser](../src/Devolutions.Terminal.Ise/Iseberg.PowerShell), [rendering](../src/Devolutions.Terminal.Ise/Iseberg/EditorRendering.cs) | | `W` | [workbench editor, commands, help, and snippet actions](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.axaml.cs) | -| `F` | [persistence/recovery integration](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Persistence.cs), `Iseberg.Core\ScriptRecovery.cs`, `ScriptFile.cs` | -| `N` | [snippet service](../src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs), `Iseberg.Core\SnippetCatalog.cs` | -| `B` | [debugger UI](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Debugger.cs), `Iseberg.Core\PowerShellSession.Debugger.cs`, `DebuggerModels.cs` | -| `O` | [ISE scripting wrappers](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Scripting.cs) | +| `F` | [persistence/recovery integration](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Persistence.cs), `Iseberg.Core\ScriptRecovery.cs`, shared file models in [contracts](../src/Devolutions.Terminal.Ise/Iseberg.Contracts) | +| `N` | [snippet service](../src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs), shared catalog models in [contracts](../src/Devolutions.Terminal.Ise/Iseberg.Contracts), cmdlets in [the child module](../src/Devolutions.Terminal.Ise/Iseberg.PowerShell) | +| `B` | [debugger UI](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Debugger.cs), [child debugger](../src/Devolutions.Terminal.Ise/Iseberg.PowerShell), DTOs in [contracts](../src/Devolutions.Terminal.Ise/Iseberg.Contracts) | +| `O` | [ISE scripting UI wrappers](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.Scripting.cs), [typed reverse dispatch](../src/Devolutions.Terminal.Ise/Iseberg/WorkbenchControl.ScriptingBridge.cs), child proxies in [the module](../src/Devolutions.Terminal.Ise/Iseberg.PowerShell) | | `P` | [DT themes](../src/Devolutions.Terminal.Settings/IsebergThemes.cs), `Iseberg\OptionsWindow.axaml.cs`, `Iseberg.Core\UserSettings.cs` | | `A` | [editor accessibility peer](../src/Devolutions.Terminal.Ise/Iseberg.Editor/AccessibleTextEditor.cs), installed AvaloniaEdit editing handlers | diff --git a/docs/iseberg.md b/docs/iseberg.md index 8fb8220..15d2aa7 100644 --- a/docs/iseberg.md +++ b/docs/iseberg.md @@ -1,10 +1,18 @@ # Iseberg workbench profiles **Iseberg** is an embedded PowerShell ISE-style workbench, not a terminal -command line or a separate application. **It is disabled in default builds and -release packages to preserve DT's NativeAOT desktop.** The workflow below requires -an explicit managed developer build with `-p:EnablePowerShellIse=true`. -In **Settings**, select **Add a new +command line or a separate application. **It is enabled in default NativeAOT +desktop builds and release packages.** The workbench executes PowerShell in an +owned `pwsh` subprocess through a private managed module shipped with DT. +When shell discovery detects `pwsh`, an **Iseberg** profile appears automatically +in the new-tab menu on Windows, Linux and macOS, including with existing settings. +Its stable generated identity preserves customizations across restarts. Hiding +the profile or disabling its shell-discovery source also hides/removes it; the +ordinary PowerShell profile and your default profile are otherwise unchanged. +Explicit terminal-only builds do not generate Iseberg profiles. Compatibility +validation still happens when the workbench is opened. + +To create additional workbench profiles, in **Settings**, select **Add a new profile**, choose **PowerShell ISE (Iseberg)** under **Profile type**, configure its name, starting directory and font, then select that profile from the new-tab menu. Existing PowerShell profiles continue to launch @@ -28,10 +36,10 @@ in DT's existing Settings tab, rather than another preferences window. Nested workbench session tabs and remote-session creation are hidden: DT owns tabs. The editor sits above the terminal in a resizable horizontal split. Editor -execution and typed console commands share the same in-process PowerShell -runspace, including variables, completion, debugger and host input. The lower +execution and typed console commands share the same persistent PowerShell +runspace in the child process, including variables, completion, debugger and host input. The lower pane uses DT's VT renderer, scrollbar, clipboard and terminal interaction -settings, not Iseberg's original transcript editor or another `pwsh` process. +settings, not Iseberg's original transcript editor or a separate console runspace. Document tabs and workbench chrome use compact desktop UI typography independently of code font and zoom. The toolbar wraps in narrow DT windows, long document titles truncate with a full-title tooltip, and the status/zoom bar stays compact. @@ -75,13 +83,36 @@ disabled in the DT host. ## Installed PowerShell prerequisite -Install **PowerShell 7.6.6 or newer within 7.6.x**, using .NET 10.0 and the -**same architecture as DT**. Make `pwsh` available on PATH, or set +Install stable **PowerShell 7.4.x, 7.5.x or 7.6.x** (minimum 7.4.6), +with its bundled .NET 8, 9 or 10 runtime, respectively. Use the latest servicing +release in your chosen series. The private module and wire contracts target +.NET 8 and compile against PowerShell 7.4 APIs; DT itself remains .NET 10/NativeAOT. +PowerShell 7.4.0 through 7.4.5 cannot load the patched SDK's +`System.Management.Automation` 7.4.6.500 assembly reference and are rejected up front. +The child architecture does not need to match DT. Make `pwsh` available on PATH, or set `DT_ISEBERG_PSHOME` to its absolute installation directory before starting DT. -DT checks its version, runtime and architecture, and loads that installed engine -in-process. It does not start an external Iseberg desktop or use `pwsh` as the -workbench console process. A short noninteractive `pwsh -NoProfile` probe -discovers and validates the installation. +DT checks its version and child-runtime compatibility. A short noninteractive +`pwsh -NoProfile` probe discovers and validates the installation. DT then starts +an owned `pwsh -NoLogo -NoProfile` child and imports the absolute path to its +`Devolutions.Iseberg.PowerShell.dll` binary module. The module creates a custom-host execution +runspace; it does not replace PowerShell's bootstrap console host. + +Parser analysis, completion, execution, host input, debugging and portable +`$psISE` proxies run in that child. The NativeAOT parent uses typed, source-generated +JSON over private bidirectional local IPC. Script text is sent as protocol data, +never interpolated into the startup command. Standard output/error are drained +separately from IPC. The module and wire contracts are DT-private and shipped in +lockstep; protocol/build mismatches fail startup instead of guessing compatibility. +The length-prefixed channel mutually authenticates a fresh challenge and the +parent's process identity. Frames are limited to 8 MiB, outstanding requests to +128, and queued outgoing frames to 512. Request overflow is reported explicitly; +outgoing queue overflow disconnects rather than silently dropping notifications. +Independent readers keep reverse host/UI callbacks and cancellation available +while a command, input prompt or debugger stop is waiting. +Terminal-size updates are acknowledged before subsequent execution, so host +RawUI dimensions reflect the latest resize. Initial layout changes are retained +until the authenticated execution session is initialized. Background command-catalog refreshes +wait for running commands to finish rather than reporting a busy-session error. This prerequisite is lazy: ordinary terminal profiles remain usable without compatible installed PowerShell. Opening Iseberg without it produces an explicit @@ -90,9 +121,9 @@ runtime/native dependency graph, which includes dependencies with separate distribution terms. Compile-time SDK references exclude runtime, native and content assets; publishing rejects accidental engine payloads. -PowerShell executes with **DT's permissions and is not sandboxed**. Runspace -isolation is not process/security isolation; scripts can access the filesystem, -network and process-wide state. There is no unconditional execution-policy +PowerShell executes with **DT's permissions and is not sandboxed**. Process +isolation protects DT's runtime boundary, not the user's data; scripts can access +the filesystem, network and other processes. There is no unconditional execution-policy bypass. **Load PowerShell profile scripts** is opt-in and defaults to off. The current-host profile is `Devolutions.Terminal.Iseberg_profile.ps1`; normal all-host profile scripts also run when enabled. Per-profile elevation, terminal @@ -170,7 +201,12 @@ Closing a tab/window or quitting DT prompts to stop running commands and save/discard dirty documents. Cancel leaves the tab alive. Bulk/application closing prepares every workbench before disposing any, so a later cancellation does not destroy an earlier approved tab. Forced host cleanup disposes runspaces, -timers and UI subscriptions. UI errors use DT notifications and tracing. +timers, owned child processes and UI subscriptions. UI errors use DT notifications and tracing. + +If the child exits or IPC fails, DT keeps open documents and reports that the +session's variables/debugger state were lost. Reopen the tab explicitly to start a +fresh session. DT does not automatically restart PowerShell or replay an execution +whose outcome is ambiguous. DT owns the host window, application theme, fonts, tabs and settings location. Standalone global settings and update checks remain disabled. Each workspace @@ -206,60 +242,169 @@ and original Invoke/InvokeSynchronous contracts remain unsupported. ## Build and distribution -The default desktop and release packages use **NativeAOT with Iseberg disabled**. -The full PowerShell parser/runspace/debugger requires dynamic code, so the -current in-process integration cannot run in NativeAOT. Iseberg remains an -explicit developer opt-in; its desktop publish is **self-contained, single-file, -managed and untrimmed**. The `dt` CLI remains NativeAOT in both variants. +The default desktop and release packages use **NativeAOT with Iseberg enabled**. +The PowerShell parser/runspace/debugger requires dynamic code only in the +`Iseberg.PowerShell` module, which is built managed and untrimmed and loaded solely +by installed PowerShell. The parent workbench, contracts and IPC client remain +AOT/trim compatible. The `dt` CLI remains NativeAOT. ```powershell dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ` - -p:EnablePowerShellIse=true -o artifacts/iseberg-dev/win-x64 -dotnet run --project src/Devolutions.Terminal -p:EnablePowerShellIse=true -dotnet test Devolutions.Terminal.slnx -p:EnablePowerShellIse=true + -p:EnablePowerShellIse=false -o artifacts/terminal-only/win-x64 +dotnet run --project src/Devolutions.Terminal +dotnet test Devolutions.Terminal.slnx ``` -The first command produces the default NativeAOT desktop. It retains saved -Iseberg profiles but reports the missing feature rather than silently opening -PowerShell terminals. Its settings editor does not offer new Iseberg profiles. -The second command produces an opt-in managed developer publish, not a default -release package payload. Use the opt-in flag consistently for restore, build -and test commands, and restore with `-p:Configuration=Release` before a Release +The first command produces the default NativeAOT desktop with the private module +DLL beside the executables. The second produces an explicit terminal-only NativeAOT desktop. It +retains saved Iseberg profiles but reports the missing feature rather than silently +opening PowerShell terminals; its settings editor does not offer new Iseberg profiles. +Use the exclusion flag consistently for restore, build and test commands, +and restore with `-p:Configuration=Release` before a Release build with `--no-restore`. Ordinary managed builds remain framework-dependent; self-contained deployment applies at publish time. Engine/workbench tests are -excluded when the feature is off. -CI still builds and tests this opt-in variant separately. Publishing an -Iseberg-enabled build with trimming or NativeAOT explicitly enabled fails before -compiling project dependencies. +excluded when the feature is off. CI builds the feature-enabled native desktop +and retains a separate terminal-only configuration gate. Windows CI runs test projects sequentially (`-m:1`) to avoid concurrent test-host startup starving short broker deadlines. It retains TRX results and per-test -hang diagnostics; the complete suite has a separate, longer time budget for the -slower hosted Windows runner. +hang diagnostics with a five-minute hang guard; the complete suite has a separate +120-minute budget because isolated `pwsh` startup is slower on hosted Windows +runners. This does not change individual test assertions or hang deadlines. Native libraries/helpers and legal notices remain loose for package signing and -license checks. macOS packaging defaults to NativeAOT; an explicitly supplied -managed publish must be self-contained and single-file, not an arbitrary loose -managed application layout. +license checks. Ship only `Devolutions.Iseberg.PowerShell.dll` as loose +content directly beside `dt.exe` and the desktop executable; it is not loaded into DT. Shared contract sources +and source-generated JSON are compiled into this module and separately into +the SMA-free `Iseberg.Contracts` project referenced by the NativeAOT parent. +The shared build-identity target fingerprints the same inputs in both builds. +`Iseberg.Contracts.dll` is a parent build artifact, not a distribution payload. +macOS app staging moves this DLL to `Contents/Resources`, while +`Contents/MacOS` contains only native code. Keep all `THIRD-PARTY-NOTICES*.txt` files in distributions. -The restored default was validated with a fresh `win-x64` Release publish -without an `EnablePowerShellIse` override. Both desktop and CLI are native PE -executables without CLR headers; the output contains no Iseberg, PowerShell -engine or CoreCLR payload, and `dt --help` exits zero. Default App, UI and -Settings.Editor suites pass 213, 14 and 64 cases respectively. Explicit opt-in -App/UI runs still pass all 533/664 cases. Platform script syntax and workflow -YAML were checked on Windows; actual Linux/macOS publishing and packaging -remain platform-CI validation responsibilities. +Do not ship the PowerShell engine or CoreCLR dependency graph. Native publication +must also be exercised by opening an Iseberg tab in the published executable; +a successful managed build or module import alone does not validate the parent +AOT boundary. Actual Linux/macOS publishing and packaging require their respective +platform/release environments. -For engine tests on a machine without the matching installation, +For engine tests on a machine without a compatible installation, `scripts/Install-IsebergTestPowerShell.ps1` downloads a pinned, SHA-256-verified portable runtime under `artifacts/test-powershell`. It does not change the system installation or PATH. Dot-source it to set `DT_ISEBERG_PSHOME` in the caller; GitHub Actions sets that variable for subsequent steps automatically. +Use `-Version 7.4.6` to check the exact compatibility minimum, `-Version 7.4.20` +for the serviced 7.4 runtime, `-Version 7.5.11` or the default `7.6.6`. +CI runs the managed suites against 7.4.6, 7.5.11 and 7.6.6 on Windows, +Linux and macOS. Old runtimes are isolated compatibility-test fixtures, +not recommended user installations. **Never package this test-runtime directory with DT.** -## Windows PowerShell ISE compatibility and validation +### Windows NativeAOT subprocess evidence + +The original two-DLL feature-enabled `win-x64` Release publish was exercised as +an actual native desktop, not a managed harness. Both desktop and CLI executables have no CLR +header. Its loose module folder contains only the two DT-owned assemblies; the +publish contains no PowerShell engine/CoreCLR payload. + +In isolated, test-owned windows, an editor script produced a computed result, +typed native-console input read and changed its variable, and subsequent editor +execution observed that change in the same child PID. Child assembly paths proved +the published bridge/contract DLLs were loaded by PowerShell 7.6.6/.NET 10.0.12. +Exact-PID parent module enumeration after execution found no SMA, CoreCLR, +hostfxr or hostpolicy. Closing the window normally also terminated its child. + +A final fresh publish also verified completion acceptance, `Read-Host`, GUI +Stop followed by execution in the same session, child-driven `$psISE` editor and +line-number changes, and a saved-script breakpoint with Step Over and Continue. +The final parent had 80 loaded modules with no SMA/CoreCLR matches; the child +loaded both bridge assemblies from the published module folder. Killing a +separate test-owned parent during a long-running script caused its exact child +to exit without a child-kill command; that check observed cleanup within 104 ms, +not a general shutdown-time guarantee. + +A separate owned-child termination check retained the unsaved document exactly, +displayed explicit session-loss/reopen guidance, and observed no replacement child +or replay across nine samples over approximately 15 seconds. This is bounded +runtime evidence, not an unbounded lifetime claim. Linux/macOS execution and signed +installer certification remain platform-CI/release responsibilities. + +The .NET 8 compatibility delivery publish was then exercised through native GUI +automation on the exact minimum **PowerShell 7.4.6/.NET 8.0.10** and the installed +**7.6.6/.NET 10.0.12**. Both passed editor/native-console shared state, accepted +completion, typed `Read-Host`, Stop followed by state-preserving execution, +`$psISE` reverse editor/option callbacks, and saved-script breakpoint, Step Over +and Continue (values 0, 10 and 42). Each retained the same child PID throughout, +loaded the published net8.0 bridge/contracts, and enumerated 80 parent modules +with no SMA/CoreCLR/hostfxr/hostpolicy. All eight owned processes exited through +normal UI closing. An earlier compatibility publish also passed these GUI +workflows on serviced PowerShell 7.4.20/.NET 8.0.31. + +### Subprocess regression evidence + +The latest complete affected-project runs passed **1,639 tests, 0 failed, +9 skipped** across the affected projects. The skips are existing Unix PTY cases +on Windows; no Iseberg case was skipped. + +| Test project | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| Devolutions.Terminal.App.Tests | 589 | 0 | 0 | +| Devolutions.Terminal.UI.Tests | 665 | 0 | 0 | +| Devolutions.Terminal.Settings.Tests | 202 | 0 | 0 | +| Devolutions.Terminal.Settings.Editor.Tests | 64 | 0 | 0 | +| Devolutions.Terminal.Connection.Tests | 119 | 0 | 9 | + +The App suite includes persistent regressions for fragmented/coalesced frames, +Unicode, invalid/truncated lengths, the exact request/queue limits, correlated +reverse callbacks, in-flight cancellation, authentication/build/process identity +mismatches and replay rejection. Process tests cover independent sessions, raw +stdout/stderr separation, child loss, cancellation and owned-child disposal. +The complete UI suite exercises the subprocess-backed editing, debugger, host +input, command forms, snippets, scripting proxies and recovery behaviors. +The final App suite passed on 7.4.6, 7.4.20, 7.5.11 and installed 7.6.6; +the final complete UI suite passed on the exact 7.4.6 minimum. A 50-iteration +transport regression covers cancellation overlapping handler cleanup and +connection disposal; cancellation/removal is synchronized so a completed +handler's disposed token source cannot cause unexpected session loss. +CLI, compatibility and package suites separately passed 34 tests. + +`SingleAssemblyModuleAuthenticatesAndExecutesWithoutContractsDll` copies only +the module into an isolated directory, completes the authenticated handshake, +executes with persistent state and parses incomplete input. It verifies that +the child contracts belong to `Devolutions.Iseberg.PowerShell`, with no separate contracts +assembly referenced or loaded, and that the parent remains SMA-free. This +regression passes within all four complete App runs above; the single-DLL +minimum-runtime UI run passes all 665 cases. +It also verifies session disposal and actual child exit before deleting the +private module copy. Windows cleanup logs and retries access/lock errors for at +most five seconds; persistent cleanup failures still fail the test. + +Automatic profile regressions cover Windows/Linux/macOS discovery, one stable +generated identity even with multiple PowerShell installations, absence when +`pwsh` is missing, disabling the shared discovery source, and retaining user +overrides and the existing default profile through settings serialization. +The complete Settings suite passes 202 cases; the terminal-only discovery +subset passes 31 cases and verifies that no Iseberg profile is generated. + +The complete Release solution builds with zero warnings/errors, and the default +Release NativeAOT desktop/CLI publish succeeds. Positive and negative publish +guard checks accept the single owned module and reject SMA, CoreCLR, +unexpected bridge dependencies (including a separate contracts DLL) or a missing +module. MSI component generation preserves the single module at +`/Devolutions.Iseberg.PowerShell.dll`, directly beside `dt.exe` +and `Devolutions.Terminal.exe`. Windows release CI signs this managed module +with the unpacked app binaries before MSI/NuGet packaging; its signature guard +requires a trusted timestamped signature and the exact DT publisher. PR builds +and local publishes remain unsigned, like the executables. Actual desktop +compiler references exclude SMA and the managed module. These checks are +separate from the published native GUI evidence above. + +## Windows PowerShell ISE compatibility and historical validation + +The GUI comparison and test totals below describe the earlier **in-process, +managed** integration. They document the behavioral baseline, not validation of +the subprocess/NativeAOT implementation. Microsoft's original Windows PowerShell ISE uses Windows PowerShell 5.1 and the `Windows PowerShell ISE Host`; DT's workbench uses installed PowerShell 7.6.6 and @@ -294,7 +439,7 @@ claim visual-description parity or full UI parity. Actual Linux/macOS packaging and installed Windows package certification still require their respective platform/release environments. -### Final Windows integration evidence +### Historical managed Windows integration evidence The Iseberg-enabled source passed **1,570 tests, 0 failed, 9 skipped** across the complete affected projects. The nine skips are the existing Unix PTY cases, which require @@ -309,8 +454,7 @@ Linux/macOS; no Iseberg case is skipped. | Devolutions.Terminal.Connection.Tests | 119 | 0 | 9 | These are successful fresh Debug `dotnet test` runs with Iseberg enabled, not -sums of overlapping filtered runs. With the feature now disabled by default, -repeat them with `-p:EnablePowerShellIse=true`. +sums of overlapping filtered runs. These totals predate the subprocess migration. Each project was run with `-p:SkipNativeRestore=true`, `-p:UseArtifactsOutput=true` and the same absolute `ArtifactsPath`, sequentially to avoid shared build-output collisions. Exact results are retained locally in @@ -365,7 +509,9 @@ at revision **`d2e723f4f8ed4e08db28a89f09a783e40aed001c`**: | Local project | Imported responsibility | | --- | --- | -| Iseberg.Core | PowerShell engine/host, execution, completion, debugger, files and settings models | +| Iseberg.Core | Parent session client, files and settings models (engine/host extracted into the child module) | +| Iseberg.Contracts | SMA-free parent contracts project; canonical contract sources and build identity also compiled directly into the child module | +| Iseberg.PowerShell | Managed binary module, custom PowerShell host, parser, execution and debugger | | Iseberg.Editor | Engine-independent AvaloniaEdit editor, accessibility, markers and editing commands | | Iseberg | Embeddable WorkbenchControl, dialogs, models and scoped resources | @@ -374,8 +520,8 @@ application themes were not adopted. Local adaptations add DT starting directories and host profile names, two-phase cancelable closing, DIP font sizes, scoped theme presets and completion popup resources, the DT VT console adapter, hidden-session action gates, and the -installed-engine resolver in DT's connections layer. DT remains on Avalonia -12.1.1; AvaloniaEdit is 12.0.0 and the compile-time PowerShell SDK is 7.6.6. +installed-engine discovery and subprocess bridge in DT's connections layer. DT remains on Avalonia +12.1.1; AvaloniaEdit is 12.0.0 and the compile-time PowerShell SDK is 7.4.20. Iseberg is **MIT**, copyright (c) 2026 Adam Driscoll. Its license is retained in [`docs/licenses/iseberg.txt`](licenses/iseberg.txt) and emitted as diff --git a/docs/macos.md b/docs/macos.md index 535da0e..d32b959 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -28,7 +28,7 @@ registration, and Homebrew remain out of scope. - Notifications through `osascript` `display notification` - `dterm:` URL scheme declared in `macos/Info.plist` (argv and Apple Event protocol activation) -- NativeAOT desktop `.app` + zip packaging on Darwin (Iseberg disabled) +- NativeAOT desktop `.app` + zip packaging on Darwin (Iseberg included) - Signed, notarized `.dmg` release artifacts via `scripts/Release-MacOsPackage.ps1` (CI only; ad-hoc unsigned zip/dmg locally or without Apple signing secrets) @@ -53,9 +53,10 @@ pwsh scripts/Test-MacOsPackage.ps1 osx-arm64 artifacts/packages/*.zip pwsh scripts/Test-MacOsRuntime.ps1 artifacts/packages ``` -`Build-MacOsPackage.ps1` publishes the NativeAOT desktop with Iseberg disabled +`Build-MacOsPackage.ps1` publishes the NativeAOT desktop with Iseberg enabled unless `MACOS_PUBLISH_DIR` supplies an existing publish, -stages `Devolutions Terminal.app` with `macos/Info.plist`, generates +stages `Devolutions Terminal.app` with `macos/Info.plist`, moves the managed +bridge DLL into `Contents/Resources/Devolutions.Iseberg.PowerShell.dll`, generates `DevolutionsTerminal.icns` from the original WT Distro vector artwork in `macos/DevolutionsTerminal.svg`. Its librsvg-rendered 1024px transparent master, `macos/DevolutionsTerminal.png`, is committed so packaging does not depend on diff --git a/docs/release.md b/docs/release.md index ed89fd3..5e369e8 100644 --- a/docs/release.md +++ b/docs/release.md @@ -34,8 +34,8 @@ CI workflows: artifacts (optional cache; not required to develop). - `build-terminal.yml` — restore natives from source, test, NativeAOT desktop, Linux packages, macOS `.app`/zip, MSIX, the `Devolutions.Terminal.Control` - NuGet package, and the browser WASM host. Opt-in managed Iseberg builds are - validated separately and are not release package payloads. + NuGet package, and the browser WASM host. NativeAOT desktop releases include + Iseberg; the explicit terminal-only configuration is also validated. ## Developer build @@ -47,19 +47,29 @@ dotnet run --project src/Devolutions.Terminal ``` Warnings are errors for production projects. Trim and NativeAOT analyzers run -continuously on compatible projects. The imported PowerShell workbench requires -dynamic code and is deliberately excluded from those analyzers. +continuously on compatible projects, including the Iseberg parent UI/client. +The private `Iseberg.PowerShell` module alone is managed and untrimmed because +it runs inside the installed PowerShell subprocess. ## Desktop distribution -Default publishes and release packages are self-contained NativeAOT and exclude -Iseberg (`EnablePowerShellIse=false`). CI uploads these NativeAOT publishes for -Windows, Linux and macOS packaging; the separately validated managed Iseberg -publish is not uploaded as a release payload. The `dt` CLI remains NativeAOT. -Iseberg requires an explicit `-p:EnablePowerShellIse=true` developer build, -which is managed and untrimmed and uses separately installed PowerShell. +Default publishes and release packages are self-contained NativeAOT and include +Iseberg. Ship the loose `Devolutions.Iseberg.PowerShell.dll` beside +`dt.exe` and `Devolutions.Terminal.exe`. Its shared contract sources are compiled into the module +and separately into the NativeAOT parent; do not ship `Iseberg.Contracts.dll`. +The module is imported by separately installed PowerShell; do not ship SMA, +PowerShell's runtime/native dependency graph, or CoreCLR. macOS staging places this DLL in +`Contents/Resources`, outside the Mach-O-only `Contents/MacOS` directory. +The `dt` CLI remains NativeAOT. Explicit terminal-only publishes use +`-p:EnablePowerShellIse=false` and do not include the bridge. See [iseberg.md](iseberg.md) for prerequisites, legal notices and guards. +Windows release CI Authenticode-signs the module with the other unpacked app +binaries before building the MSI and NuGet packages. Verification requires the +module, a trusted timestamped signature and the same exact DT publisher as the +executables. Ordinary PR builds and local publishes remain unsigned, like the +other app binaries. + ```powershell dotnet publish src/Devolutions.Terminal -c Release -r win-x64 --self-contained ` -o artifacts/native/win-x64 @@ -71,6 +81,7 @@ Each publish directory contains: - `Devolutions.Terminal.exe` — GUI host and broker primary - `dt.exe` — command-line/broker client (Windows GUI subsystem; attaches to a parent console) +- `Devolutions.Iseberg.PowerShell.dll` — managed binary module loaded only by child `pwsh` - Avalonia/Skia native dependencies - bundled Noto Color Emoji fallback and its SIL OFL notice diff --git a/scripts/Build-LinuxPackage.sh b/scripts/Build-LinuxPackage.sh index c5fca4a..11310c4 100644 --- a/scripts/Build-LinuxPackage.sh +++ b/scripts/Build-LinuxPackage.sh @@ -98,7 +98,6 @@ else -c Release -r "$rid" --self-contained true - -p:EnablePowerShellIse=false -p:PublishAot=true -o "$publish_dir" -p:VersionPrefix="$version" diff --git a/scripts/Build-MacOsPackage.ps1 b/scripts/Build-MacOsPackage.ps1 index bea589f..da1aada 100644 --- a/scripts/Build-MacOsPackage.ps1 +++ b/scripts/Build-MacOsPackage.ps1 @@ -72,7 +72,6 @@ try { '-c', 'Release', '-r', $Rid, '--self-contained', 'true', - '-p:EnablePowerShellIse=false', '-p:PublishAot=true', '-o', $publishDir, "-p:VersionPrefix=$Version", diff --git a/scripts/Install-IsebergTestPowerShell.ps1 b/scripts/Install-IsebergTestPowerShell.ps1 index 5b1b21a..3290ca1 100644 --- a/scripts/Install-IsebergTestPowerShell.ps1 +++ b/scripts/Install-IsebergTestPowerShell.ps1 @@ -1,22 +1,52 @@ #Requires -Version 7 [CmdletBinding()] -param([string]$DestinationRoot = (Join-Path $PSScriptRoot '..\artifacts\test-powershell')) +param( + [string]$DestinationRoot = (Join-Path $PSScriptRoot '..\artifacts\test-powershell'), + [ValidateSet('7.4.6', '7.4.20', '7.5.11', '7.6.6')] + [string]$Version = '7.6.6' +) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true -$version = '7.6.6' $architecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() $platform = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } elseif ($IsLinux) { 'linux' } else { throw 'Unsupported test platform.' } $rid = "$platform-$architecture" -$hashes = @{ - 'win-x64' = '02fe458be20493fbdf43f61ea20610b811ee6c738ab1676c61b9cfcd1a33c860' - 'win-arm64' = 'bbde9dda31d148415eccb5fbe1638e6400a144187b006e5b3fd8ec2f39d781be' - 'linux-x64' = 'ddbc4a2d113bbd46d283cfedcbcd117a70caefd7673f41f2b4e0000badf103bc' - 'linux-arm64' = '924829e54c983648f6f1419a2dc7f9433c861b2fb5bd57736ff096c24f133729' - 'osx-x64' = 'e325ed9f666894eb39a5ea52800b602da2fb4242bbe9747ceddb39cdc66de805' - 'osx-arm64' = '6df833d094ebac1c1a74340d7b3437f4aaf5e03ce640484a1c4359f3ce8b3db1' +$releases = @{ + '7.4.6' = @{ + 'win-x64' = 'ed49ce5adb2162cc4a835d740486be729ba904627cca71fcb6c2b95be11b993d' + 'win-arm64' = '875af8ae039abb583976129b8508c7cc39f0371ae790db096561e44019da0165' + 'linux-x64' = '6f6015203c47806c5cc444c19d8ed019695e610fbd948154264bf9ca8e157561' + 'linux-arm64' = 'c0159b03e85f44ae1e7697818a011558da6c813d0aae848bf5ac13bf435d8624' + 'osx-x64' = '7a18daed105b7cfc80bf8cc00762fe7990105dd23f951cc32ceb744651650e3d' + 'osx-arm64' = 'a482d668787ef98c37f0a5a7696107dffdb3dc340c5be3d1c153ec9d239072a8' + } + '7.4.20' = @{ + 'win-x64' = 'fb88cd3731847006b157978d6c0a426390d346696d91d1ed85b5e5eb88cb2d40' + 'win-arm64' = '67eff749d5dae357501c95a3eec1dcd93f669824e3ca2b1d2adcdecdebf04f35' + 'linux-x64' = 'ed8008345c7f5337f5e293145f5e243ce132e5ee4dfc24ac551c45a4b3092181' + 'linux-arm64' = '4d1a7b468eae627809eac5fae79c4e645cd9362fdf9f626298686b4489eb4724' + 'osx-x64' = '634d04e806b37a441b140c875e96662a5eb14efd0e38060a730b991069602138' + 'osx-arm64' = '7f9a2503304ee239330f5c4ccc7eef11894b095d7ccb8f9fa78a16c6ee508edc' + } + '7.5.11' = @{ + 'win-x64' = '75cdab18db9c8ac32f02e82149698166551e42d2a904da4b3a60fb5fcb3ad021' + 'win-arm64' = '990734d5e116d3709791f261a80925aa8c4f38d51f60656c7e36ae1866a4279c' + 'linux-x64' = '82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8' + 'linux-arm64' = '830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672' + 'osx-x64' = '44828c7173cddb335570a76089f2831b9082997eabafd599b115aaa944f90e69' + 'osx-arm64' = '93c5d5b71a3937e3dbb68934886d318689f57ecda466443764a9d7101f196e6f' + } + '7.6.6' = @{ + 'win-x64' = '02fe458be20493fbdf43f61ea20610b811ee6c738ab1676c61b9cfcd1a33c860' + 'win-arm64' = 'bbde9dda31d148415eccb5fbe1638e6400a144187b006e5b3fd8ec2f39d781be' + 'linux-x64' = 'ddbc4a2d113bbd46d283cfedcbcd117a70caefd7673f41f2b4e0000badf103bc' + 'linux-arm64' = '924829e54c983648f6f1419a2dc7f9433c861b2fb5bd57736ff096c24f133729' + 'osx-x64' = 'e325ed9f666894eb39a5ea52800b602da2fb4242bbe9747ceddb39cdc66de805' + 'osx-arm64' = '6df833d094ebac1c1a74340d7b3437f4aaf5e03ce640484a1c4359f3ce8b3db1' + } } +$hashes = $releases[$Version] if (-not $hashes.ContainsKey($rid)) { throw "No pinned PowerShell test runtime for $rid." } $root = [IO.Path]::GetFullPath($DestinationRoot) $engineDirectory = Join-Path $root "$version-$rid" diff --git a/scripts/Stage-MacOsApp.ps1 b/scripts/Stage-MacOsApp.ps1 index 3ba2f70..29305b5 100644 --- a/scripts/Stage-MacOsApp.ps1 +++ b/scripts/Stage-MacOsApp.ps1 @@ -84,6 +84,15 @@ $resources = Join-Path $contents 'Resources' New-Item -ItemType Directory -Force -Path $macosDir, $resources | Out-Null Invoke-Native -FilePath cp -ArgumentList '-a', "$publishDir/.", "$macosDir/" +$bridgeModule = Join-Path $macosDir 'Devolutions.Iseberg.PowerShell.dll' +foreach ($legacyPayload in @('Iseberg.PowerShell', 'Iseberg.PowerShell.dll', 'Iseberg.Contracts.dll')) { + if (Test-Path -LiteralPath (Join-Path $macosDir $legacyPayload)) { + throw "Unexpected legacy Iseberg bridge payload: $legacyPayload." + } +} +if (Test-Path -LiteralPath $bridgeModule -PathType Leaf) { + Move-Item -LiteralPath $bridgeModule -Destination $resources +} Get-ChildItem -LiteralPath $macosDir -Recurse -Filter '*.pdb' | Remove-Item -Force Get-ChildItem -LiteralPath $macosDir -Recurse -Filter '*.dbg' | Remove-Item -Force Get-ChildItem -LiteralPath $macosDir -Recurse -Directory -Filter '*.dSYM' | diff --git a/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs b/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs index 1e41800..88cbda7 100644 --- a/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs +++ b/src/Devolutions.Terminal.App/Connections/IsebergTerminalConnection.cs @@ -1,7 +1,6 @@ #if POWERSHELL_ISE using System.Globalization; using System.Text; -using System.Management.Automation.Language; using Avalonia.Media; using Avalonia.Threading; using Devolutions.Terminal.Connection; @@ -42,6 +41,8 @@ public sealed class IsebergTerminalConnection : IRestartableTerminalConnection private int? _selectionAnchor; private char? _pendingHighSurrogate; private bool _syntaxHighlighting = true; + private bool _submissionPending; + private readonly CancellationTokenSource _inputOperations = new(); private sealed record InputEdit(string Text, int Caret); public IsebergTerminalConnection(WorkbenchControl workbench, SessionModel session, Action? focusInput = null) @@ -62,7 +63,7 @@ public IsebergTerminalConnection(WorkbenchControl workbench, SessionModel sessio public TerminalConnectionState State { get; private set; } = TerminalConnectionState.NotConnected; public TerminalProcessMetadata? ProcessMetadata => null; public TerminalExitInfo? LastExitInfo { get; private set; } - public bool IsInputEnabled => IsRunning && !_inputDisabled && + public bool IsInputEnabled => IsRunning && !_inputDisabled && !_submissionPending && _session.Engine.State != SessionState.Failed && (_input is not null || _acceptsCommands && (!_session.IsConsoleSubmissionPending || _session.Engine.IsDebuggerPaused || _session.Engine.IsNestedPromptActive)); public bool HasPendingInput => _input is not null; @@ -153,10 +154,22 @@ private void RemoveSelection() var text = InputText; var caret = _caret; var revision = _revision; - var result = await _session.Engine.CompleteAsync(text, caret, cancellationToken); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _inputOperations.Token); + var result = await _session.Engine.CompleteAsync(text, caret, cancellation.Token); return IsInputEnabled && _input is null && revision == _revision ? result : null; } + public async Task RequestAnalysisAsync(CancellationToken cancellationToken = default) + { + if (!IsInputEnabled || _input is not null) return null; + var revision = _revision; + var text = InputText; + var cached = _session.Console.InputAnalysis; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _inputOperations.Token); + var result = cached ?? await _session.Engine.AnalyzeAsync(text, cancellationToken: cancellation.Token); + return IsInputEnabled && _input is null && revision == _revision && InputText == text ? result : null; + } + public bool ApplyCompletion(CompletionSet completion, int index, int expectedRevision) { if (!IsInputEnabled || _input is not null || expectedRevision != _revision) return false; @@ -186,6 +199,7 @@ public Task StartAsync(TerminalLaunchOptions options, CancellationToken cancella Resize(options.Columns, options.Rows); _session.Engine.Output += WriteOutput; _session.Engine.InputRequested += OnInputRequested; + _session.Console.AnalysisChanged += OnAnalysisChanged; State = TerminalConnectionState.Connected; IsRunning = true; Emit(Escape + "[?2004h"); @@ -227,6 +241,7 @@ public void RefreshState(bool acceptsCommands, bool inputDisabled) if (!IsRunning) return; _acceptsCommands = acceptsCommands; _inputDisabled = inputDisabled; + if (_workbench.IsDisposed) _inputOperations.Cancel(); if (!IsInputEnabled || _input is not null) return; if (!_promptShown) { @@ -467,6 +482,7 @@ private void NavigateHistory(int delta) private void Submit() { + if (_submissionPending) return; var text = _line.ToString(); if (_input is { } request) { @@ -477,12 +493,6 @@ private void Submit() request.Response.TrySetResult(text); return; } - if (EditorAnalysis.Analyze(text).Errors.Any(error => error.IncompleteInput || - error.ErrorId is "MissingEndParenthesisInExpression" or "MissingEndParenthesisInMethodCall")) - { - Insert("\n"); - return; - } if (string.IsNullOrWhiteSpace(text)) { Emit("\r\n"); @@ -491,11 +501,31 @@ private void Submit() RefreshState(_acceptsCommands, _inputDisabled); return; } - _acceptsCommands = false; - HidePrompt(); - ReplaceLine(""); - _undo.Clear(); _redo.Clear(); - Observe(_workbench.SubmitConsoleInputAsync(text)); + _submissionPending = true; + Observe(SubmitAnalyzedAsync(text, _revision)); + } + + private async Task SubmitAnalyzedAsync(string text, int revision) + { + try + { + var analysis = await _session.Engine.AnalyzeAsync(text, cancellationToken: _inputOperations.Token); + if (!IsRunning || _disposed || _input is not null || _revision != revision || text != _line.ToString()) return; + if (analysis.Errors.Any(error => error.IncompleteInput || + error.ErrorId is "MissingEndParenthesisInExpression" or "MissingEndParenthesisInMethodCall")) + { + _submissionPending = false; + Insert("\n"); + return; + } + _acceptsCommands = false; + HidePrompt(); + ReplaceLine(""); + _undo.Clear(); _redo.Clear(); + _submissionPending = false; + await _workbench.SubmitConsoleInputAsync(text); + } + finally { _submissionPending = false; InputChanged?.Invoke(this, EventArgs.Empty); } } public async Task CompleteAsync(bool backwards = false) @@ -510,7 +540,7 @@ public async Task CompleteAsync(bool backwards = false) { var revision = _revision; var caret = _caret; - var result = await _session.Engine.CompleteAsync(text, _caret); + var result = await _session.Engine.CompleteAsync(text, _caret, _inputOperations.Token); if (!IsInputEnabled || _input is not null || revision != _revision || caret != _caret || result.Matches.Count == 0) return; _completionOriginal = text; _completions = result; @@ -551,7 +581,8 @@ private string DisplayInput(string value, bool highlighted = false) if (_input?.Secret == true) return new string('*', StringInfo.ParseCombiningCharacters(value).Length); var display = new StringBuilder(); Token[] tokens = []; - if (highlighted && _syntaxHighlighting && _input is null) Parser.ParseInput(value, out tokens, out _); + if (highlighted && _syntaxHighlighting && _input is null && _session.Console.InputAnalysis is { } analysis) + tokens = analysis.Tokens; string? activeColor = null; var tokenIndex = 0; var selected = false; @@ -596,10 +627,17 @@ private void Redraw() if (_caret != text.Length) Emit(Escape + "[u" + _prefix + DisplayInput(text[.._caret], highlighted: true)); } + private void OnAnalysisChanged() + { + if (Dispatcher.UIThread.CheckAccess()) Redraw(); + else Dispatcher.UIThread.Post(Redraw); + } + private void Observe(Task task) => _ = ObserveAsync(task); private async Task ObserveAsync(Task task) { try { await task; } + catch (OperationCanceledException) when (_inputOperations.IsCancellationRequested) { } catch (Exception error) { if (IsRunning) await _workbench.ReportConsoleErrorAsync(error); @@ -619,10 +657,12 @@ public Task CloseAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (!IsRunning) return Task.CompletedTask; + _inputOperations.Cancel(); IsRunning = false; State = TerminalConnectionState.Closed; _session.Engine.Output -= WriteOutput; _session.Engine.InputRequested -= OnInputRequested; + _session.Console.AnalysisChanged -= OnAnalysisChanged; _input?.Response.TrySetCanceled(); _input = null; _line.Clear(); @@ -638,6 +678,7 @@ public async ValueTask DisposeAsync() { if (_disposed) return; await CloseAsync(); + _inputOperations.Dispose(); _disposed = true; State = TerminalConnectionState.Disposed; } diff --git a/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs b/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs index 7b58a80..50e3900 100644 --- a/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs +++ b/src/Devolutions.Terminal.App/Connections/PowerShellInstallation.cs @@ -1,16 +1,16 @@ using System.ComponentModel; using System.Diagnostics; -using System.Reflection; using System.Runtime.InteropServices; -using System.Runtime.Loader; using System.Text.Json; +using System.Text.Json.Serialization; +using Iseberg.Core; namespace Devolutions.Terminal.App.Connections; public sealed record PowerShellInstallation(string Home, string Version, string RuntimeVersion, string Architecture) { public const string HomeVariable = "DT_ISEBERG_PSHOME"; - public static Version MinimumVersion { get; } = new(7, 6, 6); + public static Version MinimumVersion => PowerShellCompatibility.MinimumVersion; public static PowerShellInstallation Find() { @@ -42,7 +42,7 @@ public static PowerShellInstallation Find() catch (Win32Exception exception) { throw new InvalidOperationException( - $"Install PowerShell {MinimumVersion} (7.6.x) on PATH, or set {HomeVariable} to its installation directory.", exception); + $"Install PowerShell {PowerShellCompatibility.SupportedVersions} on PATH, or set {HomeVariable} to its installation directory.", exception); } var output = process.StandardOutput.ReadToEndAsync(); var error = process.StandardError.ReadToEndAsync(); @@ -57,68 +57,36 @@ public static PowerShellInstallation Find() PowerShellInstallation installation; try { - installation = JsonSerializer.Deserialize(output.GetAwaiter().GetResult()) + installation = JsonSerializer.Deserialize(output.GetAwaiter().GetResult(), + PowerShellInstallationJsonContext.Default.PowerShellInstallation) ?? throw new InvalidOperationException("PowerShell returned an empty installation description."); } catch (JsonException exception) { throw new InvalidOperationException("PowerShell returned an invalid installation description.", exception); } - installation.Validate(Environment.Version, RuntimeInformation.ProcessArchitecture); + installation.Validate(); return installation; } - public void Validate(Version runtimeVersion, Architecture architecture) + public void Validate() { if (!System.Version.TryParse(Version, out var version) || - version.Major != MinimumVersion.Major || version.Minor != MinimumVersion.Minor || - version < MinimumVersion) - throw new InvalidOperationException($"Iseberg requires PowerShell {MinimumVersion} or newer in the 7.6.x series; found {Version}."); + !PowerShellCompatibility.IsSupportedVersion(version)) + throw new InvalidOperationException($"Iseberg requires PowerShell {PowerShellCompatibility.SupportedVersions}; found {Version}."); if (!System.Version.TryParse(RuntimeVersion, out var runtime) || - runtime.Major != runtimeVersion.Major || runtime.Minor != runtimeVersion.Minor) - throw new InvalidOperationException($"PowerShell must use .NET {runtimeVersion.Major}.{runtimeVersion.Minor}; found {RuntimeVersion}."); - if (!string.Equals(Architecture, architecture.ToString(), StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"PowerShell architecture must match Iseberg ({architecture}); found {Architecture}."); + !PowerShellCompatibility.IsSupportedRuntime(version, runtime)) + throw new InvalidOperationException($"PowerShell {version.Major}.{version.Minor} requires its bundled .NET {PowerShellCompatibility.RuntimeMajor(version)} runtime; found {RuntimeVersion}."); + if (!Enum.TryParse(Architecture, out var architecture) || + !Enum.IsDefined(architecture)) + throw new InvalidOperationException($"PowerShell returned an unknown process architecture: {Architecture}."); if (string.IsNullOrWhiteSpace(Home) || !Path.IsPathFullyQualified(Home) || !File.Exists(Path.Combine(Home, "System.Management.Automation.dll")) || !File.Exists(Path.Combine(Home, "pwsh.dll"))) throw new InvalidOperationException($"PowerShell installation is incomplete: {Home}."); } - public void Register() - { - var resolver = new AssemblyDependencyResolver(Path.Combine(Home, "pwsh.dll")); - Assembly? ResolveAssembly(AssemblyLoadContext context, AssemblyName name) - { - var path = resolver.ResolveAssemblyToPath(name); - if (path is null && name.Name is { } assemblyName) - { - var candidate = Path.Combine(Home, assemblyName + ".dll"); - if (File.Exists(candidate)) path = candidate; - } - return path is null ? null : context.LoadFromAssemblyPath(path); - } - IntPtr ResolveNative(Assembly assembly, string name) - { - var path = resolver.ResolveUnmanagedDllToPath(name); - if (path is null) - { - var fileName = OperatingSystem.IsWindows() ? name + ".dll" - : OperatingSystem.IsMacOS() ? "lib" + name + ".dylib" : "lib" + name + ".so"; - var candidate = Path.Combine(Home, fileName); - if (File.Exists(candidate)) path = candidate; - } - return path is null ? IntPtr.Zero : NativeLibrary.Load(path); - } - AssemblyLoadContext.Default.Resolving += ResolveAssembly; - AssemblyLoadContext.Default.ResolvingUnmanagedDll += ResolveNative; - // Bind the engine before JIT-compiling any desktop or core code that references its types. - try { AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(Home, "System.Management.Automation.dll")); } - catch - { - AssemblyLoadContext.Default.Resolving -= ResolveAssembly; - AssemblyLoadContext.Default.ResolvingUnmanagedDll -= ResolveNative; - throw; - } - } } + +[JsonSerializable(typeof(PowerShellInstallation))] +internal sealed partial class PowerShellInstallationJsonContext : JsonSerializerContext; diff --git a/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs b/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs index 554bdf4..0ae80fc 100644 --- a/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs +++ b/src/Devolutions.Terminal.App/Connections/PowerShellIseRuntime.cs @@ -12,7 +12,7 @@ public static Task InitializeAsync() { // A failed discovery can be retried after the user installs PowerShell or changes the override. if (_initialization is null || _initialization.IsFaulted) - _initialization = Task.Run(() => PowerShellInstallation.Find().Register()); + _initialization = Task.Run(() => PowerShellInstallation.Find()); return _initialization; } } diff --git a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj index 41ca450..5c6bdb8 100644 --- a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj +++ b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj @@ -7,8 +7,6 @@ $(DefineConstants);POWERSHELL_ISE - false - false diff --git a/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs b/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs index e198492..e8aa932 100644 --- a/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs +++ b/src/Devolutions.Terminal.App/Views/DtIsebergConsole.cs @@ -59,7 +59,7 @@ public DtIsebergConsole(TermControl terminal, Control view, ProfileSettings prof completionTimer.Tick += async (_, _) => { completionTimer.Stop(); - await ObserveCompletionAsync(); + await ObserveCompletionAsync(automatic: true); }; } public TermControl Terminal { get; } @@ -133,7 +133,7 @@ private void OnInputChanged(object? sender, EventArgs args) if (acceptingCompletion) return; CloseCompletion(); if (preferences.ConsoleIntelliSense && Terminal.IsKeyboardFocusWithin && IsInputEnabled && !HasPendingInput && - EditorAnalysis.CompletionFilter(InputText, CaretOffset) is not null) + CaretOffset > 0 && "$-:. [\\/".Contains(InputText[CaretOffset - 1])) completionTimer.Start(); } @@ -145,9 +145,9 @@ private void CloseCompletion() completions = null; } - private async Task ObserveCompletionAsync() + private async Task ObserveCompletionAsync(bool automatic = false) { - try { await ShowCompletionAsync(); } + try { await ShowCompletionAsync(automatic); } catch (OperationCanceledException) { } catch (Exception error) { @@ -156,7 +156,7 @@ private async Task ObserveCompletionAsync() } } - private async Task ShowCompletionAsync() + private async Task ShowCompletionAsync(bool automatic = false) { var connection = _connection; if (connection is null || !IsInputEnabled || HasPendingInput || workbench?.IsDisposed != false) return; @@ -167,7 +167,17 @@ private async Task ShowCompletionAsync() var revision = connection.InputRevision; try { + IReadOnlySet? filter = null; + if (automatic) + { + var analysis = await connection.RequestAnalysisAsync(cancellation.Token); + if (analysis is null || revision != connection.InputRevision) return; + filter = EditorAnalysis.CompletionFilter(analysis, InputText, CaretOffset); + if (filter is null) return; + } var result = await connection.RequestCompletionAsync(cancellation.Token); + if (result is not null && filter is not null) + result = result with { Matches = result.Matches.Where(match => filter.Contains(match.ResultType)).ToArray() }; if (result is null || result.Matches.Count == 0 || revision != connection.InputRevision || workbench.IsDisposed || !Terminal.IsKeyboardFocusWithin || !IsInputEnabled) return; completions = result; diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs b/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs index 3b19b58..fd3d116 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.PowerShellIse.cs @@ -27,7 +27,7 @@ private async Task CreatePowerShellIseTabAsync( return await CreatePowerShellIseTabCoreAsync(profile, restored, regenerateIdentities).ConfigureAwait(true); #else await Task.CompletedTask; - throw new NotSupportedException("This terminal-only build does not include Iseberg. Use an Iseberg-enabled managed DT distribution."); + throw new NotSupportedException("This terminal-only build does not include Iseberg. Use a DT distribution with Iseberg enabled."); #endif } diff --git a/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs b/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs index 4fd04ad..936b2e9 100644 --- a/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs +++ b/src/Devolutions.Terminal.App/Views/PowerShellIseWorkspaceCatalog.cs @@ -15,7 +15,7 @@ public static async Task RecordProfileAsync(string workspaceDirectory, string? p var path = Path.Combine(workspaceDirectory, "profile.json"); if (File.Exists(path)) { - var ownerProfile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)) + var ownerProfile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), IseJsonContext.Default.String) ?? throw new InvalidDataException($"Empty ISE profile identity: {path}"); if (!string.Equals(ownerProfile, profileId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("This ISE workspace belongs to a different profile."); @@ -24,7 +24,7 @@ public static async Task RecordProfileAsync(string workspaceDirectory, string? p var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { - await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(profileId)); + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(profileId, IseJsonContext.Default.String)); if (OperatingSystem.IsWindows()) File.Move(temporary, path, overwrite: false); else { @@ -59,7 +59,7 @@ public static async Task RecordProfileAsync(string workspaceDirectory, string? p if (!Guid.TryParseExact(Path.GetFileName(directory), "N", out var id)) continue; var marker = Path.Combine(directory, "profile.json"); if (!File.Exists(marker)) continue; - var ownerProfile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(marker)) + var ownerProfile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(marker), IseJsonContext.Default.String) ?? throw new InvalidDataException($"Empty ISE profile identity: {marker}"); if (!string.Equals(ownerProfile, profileId, StringComparison.OrdinalIgnoreCase)) continue; var settings = Path.Combine(directory, "settings.json"); diff --git a/src/Devolutions.Terminal.Ise/Directory.Build.props b/src/Devolutions.Terminal.Ise/Directory.Build.props index 75b57e4..232ef15 100644 --- a/src/Devolutions.Terminal.Ise/Directory.Build.props +++ b/src/Devolutions.Terminal.Ise/Directory.Build.props @@ -1,10 +1,7 @@ - false - false - false false - false + true diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/AssemblyInfo.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/AssemblyInfo.cs new file mode 100644 index 0000000..a9009cf --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Iseberg.Core")] diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/BridgeConnection.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/BridgeConnection.cs new file mode 100644 index 0000000..0086a9c --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/BridgeConnection.cs @@ -0,0 +1,206 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading.Channels; + +namespace Iseberg.Core; + +public sealed class BridgeRemoteException(BridgeFault fault) : InvalidOperationException(fault.Message) +{ + public BridgeFault Fault { get; } = fault; +} + +/// Persistent bidirectional correlation; handlers never execute on the frame reader. +public sealed class BridgeConnection(Stream stream) : IAsyncDisposable +{ + private readonly CancellationTokenSource lifetime = new(); + private readonly ConcurrentDictionary> pending = new(); + private readonly ConcurrentDictionary incoming = new(); + private readonly SemaphoreSlim requestSlots = new(IseBridgeProtocol.MaximumPendingRequests); + private readonly Channel writes = Channel.CreateBounded( + new BoundedChannelOptions(IseBridgeProtocol.MaximumQueuedFrames) + { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + private long nextId; + private int failed; + private Task? reader; + private Task? writer; + public Func>? Request { get; set; } + public Action? Event { get; set; } + public event Action? Disconnected; + public Task Completion => reader ?? Task.CompletedTask; + + public void Start() + { + if (reader is not null) throw new InvalidOperationException("The bridge reader is already running."); + writer = Task.Run(WriteLoopAsync); + reader = Task.Run(ReadLoopAsync); + } + + public async Task CallAsync(string operation, JsonElement payload, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(lifetime.IsCancellationRequested, this); + cancellationToken.ThrowIfCancellationRequested(); + if (!requestSlots.Wait(0)) + throw new InvalidOperationException("The private PowerShell request queue is full."); + var id = Interlocked.Increment(ref nextId); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!pending.TryAdd(id, result)) throw new InvalidOperationException("Duplicate bridge request."); + using var registration = cancellationToken.Register(() => + { + if (pending.TryRemove(id, out var request)) + { + request.TrySetCanceled(cancellationToken); + if (!lifetime.IsCancellationRequested) + { + try { Send(new("cancel", id, operation, BridgeJson.Empty)); } + catch (IOException) { } + } + } + }); + try + { + Send(new("request", id, operation, payload)); + return await result.Task.ConfigureAwait(false); + } + finally + { + pending.TryRemove(id, out _); + requestSlots.Release(); + } + } + + public void Publish(string operation, JsonElement payload) => Send(new("event", 0, operation, payload)); + + private void Send(BridgeEnvelope envelope) + { + if (!writes.Writer.TryWrite(envelope)) + { + var error = new IOException("The private PowerShell transport queue overflowed. Session state was lost."); + Fail(error); + throw error; + } + } + + private async Task WriteLoopAsync() + { + try + { + await foreach (var envelope in writes.Reader.ReadAllAsync(lifetime.Token).ConfigureAwait(false)) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(envelope, IseBridgeJsonContext.Default.BridgeEnvelope); + if (bytes.Length > IseBridgeProtocol.MaximumFrameBytes) + throw new InvalidDataException("The private PowerShell frame exceeds its size limit."); + var prefix = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(prefix, bytes.Length); + await stream.WriteAsync(prefix, lifetime.Token).ConfigureAwait(false); + await stream.WriteAsync(bytes, lifetime.Token).ConfigureAwait(false); + await stream.FlushAsync(lifetime.Token).ConfigureAwait(false); + } + } + catch (Exception exception) { Fail(exception); } + } + + private async Task ReadLoopAsync() + { + try + { + var prefix = new byte[4]; + while (!lifetime.IsCancellationRequested) + { + await stream.ReadExactlyAsync(prefix, lifetime.Token).ConfigureAwait(false); + var length = BinaryPrimitives.ReadInt32LittleEndian(prefix); + if (length is <= 0 or > IseBridgeProtocol.MaximumFrameBytes) + throw new InvalidDataException("Invalid private PowerShell frame length."); + var bytes = new byte[length]; + await stream.ReadExactlyAsync(bytes, lifetime.Token).ConfigureAwait(false); + var envelope = JsonSerializer.Deserialize(bytes, IseBridgeJsonContext.Default.BridgeEnvelope) + ?? throw new InvalidDataException("Empty private PowerShell frame."); + switch (envelope.Kind) + { + case "response": + if (pending.TryRemove(envelope.Id, out var result)) + { + if (envelope.Fault is { Code: "Canceled" }) result.TrySetCanceled(); + else if (envelope.Fault is { } fault) result.TrySetException(new BridgeRemoteException(fault)); + else result.TrySetResult(envelope.Payload); + } + break; + case "request": + if (incoming.Count >= IseBridgeProtocol.MaximumPendingRequests) + throw new InvalidDataException("The private PowerShell incoming request queue is full."); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); + if (!incoming.TryAdd(envelope.Id, cancellation)) + throw new InvalidDataException("Duplicate private PowerShell incoming request."); + _ = Task.Run(() => DispatchAsync(envelope, cancellation)); + break; + case "cancel": + lock (incoming) + { + if (incoming.TryGetValue(envelope.Id, out var canceled)) canceled.Cancel(); + } + break; + case "event": + Event?.Invoke(envelope.Operation, envelope.Payload); + break; + default: throw new InvalidDataException("Unknown private PowerShell envelope kind."); + } + } + } + catch (Exception exception) { Fail(exception); } + } + + private async Task DispatchAsync(BridgeEnvelope envelope, CancellationTokenSource cancellation) + { + try + { + var handler = Request ?? throw new InvalidOperationException("No private PowerShell request handler is attached."); + var value = await handler(envelope.Operation, envelope.Payload, cancellation.Token).ConfigureAwait(false); + if (!lifetime.IsCancellationRequested) Send(new("response", envelope.Id, envelope.Operation, value)); + } + catch (Exception exception) + { + var fault = exception switch + { + OperationCanceledException => new BridgeFault("Canceled", "The PowerShell operation was canceled."), + FileConflictException conflict => new BridgeFault("FileConflict", conflict.Message, + conflict.FilePath, conflict.ExpectedVersion, conflict.ActualVersion), + _ => new BridgeFault(exception.GetType().Name, exception.Message) + }; + if (!lifetime.IsCancellationRequested) + { + try { Send(new("response", envelope.Id, envelope.Operation, BridgeJson.Empty, fault)); } + catch (IOException) { } + } + } + finally + { + lock (incoming) + { + incoming.TryRemove(envelope.Id, out _); + cancellation.Dispose(); + } + } + } + + private void Fail(Exception exception) + { + if (Interlocked.Exchange(ref failed, 1) != 0) return; + lifetime.Cancel(); + writes.Writer.TryComplete(); + stream.Dispose(); + foreach (var result in pending.Values) result.TrySetException(exception); + lock (incoming) + { + foreach (var cancellation in incoming.Values) cancellation.Cancel(); + } + Disconnected?.Invoke(exception); + } + + public async ValueTask DisposeAsync() + { + Fail(new ObjectDisposedException(nameof(BridgeConnection))); + if (reader is not null) await reader.ConfigureAwait(false); + if (writer is not null) await writer.ConfigureAwait(false); + lifetime.Dispose(); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/BridgeModels.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/BridgeModels.cs new file mode 100644 index 0000000..92360b8 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/BridgeModels.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Security.Cryptography; +using System.Text; + +namespace Iseberg.Core; + +public static class IseBridgeProtocol +{ + public const int Version = 1; + public const int MaximumFrameBytes = 8 * 1024 * 1024; + public const int MaximumPendingRequests = 128; + public const int MaximumQueuedFrames = 512; + public const int MaximumOutputCharacters = 16 * 1024; + public const string AuthenticationEnvironment = "DT_ISEBERG_BRIDGE_AUTH"; + public const string ModuleFileName = "Devolutions.Iseberg.PowerShell.dll"; + public static string BuildIdentity => IseBridgeBuild.Identity; + public static string AuthenticationProof(string key, string challenge, string role) => + Convert.ToHexString(HMACSHA256.HashData(Convert.FromHexString(key), Encoding.UTF8.GetBytes(role + ":" + challenge))); +} + +public sealed record BridgeEnvelope(string Kind, long Id, string Operation, JsonElement Payload, + BridgeFault? Fault = null); +public sealed record BridgeFault(string Code, string Message, string? FilePath = null, + string? ExpectedVersion = null, string? ActualVersion = null); +public sealed record BridgeHello(int Protocol, string Build, string Authentication, string Challenge, int ParentProcessId, + long ParentStartTimeUtcTicks); +public sealed record BridgeHelloResult(int Protocol, string Build, string PowerShellVersion, int ProcessId, + string AuthenticationProof); +public sealed record SessionStatus(SessionState State, string Prompt, string Version, Guid LocalRunspaceId, + Guid RunspaceId, bool IsRunspacePushed, bool IsRemote, string? RemoteComputerName, + bool IsDebuggerPaused, bool IsNestedPromptActive, string NestedPrompt); +public sealed record SessionInitialize(string? SnippetDirectory, bool ScriptingEnabled); +public sealed record TextRequest(string Text); +public sealed record AnalyzeRequest(string Text, string? DocumentPath = null); +public sealed record ExecuteRequest(string Script, string? FilePath = null); +public sealed record CompleteRequest(string Text, int Cursor); +public sealed record CommandFormRequest(string Name, string? Module = null); +public sealed record TerminalSizeRequest(int Columns, int Rows); +public sealed record ResumeRequest(DebuggerResumeAction Action); +public sealed record InspectRequest(string[] Watches, int FrameIndex); +public sealed record ValueChildrenRequest(long Reference, int Offset, int Count); +public sealed record BreakpointUpdateRequest(int Id, BreakpointSpec Spec); +public sealed record BreakpointIdRequest(int Id, bool Enabled = true); +public sealed record LineBreakpointsRequest(string Path, BreakpointSpec[] Specs); +public sealed record ScriptNotification(string ObjectId, string PropertyName); +public sealed record HostInput(string Caption, string Message, bool Secret, IReadOnlyList Choices, + IReadOnlyList DefaultChoices, bool MultipleChoice); +public sealed record HostShowCommand(CommandFormDescription? Command, IReadOnlyList Commands, + string HelpText, CommandHelpDocument? HelpDocument, Uri? HelpUri, bool PassThru, double Width, double Height); +public sealed record HostResponse(string? Text); +public sealed record RemoteOpenRequest(string Path, ScriptEncoding? Encoding = null); +public sealed record RemoteFileData(string Path, byte[] Bytes, Guid RunspaceId, string ComputerName); +public sealed record RemoteSaveRequest(string Path, byte[] Bytes, string? ExpectedVersion, string? OriginalPath, + bool UseSavedVersion, Guid RunspaceId); +public sealed record RemoteSaveResult(string Path, Guid RunspaceId, string ComputerName); +public enum RemoteConnectionKind { Ssh, WSMan } +public sealed record RemoteConnectionInfo(RemoteConnectionKind Kind, string ComputerName, + string? UserName = null, int Port = 0, string? KeyFilePath = null, string? Subsystem = null, + string? ConnectionUri = null, string? ConfigurationName = null, string? Password = null); +public sealed record CommandFormBuildRequest(CommandFormDescription Description, string ParameterSet, + Dictionary Values); +public sealed record ExpressionValidation(string Text, bool IsValid, string? Error = null); +public sealed record SnippetCreateRequest(string Title, string Description, string Text, string Author, + int CaretOffset, bool Force); +public sealed record SnippetImportRequest(string Path, bool Recurse); + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true, GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(BridgeEnvelope))] +[JsonSerializable(typeof(BridgeHello))] +[JsonSerializable(typeof(BridgeHelloResult))] +[JsonSerializable(typeof(SessionStatus))] +[JsonSerializable(typeof(SessionInitialize))] +[JsonSerializable(typeof(TextRequest))] +[JsonSerializable(typeof(AnalyzeRequest))] +[JsonSerializable(typeof(ExecuteRequest))] +[JsonSerializable(typeof(CompleteRequest))] +[JsonSerializable(typeof(CommandFormRequest))] +[JsonSerializable(typeof(TerminalSizeRequest))] +[JsonSerializable(typeof(ResumeRequest))] +[JsonSerializable(typeof(InspectRequest))] +[JsonSerializable(typeof(ValueChildrenRequest))] +[JsonSerializable(typeof(BreakpointSpec))] +[JsonSerializable(typeof(BreakpointUpdateRequest))] +[JsonSerializable(typeof(BreakpointIdRequest))] +[JsonSerializable(typeof(LineBreakpointsRequest))] +[JsonSerializable(typeof(ScriptNotification))] +[JsonSerializable(typeof(HostInput))] +[JsonSerializable(typeof(HostShowCommand))] +[JsonSerializable(typeof(HostResponse))] +[JsonSerializable(typeof(RemoteOpenRequest))] +[JsonSerializable(typeof(RemoteFileData))] +[JsonSerializable(typeof(RemoteSaveRequest))] +[JsonSerializable(typeof(RemoteSaveResult))] +[JsonSerializable(typeof(RemoteConnectionInfo))] +[JsonSerializable(typeof(CommandFormBuildRequest))] +[JsonSerializable(typeof(CommandFormResult))] +[JsonSerializable(typeof(ExpressionValidation))] +[JsonSerializable(typeof(SnippetCreateRequest))] +[JsonSerializable(typeof(SnippetImportRequest))] +[JsonSerializable(typeof(SnippetLoadResult))] +[JsonSerializable(typeof(ScriptAnalysis))] +[JsonSerializable(typeof(CompletionSet))] +[JsonSerializable(typeof(CommandDescription[]))] +[JsonSerializable(typeof(CommandFormDescription))] +[JsonSerializable(typeof(CommandHelpDocument))] +[JsonSerializable(typeof(DebugSnapshot))] +[JsonSerializable(typeof(DebugChildren))] +[JsonSerializable(typeof(DebugBreakpoint))] +[JsonSerializable(typeof(DebugBreakpoint[]))] +[JsonSerializable(typeof(OutputEntry))] +[JsonSerializable(typeof(ProgressUpdate))] +[JsonSerializable(typeof(DebugLocation))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(JsonElement))] +public partial class IseBridgeJsonContext : JsonSerializerContext; + +public static class BridgeJson +{ + public static JsonElement Element(T value) => + JsonSerializer.SerializeToElement(value, TypeInfo()); + public static T Read(JsonElement value) => + value.Deserialize(TypeInfo()) ?? throw new InvalidDataException($"Missing {typeof(T).Name} bridge payload."); + public static System.Text.Json.Serialization.Metadata.JsonTypeInfo TypeInfo() => + (System.Text.Json.Serialization.Metadata.JsonTypeInfo)(IseBridgeJsonContext.Default.GetTypeInfo(typeof(T)) + ?? throw new InvalidOperationException($"No private JSON contract for {typeof(T).Name}.")); + public static JsonElement Empty => Element(true); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandForm.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/CommandForm.cs similarity index 61% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/CommandForm.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Contracts/CommandForm.cs index 4fe8e07..17abc68 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandForm.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/CommandForm.cs @@ -1,6 +1,3 @@ -using System.Management.Automation; -using System.Management.Automation.Language; - namespace Iseberg.Core; public enum CommandParameterKind { Text, Switch, Boolean, Choice } @@ -56,7 +53,7 @@ public CommandParameterValue Value(string name) return value; } - public CommandFormResult Build() + public CommandFormResult Build(IReadOnlyDictionary? expressionValidity = null) { var parts = new List { @@ -90,8 +87,9 @@ public CommandFormResult Build() else if (value.IsExpression) { argument = "(" + value.Text + "\n)"; - Parser.ParseInput(argument, out _, out var errors); - if (string.IsNullOrWhiteSpace(value.Text) || errors.Length > 0) invalid.Add(parameter.Name); + if (string.IsNullOrWhiteSpace(value.Text) || + expressionValidity is null || !expressionValidity.TryGetValue(parameter.Name, out var valid) || !valid) + invalid.Add(parameter.Name); } else if (parameter.IsArray) argument = "@(" + string.Join(", ", value.Text.Replace("\r\n", "\n").Split('\n').Select(entry => @@ -106,41 +104,10 @@ public CommandFormResult Build() private static string Quote(string text) => "'" + text.Replace("'", "''") + "'"; - internal static CommandFormDescription Describe(CommandInfo command) - { - var resolved = command; - while (resolved is AliasInfo alias) - resolved = alias.ResolvedCommand ?? throw new InvalidOperationException($"Cannot resolve alias '{command.Name}'."); - var sets = resolved.ParameterSets.Select(set => new CommandParameterSetDescription(set.Name, set.IsDefault, - set.Parameters.OrderByDescending(p => p.IsMandatory).ThenBy(p => p.Position < 0 ? int.MaxValue : p.Position) - .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase).Select(DescribeParameter).ToArray())).ToArray(); - return new(command.Name, string.IsNullOrEmpty(command.ModuleName) ? command.Name : command.ModuleName + "\\" + command.Name, - sets.Length == 0 ? [new("__AllParameterSets", true, [])] : sets); - } - - private static CommandParameterDescription DescribeParameter(CommandParameterInfo parameter) - { - var type = parameter.ParameterType; - var element = type.IsArray ? type.GetElementType()! : type; - element = Nullable.GetUnderlyingType(element) ?? element; - var validation = parameter.Attributes.OfType().FirstOrDefault(); - var choices = validation?.ValidValues.ToArray() - ?? (element.IsEnum ? Enum.GetNames(element) : type.IsArray && element == typeof(bool) ? ["True", "False"] : []); - var kind = !type.IsArray && element == typeof(SwitchParameter) ? CommandParameterKind.Switch - : element == typeof(bool) ? CommandParameterKind.Boolean - : choices.Length > 0 ? CommandParameterKind.Choice : CommandParameterKind.Text; - return new(parameter.Name, type.Name, kind, parameter.IsMandatory, - parameter.Position < 0 ? null : parameter.Position, type.IsArray, CommonParameters.Contains(parameter.Name), - parameter.Attributes.OfType().Any(), - parameter.ValueFromPipeline || parameter.ValueFromPipelineByPropertyName, - parameter.Attributes.OfType().FirstOrDefault(a => !string.IsNullOrEmpty(a.HelpMessage))?.HelpMessage ?? "", - parameter.Aliases.ToArray(), choices, validation?.IgnoreCase ?? true); - } - - private static readonly HashSet CommonParameters = new(StringComparer.OrdinalIgnoreCase) - { - "Verbose", "Debug", "ErrorAction", "WarningAction", "InformationAction", "ProgressAction", - "ErrorVariable", "WarningVariable", "InformationVariable", "OutVariable", "OutBuffer", "PipelineVariable", - "WhatIf", "Confirm" - }; + public CommandFormBuildRequest ToRequest() => new(Description, SelectedSet.Name, + values.ToDictionary(entry => entry.Key, entry => new CommandParameterValue + { + Included = entry.Value.Included, Text = entry.Value.Text, + IsExpression = entry.Value.IsExpression, Boolean = entry.Value.Boolean + }, StringComparer.OrdinalIgnoreCase)); } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/CommandHelp.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/CommandHelp.cs new file mode 100644 index 0000000..c1016fd --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/CommandHelp.cs @@ -0,0 +1,33 @@ +using System.Collections; + +using System.Text; + +namespace Iseberg.Core; + +public enum HelpSectionKind { Synopsis, Syntax, Description, Parameters, Inputs, Outputs, Notes, Examples, RelatedLinks, Remarks } + +public sealed class HelpViewSettings +{ + public List Sections { get; set; } = [.. Enum.GetValues()]; + public bool MatchCase { get; set; } + public bool WholeWord { get; set; } + public double Zoom { get; set; } = 100; + + public HelpViewSettings Copy() => new() { Sections = [.. Sections], MatchCase = MatchCase, WholeWord = WholeWord, Zoom = Zoom }; + + public void Normalize() + { + Sections ??= [.. Enum.GetValues()]; + if (Sections.Any(section => !Enum.IsDefined(section))) throw new InvalidDataException("An unknown help section was saved."); + Sections = Sections.Distinct().ToList(); + Zoom = double.IsFinite(Zoom) ? Math.Clamp(Zoom, 20, 400) : 100; + } +} + +public sealed record CommandHelpSection(HelpSectionKind Kind, string Text); + +public sealed record CommandHelpDocument(string Name, IReadOnlyList Sections) +{ + public string ToText() => string.Join("\n\n", Sections.Select(section => section.Kind + "\n" + section.Text)); + +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/DebuggerModels.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/DebuggerModels.cs similarity index 91% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/DebuggerModels.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Contracts/DebuggerModels.cs index 4084231..5110a37 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/DebuggerModels.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/DebuggerModels.cs @@ -1,5 +1,3 @@ -using System.Management.Automation; - namespace Iseberg.Core; public enum BreakpointKind { Line, Command, Variable } @@ -18,8 +16,6 @@ public void Validate() throw new ArgumentException("Invalid breakpoint kind or variable access mode."); if (!string.IsNullOrWhiteSpace(Condition) && !string.IsNullOrWhiteSpace(Action)) throw new ArgumentException("Use a condition or an action, not both."); - if (!string.IsNullOrWhiteSpace(Condition)) _ = ScriptBlock.Create($"if ({Condition}) {{ break }}"); - if (!string.IsNullOrWhiteSpace(Action)) _ = ScriptBlock.Create(Action); } } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/EditorAnalysis.cs similarity index 55% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Contracts/EditorAnalysis.cs index 0d1796d..843af24 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/EditorAnalysis.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/EditorAnalysis.cs @@ -1,69 +1,22 @@ -using System.Management.Automation.Language; -using System.Management.Automation; using System.Xml; namespace Iseberg.Core; -public sealed record ScriptDiagnostic(string Code, string Message, int Start, int End); -public sealed record XmlTokenSpan(int Start, int End, string Kind); - -public sealed record ScriptAnalysis(Token[] Tokens, ParseError[] Errors, IReadOnlyList<(int Start, int End)> Folds) -{ - public bool IsXml { get; init; } - public IReadOnlyList XmlTokens { get; init; } = []; - public IReadOnlyList Diagnostics { get; init; } = Errors.Select(error => - new ScriptDiagnostic(error.ErrorId, error.Message, error.Extent.StartOffset, error.Extent.EndOffset)).ToArray(); -} - public static class EditorAnalysis { - public static string? CommandNameAtCaret(string text, int caret) + public static string? CommandNameAtCaret(ScriptAnalysis analysis, int caret) { ArgumentOutOfRangeException.ThrowIfNegative(caret); - ArgumentOutOfRangeException.ThrowIfGreaterThan(caret, text.Length); - var ast = Parser.ParseInput(text, out _, out _); - return ast.FindAll(node => node is CommandAst && node.Extent.StartOffset <= caret && - node.Extent.EndOffset >= caret, searchNestedScriptBlocks: true) - .OfType().OrderBy(command => command.Extent.EndOffset - command.Extent.StartOffset) - .FirstOrDefault()?.GetCommandName(); + return analysis.Commands.Where(command => command.Start <= caret && command.End >= caret) + .OrderBy(command => command.End - command.Start).FirstOrDefault()?.Name; } - public static (int Open, int Close)? MatchingBrace(string text, int caret) + public static (int Open, int Close)? MatchingBrace(ScriptAnalysis analysis, int caret) { ArgumentOutOfRangeException.ThrowIfNegative(caret); - ArgumentOutOfRangeException.ThrowIfGreaterThan(caret, text.Length); - var stack = new Stack<(TokenKind Kind, int Offset)>(); - var pairs = new List<(int Open, int Close)>(); - Parser.ParseInput(text, out var tokens, out _); - foreach (var token in Flatten(tokens)) - { - var kind = token.Kind switch - { - TokenKind.AtCurly => TokenKind.LCurly, - TokenKind.AtParen or TokenKind.DollarParen => TokenKind.LParen, - _ => token.Kind - }; - if (kind is TokenKind.LCurly or TokenKind.LParen or TokenKind.LBracket) - stack.Push((kind, token.Extent.EndOffset - 1)); - else if (kind is TokenKind.RCurly or TokenKind.RParen or TokenKind.RBracket) - { - var expected = kind switch - { - TokenKind.RCurly => TokenKind.LCurly, - TokenKind.RParen => TokenKind.LParen, - _ => TokenKind.LBracket - }; - if (stack.TryPeek(out var open) && open.Kind == expected) - { - stack.Pop(); - pairs.Add((open.Offset, token.Extent.StartOffset)); - } - else stack.Clear(); - } - } foreach (var offset in new[] { caret, caret - 1 }) - foreach (var pair in pairs) - if (pair.Open == offset || pair.Close == offset) return pair; + foreach (var pair in analysis.BracePairs) + if (pair.Open == offset || pair.Close == offset) return (pair.Open, pair.Close); return null; } @@ -71,7 +24,7 @@ private static IEnumerable Flatten(IEnumerable tokens) { foreach (var token in tokens) { - if (token is StringExpandableToken { NestedTokens: { } nested }) + if (token.NestedTokens is { } nested) { foreach (var child in Flatten(nested)) yield return child; } @@ -79,10 +32,10 @@ private static IEnumerable Flatten(IEnumerable tokens) } } - public static IReadOnlySet? CompletionFilter(string text, int caret) + public static IReadOnlySet? CompletionFilter(ScriptAnalysis analysis, string text, int caret) { if (caret < 1 || caret > text.Length) return null; - Parser.ParseInput(text, out var tokens, out _); + var tokens = analysis.Tokens; var current = TokenAtCaret(tokens, caret); var inString = current?.Kind is TokenKind.StringLiteral or TokenKind.StringExpandable or TokenKind.HereStringLiteral or TokenKind.HereStringExpandable; if (current?.Kind == TokenKind.Comment) return null; @@ -114,7 +67,7 @@ private static IEnumerable Flatten(IEnumerable tokens) private static Token? TokenAtCaret(IEnumerable tokens, int caret) { var token = tokens.FirstOrDefault(t => t.Extent.StartOffset < caret && t.Extent.EndOffset >= caret); - if (token is StringExpandableToken { NestedTokens: { } nested }) + if (token?.NestedTokens is { } nested) return TokenAtCaret(nested, caret) ?? token; return token; } @@ -123,91 +76,26 @@ public static bool IsXmlDocument(string? path) => Path.GetExtension(path)?.Equals(".xml", StringComparison.OrdinalIgnoreCase) == true || Path.GetExtension(path)?.Equals(".ps1xml", StringComparison.OrdinalIgnoreCase) == true; - public static VariableToken? VariableAtOffset(IEnumerable tokens, int offset) + public static Token? VariableAtOffset(IEnumerable tokens, int offset) { foreach (var token in tokens) { if (offset < token.Extent.StartOffset || offset >= token.Extent.EndOffset) continue; - if (token is VariableToken variable) return variable; - if (token is StringExpandableToken { NestedTokens: { } nested }) + if (token.VariablePath is not null) return token; + if (token.NestedTokens is { } nested) return VariableAtOffset(nested, offset); } return null; } - public static (int Start, int End)? StatementAtPosition(string text, int line, int column) + public static (int Start, int End)? StatementAtPosition(ScriptAnalysis analysis, int line, int column) { if (line < 1 || column < 1) return null; - var offset = OffsetAtPosition(text, line, column, out var valid); - if (!valid || offset >= text.Length) return null; - var ast = Parser.ParseInput(text, out _, out _); - // A pipeline is the executable statement, rather than its individual command or variable. - var statement = ast.FindAll(node => node is StatementAst && node is not StatementBlockAst && - node.Extent.StartOffset == offset, searchNestedScriptBlocks: true) - .OrderBy(node => node.Extent.EndOffset - node.Extent.StartOffset).FirstOrDefault(); - return statement is null ? null : (statement.Extent.StartOffset, statement.Extent.EndOffset); - } - - public static ScriptAnalysis Analyze(string text) => Analyze(text, null); - - public static ScriptAnalysis Analyze(string text, string? documentPath) - { - ArgumentNullException.ThrowIfNull(text); - if (IsXmlDocument(documentPath)) return AnalyzeXml(text); - Parser.ParseInput(text, out var tokens, out var errors); - var stack = new Stack(); - var regions = new Stack(); - var regionFolds = new List<(int Start, int End)>(); - var folds = new List<(int Start, int End)>(); - foreach (var token in Flatten(tokens)) - { - if (token.Kind is TokenKind.LCurly or TokenKind.AtCurly) - stack.Push(token); - else if (token.Kind == TokenKind.RCurly && stack.TryPop(out var open) && - token.Extent.EndLineNumber > open.Extent.StartLineNumber) - folds.Add((open.Extent.StartOffset, token.Extent.EndOffset)); - } - foreach (var token in tokens) - { - if (token.Kind == TokenKind.Comment && !token.Text.StartsWith("<#", StringComparison.Ordinal) && - IsLineDirective(text, token)) - { - if (IsDirective(token.Text, "#region")) regions.Push(token); - else if (IsDirective(token.Text, "#endregion") && regions.TryPop(out var region) && - token.Extent.StartLineNumber > region.Extent.StartLineNumber) - regionFolds.Add((region.Extent.StartOffset, token.Extent.EndOffset)); - } - if (token.Extent.EndLineNumber <= token.Extent.StartLineNumber) continue; - var closed = token.Kind switch - { - TokenKind.Comment => token.Text.StartsWith("<#", StringComparison.Ordinal) && - token.Text.EndsWith("#>", StringComparison.Ordinal), - TokenKind.HereStringLiteral => token.Text.EndsWith("'@", StringComparison.Ordinal), - TokenKind.HereStringExpandable => token.Text.EndsWith("\"@", StringComparison.Ordinal), - TokenKind.StringLiteral => token.Text.EndsWith("'", StringComparison.Ordinal), - TokenKind.StringExpandable => token.Text.EndsWith("\"", StringComparison.Ordinal), - _ => false - }; - if (closed && !errors.Any(error => error.IncompleteInput && - error.Extent.StartOffset >= token.Extent.StartOffset && error.Extent.StartOffset < token.Extent.EndOffset)) - folds.Add((token.Extent.StartOffset, token.Extent.EndOffset)); - } - // Unclosed outer regions must not hide even otherwise matched nested directives. - var unmatchedRegion = regions.Count == 0 ? int.MaxValue : regions.Min(region => region.Extent.StartOffset); - folds.AddRange(regionFolds.Where(fold => fold.Start < unmatchedRegion)); - return new(tokens, errors, folds.Distinct().OrderBy(f => f.Start).ThenByDescending(f => f.End).ToArray()); + var statement = analysis.Statements.Where(extent => extent.StartLineNumber == line && extent.StartColumnNumber == column) + .OrderBy(extent => extent.EndOffset - extent.StartOffset).FirstOrDefault(); + return statement is null ? null : (statement.StartOffset, statement.EndOffset); } - private static bool IsDirective(string text, string directive) => - text.StartsWith(directive, StringComparison.OrdinalIgnoreCase) && - (text.Length == directive.Length || char.IsWhiteSpace(text[directive.Length])); - - private static bool IsLineDirective(string text, Token token) - { - for (var index = token.Extent.StartOffset - 1; index >= 0 && text[index] is not ('\r' or '\n'); index--) - if (!char.IsWhiteSpace(text[index])) return false; - return true; - } private static int OffsetAtPosition(string text, int line, int column, out bool valid) { @@ -228,7 +116,7 @@ private static int OffsetAtPosition(string text, int line, int column, out bool return valid ? start + column - 1 : text.Length; } - private static ScriptAnalysis AnalyzeXml(string text) + public static ScriptAnalysis AnalyzeXml(string text) { var diagnostics = new List(); try @@ -323,7 +211,7 @@ void Fold(int start, int end) } offset = Math.Max(start + 1, cursor); } - return new([], [], folds.OrderBy(fold => fold.Start).ToArray()) + return new([], [], folds.OrderBy(fold => fold.Start).Select(fold => new ScriptSpan(fold.Start, fold.End)).ToArray()) { IsXml = true, XmlTokens = spans, diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/EditorModels.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/EditorModels.cs new file mode 100644 index 0000000..fd4d3ff --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/EditorModels.cs @@ -0,0 +1,27 @@ +namespace Iseberg.Core; + +public sealed record ScriptExtent(int StartOffset, int EndOffset, int StartLineNumber, + int StartColumnNumber, int EndLineNumber, int EndColumnNumber, string Text); +public sealed record VariablePath(string UserPath, bool IsUnqualified, bool IsDriveQualified); +public sealed record Token(TokenKind Kind, TokenFlags TokenFlags, ScriptExtent Extent, string Text) +{ + public Token[]? NestedTokens { get; init; } + public VariablePath? VariablePath { get; init; } +} +public sealed record ParseError(string ErrorId, string Message, ScriptExtent Extent, bool IncompleteInput); +public sealed record ScriptSpan(int Start, int End); +public sealed record BracePair(int Open, int Close); +public sealed record CommandSpan(int Start, int End, string? Name); +public sealed record ScriptDiagnostic(string Code, string Message, int Start, int End); +public sealed record XmlTokenSpan(int Start, int End, string Kind); + +public sealed record ScriptAnalysis(Token[] Tokens, ParseError[] Errors, IReadOnlyList Folds) +{ + public bool IsXml { get; init; } + public IReadOnlyList XmlTokens { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } = Errors.Select(error => + new ScriptDiagnostic(error.ErrorId, error.Message, error.Extent.StartOffset, error.Extent.EndOffset)).ToArray(); + public IReadOnlyList BracePairs { get; init; } = []; + public IReadOnlyList Statements { get; init; } = []; + public IReadOnlyList Commands { get; init; } = []; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/Iseberg.Contracts.Shared.targets b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/Iseberg.Contracts.Shared.targets new file mode 100644 index 0000000..691d8ce --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/Iseberg.Contracts.Shared.targets @@ -0,0 +1,33 @@ + + + <_IsebergContractsDirectory>$(MSBuildThisFileDirectory) + + + + + + + <_BridgeIdentityInput Include="$(_IsebergContractsDirectory)*.cs;$(_IsebergContractsDirectory)*.csproj;$(_IsebergContractsDirectory)*.targets;$(_IsebergContractsDirectory)..\Iseberg.PowerShell\*.cs;$(_IsebergContractsDirectory)..\Iseberg.PowerShell\*.csproj;$(_IsebergContractsDirectory)..\Iseberg.Core\PowerShellSession*.cs;$(_IsebergContractsDirectory)..\Iseberg.Core\PowerShellProcessDiscovery.cs;$(_IsebergContractsDirectory)..\Iseberg.Core\AnsiOutputParser.cs;$(_IsebergContractsDirectory)..\Iseberg.Core\IseSnippetService.cs;$(_IsebergContractsDirectory)..\Iseberg.Core\SessionRequests.cs;$(_IsebergContractsDirectory)..\Iseberg.Core\TerminalApplication.cs;$(_IsebergContractsDirectory)..\..\..\Directory.Build.props;$(_IsebergContractsDirectory)..\..\..\Directory.Packages.props" /> + + + + + + + + + + <_BridgeBuildIdentity>@(_BridgeFingerprintHash->'%(FileHash)') + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/Iseberg.Contracts.csproj b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/Iseberg.Contracts.csproj new file mode 100644 index 0000000..7fb597c --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/Iseberg.Contracts.csproj @@ -0,0 +1,13 @@ + + + net8.0 + true + true + false + false + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/PowerShellCompatibility.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/PowerShellCompatibility.cs new file mode 100644 index 0000000..49d2b01 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/PowerShellCompatibility.cs @@ -0,0 +1,15 @@ +namespace Iseberg.Core; + +public static class PowerShellCompatibility +{ + public static Version MinimumVersion { get; } = new(7, 4, 6); + public const string SupportedVersions = "7.4.6+ within 7.4.x, 7.5.x or 7.6.x"; + + public static bool IsSupportedVersion(Version version) => + version.Major == 7 && version.Minor is >= 4 and <= 6 && version >= MinimumVersion; + + public static int RuntimeMajor(Version version) => version.Minor + 4; + + public static bool IsSupportedRuntime(Version version, Version runtime) => + IsSupportedVersion(version) && runtime.Major == RuntimeMajor(version); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/PowerShellEnums.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/PowerShellEnums.cs new file mode 100644 index 0000000..2597cf1 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/PowerShellEnums.cs @@ -0,0 +1,229 @@ +namespace Iseberg.Core; + +public enum TokenKind : long +{ + Unknown = 0, + Variable = 1, + SplattedVariable = 2, + Parameter = 3, + Number = 4, + Label = 5, + Identifier = 6, + Generic = 7, + NewLine = 8, + LineContinuation = 9, + Comment = 10, + EndOfInput = 11, + StringLiteral = 12, + StringExpandable = 13, + HereStringLiteral = 14, + HereStringExpandable = 15, + LParen = 16, + RParen = 17, + LCurly = 18, + RCurly = 19, + LBracket = 20, + RBracket = 21, + AtParen = 22, + AtCurly = 23, + DollarParen = 24, + Semi = 25, + AndAnd = 26, + OrOr = 27, + Ampersand = 28, + Pipe = 29, + Comma = 30, + MinusMinus = 31, + PlusPlus = 32, + DotDot = 33, + ColonColon = 34, + Dot = 35, + Exclaim = 36, + Multiply = 37, + Divide = 38, + Rem = 39, + Plus = 40, + Minus = 41, + Equals = 42, + PlusEquals = 43, + MinusEquals = 44, + MultiplyEquals = 45, + DivideEquals = 46, + RemainderEquals = 47, + Redirection = 48, + RedirectInStd = 49, + Format = 50, + Not = 51, + Bnot = 52, + And = 53, + Or = 54, + Xor = 55, + Band = 56, + Bor = 57, + Bxor = 58, + Join = 59, + Ieq = 60, + Ine = 61, + Ige = 62, + Igt = 63, + Ilt = 64, + Ile = 65, + Ilike = 66, + Inotlike = 67, + Imatch = 68, + Inotmatch = 69, + Ireplace = 70, + Icontains = 71, + Inotcontains = 72, + Iin = 73, + Inotin = 74, + Isplit = 75, + Ceq = 76, + Cne = 77, + Cge = 78, + Cgt = 79, + Clt = 80, + Cle = 81, + Clike = 82, + Cnotlike = 83, + Cmatch = 84, + Cnotmatch = 85, + Creplace = 86, + Ccontains = 87, + Cnotcontains = 88, + Cin = 89, + Cnotin = 90, + Csplit = 91, + Is = 92, + IsNot = 93, + As = 94, + PostfixPlusPlus = 95, + PostfixMinusMinus = 96, + Shl = 97, + Shr = 98, + Colon = 99, + QuestionMark = 100, + QuestionQuestionEquals = 101, + QuestionQuestion = 102, + QuestionDot = 103, + QuestionLBracket = 104, + Begin = 119, + Break = 120, + Catch = 121, + Class = 122, + Continue = 123, + Data = 124, + Define = 125, + Do = 126, + Dynamicparam = 127, + Else = 128, + ElseIf = 129, + End = 130, + Exit = 131, + Filter = 132, + Finally = 133, + For = 134, + Foreach = 135, + From = 136, + Function = 137, + If = 138, + In = 139, + Param = 140, + Process = 141, + Return = 142, + Switch = 143, + Throw = 144, + Trap = 145, + Try = 146, + Until = 147, + Using = 148, + Var = 149, + While = 150, + Workflow = 151, + Parallel = 152, + Sequence = 153, + InlineScript = 154, + Configuration = 155, + DynamicKeyword = 156, + Public = 157, + Private = 158, + Static = 159, + Interface = 160, + Enum = 161, + Namespace = 162, + Module = 163, + Type = 164, + Assembly = 165, + Command = 166, + Hidden = 167, + Base = 168, + Default = 169, + Clean = 170, +} + +[Flags] +public enum TokenFlags : long +{ + None = 0, + BinaryPrecedenceLogical = 1, + BinaryPrecedenceBitwise = 2, + BinaryPrecedenceComparison = 5, + BinaryPrecedenceCoalesce = 7, + BinaryPrecedenceAdd = 9, + BinaryPrecedenceMultiply = 10, + BinaryPrecedenceFormat = 12, + BinaryPrecedenceRange = 13, + BinaryPrecedenceMask = 15, + Keyword = 16, + ScriptBlockBlockName = 32, + BinaryOperator = 256, + UnaryOperator = 512, + CaseSensitiveOperator = 1024, + TernaryOperator = 2048, + SpecialOperator = 4096, + AssignmentOperator = 8192, + ParseModeInvariant = 32768, + TokenInError = 65536, + DisallowedInRestrictedMode = 131072, + PrefixOrPostfixOperator = 262144, + CommandName = 524288, + MemberName = 1048576, + TypeName = 2097152, + AttributeName = 4194304, + CanConstantFold = 8388608, + StatementDoesntSupportAttributes = 16777216, +} + +public enum CompletionResultType : long +{ + Text = 0, + History = 1, + Command = 2, + ProviderItem = 3, + ProviderContainer = 4, + Property = 5, + Method = 6, + ParameterName = 7, + ParameterValue = 8, + Variable = 9, + Namespace = 10, + Type = 11, + Keyword = 12, + DynamicKeyword = 13, +} + +public enum DebuggerResumeAction : long +{ + Continue = 0, + StepInto = 1, + StepOut = 2, + StepOver = 3, + Stop = 4, +} + +public enum VariableAccessMode : long +{ + Read = 0, + Write = 1, + ReadWrite = 2, +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptEncoding.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/ScriptEncoding.cs similarity index 100% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptEncoding.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Contracts/ScriptEncoding.cs diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptFile.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/ScriptFile.cs similarity index 100% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptFile.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Contracts/ScriptFile.cs diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Contracts/SessionEvents.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/SessionEvents.cs new file mode 100644 index 0000000..5145bcd --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/SessionEvents.cs @@ -0,0 +1,17 @@ + + +namespace Iseberg.Core; + +public enum OutputKind { Output, Error, Warning, Verbose, Debug, Information, Command } +public enum SessionState { Starting, Ready, Running, Debugging, NestedPrompt, Disposed, Failed } + +public sealed record OutputStyle(string? Foreground = null, string? Background = null, bool Bold = false, bool Underline = false, bool Inverse = false); +public sealed record OutputEntry(string Text, OutputKind Kind = OutputKind.Output, int CodeStart = 0, OutputStyle? Style = null); +public sealed record DebugLocation(string? ScriptPath, int Line, int Column, string Message); +public sealed record ProgressUpdate(string Activity, string Status, int Percent, bool Completed, + long SourceId = 0, int ActivityId = 0, int ParentActivityId = -1, int SecondsRemaining = -1, string? CurrentOperation = null); +public sealed record CompletionSet(int Start, int Length, IReadOnlyList Matches); +public sealed record CompletionResult(string CompletionText, string ListItemText, + CompletionResultType ResultType, string ToolTip); +public sealed record CommandDescription(string Name, string Module, string Kind, string Definition); +public sealed record PromptChoice(string Label, string Help); diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/SnippetCatalog.cs b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/SnippetCatalog.cs similarity index 98% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/SnippetCatalog.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Contracts/SnippetCatalog.cs index 0935dc7..e42959c 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/SnippetCatalog.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Contracts/SnippetCatalog.cs @@ -43,7 +43,8 @@ public static class SnippetCatalog { private static readonly XNamespace Namespace = "http://schemas.microsoft.com/PowerShell/Snippets"; private const string Legacy = "Requires Windows PowerShell 5.1; workflow and classic DSC syntax are not supported by PowerShell 7."; - public static string UserDirectory => Path.Combine(Path.GetDirectoryName(UserSettings.SettingsPath)!, "Snippets"); + public static string UserDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Iseberg", "Snippets"); public static IReadOnlyList BuiltIns { get; } = Array.AsReadOnly(new[] { Template("if", "Conditional execution.", "if ($condition) {\n \n}"), diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseJsonContext.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseJsonContext.cs new file mode 100644 index 0000000..5a12857 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Iseberg.Core; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(UserSettings))] +[JsonSerializable(typeof(HelpViewSettings))] +[JsonSerializable(typeof(ThemeFile))] +[JsonSerializable(typeof(WorkbenchState))] +[JsonSerializable(typeof(RecoveredScript))] +[JsonSerializable(typeof(string))] +public sealed partial class IseJsonContext : JsonSerializerContext; diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs index d0e2825..cd26dfa 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetService.cs @@ -5,6 +5,7 @@ public sealed class IseSnippetService(string? userDirectory = null) private readonly object sync = new(); private readonly List imported = []; public string UserDirectory { get; } = userDirectory ?? SnippetCatalog.UserDirectory; + public Func>? LoadFromSessionAsync { get; init; } private IReadOnlyList Directories => userDirectory is null ? SnippetCatalog.DefaultDirectories : [UserDirectory]; public IEnumerable GetUserFiles() => Directories.Where(Directory.Exists) @@ -38,6 +39,7 @@ public void Import(string path, bool recurse) public async Task LoadAsync() { + if (LoadFromSessionAsync is { } load) return await load(); var result = await SnippetCatalog.LoadAsync(Directories); lock (sync) return result with { Snippets = result.Snippets.Concat(imported).Distinct().ToArray() }; } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj b/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj index 6b15f56..9458669 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/Iseberg.Core.csproj @@ -1,5 +1,10 @@ + + true + true + false + - + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellProcessDiscovery.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellProcessDiscovery.cs new file mode 100644 index 0000000..350005b --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellProcessDiscovery.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; + +namespace Iseberg.Core; + +/// Discovers the subprocess without probing or loading any PowerShell assemblies. +public static class PowerShellProcessDiscovery +{ + public static Version MinimumVersion => PowerShellCompatibility.MinimumVersion; + public static bool IsSupportedVersion(Version version) => + PowerShellCompatibility.IsSupportedVersion(version); + public static string ModulePath + { + get + { + var besideExecutable = Path.Combine(AppContext.BaseDirectory, IseBridgeProtocol.ModuleFileName); + if (File.Exists(besideExecutable) || !OperatingSystem.IsMacOS()) return besideExecutable; + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Resources", + IseBridgeProtocol.ModuleFileName)); + } + } + + public static string FindExecutable() + { + var executable = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh"; + if (Environment.GetEnvironmentVariable("DT_ISEBERG_PSHOME") is { Length: > 0 } home) + { + var candidate = Path.Combine(Path.GetFullPath(home), executable); + if (!File.Exists(candidate)) + throw new FileNotFoundException("DT_ISEBERG_PSHOME must identify an installed PowerShell directory.", candidate); + return candidate; + } + foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(directory)) continue; + var candidate = Path.Combine(directory.Trim('"'), executable); + if (File.Exists(candidate)) return Path.GetFullPath(candidate); + } + throw new FileNotFoundException($"Iseberg requires installed PowerShell {PowerShellCompatibility.SupportedVersions}. Install pwsh on PATH or set DT_ISEBERG_PSHOME."); + } + + public static async Task GetVersionAsync(string? executable = null, CancellationToken cancellationToken = default) + { + var start = new ProcessStartInfo(executable ?? FindExecutable()) + { + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true + }; + foreach (var argument in new[] { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.ToString()" }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new InvalidOperationException("PowerShell did not start."); + var output = process.StandardOutput.ReadToEndAsync(cancellationToken); + var error = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(15), cancellationToken); + if (process.ExitCode != 0 || !Version.TryParse((await output).Trim(), out var version)) + throw new InvalidOperationException("Could not determine installed PowerShell version: " + await error); + return version; + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs index 54e6238..a91c830 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.cs @@ -1,580 +1,485 @@ -using System.Management.Automation; -using System.Management.Automation.Runspaces; +using System.Diagnostics; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; namespace Iseberg.Core; -/// Owns a persistent PowerShell runspace with serialized execution, completion, and debugging. -public sealed partial class PowerShellSession : IAsyncDisposable +/// Owns one installed pwsh subprocess. The desktop never loads the PowerShell engine. +public sealed class PowerShellSession : IAsyncDisposable { - private readonly SemaphoreSlim gate = new(1, 1); + private readonly string? snippetDirectory; private readonly object sync = new(); - private readonly AutoResetEvent debuggerWake = new(false); - private readonly Runspace localRunspace; - private readonly WorkbenchHost host; - private Runspace runspace => pushedRunspace ?? localRunspace; - private PowerShell? active; - private InputRequest? pendingInput; - private ShowCommandRequest? pendingShowCommand; - private CommandErrorRequest? pendingCommandError; - private DebuggerResumeAction resumeAction; - private bool disposed; - private Task? disposalTask; - private bool stopRequested; - private object? iseObjectModel; + private readonly CancellationTokenSource lifetime = new(); + private readonly StringBuilder diagnostics = new(); + private BridgeConnection? connection; + private NamedPipeServerStream? pipe; + private Process? child; + private Task? outputDrain; + private Task? errorDrain; + private Task? exitObservation; + private Task? disposal; + private Task terminalSizeUpdate = Task.CompletedTask; + private volatile SessionStatus status = new(SessionState.Starting, "PS> ", "", Guid.Empty, Guid.Empty, + false, false, null, false, false, "[Nested 0]: PS> "); + private int initializing; + private int executionRequests; + private int failed; + private volatile bool authenticated; + private bool initialized; + private volatile bool closing; + private int columns = 120; + private int rows = 40; public IseSnippetService Snippets { get; } - - /// Formatted stream entries, including script errors; callbacks may occur on pipeline threads. + public Func>? ScriptingCallback { get; set; } + public string? ExecutablePath { get; init; } + public string ModulePath { get; init; } = PowerShellProcessDiscovery.ModulePath; + public int? ChildProcessId => child?.Id; public event Action? Output; - /// Execution-state transitions; marshal callbacks before updating a UI. public event Action? StateChanged; - /// Interactive input requiring a host response; do not block or re-enter the engine in the handler. public event Action? InputRequested; public event Action? ShowCommandRequested; public event Action? CommandErrorRequested; public event Action? ProgressChanged; public event Action? DebuggerStopped; public event Action? ConsoleCleared; - public SessionState State { get; private set; } = SessionState.Starting; - public string Prompt { get; private set; } = "PS> "; - public string Version => PSVersionInfo.PSVersion.ToString(); - public Guid LocalRunspaceId => localRunspace.InstanceId; - public void SetTerminalSize(int columns, int rows) => host.SetTerminalSize(columns, rows); + public event Action? RunspaceChanged; + public SessionState State => status.State; + /// True until an execution or connection request completes, including its final state events. + public bool IsExecuting => Volatile.Read(ref executionRequests) != 0; + public string Prompt => status.Prompt; + public string Version => status.Version; + public Guid LocalRunspaceId => status.LocalRunspaceId; + public Guid RunspaceId => status.RunspaceId; + public bool IsRunspacePushed => status.IsRunspacePushed; + public bool IsRemote => status.IsRemote; + public string? RemoteComputerName => status.RemoteComputerName; + public string DebugPrompt => IsRemote ? $"[{RemoteComputerName}]: [DBG]: PS> " : "[DBG]: PS> "; + public bool IsDebuggerPaused => State == SessionState.Debugging && status.IsDebuggerPaused; + public bool IsNestedPromptActive => State == SessionState.NestedPrompt && status.IsNestedPromptActive; + public string NestedPrompt => status.NestedPrompt; - /// Creates an unopened local runspace with optional custom snippet storage. public PowerShellSession(string? snippetDirectory = null) { - Snippets = new(snippetDirectory); - host = new WorkbenchHost(entry => Output?.Invoke(entry), ReadInput, - update => ProgressChanged?.Invoke(update), () => ConsoleCleared?.Invoke(), ReadShowCommand, - () => runspace, () => IsRunspacePushed, remote => PushRunspace(remote, false), PopRunspace, - EnterNestedPrompt, ExitNestedPrompt, ReadCommandError, Snippets); - var initialState = InitialSessionState.CreateDefault2(); - // The default Unix function clears a terminal instead of this graphical host. - initialState.Commands.Remove("Clear-Host", typeof(SessionStateFunctionEntry)); - initialState.Commands.Add(new SessionStateFunctionEntry("Clear-Host", """ - $rawUI = $Host.UI.RawUI - $rawUI.SetBufferContents( - [System.Management.Automation.Host.Rectangle]::new(-1, -1, -1, -1), - [System.Management.Automation.Host.BufferCell]::new( - ' ', - $rawUI.ForegroundColor, - $rawUI.BackgroundColor, - [System.Management.Automation.Host.BufferCellType]::Complete)) - """)); - initialState.Commands.Remove("clear", typeof(SessionStateAliasEntry)); - initialState.Commands.Add(new SessionStateAliasEntry("clear", "Clear-Host")); - // These binary cmdlets must be available even when Restricted prevents loading module type data. - initialState.Commands.Add(new SessionStateCmdletEntry("Set-ExecutionPolicy", typeof(Microsoft.PowerShell.Commands.SetExecutionPolicyCommand), null)); - initialState.Commands.Add(new SessionStateCmdletEntry("Get-ExecutionPolicy", typeof(Microsoft.PowerShell.Commands.GetExecutionPolicyCommand), null)); - initialState.Commands.Add(new SessionStateCmdletEntry("Show-IsebergCommand", typeof(ShowCommandCommand), null)); - initialState.Commands.Add(new SessionStateCmdletEntry("Start-IsebergTerminal", typeof(StartTerminalCommand), null)); - initialState.Commands.Add(new SessionStateCmdletEntry("New-IseSnippet", typeof(NewIseSnippetCommand), null)); - initialState.Commands.Add(new SessionStateCmdletEntry("Get-IseSnippet", typeof(GetIseSnippetCommand), null)); - initialState.Commands.Add(new SessionStateCmdletEntry("Import-IseSnippet", typeof(ImportIseSnippetCommand), null)); - // A function keeps precedence when Utility is auto-imported by commands such as Get-Help. - initialState.Commands.Remove("Show-Command", typeof(SessionStateFunctionEntry)); - initialState.Commands.Add(new SessionStateFunctionEntry("Show-Command", """ - [CmdletBinding()] - param( - [Parameter(Position=0)] [ValidateNotNullOrEmpty()] [string] $Name, - [switch] $PassThru, - [switch] $NoCommonParameter, - [switch] $ErrorPopup, - [ValidateRange(300, [int]::MaxValue)] [int] $Width = 360, - [ValidateRange(300, [int]::MaxValue)] [int] $Height = 410 - ) - Show-IsebergCommand @PSBoundParameters - """)); - localRunspace = RunspaceFactory.CreateRunspace(host, initialState); - runspace.ThreadOptions = PSThreadOptions.ReuseThread; - if (OperatingSystem.IsWindows()) - runspace.ApartmentState = ApartmentState.STA; - } - - public void ConfigureIseObjectModel(object model) - { - ArgumentNullException.ThrowIfNull(model); - if (!gate.Wait(0)) throw new InvalidOperationException("Configure the ISE object model before executing commands."); - try - { - ObjectDisposedException.ThrowIf(disposed, this); - if (State is not (SessionState.Starting or SessionState.Ready)) - throw new InvalidOperationException("Configure the ISE object model before executing commands."); - iseObjectModel = model; - if (localRunspace.RunspaceStateInfo.State == RunspaceState.Opened) - localRunspace.SessionStateProxy.SetVariable("psISE", model); - } - finally { gate.Release(); } + this.snippetDirectory = snippetDirectory; + Snippets = new(snippetDirectory) { LoadFromSessionAsync = LoadSnippetsAsync }; } - /// Opens the local runspace and prepares its host. Profiles are not loaded automatically. public async Task InitializeAsync() { - await gate.WaitAsync(); + if (Interlocked.Exchange(ref initializing, 1) != 0) + throw new InvalidOperationException("This PowerShell tab has already been initialized."); + ObjectDisposedException.ThrowIf(closing, this); try { - ObjectDisposedException.ThrowIf(disposed, this); - if (State != SessionState.Starting || runspace.RunspaceStateInfo.State != RunspaceState.BeforeOpen) - throw new InvalidOperationException("This PowerShell tab has already been initialized."); - await Task.Run(() => + if (!Path.IsPathFullyQualified(ModulePath) || !File.Exists(ModulePath)) + throw new FileNotFoundException("The DT-owned Iseberg PowerShell bridge module is missing.", ModulePath); + var executable = ExecutablePath ?? PowerShellProcessDiscovery.FindExecutable(); + var endpoint = "dt-iseberg-" + Guid.NewGuid().ToString("N"); + var authentication = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var challenge = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + pipe = new(endpoint, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + var start = new ProcessStartInfo(executable) { - runspace.Open(); - runspace.Debugger.SetDebugMode(DebugModes.LocalScript); - runspace.Debugger.DebuggerStop += OnDebuggerStop; - var config = OperatingSystem.IsWindows() - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell") - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "powershell"); - var profile = PSObject.AsPSObject(Path.Combine(config, "Devolutions.Terminal.Iseberg_profile.ps1")); - profile.Properties.Add(new PSNoteProperty("CurrentUserCurrentHost", profile.BaseObject)); - profile.Properties.Add(new PSNoteProperty("CurrentUserAllHosts", Path.Combine(config, "profile.ps1"))); - var engineHome = Path.GetDirectoryName(typeof(PSObject).Assembly.Location)!; - profile.Properties.Add(new PSNoteProperty("AllUsersCurrentHost", Path.Combine(engineHome, "Devolutions.Terminal.Iseberg_profile.ps1"))); - profile.Properties.Add(new PSNoteProperty("AllUsersAllHosts", Path.Combine(engineHome, "profile.ps1"))); - runspace.SessionStateProxy.SetVariable("PROFILE", profile); - if (iseObjectModel is not null) runspace.SessionStateProxy.SetVariable("psISE", iseObjectModel); - RefreshPrompt(); - }); - SetState(SessionState.Ready); - } - finally { gate.Release(); } - } - - public async Task SetWorkingDirectoryAsync(string path) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - if (!Path.IsPathFullyQualified(path)) throw new ArgumentException("An absolute directory is required.", nameof(path)); - await gate.WaitAsync(); - try - { - EnsureReady(); - await Task.Run(() => - { - localRunspace.SessionStateProxy.Path.SetLocation(path); - RefreshPrompt(); - }); - } - finally { gate.Release(); } - } - - /// Executes text or a named script in the persistent session. Script errors are delivered through Output. - public Task ExecuteAsync(string script, string? filePath = null) => ExecuteAsync(script, filePath, null); - - public Task ExecuteMenuActionAsync(ScriptBlock action) - { - ArgumentNullException.ThrowIfNull(action); - if (IsRunspacePushed) throw new PSNotSupportedException("ISE Add-ons menu actions require their original local runspace. Exit the remote session first."); - return ExecuteAsync("ISE Add-ons menu action", null, action); - } - - private async Task ExecuteAsync(string script, string? filePath, ScriptBlock? action) - { - if (!await gate.WaitAsync(0)) - throw new InvalidOperationException("This PowerShell tab is busy."); - var started = false; - try - { - EnsureReady(); - lock (sync) stopRequested = false; - started = true; - SetState(SessionState.Running); - Output?.Invoke(new(Prompt + (filePath ?? script) + Environment.NewLine, OutputKind.Command, Prompt.Length)); - await Task.Run(() => + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true + }; + using var parent = Process.GetCurrentProcess(); + var bootstrap = "Import-Module -Name '" + ModulePath.Replace("'", "''") + + "' -ErrorAction Stop; Start-IsebergBridge -PipeName '" + endpoint + + "' -ParentProcessId " + Environment.ProcessId + + " -ParentStartTimeUtcTicks " + parent.StartTime.ToUniversalTime().Ticks; + foreach (var argument in new[] { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", bootstrap }) + start.ArgumentList.Add(argument); + start.Environment[IseBridgeProtocol.AuthenticationEnvironment] = authentication; + child = Process.Start(start) ?? throw new InvalidOperationException("The PowerShell bridge did not start."); + outputDrain = DrainAsync(child.StandardOutput); + errorDrain = DrainAsync(child.StandardError); + exitObservation = ObserveExitAsync(child); + await pipe.WaitForConnectionAsync(lifetime.Token).WaitAsync(TimeSpan.FromSeconds(30), lifetime.Token).ConfigureAwait(false); + connection = new(pipe) { Request = HandleRequestAsync, Event = HandleEvent }; + connection.Disconnected += OnDisconnected; + connection.Start(); + var hello = await CallAsync("hello", + new(IseBridgeProtocol.Version, IseBridgeProtocol.BuildIdentity, + IseBridgeProtocol.AuthenticationProof(authentication, challenge, "parent"), challenge, + Environment.ProcessId, parent.StartTime.ToUniversalTime().Ticks)) + .WaitAsync(TimeSpan.FromSeconds(15), lifetime.Token).ConfigureAwait(false); + if (hello.Protocol != IseBridgeProtocol.Version || hello.Build != IseBridgeProtocol.BuildIdentity || + hello.ProcessId != child.Id || + !CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(hello.AuthenticationProof), + Encoding.UTF8.GetBytes(IseBridgeProtocol.AuthenticationProof(authentication, challenge, "child"))) || + !System.Version.TryParse(hello.PowerShellVersion, out var version) || + !PowerShellProcessDiscovery.IsSupportedVersion(version)) + throw new InvalidOperationException($"Iseberg requires the matching DT bridge build and PowerShell {PowerShellCompatibility.SupportedVersions}."); + authenticated = true; + await SendAsync("initialize", new SessionInitialize(snippetDirectory, ScriptingCallback is not null)).ConfigureAwait(false); + Task resize; + lock (sync) { - if (IsRunspacePushed && filePath is null && IsExitSessionCommand(script)) - { - PopRunspace(); - RefreshPrompt(); - return; - } - using var shell = CreateShell(); - try - { - if (action is not null) - // Keep the original block, including module/session state and GetNewClosure(). - shell.AddScript(". $args[0]", useLocalScope: false).AddArgument(action); - else if (filePath is null) - shell.AddScript(script, useLocalScope: false); - else - shell.AddScript(". '" + filePath.Replace("'", "''") + "'", useLocalScope: false); - shell.AddCommand("Out-Default"); - using var output = new PSDataCollection(); - IAsyncResult invocation; - lock (sync) - { - if (stopRequested) throw new PipelineStoppedException(); - invocation = shell.BeginInvoke(null, output); - active = shell; - } - shell.EndInvoke(invocation); - } - catch (PipelineStoppedException) - { - Output?.Invoke(new("Execution stopped." + Environment.NewLine, OutputKind.Warning)); - } - catch (RuntimeException exception) - { - Output?.Invoke(new(exception.ErrorRecord.ToString() + Environment.NewLine, OutputKind.Error)); - } - finally - { - lock (sync) active = null; - DebuggerStopped?.Invoke(null); - RefreshPrompt(); - } - }); + initialized = true; + resize = terminalSizeUpdate = UpdateTerminalSizeAsync(terminalSizeUpdate, columns, rows); + } + await resize.ConfigureAwait(false); } - finally + catch (Exception exception) { - if (started && !disposed) SetState(SessionState.Ready); - gate.Release(); + LoseSession(exception); + await TerminateChildAsync().ConfigureAwait(false); + throw; } } - /// Requests cancellation of execution, debugger evaluation, and pending interactive host requests. - public Task StopAsync() + public void SetTerminalSize(int columns, int rows) { + ArgumentOutOfRangeException.ThrowIfLessThan(columns, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1); + Task update; lock (sync) { - stopRequested = true; - pendingInput?.Response.TrySetCanceled(); - pendingShowCommand?.Response.TrySetCanceled(); - pendingCommandError?.Response.TrySetCanceled(); - foreach (var frame in nestedFrames) frame.Exit = true; - resumeAction = DebuggerResumeAction.Stop; - resumeRequested = true; - debuggerWake.Set(); - connectingRunspace?.CloseAsync(); - if (runspace.RunspaceStateInfo.State == RunspaceState.Opened) - runspace.Debugger?.StopProcessCommand(); - if (active is { } shell) - return shell.StopAsync(null, null); + this.columns = columns; + this.rows = rows; + if (!initialized || State is SessionState.Failed or SessionState.Disposed) return; + update = terminalSizeUpdate = UpdateTerminalSizeAsync(terminalSizeUpdate, columns, rows); } - return Task.CompletedTask; + Observe(update); } - public void Resume(DebuggerResumeAction action) + private async Task UpdateTerminalSizeAsync(Task previous, int columns, int rows) { - lock (sync) - { - if (State != SessionState.Debugging) - throw new InvalidOperationException("The debugger is not paused."); - resumeAction = action; - resumeRequested = true; - debuggerWake.Set(); - } + await previous.ConfigureAwait(false); + await SendAsync("terminalSize", new TerminalSizeRequest(columns, rows)).ConfigureAwait(false); } - public Task SetBreakpointsAsync(string path, IEnumerable lines) => - SetLineBreakpointsAsync(path, lines.Select(line => new BreakpointSpec(BreakpointKind.Line, path, Line: line))); - - /// Completes text at a zero-based cursor offset in the current runspace or paused debugger context. - public Task CompleteAsync(string text, int cursor, CancellationToken cancellationToken = default) => - State == SessionState.NestedPrompt ? NestedQueryAsync(shell => - { - var result = CommandCompletion.CompleteInput(text, cursor, null, shell); - return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.ToArray()); - }, cancellationToken) : State == SessionState.Debugging ? CompletePausedAsync(text, cursor, cancellationToken) : QueryAsync(shell => - { - var result = CommandCompletion.CompleteInput(text, cursor, null, shell); - return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.ToArray()); - }, cancellationToken); - - public Task> GetCommandsAsync(CancellationToken cancellationToken = default) => - QueryAsync>(shell => - { - shell.AddCommand("Get-Command"); - if (IsRemote) - shell.AddCommand("Microsoft.PowerShell.Utility\\Select-Object") - .AddParameter("Property", new[] { "Name", "ModuleName", "CommandType", "Definition" }); - var commands = shell.Invoke(); - ThrowQueryErrors(shell); - return commands.Select(command => new CommandDescription( - command.Properties["Name"].Value.ToString()!, command.Properties["ModuleName"].Value?.ToString() ?? "", - command.Properties["CommandType"].Value.ToString()!, command.Properties["Definition"].Value?.ToString() ?? "")) - .OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase).DistinctBy(command => command.Name).ToArray(); - }, cancellationToken); - - internal static IReadOnlyList DescribeCommands(IEnumerable commands) => - commands.OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase) - .Select(command => new CommandDescription(command.Name, command.ModuleName, - command.CommandType.ToString(), command.Definition)) - .DistinctBy(command => command.Name).ToArray(); - - public Task GetHelpAsync(string name) => QueryAsync(shell => + public Task SetWorkingDirectoryAsync(string path) { - var output = shell.AddCommand("Get-Help").AddParameter("Name", name).AddParameter("Full") - .AddCommand("Out-String").AddParameter("Width", 100).Invoke(); - ThrowQueryErrors(shell); - return string.Join(Environment.NewLine, output); - }); - - public Task GetHelpDocumentAsync(string name) => QueryAsync(shell => - { - var help = shell.AddCommand("Get-Help").AddParameter("Name", name).AddParameter("Full").Invoke(); - ThrowQueryErrors(shell); - return CommandHelpDocument.FromHelp(name, help); - }); - - public Task GetCommandFormAsync(string name, string? module = null, - CancellationToken cancellationToken = default) => QueryAsync(shell => + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Path.IsPathFullyQualified(path)) throw new ArgumentException("An absolute directory is required.", nameof(path)); + return SendAsync("workingDirectory", new TextRequest(path)); + } + public Task ExecuteAsync(string script, string? filePath = null) => + SendExecutionAsync("execute", new ExecuteRequest(script, filePath)); + public Task ExecuteMenuActionAsync(string callbackHandle) => + SendExecutionAsync("menuAction", new TextRequest(callbackHandle)); + public Task StopAsync() => connection is null || State is SessionState.Failed or SessionState.Disposed + ? Task.CompletedTask : SendAsync("stop", true); + public void Resume(DebuggerResumeAction action) { - if (IsRemote) return GetRemoteCommandForm(shell, name, module); - shell.AddCommand("Get-Command").AddParameter("Name", WildcardPattern.Escape(name)); - if (!string.IsNullOrEmpty(module)) shell.AddParameter("Module", module); - var commands = shell.Invoke(); - ThrowQueryErrors(shell); - var command = commands.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException($"Command '{name}' was not found in this PowerShell tab."); - return CommandForm.Describe(command); - }, cancellationToken, waitForGate: true); - - public Task GetHelpUriAsync(string name) => QueryAsync(shell => + if (!IsDebuggerPaused) throw new InvalidOperationException("The debugger is not paused."); + status = status with { IsDebuggerPaused = false }; + Observe(SendAsync("resume", new ResumeRequest(action))); + } + public void BreakAll() { - var help = shell.AddCommand("Get-Help").AddParameter("Name", name).Invoke(); - ThrowQueryErrors(shell); - return FindHelpUri(help); - }); - - internal static Uri? FindHelpUri(IEnumerable help) + if (State != SessionState.Running) throw new InvalidOperationException("A script must be running to break execution."); + Observe(SendAsync("breakAll", true)); + } + public Task PauseForBreakpointEditAsync(CancellationToken cancellationToken = default) => + SendAsync("pauseForBreakpointEdit", true, cancellationToken); + public Task EvaluateAsync(string script) => SendAsync("evaluate", new TextRequest(script)); + public Task EvaluateNestedAsync(string script) => SendAsync("evaluateNested", new TextRequest(script)); + public Task CompleteAsync(string text, int cursor, CancellationToken cancellationToken = default) => + CallAsync("complete", new(text, cursor), cancellationToken); + public Task AnalyzeAsync(string text, string? documentPath = null, CancellationToken cancellationToken = default) { - foreach (var entry in help) - { - if (entry.Properties["RelatedLinks"]?.Value is not PSObject links) continue; - if (links.Properties["navigationLink"]?.Value is not System.Collections.IEnumerable navigation) continue; - foreach (var link in navigation) - { - var value = PSObject.AsPSObject(link).Properties["uri"]?.Value?.ToString(); - if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") return uri; - } - } - return null; + ObjectDisposedException.ThrowIf(closing || State == SessionState.Disposed, this); + ArgumentNullException.ThrowIfNull(text); + cancellationToken.ThrowIfCancellationRequested(); + return EditorAnalysis.IsXmlDocument(documentPath) ? Task.FromResult(EditorAnalysis.AnalyzeXml(text)) + : CallAsync("analyze", new(text, documentPath), cancellationToken); } - - private async Task QueryAsync(Func query, CancellationToken cancellationToken = default, - bool waitForGate = false) + public async Task> GetCommandsAsync(CancellationToken cancellationToken = default) => + await CallAsync("commands", true, cancellationToken).ConfigureAwait(false); + public Task GetHelpAsync(string name) => CallAsync("help", new(name)); + public Task GetHelpDocumentAsync(string name) => + CallAsync("helpDocument", new(name)); + public Task GetCommandFormAsync(string name, string? module = null, + CancellationToken cancellationToken = default) => + CallAsync("commandForm", new(name, module), cancellationToken); + public async Task GetHelpUriAsync(string name) { - if (waitForGate) await gate.WaitAsync(cancellationToken); - else if (!await gate.WaitAsync(0)) - throw new InvalidOperationException("Wait for the running command to finish."); - try - { - EnsureReady(); - return await Task.Run(() => - { - using var shell = CreateShell(); - using var registration = cancellationToken.Register(() => shell.Stop()); - cancellationToken.ThrowIfCancellationRequested(); - var debugger = shell.Runspace.Debugger; - var debugMode = debugger.DebugMode; - debugger.SetDebugMode(DebugModes.None); - try - { - var result = query(shell); - cancellationToken.ThrowIfCancellationRequested(); - return result; - } - catch (PipelineStoppedException) when (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(cancellationToken); - } - finally { debugger.SetDebugMode(debugMode); } - }); - } - finally { gate.Release(); } + var value = await CallAsync("helpUri", new(name)).ConfigureAwait(false); + return value.Text is null ? null : new Uri(value.Text, UriKind.Absolute); } - - private PowerShell CreateShell() + public Task BuildCommandFormAsync(CommandForm form, CancellationToken cancellationToken = default) => + CallAsync("buildCommandForm", form.ToRequest(), cancellationToken); + public Task ValidateBreakpointAsync(BreakpointSpec spec, CancellationToken cancellationToken = default) => + SendAsync("validateBreakpoint", spec, cancellationToken); + public Task InspectAsync(IEnumerable watches, int frameIndex = 0) => + CallAsync("inspect", new(watches.ToArray(), frameIndex)); + public Task GetValueChildrenAsync(long reference, int offset = 0, int count = 100) => + CallAsync("valueChildren", new(reference, offset, count)); + public async Task> GetBreakpointsAsync() => + await CallAsync("breakpoints", true).ConfigureAwait(false); + public Task AddBreakpointAsync(BreakpointSpec spec) => + CallAsync("addBreakpoint", spec); + public Task UpdateBreakpointAsync(int id, BreakpointSpec spec) => + CallAsync("updateBreakpoint", new(id, spec)); + public Task SetBreakpointEnabledAsync(int id, bool enabled) => + SendAsync("enableBreakpoint", new BreakpointIdRequest(id, enabled)); + public Task RemoveBreakpointAsync(int id) => SendAsync("removeBreakpoint", new BreakpointIdRequest(id)); + public Task RemoveAllBreakpointsAsync() => SendAsync("removeAllBreakpoints", true); + public Task SetLineBreakpointsAsync(string path, IEnumerable specs) => + SendAsync("lineBreakpoints", new LineBreakpointsRequest(path, specs.ToArray())); + public Task SetBreakpointsAsync(string path, IEnumerable lines) => + SetLineBreakpointsAsync(path, lines.Select(line => new BreakpointSpec(BreakpointKind.Line, path, Line: line))); + public Task ConnectAsync(RemoteConnectionInfo remote, CancellationToken cancellationToken = default) => + SendExecutionAsync("connect", remote, cancellationToken); + public Task ExitRemoteSessionAsync() => SendExecutionAsync("exitRemoteSession", true); + public Task OpenRemoteFileAsync(string path) => OpenRemoteFileAsync(path, null); + public async Task OpenRemoteFileAsync(string path, ScriptEncoding? choice) { - var shell = PowerShell.Create(); - shell.Runspace = runspace; - shell.Streams.Error.DataAdded += (_, e) => Output?.Invoke(new(shell.Streams.Error[e.Index].ToString() + Environment.NewLine, OutputKind.Error)); - return shell; + var file = await CallAsync("openRemoteFile", new(path, choice)).ConfigureAwait(false); + return ScriptFile.FromRemoteBytes(file.Path, file.Bytes, file.RunspaceId, file.ComputerName, choice); } - - private static void ThrowQueryErrors(PowerShell shell) + public Task SaveRemoteFileAsync(ScriptFile file, string? path = null) => + SaveRemoteFileCoreAsync(file, path, file.SavedVersion, true); + public Task SaveRemoteFileAsync(ScriptFile file, string? path, string? expectedVersion) => + SaveRemoteFileCoreAsync(file, path, expectedVersion, false); + private async Task SaveRemoteFileCoreAsync(ScriptFile file, string? path, string? expectedVersion, bool useSavedVersion) { - if (shell.HadErrors) - throw new InvalidOperationException(string.Join(Environment.NewLine, shell.Streams.Error.Select(e => e.ToString()))); + if (file.IsRemote && (!IsRemote || file.RemoteRunspaceId != RunspaceId)) + throw new InvalidOperationException("This document belongs to another remote connection. Reconnect and reopen it before saving or running it."); + if (!file.IsRemote && (!IsRemote || file.Path is not null)) + throw new InvalidOperationException("Only remote documents or untitled scripts can be saved to the remote session."); + ArgumentException.ThrowIfNullOrWhiteSpace(path ?? file.Path); + var snapshot = file.Text; + var encoding = file.EncodingChoice; + var bytes = ScriptFile.Encode(snapshot, encoding); + var result = await CallAsync("saveRemoteFile", + new(path ?? file.Path!, bytes, expectedVersion, file.IsRemote ? file.Path : null, useSavedVersion, RunspaceId)).ConfigureAwait(false); + file.MarkRemoteSaved(result.Path, snapshot, encoding, ScriptFile.GetVersion(bytes), result.RunspaceId, result.ComputerName); } - - private void EnsureReady() + public Task RemoteFileExistsAsync(string path) => CallAsync("remoteFileExists", new(path)); + public Task GetRemoteFileVersionAsync(string path) => ReadRemoteFileVersionAsync(path); + public async Task ReadRemoteFileVersionAsync(string path) => + (await CallAsync("remoteFileVersion", new(path)).ConfigureAwait(false)).Text; + public async Task HasRemoteFileChangesAsync(ScriptFile file) { - ObjectDisposedException.ThrowIf(disposed, this); - if (State != SessionState.Ready) - throw new InvalidOperationException("The PowerShell tab is not ready."); + if (!IsRemote || file.RemoteRunspaceId != RunspaceId) + throw new InvalidOperationException("This document belongs to another remote connection."); + return !ScriptFile.VersionsMatch(file.SavedVersion, await ReadRemoteFileVersionAsync(file.Path!).ConfigureAwait(false)); } - - private string ReadInput(InputRequest request) + public Task NotifyScriptingAsync(string objectId, string propertyName) => + SendAsync("scriptingNotification", new ScriptNotification(objectId, propertyName)); + public Task LoadSnippetsAsync() => CallAsync("snippets", true); + public Task CreateSnippetAsync(string title, string description, string text, string author, int caretOffset, bool force) => + SendAsync("createSnippet", new SnippetCreateRequest(title, description, text, author, caretOffset, force)); + public Task ImportSnippetAsync(string path, bool recurse) => SendAsync("importSnippet", new SnippetImportRequest(path, recurse)); + + private async Task CallAsync(string operation, TRequest payload, + CancellationToken cancellationToken = default) { - lock (sync) + ObjectDisposedException.ThrowIf(State == SessionState.Disposed || closing && operation != "dispose", this); + if (State == SessionState.Failed) + throw new InvalidOperationException("This PowerShell session is unavailable. Documents remain open; reopen the tab to create a new session."); + var bridge = connection ?? throw new InvalidOperationException("Initialize this PowerShell tab first."); + try { - if (stopRequested) throw new PipelineStoppedException(); - pendingInput = request; + return BridgeJson.Read(await bridge.CallAsync(operation, BridgeJson.Element(payload), cancellationToken).ConfigureAwait(false)); } - try + catch (BridgeRemoteException exception) when (exception.Fault.Code == "FileConflict") { - if (InputRequested is null) - throw new PSNotSupportedException("No input handler is attached to this host."); - InputRequested.Invoke(request); - return request.Response.Task.GetAwaiter().GetResult(); + throw new FileConflictException(exception.Fault.FilePath!, exception.Fault.ExpectedVersion, exception.Fault.ActualVersion); } - catch (OperationCanceledException) { throw new PipelineStoppedException(); } - finally { lock (sync) pendingInput = null; } - } - - private string? ReadShowCommand(ShowCommandRequest request) - { - lock (sync) + catch (BridgeRemoteException exception) when (exception.Fault.Code is "PSNotSupportedException" or "NotSupportedException") { - if (stopRequested) throw new PipelineStoppedException(); - pendingShowCommand = request; + throw new NotSupportedException(exception.Message); } - try + catch (BridgeRemoteException exception) when (exception.Fault.Code is "ArgumentException" or "ArgumentNullException" or "ArgumentOutOfRangeException") { - if (ShowCommandRequested is null) - throw new PSNotSupportedException("No Show-Command handler is attached to this host."); - ShowCommandRequested.Invoke(request); - return request.Response.Task.GetAwaiter().GetResult(); + throw new ArgumentException(exception.Message); } - catch (OperationCanceledException) { throw new PipelineStoppedException(); } - finally { lock (sync) pendingShowCommand = null; } - } - - private void OnDebuggerStop(object? sender, DebuggerStopEventArgs e) - { - var previousState = State; - lock (sync) + catch (BridgeRemoteException exception) when (exception.Fault.Code == "ParseException") + { + throw new ArgumentException(exception.Message); + } + catch (BridgeRemoteException exception) when (exception.Fault.Code == "ObjectDisposedException") { - resumeRequested = stopRequested; - resumeAction = stopRequested ? DebuggerResumeAction.Stop : DebuggerResumeAction.Continue; - SetState(SessionState.Debugging); + throw new ObjectDisposedException(nameof(PowerShellSession), exception.Message); } + catch (BridgeRemoteException exception) when (exception.Fault.Code == "InvalidOperationException") + { + throw new InvalidOperationException(exception.Message); + } + } + private async Task SendAsync(string operation, T value, CancellationToken cancellationToken = default) => + _ = await CallAsync(operation, value, cancellationToken).ConfigureAwait(false); - var invocation = e.InvocationInfo; - DebuggerStopped?.Invoke(new(invocation?.ScriptName, invocation?.ScriptLineNumber ?? 0, - invocation?.OffsetInLine ?? 0, invocation?.PositionMessage ?? "Execution paused without a script location.")); + private async Task SendExecutionAsync(string operation, T value, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref executionRequests); try { - while (true) - { - DebugWork? work; - lock (sync) - { - if (resumeRequested) break; - work = debugWork.Count > 0 ? debugWork.Dequeue() : null; - } - if (work is null) debuggerWake.WaitOne(); - else work.Execute(); - } + Task resize; + lock (sync) resize = terminalSizeUpdate; + await resize.WaitAsync(cancellationToken).ConfigureAwait(false); + await SendAsync(operation, value, cancellationToken).ConfigureAwait(false); } finally { - lock (sync) - { - e.ResumeAction = resumeAction; - debugValues.Clear(); - while (debugWork.TryDequeue(out var work)) - work.Fail(new InvalidOperationException("The debugger has resumed.")); - } + // A Ready/runspace event can precede the correlated execution reply. + if (Interlocked.Decrement(ref executionRequests) == 0 && !closing && State == SessionState.Ready) + StateChanged?.Invoke(SessionState.Ready); } - SetState(previousState); - DebuggerStopped?.Invoke(null); } - private void ReadCommandError(string message) + private void HandleEvent(string operation, JsonElement payload) { - var request = new CommandErrorRequest(message); - lock (sync) + if (closing || Volatile.Read(ref failed) != 0) return; + if (!authenticated) throw new InvalidDataException("The private PowerShell bridge sent events before authenticating."); + switch (operation) { - if (stopRequested) throw new PipelineStoppedException(); - pendingCommandError = request; + case "status": + status = BridgeJson.Read(payload); + StateChanged?.Invoke(status.State); + break; + case "output": Output?.Invoke(BridgeJson.Read(payload)); break; + case "progress": ProgressChanged?.Invoke(BridgeJson.Read(payload)); break; + case "debugger": DebuggerStopped?.Invoke(payload.ValueKind == JsonValueKind.Null ? null : BridgeJson.Read(payload)); break; + case "clear": ConsoleCleared?.Invoke(); break; + case "runspace": + status = BridgeJson.Read(payload); + RunspaceChanged?.Invoke(); + break; + default: throw new InvalidDataException("Unknown private PowerShell event."); } - try + } + private async Task HandleRequestAsync(string operation, JsonElement payload, CancellationToken cancellationToken) + { + if (!authenticated) throw new InvalidOperationException("The private PowerShell bridge has not authenticated."); + switch (operation) { - if (CommandErrorRequested is null) - throw new PSNotSupportedException("No command error popup handler is attached to this host." + Environment.NewLine + message); - CommandErrorRequested.Invoke(request); - request.Response.Task.GetAwaiter().GetResult(); + case "input": + var data = BridgeJson.Read(payload); + var input = new InputRequest(data.Caption, data.Message, data.Secret) + { Choices = data.Choices, DefaultChoices = data.DefaultChoices, MultipleChoice = data.MultipleChoice }; + using (cancellationToken.Register(() => input.Response.TrySetCanceled(cancellationToken))) + { + if (InputRequested is null) throw new InvalidOperationException("No input handler is attached to this host."); + InputRequested.Invoke(input); + return BridgeJson.Element(new HostResponse(await input.Response.Task.ConfigureAwait(false))); + } + case "showCommand": + var show = BridgeJson.Read(payload); + var request = new ShowCommandRequest + { Command = show.Command, Commands = show.Commands, HelpText = show.HelpText, HelpDocument = show.HelpDocument, + HelpUri = show.HelpUri, PassThru = show.PassThru, Width = show.Width, Height = show.Height }; + using (cancellationToken.Register(() => request.Response.TrySetCanceled(cancellationToken))) + { + if (ShowCommandRequested is null) throw new InvalidOperationException("No Show-Command handler is attached to this host."); + ShowCommandRequested.Invoke(request); + return BridgeJson.Element(new HostResponse(await request.Response.Task.ConfigureAwait(false))); + } + case "commandError": + var error = new CommandErrorRequest(BridgeJson.Read(payload).Text); + using (cancellationToken.Register(() => error.Response.TrySetCanceled(cancellationToken))) + { + if (CommandErrorRequested is null) throw new InvalidOperationException("No command error popup handler is attached to this host."); + CommandErrorRequested.Invoke(error); + await error.Response.Task.ConfigureAwait(false); + return BridgeJson.Empty; + } + default: + if ((operation != "ise" && !operation.StartsWith("ise.", StringComparison.Ordinal)) || ScriptingCallback is null) + throw new InvalidOperationException($"Unsupported parent scripting operation '{operation}'."); + return await ScriptingCallback(operation, payload, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) { throw new PipelineStoppedException(); } - finally { lock (sync) pendingCommandError = null; } } - private void RefreshPrompt() + private async Task DrainAsync(StreamReader reader) { - using var shell = PowerShell.Create(); - shell.Runspace = runspace; - shell.AddScript("prompt", useLocalScope: true); - var debugger = shell.Runspace.Debugger; - var debugMode = debugger.DebugMode; - debugger.SetDebugMode(DebugModes.None); try { - IAsyncResult invocation; - lock (sync) + var buffer = new char[4096]; + int read; + while ((read = await reader.ReadAsync(buffer, lifetime.Token).ConfigureAwait(false)) != 0) { - if (stopRequested) + // Raw stdout/stderr is diagnostics, never a second script-output transport. + lock (sync) { - Prompt = IsRemote ? $"[{RemoteComputerName}]: PS> " : "PS> "; - return; + if (diagnostics.Length + read > 16384) diagnostics.Remove(0, Math.Min(diagnostics.Length, read)); + diagnostics.Append(buffer, 0, read); } - invocation = shell.BeginInvoke(); - active = shell; - } - var values = shell.EndInvoke(invocation); - if (shell.HadErrors) - { - Output?.Invoke(new("The prompt function failed: " + string.Join("; ", shell.Streams.Error) + Environment.NewLine, OutputKind.Error)); - Prompt = "PS> "; } - else - Prompt = string.Concat(values.Select(v => v.ToString())); - } - catch (PipelineStoppedException) - { - Prompt = "PS> "; - Output?.Invoke(new("Prompt evaluation stopped." + Environment.NewLine, OutputKind.Warning)); } - catch (RuntimeException exception) - { - Prompt = "PS> "; - Output?.Invoke(new("The prompt function failed: " + exception.Message + Environment.NewLine, OutputKind.Error)); - } - finally + catch (Exception exception) when (exception is IOException or OperationCanceledException or ObjectDisposedException) { } + } + private async Task ObserveExitAsync(Process process) + { + await process.WaitForExitAsync().ConfigureAwait(false); + if (!closing) LoseSession(new IOException($"PowerShell child exited ({process.ExitCode}). {StartupDiagnostics()}")); + } + private string StartupDiagnostics() { lock (sync) return diagnostics.ToString().Trim(); } + private void OnDisconnected(Exception exception) + { + if (!closing) LoseSession(exception); + } + private void LoseSession(Exception exception) + { + if (closing || Interlocked.Exchange(ref failed, 1) != 0) return; + status = status with { State = SessionState.Failed, IsDebuggerPaused = false, IsNestedPromptActive = false }; + lifetime.Cancel(); + pipe?.Dispose(); + DebuggerStopped?.Invoke(null); + StateChanged?.Invoke(SessionState.Failed); + Output?.Invoke(new("PowerShell session state was lost: " + exception.Message + + Environment.NewLine + "Editor documents are preserved. Reopen this tab to start a new session; commands are not replayed." + + Environment.NewLine, OutputKind.Error)); + _ = TerminateChildAsync(); + } + private async void Observe(Task task) + { + try { await task.ConfigureAwait(false); } + catch (Exception exception) { - lock (sync) active = null; - debugger.SetDebugMode(debugMode); + if (!closing && State != SessionState.Failed) + Output?.Invoke(new(exception.Message + Environment.NewLine, OutputKind.Error)); } - if (IsRemote) Prompt = $"[{RemoteComputerName}]: " + Prompt; } - - private void SetState(SessionState state) + private async Task TerminateChildAsync() { - State = state; - StateChanged?.Invoke(state); + var process = child; + if (process is null) return; + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().ConfigureAwait(false); + } + catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception) { } } - - /// Stops execution and releases the runspace. Repeated and concurrent calls await the same disposal. public ValueTask DisposeAsync() { - lock (sync) return new ValueTask(disposalTask ??= DisposeCoreAsync()); + lock (sync) return new(disposal ??= DisposeCoreAsync()); } - private async Task DisposeCoreAsync() { - await StopAsync(); - await gate.WaitAsync(); + closing = true; try { - if (disposed) return; - disposed = true; - if (pushedRunspace is not null) PopRunspace(); - if (localRunspace.Debugger is { } debugger) debugger.DebuggerStop -= OnDebuggerStop; - await Task.Run(localRunspace.Dispose); - iseObjectModel = null; - debuggerWake.Dispose(); - SetState(SessionState.Disposed); + if (connection is not null && State != SessionState.Failed) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + try { await SendAsync("dispose", true, deadline.Token).ConfigureAwait(false); } + catch (Exception exception) when (exception is IOException or InvalidOperationException or OperationCanceledException) { } + } + } + finally + { + lifetime.Cancel(); + if (connection is not null) await connection.DisposeAsync().ConfigureAwait(false); + pipe?.Dispose(); + await TerminateChildAsync().ConfigureAwait(false); + if (outputDrain is not null) await outputDrain.ConfigureAwait(false); + if (errorDrain is not null) await errorDrain.ConfigureAwait(false); + if (exitObservation is not null) await exitObservation.ConfigureAwait(false); + child?.Dispose(); + lifetime.Dispose(); + ScriptingCallback = null; + status = status with { State = SessionState.Disposed, IsDebuggerPaused = false, IsNestedPromptActive = false }; + StateChanged?.Invoke(SessionState.Disposed); } - finally { gate.Release(); } } } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs index 443d802..45da5c8 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ReleaseUpdateChecker.cs @@ -7,7 +7,7 @@ namespace Iseberg.Core; public sealed record ReleaseUpdate(string Version, Uri ReleaseUri, Uri DownloadUri, bool HasPackage); -public sealed class ReleaseUpdateChecker(HttpClient client) +public sealed partial class ReleaseUpdateChecker(HttpClient client) { public const string RepositoryUrl = "https://github.com/adamdriscoll/iseberg"; public static Uri LatestReleaseApi { get; } = new("https://api.github.com/repos/adamdriscoll/iseberg/releases/latest"); @@ -22,7 +22,7 @@ public sealed class ReleaseUpdateChecker(HttpClient client) using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) return null; response.EnsureSuccessStatusCode(); - var release = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false) + var release = await response.Content.ReadFromJsonAsync(ReleaseJsonContext.Default.GitHubRelease, cancellationToken).ConfigureAwait(false) ?? throw new InvalidDataException("GitHub returned an empty release."); if (release.Draft || release.Prerelease) return null; if (release.Assets is null || release.Assets.Any(asset => asset is null)) @@ -67,4 +67,7 @@ private sealed class GitHubAsset [JsonPropertyName("name")] public string Name { get; init; } = ""; [JsonPropertyName("state")] public string State { get; init; } = ""; } + + [JsonSerializable(typeof(GitHubRelease))] + private partial class ReleaseJsonContext : JsonSerializerContext; } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs index bff4f96..7194e7d 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ScriptRecovery.cs @@ -29,7 +29,8 @@ public async Task SaveAsync(Guid id, ScriptFile file, string? sessionName = null if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; await using (var stream = new FileStream(temporary, options)) await JsonSerializer.SerializeAsync(stream, new RecoveredScript(id, file.Name, file.Path, file.Text, - released ? 0 : ownerProcessId, ownerStartedUtc, file.EncodingChoice, sessionName)); + released ? 0 : ownerProcessId, ownerStartedUtc, file.EncodingChoice, sessionName), + IseJsonContext.Default.RecoveredScript); File.Move(temporary, path, overwrite: true); } finally { File.Delete(temporary); } @@ -41,7 +42,7 @@ public async Task> ReadAsync() var scripts = new List(); foreach (var path in Directory.EnumerateFiles(directory, "*.json").Order()) { - var script = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)) + var script = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), IseJsonContext.Default.RecoveredScript) ?? throw new InvalidDataException($"Empty recovery file: {path}"); if (script.Id == Guid.Empty || string.IsNullOrWhiteSpace(script.Name) || script.Text is null) throw new InvalidDataException($"Invalid recovery file: {path}"); diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionEvents.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionRequests.cs similarity index 55% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/SessionEvents.cs rename to src/Devolutions.Terminal.Ise/Iseberg.Core/SessionRequests.cs index f79cedc..749767d 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionEvents.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/SessionRequests.cs @@ -1,18 +1,5 @@ -using System.Management.Automation; - namespace Iseberg.Core; -public enum OutputKind { Output, Error, Warning, Verbose, Debug, Information, Command } -public enum SessionState { Starting, Ready, Running, Debugging, NestedPrompt, Disposed } - -public sealed record OutputStyle(string? Foreground = null, string? Background = null, bool Bold = false, bool Underline = false, bool Inverse = false); -public sealed record OutputEntry(string Text, OutputKind Kind = OutputKind.Output, int CodeStart = 0, OutputStyle? Style = null); -public sealed record DebugLocation(string? ScriptPath, int Line, int Column, string Message); -public sealed record ProgressUpdate(string Activity, string Status, int Percent, bool Completed, - long SourceId = 0, int ActivityId = 0, int ParentActivityId = -1, int SecondsRemaining = -1, string? CurrentOperation = null); -public sealed record CompletionSet(int Start, int Length, IReadOnlyList Matches); -public sealed record CommandDescription(string Name, string Module, string Kind, string Definition); - public sealed class ShowCommandRequest { public CommandFormDescription? Command { get; init; } @@ -37,8 +24,6 @@ public sealed class InputRequest(string caption, string message, bool secret = f public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } -public sealed record PromptChoice(string Label, string Help); - public sealed class CommandErrorRequest(string message) { public string Message { get; } = message; diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs index e7426bc..31e7412 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/ThemeFile.cs @@ -31,7 +31,7 @@ public static async Task LoadAsync(string path) !document.RootElement.TryGetProperty("Theme", out var content) || content.ValueKind != JsonValueKind.Object || !content.TryGetProperty("Name", out _) || !content.TryGetProperty("Colors", out _)) throw new InvalidDataException("The theme file must include a named theme and its colors."); - var theme = JsonSerializer.Deserialize(json) + var theme = JsonSerializer.Deserialize(json, IseJsonContext.Default.ThemeFile) ?? throw new InvalidDataException("The theme file is empty."); theme.Validate(); return theme; @@ -43,7 +43,7 @@ public async Task SaveAsync(string path) var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { - await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(this, IseJsonContext.Default.ThemeFile)); File.Move(temporary, path, overwrite: true); } finally { File.Delete(temporary); } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs index 248a0dc..56e5d36 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/UserSettings.cs @@ -42,12 +42,14 @@ public static async Task LoadAsync(string? path = null) path ??= SettingsPath; if (!File.Exists(path)) return new(); var json = await File.ReadAllTextAsync(path); - var settings = JsonSerializer.Deserialize(json) ?? throw new InvalidDataException("The settings file is empty."); + var settings = JsonSerializer.Deserialize(json, IseJsonContext.Default.UserSettings) + ?? throw new InvalidDataException("The settings file is empty."); settings.Normalize(); return settings; } - public UserSettings Copy() => JsonSerializer.Deserialize(JsonSerializer.Serialize(this))!; + public UserSettings Copy() => JsonSerializer.Deserialize( + JsonSerializer.Serialize(this, IseJsonContext.Default.UserSettings), IseJsonContext.Default.UserSettings)!; public void Normalize() { @@ -82,7 +84,7 @@ public void Normalize() { if (breakpoint is null) throw new InvalidDataException("A saved breakpoint is empty."); try { breakpoint.Validate(); } - catch (Exception exception) when (exception is ArgumentException or System.Management.Automation.ParseException) + catch (ArgumentException exception) { throw new InvalidDataException("A saved breakpoint is invalid.", exception); } } } @@ -95,7 +97,7 @@ public async Task SaveAsync(string? path = null) var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { - await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(this, IseJsonContext.Default.UserSettings)); File.Move(temporary, path, overwrite: true); } finally { if (File.Exists(temporary)) File.Delete(temporary); } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs index d993e5f..15272d5 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchState.cs @@ -63,7 +63,7 @@ public sealed class WorkbenchStateStore(string? path = null) public async Task LoadAsync() { if (!File.Exists(Path)) return null; - var state = JsonSerializer.Deserialize(await File.ReadAllTextAsync(Path)) + var state = JsonSerializer.Deserialize(await File.ReadAllTextAsync(Path), IseJsonContext.Default.WorkbenchState) ?? throw new InvalidDataException("The saved workbench configuration is empty."); state.Validate(); return state.OwnerIsRunning() ? null : state; @@ -79,7 +79,7 @@ public async Task SaveAsync(WorkbenchState state) var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; await using (var stream = new FileStream(temporary, options)) - await JsonSerializer.SerializeAsync(stream, state, new JsonSerializerOptions { WriteIndented = true }); + await JsonSerializer.SerializeAsync(stream, state, IseJsonContext.Default.WorkbenchState); File.Move(temporary, Path, overwrite: true); } finally { File.Delete(temporary); } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/BridgeService.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/BridgeService.cs new file mode 100644 index 0000000..7648075 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/BridgeService.cs @@ -0,0 +1,409 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO.Pipes; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Iseberg.Core; + +namespace Iseberg.PowerShellHost; + +[Cmdlet(VerbsLifecycle.Start, "IsebergBridge")] +public sealed class StartIsebergBridgeCommand : PSCmdlet +{ + private readonly CancellationTokenSource cancellation = new(); + [Parameter(Mandatory = true)] public string PipeName { get; set; } = ""; + [Parameter(Mandatory = true)] public int ParentProcessId { get; set; } + [Parameter(Mandatory = true)] public long ParentStartTimeUtcTicks { get; set; } + + protected override void EndProcessing() + { + var authentication = Environment.GetEnvironmentVariable(IseBridgeProtocol.AuthenticationEnvironment); + Environment.SetEnvironmentVariable(IseBridgeProtocol.AuthenticationEnvironment, null); + if (authentication is null) throw new InvalidOperationException("The private DT bridge authentication is missing."); + using var service = new BridgeService(authentication, ParentProcessId, ParentStartTimeUtcTicks); + try { service.RunAsync(PipeName, cancellation.Token).GetAwaiter().GetResult(); } + finally { cancellation.Dispose(); } + } + protected override void StopProcessing() => cancellation.Cancel(); +} + +internal sealed class BridgeService(string authentication, int parentProcessId, long parentStartTimeUtcTicks) + : IIseScriptingBridge, IDisposable +{ + private readonly ConcurrentDictionary callbacks = new(StringComparer.Ordinal); + private readonly CancellationTokenSource lifetime = new(); + private BridgeConnection? connection; + private ManagedPowerShellSession? engine; + private IseObjectProxy? proxy; + private int authenticated; + private int initialized; + private int queuedNotifications; + private bool disposed; + + public async Task RunAsync(string endpoint, CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token, cancellationToken); + using var pipe = new NamedPipeClientStream(".", endpoint, PipeDirection.InOut, PipeOptions.Asynchronous); + _ = WatchParentAsync(linked.Token); + await pipe.ConnectAsync(15000, linked.Token).ConfigureAwait(false); + connection = new(pipe) { Request = DispatchAsync }; + connection.Disconnected += exception => + { + lifetime.Cancel(); + _ = ExitAfterDisconnectAsync(); + }; + connection.Start(); + try { await connection.Completion.WaitAsync(linked.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (linked.IsCancellationRequested) { } + finally + { + if (engine is not null) + { + try { await engine.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); } + catch (Exception) { } + } + await connection.DisposeAsync().ConfigureAwait(false); + } + } + + private async Task WatchParentAsync(CancellationToken cancellationToken) + { + try + { + using var parent = Process.GetProcessById(parentProcessId); + if (parent.StartTime.ToUniversalTime().Ticks != parentStartTimeUtcTicks) ExitOwnedChild(); + while (!cancellationToken.IsCancellationRequested) + { + if (parent.HasExited) ExitOwnedChild(); + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception) { ExitOwnedChild(); } + } + + private static async Task ExitAfterDisconnectAsync() + { + await Task.Delay(TimeSpan.FromSeconds(4)).ConfigureAwait(false); + ExitOwnedChild(); + } + private static void ExitOwnedChild() + { + using var process = Process.GetCurrentProcess(); + process.Kill(entireProcessTree: true); + } + + private async Task DispatchAsync(string operation, JsonElement payload, CancellationToken cancellationToken) + { + if (operation == "hello") + { + if (Interlocked.CompareExchange(ref authenticated, -1, 0) != 0) + throw new InvalidOperationException("The private bridge has already authenticated."); + var hello = BridgeJson.Read(payload); + if (hello.Protocol != IseBridgeProtocol.Version || hello.Build != IseBridgeProtocol.BuildIdentity || + hello.ParentProcessId != parentProcessId || hello.ParentStartTimeUtcTicks != parentStartTimeUtcTicks || + !CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(hello.Authentication), + Encoding.UTF8.GetBytes(IseBridgeProtocol.AuthenticationProof(authentication, hello.Challenge, "parent")))) + { + authenticated = 0; + throw new InvalidOperationException("DT bridge authentication or build identity mismatch."); + } + var version = PSVersionInfo.PSVersion; + if (!PowerShellCompatibility.IsSupportedRuntime(version, Environment.Version)) + throw new InvalidOperationException($"This DT bridge requires PowerShell {PowerShellCompatibility.SupportedVersions} with its bundled .NET runtime."); + Volatile.Write(ref authenticated, 1); + return BridgeJson.Element(new BridgeHelloResult(IseBridgeProtocol.Version, IseBridgeProtocol.BuildIdentity, + version.ToString(), Environment.ProcessId, IseBridgeProtocol.AuthenticationProof(authentication, hello.Challenge, "child"))); + } + if (Volatile.Read(ref authenticated) != 1) + throw new InvalidOperationException("Authenticate the private DT bridge before submitting operations."); + cancellationToken.ThrowIfCancellationRequested(); + if (operation == "initialize") + { + if (Interlocked.Exchange(ref initialized, 1) != 0) + throw new InvalidOperationException("The private session is already initialized."); + var initialize = BridgeJson.Read(payload); + engine = new(initialize.SnippetDirectory); + WireEvents(engine); + if (initialize.ScriptingEnabled) + { + proxy = new IseObjectProxy(this); + engine.ConfigureIseObjectModel(proxy); + } + await engine.InitializeAsync().ConfigureAwait(false); + return BridgeJson.Empty; + } + if (operation == "analyze") + { + var request = BridgeJson.Read(payload); + return BridgeJson.Element(PowerShellEditorAnalysis.Analyze(request.Text, request.DocumentPath)); + } + var session = engine ?? throw new InvalidOperationException("Initialize the private PowerShell session first."); + using var registration = cancellationToken.Register(() => + { + // Completion and discovery use their own cancellation lane; do not resume a paused script. + if (operation is "execute" or "menuAction" or "evaluate" or "evaluateNested" or "dispose") + { + try { _ = session.StopAsync(); } + catch (Exception) { } + } + }); + switch (operation) + { + case "execute": + var execute = BridgeJson.Read(payload); + await session.ExecuteAsync(execute.Script, execute.FilePath).ConfigureAwait(false); + break; + case "menuAction": + var handle = BridgeJson.Read(payload).Text; + if (!callbacks.TryGetValue(handle, out var callback)) + throw new InvalidOperationException("This Add-ons menu action has expired."); + await session.ExecuteMenuActionAsync(callback).ConfigureAwait(false); + break; + case "stop": await session.StopAsync().ConfigureAwait(false); break; + case "resume": + session.Resume((System.Management.Automation.DebuggerResumeAction)(long)BridgeJson.Read(payload).Action); + break; + case "breakAll": session.BreakAll(); break; + case "pauseForBreakpointEdit": await session.PauseForBreakpointEditAsync(cancellationToken).ConfigureAwait(false); break; + case "workingDirectory": + await session.SetWorkingDirectoryAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false); + break; + case "terminalSize": + var size = BridgeJson.Read(payload); + session.SetTerminalSize(size.Columns, size.Rows); + break; + case "evaluate": await session.EvaluateAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false); break; + case "evaluateNested": await session.EvaluateNestedAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false); break; + case "complete": + var completion = BridgeJson.Read(payload); + return BridgeJson.Element(await session.CompleteAsync(completion.Text, completion.Cursor, cancellationToken).ConfigureAwait(false)); + case "commands": return BridgeJson.Element((await session.GetCommandsAsync(cancellationToken).ConfigureAwait(false)).ToArray()); + case "help": return BridgeJson.Element(await session.GetHelpAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false)); + case "helpDocument": return BridgeJson.Element(await session.GetHelpDocumentAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false)); + case "helpUri": + return BridgeJson.Element(new HostResponse((await session.GetHelpUriAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false))?.AbsoluteUri)); + case "commandForm": + var command = BridgeJson.Read(payload); + return BridgeJson.Element(await session.GetCommandFormAsync(command.Name, command.Module, cancellationToken).ConfigureAwait(false)); + case "buildCommandForm": return BridgeJson.Element(BuildCommandForm(BridgeJson.Read(payload))); + case "validateBreakpoint": + ManagedPowerShellSession.ValidateBreakpoint(BridgeJson.Read(payload)); + break; + case "inspect": + var inspect = BridgeJson.Read(payload); + return BridgeJson.Element(await session.InspectAsync(inspect.Watches, inspect.FrameIndex).ConfigureAwait(false)); + case "valueChildren": + var children = BridgeJson.Read(payload); + return BridgeJson.Element(await session.GetValueChildrenAsync(children.Reference, children.Offset, children.Count).ConfigureAwait(false)); + case "breakpoints": return BridgeJson.Element((await session.GetBreakpointsAsync().ConfigureAwait(false)).ToArray()); + case "addBreakpoint": return BridgeJson.Element(await session.AddBreakpointAsync(BridgeJson.Read(payload)).ConfigureAwait(false)); + case "updateBreakpoint": + var update = BridgeJson.Read(payload); + return BridgeJson.Element(await session.UpdateBreakpointAsync(update.Id, update.Spec).ConfigureAwait(false)); + case "enableBreakpoint": + var enable = BridgeJson.Read(payload); + await session.SetBreakpointEnabledAsync(enable.Id, enable.Enabled).ConfigureAwait(false); + break; + case "removeBreakpoint": + await session.RemoveBreakpointAsync(BridgeJson.Read(payload).Id).ConfigureAwait(false); + break; + case "removeAllBreakpoints": await session.RemoveAllBreakpointsAsync().ConfigureAwait(false); break; + case "lineBreakpoints": + var lines = BridgeJson.Read(payload); + await session.SetLineBreakpointsAsync(lines.Path, lines.Specs).ConfigureAwait(false); + break; + case "connect": + await session.ConnectAsync(CreateConnection(BridgeJson.Read(payload)), cancellationToken).ConfigureAwait(false); + break; + case "exitRemoteSession": await session.ExitRemoteSessionAsync().ConfigureAwait(false); break; + case "openRemoteFile": + var open = BridgeJson.Read(payload); + var file = await session.OpenRemoteFileAsync(open.Path, open.Encoding).ConfigureAwait(false); + return BridgeJson.Element(new RemoteFileData(file.Path!, ScriptFile.Encode(file.Text, file.EncodingChoice), + file.RemoteRunspaceId!.Value, file.RemoteComputerName!)); + case "saveRemoteFile": return BridgeJson.Element(await session.SaveRemoteDataAsync(BridgeJson.Read(payload)).ConfigureAwait(false)); + case "remoteFileExists": return BridgeJson.Element(await session.RemoteFileExistsAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false)); + case "remoteFileVersion": + return BridgeJson.Element(new HostResponse(await session.ReadRemoteFileVersionAsync(BridgeJson.Read(payload).Text).ConfigureAwait(false))); + case "snippets": return BridgeJson.Element(await session.Snippets.LoadAsync().ConfigureAwait(false)); + case "createSnippet": + var snippet = BridgeJson.Read(payload); + session.Snippets.Create(snippet.Title, snippet.Description, snippet.Text, snippet.Author, snippet.CaretOffset, snippet.Force); + break; + case "importSnippet": + var import = BridgeJson.Read(payload); + session.Snippets.Import(import.Path, import.Recurse); + break; + case "scriptingNotification": + var notification = BridgeJson.Read(payload); + if (proxy is IIseObjectProxyNotifications notifications) + notifications.Notify(notification.ObjectId, notification.PropertyName); + break; + case "dispose": + callbacks.Clear(); + await session.DisposeAsync().ConfigureAwait(false); + break; + default: throw new InvalidOperationException($"Unsupported private PowerShell operation '{operation}'."); + } + cancellationToken.ThrowIfCancellationRequested(); + return BridgeJson.Empty; + } + + private void WireEvents(ManagedPowerShellSession session) + { + session.Output += entry => + { + for (var offset = 0; offset < entry.Text.Length; offset += IseBridgeProtocol.MaximumOutputCharacters) + { + var length = Math.Min(entry.Text.Length - offset, IseBridgeProtocol.MaximumOutputCharacters); + Publish("output", entry with { Text = entry.Text.Substring(offset, length), CodeStart = Math.Clamp(entry.CodeStart - offset, 0, length) }); + } + }; + session.StateChanged += _ => Publish("status", session.Status()); + session.RunspaceChanged += () => Publish("runspace", session.Status()); + session.ProgressChanged += progress => Publish("progress", progress); + session.ConsoleCleared += () => Publish("clear", true); + session.DebuggerStopped += location => Publish("debugger", location); + session.InputRequested += request => _ = CompleteInputAsync(request); + session.ShowCommandRequested += request => _ = CompleteShowCommandAsync(request); + session.CommandErrorRequested += request => _ = CompleteCommandErrorAsync(request); + } + private void Publish(string operation, T value) => connection!.Publish(operation, BridgeJson.Element(value)); + + private async Task CompleteInputAsync(InputRequest request) + { + try + { + var payload = new HostInput(request.Caption, request.Message, request.Secret, request.Choices, + request.DefaultChoices, request.MultipleChoice); + var response = await ReverseAsync("input", payload, request.Response.Task).ConfigureAwait(false); + request.Response.TrySetResult(response.Text ?? ""); + } + catch (Exception exception) { request.Response.TrySetException(exception); } + } + private async Task CompleteShowCommandAsync(ShowCommandRequest request) + { + try + { + var payload = new HostShowCommand(request.Command, request.Commands, request.HelpText, request.HelpDocument, + request.HelpUri, request.PassThru, request.Width, request.Height); + var response = await ReverseAsync("showCommand", payload, request.Response.Task).ConfigureAwait(false); + request.Response.TrySetResult(response.Text); + } + catch (Exception exception) { request.Response.TrySetException(exception); } + } + private async Task CompleteCommandErrorAsync(CommandErrorRequest request) + { + try + { + _ = await ReverseAsync("commandError", new(request.Message), request.Response.Task).ConfigureAwait(false); + request.Response.TrySetResult(); + } + catch (Exception exception) { request.Response.TrySetException(exception); } + } + private async Task ReverseAsync(string operation, TRequest request, Task responseTask) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); + _ = responseTask.ContinueWith(task => + { + if (task.IsCanceled) + { + try { cancellation.Cancel(); } + catch (ObjectDisposedException) { } + } + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + return BridgeJson.Read(await connection!.CallAsync(operation, BridgeJson.Element(request), cancellation.Token).ConfigureAwait(false)); + } + public JsonElement Invoke(string operation, JsonElement arguments) + { + if (operation != "ise" && !operation.StartsWith("ise.", StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid portable ISE operation name."); + return connection!.CallAsync(operation, arguments, lifetime.Token).GetAwaiter().GetResult(); + } + public string RegisterCallback(ScriptBlock action) + { + ArgumentNullException.ThrowIfNull(action); + if (callbacks.Count >= IseBridgeProtocol.MaximumPendingRequests * 8) + throw new InvalidOperationException("Too many portable ISE menu callbacks."); + var handle = Guid.NewGuid().ToString("N"); + callbacks[handle] = action; + return handle; + } + public void RemoveCallback(string handle) => callbacks.TryRemove(handle, out _); + public void ScheduleNotification(Action notification) + { + if (Interlocked.Increment(ref queuedNotifications) > IseBridgeProtocol.MaximumPendingRequests) + { + Interlocked.Decrement(ref queuedNotifications); + throw new InvalidOperationException("The portable ISE notification queue is full."); + } + _ = DeliverNotificationAsync(notification); + } + private async Task DeliverNotificationAsync(Action notification) + { + try + { + await (engine ?? throw new InvalidOperationException("The scripting session is not initialized.")) + .DispatchNotificationAsync(notification).ConfigureAwait(false); + } + catch (Exception exception) + { + if (!lifetime.IsCancellationRequested) + Publish("output", new OutputEntry("Portable ISE notification failed: " + exception.Message + Environment.NewLine, OutputKind.Error)); + } + finally { Interlocked.Decrement(ref queuedNotifications); } + } + + private static CommandFormResult BuildCommandForm(CommandFormBuildRequest request) + { + var form = new CommandForm(request.Description); + form.SelectSet(request.ParameterSet); + var validation = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in request.Values) + { + var value = form.Value(entry.Key); + value.Included = entry.Value.Included; + value.Text = entry.Value.Text; + value.IsExpression = entry.Value.IsExpression; + value.Boolean = entry.Value.Boolean; + if (value.Included && value.IsExpression) + { + System.Management.Automation.Language.Parser.ParseInput("(" + value.Text + "\n)", out _, out var errors); + validation[entry.Key] = !string.IsNullOrWhiteSpace(value.Text) && errors.Length == 0; + } + } + return form.Build(validation); + } + + private static RunspaceConnectionInfo CreateConnection(RemoteConnectionInfo request) + { + if (request.Kind == RemoteConnectionKind.Ssh) + return new SSHConnectionInfo(request.UserName ?? "", request.ComputerName, request.KeyFilePath, + request.Port == 0 ? 22 : request.Port, request.Subsystem ?? "powershell"); + if (request.Kind != RemoteConnectionKind.WSMan) + throw new ArgumentException("Unsupported remote connection kind."); + PSCredential? credential = null; + if (request.UserName is { Length: > 0 } user) + { + var password = new SecureString(); + foreach (var character in request.Password ?? "") password.AppendChar(character); + password.MakeReadOnly(); + credential = new(user, password); + } + return new WSManConnectionInfo(new Uri(request.ConnectionUri ?? $"http://{request.ComputerName}:{(request.Port == 0 ? 5985 : request.Port)}/wsman"), + request.ConfigurationName ?? "http://schemas.microsoft.com/powershell/Microsoft.PowerShell", credential); + } + public void Dispose() + { + if (disposed) return; + disposed = true; + lifetime.Cancel(); + callbacks.Clear(); + lifetime.Dispose(); + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/CommandMetadata.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/CommandMetadata.cs new file mode 100644 index 0000000..016e981 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/CommandMetadata.cs @@ -0,0 +1,43 @@ +using System.Management.Automation; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; +internal static class CommandMetadata +{ + internal static CommandFormDescription Describe(CommandInfo command) + { + var resolved = command; + while (resolved is AliasInfo alias) + resolved = alias.ResolvedCommand ?? throw new InvalidOperationException($"Cannot resolve alias '{command.Name}'."); + var sets = resolved.ParameterSets.Select(set => new CommandParameterSetDescription(set.Name, set.IsDefault, + set.Parameters.OrderByDescending(p => p.IsMandatory).ThenBy(p => p.Position < 0 ? int.MaxValue : p.Position) + .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase).Select(DescribeParameter).ToArray())).ToArray(); + return new(command.Name, string.IsNullOrEmpty(command.ModuleName) ? command.Name : command.ModuleName + "\\" + command.Name, + sets.Length == 0 ? [new("__AllParameterSets", true, [])] : sets); + } + + private static CommandParameterDescription DescribeParameter(CommandParameterInfo parameter) + { + var type = parameter.ParameterType; + var element = type.IsArray ? type.GetElementType()! : type; + element = Nullable.GetUnderlyingType(element) ?? element; + var validation = parameter.Attributes.OfType().FirstOrDefault(); + var choices = validation?.ValidValues.ToArray() + ?? (element.IsEnum ? Enum.GetNames(element) : type.IsArray && element == typeof(bool) ? ["True", "False"] : []); + var kind = !type.IsArray && element == typeof(SwitchParameter) ? CommandParameterKind.Switch + : element == typeof(bool) ? CommandParameterKind.Boolean + : choices.Length > 0 ? CommandParameterKind.Choice : CommandParameterKind.Text; + return new(parameter.Name, type.Name, kind, parameter.IsMandatory, + parameter.Position < 0 ? null : parameter.Position, type.IsArray, CommonParameters.Contains(parameter.Name), + parameter.Attributes.OfType().Any(), + parameter.ValueFromPipeline || parameter.ValueFromPipelineByPropertyName, + parameter.Attributes.OfType().FirstOrDefault(a => !string.IsNullOrEmpty(a.HelpMessage))?.HelpMessage ?? "", + parameter.Aliases.ToArray(), choices, validation?.IgnoreCase ?? true); + } + + private static readonly HashSet CommonParameters = new(StringComparer.OrdinalIgnoreCase) + { + "Verbose", "Debug", "ErrorAction", "WarningAction", "InformationAction", "ProgressAction", + "ErrorVariable", "WarningVariable", "InformationVariable", "OutVariable", "OutBuffer", "PipelineVariable", + "WhatIf", "Confirm" + }; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandHelp.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/HelpMetadata.cs similarity index 81% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/CommandHelp.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/HelpMetadata.cs index 98f6546..ad2d85d 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/CommandHelp.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/HelpMetadata.cs @@ -1,35 +1,10 @@ using System.Collections; -using System.Management.Automation; using System.Text; - -namespace Iseberg.Core; - -public enum HelpSectionKind { Synopsis, Syntax, Description, Parameters, Inputs, Outputs, Notes, Examples, RelatedLinks, Remarks } - -public sealed class HelpViewSettings -{ - public List Sections { get; set; } = [.. Enum.GetValues()]; - public bool MatchCase { get; set; } - public bool WholeWord { get; set; } - public double Zoom { get; set; } = 100; - - public HelpViewSettings Copy() => new() { Sections = [.. Sections], MatchCase = MatchCase, WholeWord = WholeWord, Zoom = Zoom }; - - public void Normalize() - { - Sections ??= [.. Enum.GetValues()]; - if (Sections.Any(section => !Enum.IsDefined(section))) throw new InvalidDataException("An unknown help section was saved."); - Sections = Sections.Distinct().ToList(); - Zoom = double.IsFinite(Zoom) ? Math.Clamp(Zoom, 20, 400) : 100; - } -} - -public sealed record CommandHelpSection(HelpSectionKind Kind, string Text); - -public sealed record CommandHelpDocument(string Name, IReadOnlyList Sections) +using System.Management.Automation; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; +internal static class HelpMetadata { - public string ToText() => string.Join("\n\n", Sections.Select(section => section.Kind + "\n" + section.Text)); - public static CommandHelpDocument FromHelp(string name, IEnumerable help) { var sections = new Dictionary>(); diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/IseObjectProxy.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/IseObjectProxy.cs new file mode 100644 index 0000000..ad2cee1 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/IseObjectProxy.cs @@ -0,0 +1,369 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Management.Automation; +using System.Text; +using System.Text.Json; +using Iseberg.Core; + +namespace Iseberg.PowerShellHost; + +/// Portable live ISE API. Only explicit UI operations cross the private bridge. +public sealed class IseObjectProxy : INotifyPropertyChanged, IIseObjectProxyNotifications +{ + private readonly IIseScriptingBridge bridge; + private readonly ConcurrentDictionary objects = new(StringComparer.Ordinal); + private string? rootId; + public IseObjectProxy(IIseScriptingBridge bridge) => this.bridge = bridge; + public event PropertyChangedEventHandler? PropertyChanged; + public IsePowerShellTab CurrentPowerShellTab => Object(Get(null, nameof(CurrentPowerShellTab))); + public IseFile? CurrentFile => NullableObject(Get(null, nameof(CurrentFile))); + public IseEditor? CurrentEditor => NullableObject(Get(null, nameof(CurrentEditor))); + public IsePowerShellTabCollection PowerShellTabs => Object(Get(null, nameof(PowerShellTabs))); + public IseOptions Options => Object(Get(null, nameof(Options))); + public object VisibleHorizontalAddOnTools => Get(null, nameof(VisibleHorizontalAddOnTools)); + public object VisibleVerticalAddOnTools => Get(null, nameof(VisibleVerticalAddOnTools)); + + public void Notify(string objectId, string propertyName) => bridge.ScheduleNotification(() => + { + if (objects.TryGetValue(objectId, out var proxy)) proxy.Notify(propertyName); + if (objectId == rootId) + PropertyChanged?.Invoke(this, new(propertyName)); + }); + + internal JsonElement Get(string? id, string member) + { + rootId ??= Request(null, "get", "Identity", []).GetProperty("id").GetString(); + return Request(id, "get", member, []); + } + internal JsonElement Call(string id, string member, params object?[] arguments) => Request(id, "call", member, arguments); + internal void Set(string id, string member, object? value) => Request(id, "set", member, [value]); + private JsonElement Request(string? id, string action, string member, object?[] arguments) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("id", id); + writer.WriteString("action", action); + writer.WriteString("member", member); + writer.WriteStartArray("arguments"); + foreach (var argument in arguments) + { + switch (argument) + { + case null: writer.WriteNullValue(); break; + case string value: writer.WriteStringValue(value); break; + case int value: writer.WriteNumberValue(value); break; + case double value: writer.WriteNumberValue(value); break; + case bool value: writer.WriteBooleanValue(value); break; + case ScriptEncoding value: + writer.WriteStartObject(); + writer.WriteNumber("codePage", value.CodePage); + writer.WriteBoolean("emitBom", value.EmitBom); + writer.WriteEndObject(); + break; + case IseProxy proxy: + if (proxy.Owner != this) throw new ArgumentException("This ISE object belongs to another workbench."); + writer.WriteStringValue(proxy.Id); + break; + default: throw new ArgumentException("This argument has no portable ISE representation."); + } + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + using var document = JsonDocument.Parse(stream.ToArray()); + return bridge.Invoke("ise", document.RootElement); + } + internal T Object(JsonElement value) where T : IseProxy + { + var id = value.GetProperty("id").GetString()!; + var type = value.GetProperty("type").GetString(); + return (T)objects.GetOrAdd(id, _ => type switch + { + "options" => new IseOptions(this, id), + "tab" => new IsePowerShellTab(this, id), + "tabs" => new IsePowerShellTabCollection(this, id), + "file" => new IseFile(this, id), + "files" => new IseFileCollection(this, id), + "editor" => new IseEditor(this, id), + "menu" => new IseMenuItem(this, id), + "menus" => new IseMenuItemCollection(this, id), + "snippet" => new IseSnippet(this, id), + "snippets" => new IseSnippetCollection(this, id), + "tools" => new IseUnsupportedTools(this, id), + _ => throw new InvalidDataException("The workbench returned an unknown ISE object type.") + }); + } + internal T? NullableObject(JsonElement value) where T : IseProxy => + value.ValueKind == JsonValueKind.Null ? null : Object(value); + internal string Register(ScriptBlock action) => bridge.RegisterCallback(action); + internal void Unregister(string handle) => bridge.RemoveCallback(handle); +} + +public abstract class IseProxy : INotifyPropertyChanged +{ + internal IseObjectProxy Owner { get; } + internal string Id { get; } + protected IseProxy(IseObjectProxy owner, string id) { Owner = owner; Id = id; } + public event PropertyChangedEventHandler? PropertyChanged; + internal virtual void Notify(string property) => PropertyChanged?.Invoke(this, new(property)); + protected JsonElement Get(string member) => Owner.Get(Id, member); + protected string ReadText(string member) => Get(member).GetString()!; + protected bool Boolean(string member) => Get(member).GetBoolean(); + protected int Integer(string member) => Get(member).GetInt32(); + protected double Number(string member) => Get(member).GetDouble(); + protected void Set(string member, object? value) => Owner.Set(Id, member, value); + protected JsonElement Call(string member, params object?[] arguments) => Owner.Call(Id, member, arguments); + protected T Object(string member) where T : IseProxy => Owner.Object(Get(member)); + protected T? NullableObject(string member) where T : IseProxy => Owner.NullableObject(Get(member)); +} + +public abstract class IseCollection(IseObjectProxy owner, string id) : IseProxy(owner, id), + IReadOnlyList, INotifyCollectionChanged where T : IseProxy +{ + private T[] Snapshot() => Get("Items").EnumerateArray().Select(Owner.Object).ToArray(); + public int Count => Snapshot().Length; + public T this[int index] => Snapshot()[index]; + public IEnumerator GetEnumerator() => ((IEnumerable)Snapshot()).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public event NotifyCollectionChangedEventHandler? CollectionChanged; + internal override void Notify(string property) + { + base.Notify(property); + if (property != "Item[]") return; + base.Notify(nameof(Count)); + CollectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); + } +} + +public sealed class IsePowerShellTabCollection(IseObjectProxy owner, string id) : IseCollection(owner, id) +{ + public IsePowerShellTab Add() => Owner.Object(Call(nameof(Add))); + public void Remove(IsePowerShellTab tab) => Call(nameof(Remove), tab); + public void SetSelectedPowerShellTab(IsePowerShellTab tab) => Call(nameof(SetSelectedPowerShellTab), tab); +} + +public sealed class IsePowerShellTab(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public string DisplayName { get => ReadText(nameof(DisplayName)); set => Set(nameof(DisplayName), value); } + public string Prompt => ReadText(nameof(Prompt)); + public bool CanInvoke => Boolean(nameof(CanInvoke)); + public string StatusText => ReadText(nameof(StatusText)); + public bool ExpandedScript { get => Boolean(nameof(ExpandedScript)); set => Set(nameof(ExpandedScript), value); } + public bool ShowCommands { get => Boolean(nameof(ShowCommands)); set => Set(nameof(ShowCommands), value); } + public double Zoom { get => Number(nameof(Zoom)); set => Set(nameof(Zoom), value); } + public bool HorizontalAddOnToolsPaneOpened => Boolean(nameof(HorizontalAddOnToolsPaneOpened)); + public bool VerticalAddOnToolsPaneOpened => Boolean(nameof(VerticalAddOnToolsPaneOpened)); + public object ConsolePane => Get(nameof(ConsolePane)); + public IseFile? SelectedFile => NullableObject(nameof(SelectedFile)); + public IseFileCollection Files => Object(nameof(Files)); + public IseMenuItem AddOnsMenu => Object(nameof(AddOnsMenu)); + public IseSnippetCollection Snippets => Object(nameof(Snippets)); + public IseUnsupportedTools HorizontalAddOnTools => Object(nameof(HorizontalAddOnTools)); + public IseUnsupportedTools VerticalAddOnTools => Object(nameof(VerticalAddOnTools)); + public void Invoke(string script) => Call(nameof(Invoke), script); + public object InvokeSynchronous(string script) => Call(nameof(InvokeSynchronous), script); + public object InvokeSynchronous(string script, bool useNewScope) => Call(nameof(InvokeSynchronous), script, useNewScope); + public object InvokeSynchronous(string script, bool useNewScope, int millisecondsTimeout) => + Call(nameof(InvokeSynchronous), script, useNewScope, millisecondsTimeout); +} + +public sealed class IseFileCollection(IseObjectProxy owner, string id) : IseCollection(owner, id) +{ + public IseFile? SelectedFile => NullableObject(nameof(SelectedFile)); + public IseFile Add() => Owner.Object(Call(nameof(Add))); + public IseFile Add(string fullPath) => Owner.Object(Call(nameof(Add), fullPath)); + public void SetSelectedFile(IseFile file) => Call(nameof(SetSelectedFile), file); + public void Remove(IseFile file) => Call(nameof(Remove), file); + public void Remove(IseFile file, bool force) => Call(nameof(Remove), file, force); +} + +public sealed class IseFile(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public string DisplayName => ReadText(nameof(DisplayName)); + public string? FullPath => Get(nameof(FullPath)).GetString(); + public bool IsUntitled => Boolean(nameof(IsUntitled)); + public bool IsSaved => Boolean(nameof(IsSaved)); + public Encoding Encoding + { + get + { + var value = Get(nameof(Encoding)); + return new ScriptEncoding(value.GetProperty("codePage").GetInt32(), value.GetProperty("emitBom").GetBoolean()).CreateEncoding(); + } + } + public IseEditor Editor => Object(nameof(Editor)); + public void Save() => Call(nameof(Save)); + public void Save(Encoding encoding) => Call(nameof(Save), new ScriptEncoding(encoding.CodePage, encoding.GetPreamble().Length > 0)); + public void SaveAs(string fullPath) => Call(nameof(SaveAs), fullPath); + public void SaveAs(string fullPath, Encoding encoding) => Call(nameof(SaveAs), fullPath, new ScriptEncoding(encoding.CodePage, encoding.GetPreamble().Length > 0)); +} + +public sealed class IseEditor(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public string Text { get => ReadText(nameof(Text)); set => Set(nameof(Text), value); } + public int LineCount => Integer(nameof(LineCount)); + public bool CanGoToMatch => Boolean(nameof(CanGoToMatch)); + public int CaretLine => Integer(nameof(CaretLine)); + public int CaretColumn => Integer(nameof(CaretColumn)); + public string CaretLineText => ReadText(nameof(CaretLineText)); + public string SelectedText => ReadText(nameof(SelectedText)); + public int GetLineLength(int lineNumber) => Call(nameof(GetLineLength), lineNumber).GetInt32(); + public void GoToMatch() => Call(nameof(GoToMatch)); + public void ToggleOutliningExpansion() => Call(nameof(ToggleOutliningExpansion)); + public void SetCaretPosition(int lineNumber, int columnNumber) => Call(nameof(SetCaretPosition), lineNumber, columnNumber); + public void Select(int startLine, int startColumn, int endLine, int endColumn) => Call(nameof(Select), startLine, startColumn, endLine, endColumn); + public void SelectCaretLine() => Call(nameof(SelectCaretLine)); + public void InsertText(string text) => Call(nameof(InsertText), text); + public void Clear() => Call(nameof(Clear)); + public void Focus() => Call(nameof(Focus)); + public void EnsureVisible(int lineNumber) => Call(nameof(EnsureVisible), lineNumber); +} + +public sealed class IseMenuItem(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public string DisplayName => ReadText(nameof(DisplayName)); + public IseMenuItemCollection Submenus => Object(nameof(Submenus)); + internal string? Callback { get; set; } + internal void Release() + { + foreach (var child in Submenus) child.Release(); + if (Callback is not { } handle) return; + Owner.Unregister(handle); + Callback = null; + } +} + +public sealed class IseMenuItemCollection(IseObjectProxy owner, string id) : IseCollection(owner, id) +{ + public IseMenuItem Add(string displayName, ScriptBlock? action, string? shortcut) + { + var handle = action is null ? null : Owner.Register(action); + try + { + var item = Owner.Object(Call(nameof(Add), displayName, handle, shortcut)); + item.Callback = handle; + return item; + } + catch + { + if (handle is not null) Owner.Unregister(handle); + throw; + } + } + public void Clear() + { + var children = this.SelectMany(Descendants).ToArray(); + Call(nameof(Clear)); + foreach (var item in children) + if (item.Callback is { } handle) { Owner.Unregister(handle); item.Callback = null; } + } + public bool Remove(IseMenuItem item) + { + var callbacks = Descendants(item).ToArray(); + var removed = Call(nameof(Remove), item).GetBoolean(); + if (removed) + foreach (var descendant in callbacks) + if (descendant.Callback is { } handle) { Owner.Unregister(handle); descendant.Callback = null; } + return removed; + } + private static IEnumerable Descendants(IseMenuItem item) + { + yield return item; + foreach (var child in item.Submenus) + foreach (var descendant in Descendants(child)) yield return descendant; + } +} + +public sealed class IseSnippetCollection(IseObjectProxy owner, string id) : IseCollection(owner, id) +{ + public void Load(string fullPath) => Call(nameof(Load), fullPath); +} + +public sealed class IseSnippet(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public string Title => ReadText(nameof(Title)); + public string DisplayTitle => ReadText(nameof(DisplayTitle)); + public string Description => ReadText(nameof(Description)); + public string Author => ReadText(nameof(Author)); + public string CodeFragment => ReadText(nameof(CodeFragment)); + public string Code => ReadText(nameof(Code)); + public string Text => ReadText(nameof(Text)); + public int CaretOffset => Integer(nameof(CaretOffset)); + public bool Indent => Boolean(nameof(Indent)); + public bool IsBuiltIn => Boolean(nameof(IsBuiltIn)); + public bool IsDefault => Boolean(nameof(IsDefault)); + public string Compatibility => ReadText(nameof(Compatibility)); + public string? FullPath => Get(nameof(FullPath)).GetString(); + public string? SchemaVersion => Get(nameof(SchemaVersion)).GetString(); + public bool? IsTabSpecific => Get(nameof(IsTabSpecific)) is { ValueKind: not JsonValueKind.Null } value ? value.GetBoolean() : null; + public (string Text, int Caret) Expand(string indentation, string newLine) + { + var result = Call(nameof(Expand), indentation, newLine); + return (result[0].GetString()!, result[1].GetInt32()); + } + public override string ToString() => Title; +} + +public sealed class IseUnsupportedTools(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public object Add(string name, Type controlType) => throw Unsupported(); + public object Add(string name, Type controlType, bool isVisible) => throw Unsupported(); + public void Clear() => throw Unsupported(); + private static PSNotSupportedException Unsupported() => new("WPF ISE add-on tools are not supported by Iseberg's Avalonia host. Use script-based AddOnsMenu actions."); +} + +public sealed class IseOptions(IseObjectProxy owner, string id) : IseProxy(owner, id) +{ + public string FontName { get => ReadText(nameof(FontName)); set => Set(nameof(FontName), value); } + public double FontSize { get => Number(nameof(FontSize)); set => Set(nameof(FontSize), value); } + public double Zoom { get => Number(nameof(Zoom)); set => Set(nameof(Zoom), value); } + public string SelectedScriptPaneState { get => ReadText(nameof(SelectedScriptPaneState)); set => Set(nameof(SelectedScriptPaneState), value); } + public bool ShowLineNumbers { get => Boolean(nameof(ShowLineNumbers)); set => Set(nameof(ShowLineNumbers), value); } + public bool ShowOutlining { get => Boolean(nameof(ShowOutlining)); set => Set(nameof(ShowOutlining), value); } + public bool WordWrap { get => Boolean(nameof(WordWrap)); set => Set(nameof(WordWrap), value); } + public bool ShowToolBar { get => Boolean(nameof(ShowToolBar)); set => Set(nameof(ShowToolBar), value); } + public bool ShowWarningBeforeSavingOnRun { get => Boolean(nameof(ShowWarningBeforeSavingOnRun)); set => Set(nameof(ShowWarningBeforeSavingOnRun), value); } + public bool ShowWarningForDuplicateFiles { get => Boolean(nameof(ShowWarningForDuplicateFiles)); set => Set(nameof(ShowWarningForDuplicateFiles), value); } + public bool ShowDefaultSnippets { get => Boolean(nameof(ShowDefaultSnippets)); set => Set(nameof(ShowDefaultSnippets), value); } + public bool ShowIntellisenseInScriptPane { get => Boolean(nameof(ShowIntellisenseInScriptPane)); set => Set(nameof(ShowIntellisenseInScriptPane), value); } + public bool ShowIntellisenseInConsolePane { get => Boolean(nameof(ShowIntellisenseInConsolePane)); set => Set(nameof(ShowIntellisenseInConsolePane), value); } + public bool UseEnterToSelectInScriptPaneIntellisense { get => Boolean(nameof(UseEnterToSelectInScriptPaneIntellisense)); set => Set(nameof(UseEnterToSelectInScriptPaneIntellisense), value); } + public bool UseEnterToSelectInConsolePaneIntellisense { get => Boolean(nameof(UseEnterToSelectInConsolePaneIntellisense)); set => Set(nameof(UseEnterToSelectInConsolePaneIntellisense), value); } + public int IntellisenseTimeoutInSeconds { get => Integer(nameof(IntellisenseTimeoutInSeconds)); set => Set(nameof(IntellisenseTimeoutInSeconds), value); } + public int AutoSaveMinuteInterval { get => Integer(nameof(AutoSaveMinuteInterval)); set => Set(nameof(AutoSaveMinuteInterval), value); } + public int MruCount { get => Integer(nameof(MruCount)); set => Set(nameof(MruCount), value); } + public bool UseLocalHelp { get => Boolean(nameof(UseLocalHelp)); set => Set(nameof(UseLocalHelp), value); } + public bool FixedWidthFontsOnly { get => Boolean(nameof(FixedWidthFontsOnly)); set => Set(nameof(FixedWidthFontsOnly), value); } + public bool LoadProfiles { get => Boolean(nameof(LoadProfiles)); set => Set(nameof(LoadProfiles), value); } + public IseThemeSnapshot Theme + { + get + { + var value = Get(nameof(Theme)); + return new() + { + Name = value.GetProperty("Name").GetString()!, + Colors = value.GetProperty("Colors").EnumerateObject().ToDictionary(color => color.Name, color => color.Value.GetString()!) + }; + } + set => Set(nameof(Theme), null); + } + public object TokenColors { get => Get(nameof(TokenColors)); set => Set(nameof(TokenColors), null); } + public object ConsoleTokenColors { get => Get(nameof(ConsoleTokenColors)); set => Set(nameof(ConsoleTokenColors), null); } + public object XmlTokenColors { get => Get(nameof(XmlTokenColors)); set => Set(nameof(XmlTokenColors), null); } + public void RestoreDefaults() => Call(nameof(RestoreDefaults)); + public void RestoreDefaultTokenColors() => Call(nameof(RestoreDefaultTokenColors)); + public void RestoreDefaultConsoleTokenColors() => Call(nameof(RestoreDefaultConsoleTokenColors)); + public void RestoreDefaultXmlTokenColors() => Call(nameof(RestoreDefaultXmlTokenColors)); +} + +public sealed class IseThemeSnapshot +{ + public string Name { get; set; } = ""; + public Dictionary Colors { get; set; } = []; + public IseThemeSnapshot Copy() => new() { Name = Name, Colors = new(Colors) }; +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetCommands.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/IseSnippetCommands.cs similarity index 98% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetCommands.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/IseSnippetCommands.cs index 69b5998..92732f7 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/IseSnippetCommands.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/IseSnippetCommands.cs @@ -1,6 +1,7 @@ using System.Management.Automation; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; public abstract class IseSnippetCommand : PSCmdlet { diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/Iseberg.PowerShell.csproj b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/Iseberg.PowerShell.csproj new file mode 100644 index 0000000..2e62557 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/Iseberg.PowerShell.csproj @@ -0,0 +1,22 @@ + + + net8.0 + Devolutions.Iseberg.PowerShell + false + false + false + false + false + false + false + false + + + + + + + + + + diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs new file mode 100644 index 0000000..ff5b7ca --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Bridge.cs @@ -0,0 +1,60 @@ +using System.Management.Automation; +using Iseberg.Core; + +namespace Iseberg.PowerShellHost; + +public sealed partial class ManagedPowerShellSession +{ + private static Iseberg.Core.CompletionResult ToCompletion(System.Management.Automation.CompletionResult result) => + new(result.CompletionText, result.ListItemText, (Iseberg.Core.CompletionResultType)(long)result.ResultType, result.ToolTip); + + internal SessionStatus Status() => new(State, Prompt, Version, LocalRunspaceId, RunspaceId, + IsRunspacePushed, IsRemote, RemoteComputerName, IsDebuggerPaused, IsNestedPromptActive, NestedPrompt); + + internal async Task SaveRemoteDataAsync(RemoteSaveRequest request) + { + if (!IsRemote || request.RunspaceId != RunspaceId) + throw new InvalidOperationException("The remote connection changed. Reopen the document before saving."); + ScriptFile file; + if (request.OriginalPath is { } original) + file = ScriptFile.FromRemoteBytes(original, request.Bytes, request.RunspaceId, RemoteComputerName!); + else + { + var decoded = ScriptFile.FromBytes(Path.GetFullPath("bridge-unsaved.ps1"), request.Bytes); + file = ScriptFile.CreateUntitled("Untitled.ps1", decoded.EncodingChoice); + file.Text = decoded.Text; + } + await SaveRemoteFileCoreAsync(file, request.Path, request.ExpectedVersion, request.UseSavedVersion); + return new(file.Path!, RunspaceId, RemoteComputerName!); + } + + internal static void ValidateBreakpoint(BreakpointSpec spec) + { + spec.Validate(); + if (!string.IsNullOrWhiteSpace(spec.Condition)) _ = ScriptBlock.Create($"if ({spec.Condition}) {{ break }}"); + if (!string.IsNullOrWhiteSpace(spec.Action)) _ = ScriptBlock.Create(spec.Action); + } + + internal Task DispatchNotificationAsync(Action notification) => + State == Iseberg.Core.SessionState.Debugging ? PausedQueryAsync(() => + { + notification(); + return true; + }) : State == Iseberg.Core.SessionState.NestedPrompt ? NestedQueryAsync(shell => + { + shell.AddCommand("Invoke-IsebergNotification").AddParameter("Callback", notification).Invoke(); + return true; + }) : QueryAsync(shell => + { + shell.AddCommand("Invoke-IsebergNotification").AddParameter("Callback", notification).Invoke(); + ThrowQueryErrors(shell); + return true; + }, waitForGate: true); +} + +[Cmdlet(VerbsLifecycle.Invoke, "IsebergNotification")] +public sealed class InvokeIsebergNotificationCommand : PSCmdlet +{ + [Parameter(Mandatory = true)] public Action Callback { get; set; } = null!; + protected override void EndProcessing() => Callback(); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Debugger.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs similarity index 94% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Debugger.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs index d8aeda3..bfc221f 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Debugger.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Debugger.cs @@ -1,9 +1,12 @@ +using DebuggerResumeAction = System.Management.Automation.DebuggerResumeAction; +using SessionState = Iseberg.Core.SessionState; using System.Collections; using System.Management.Automation; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; -public sealed partial class PowerShellSession +public sealed partial class ManagedPowerShellSession { private readonly Queue debugWork = new(); private readonly Dictionary breakpointSpecs = []; @@ -27,6 +30,8 @@ private Task PausedQueryAsync(Func query, CancellationToken cancellatio ObjectDisposedException.ThrowIf(disposed, this); if (State != SessionState.Debugging || resumeRequested) throw new InvalidOperationException("The debugger is not paused."); + if (debugWork.Count >= IseBridgeProtocol.MaximumPendingRequests) + throw new InvalidOperationException("The paused debugger work queue is full."); debugWork.Enqueue(new(() => { try @@ -58,7 +63,7 @@ public async Task PauseForBreakpointEditAsync(CancellationToken cancellationToke void Changed(SessionState state) { if (state is SessionState.Debugging or SessionState.Ready) ready.TrySetResult(); - else if (state == SessionState.Disposed) ready.TrySetException(new ObjectDisposedException(nameof(PowerShellSession))); + else if (state == SessionState.Disposed) ready.TrySetException(new ObjectDisposedException(nameof(ManagedPowerShellSession))); } lock (sync) { @@ -240,7 +245,7 @@ private Task CompletePausedAsync(string text, int cursor, Cancell var command = new PSCommand().AddCommand("TabExpansion2").AddArgument(text).AddArgument(cursor); var result = Inspect(command).Select(value => value.BaseObject).OfType().FirstOrDefault() ?? throw new InvalidOperationException("PowerShell completion did not return a completion result."); - return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.ToArray()); + return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); }, cancellationToken); public Task> GetBreakpointsAsync() => @@ -249,13 +254,13 @@ public Task> GetBreakpointsAsync() => public Task AddBreakpointAsync(BreakpointSpec spec) { - spec.Validate(); + ValidateBreakpoint(spec); return BreakpointQueryAsync(() => DescribeBreakpoint(CreateBreakpoint(spec))); } public Task UpdateBreakpointAsync(int id, BreakpointSpec spec) { - spec.Validate(); + ValidateBreakpoint(spec); return BreakpointQueryAsync(() => { var previous = FindBreakpoint(id); @@ -292,7 +297,7 @@ public Task RemoveAllBreakpointsAsync() => BreakpointQueryAsync(() => public Task SetLineBreakpointsAsync(string path, IEnumerable specs) { var requested = specs.Select(spec => spec with { Kind = BreakpointKind.Line, ScriptPath = path }).ToArray(); - foreach (var spec in requested) spec.Validate(); + foreach (var spec in requested) ValidateBreakpoint(spec); if (requested.DistinctBy(spec => spec.Line).Count() != requested.Length) throw new ArgumentException("Only one editor breakpoint can be specified per line."); return BreakpointQueryAsync(() => @@ -336,14 +341,14 @@ private Breakpoint FindBreakpoint(int id) => runspace.Debugger.GetBreakpoint(id) private Breakpoint CreateBreakpoint(BreakpointSpec spec) { - spec.Validate(); + ValidateBreakpoint(spec); var action = BreakpointAction(spec); var script = string.IsNullOrWhiteSpace(spec.ScriptPath) ? null : spec.ScriptPath; Breakpoint breakpoint = spec.Kind switch { BreakpointKind.Line => runspace.Debugger.SetLineBreakpoint(script!, spec.Line, 0, action), BreakpointKind.Command => runspace.Debugger.SetCommandBreakpoint(spec.Target, action, script), - BreakpointKind.Variable => runspace.Debugger.SetVariableBreakpoint(spec.Target.TrimStart('$'), spec.AccessMode, action, script), + BreakpointKind.Variable => runspace.Debugger.SetVariableBreakpoint(spec.Target.TrimStart('$'), (System.Management.Automation.VariableAccessMode)(long)spec.AccessMode, action, script), _ => throw new ArgumentException("Invalid breakpoint kind.") }; if (!spec.Enabled) breakpoint = runspace.Debugger.DisableBreakpoint(breakpoint); @@ -358,7 +363,7 @@ private DebugBreakpoint DescribeBreakpoint(Breakpoint breakpoint) { LineBreakpoint line => new(BreakpointKind.Line, line.Script, Line: line.Line, Action: line.Action?.ToString() ?? ""), CommandBreakpoint command => new(BreakpointKind.Command, command.Script, command.Command, Action: command.Action?.ToString() ?? ""), - VariableBreakpoint variable => new(BreakpointKind.Variable, variable.Script, variable.Variable, AccessMode: variable.AccessMode, Action: variable.Action?.ToString() ?? ""), + VariableBreakpoint variable => new(BreakpointKind.Variable, variable.Script, variable.Variable, AccessMode: (Iseberg.Core.VariableAccessMode)(long)variable.AccessMode, Action: variable.Action?.ToString() ?? ""), _ => throw new InvalidOperationException("Unknown PowerShell breakpoint type.") }; return new(breakpoint.Id, spec with { Enabled = breakpoint.Enabled }, breakpoint.HitCount); diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Nested.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Nested.cs similarity index 93% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Nested.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Nested.cs index 87b573c..dd5695b 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Nested.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Nested.cs @@ -1,8 +1,10 @@ using System.Management.Automation; +using SessionState = Iseberg.Core.SessionState; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; -public sealed partial class PowerShellSession +public sealed partial class ManagedPowerShellSession { private readonly Stack nestedFrames = new(); private sealed class NestedFrame @@ -83,6 +85,8 @@ private Task NestedQueryAsync(Func query, CancellationToken ObjectDisposedException.ThrowIf(disposed, this); if (!IsNestedPromptActive) throw new InvalidOperationException("No nested prompt is available."); var frame = nestedFrames.Peek(); + if (frame.Work.Count >= IseBridgeProtocol.MaximumPendingRequests) + throw new InvalidOperationException("The nested prompt work queue is full."); frame.Busy = true; StateChanged?.Invoke(State); frame.Work.Enqueue(new(() => diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Remoting.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Remoting.cs similarity index 97% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Remoting.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Remoting.cs index a02834b..d9661c9 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/PowerShellSession.Remoting.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.Remoting.cs @@ -1,12 +1,14 @@ using System.Management.Automation; +using SessionState = Iseberg.Core.SessionState; using System.Management.Automation.Language; using System.Management.Automation.Runspaces; using System.Reflection; using System.Text.Json; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; -public sealed partial class PowerShellSession +public sealed partial class ManagedPowerShellSession { private Runspace? pushedRunspace; private bool ownsPushedRunspace; @@ -22,13 +24,14 @@ public sealed partial class PowerShellSession public async Task ConnectAsync(RunspaceConnectionInfo connection, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(connection); - await gate.WaitAsync(cancellationToken); + await EnterExecutionAsync("This PowerShell tab is busy.", cancellationToken); Runspace? remote = null; + var started = false; try { EnsureReady(); if (IsRunspacePushed) throw new InvalidOperationException("Exit the current remote session before connecting."); - lock (sync) stopRequested = false; + started = true; SetState(SessionState.Running); remote = RunspaceFactory.CreateRunspace(connection, host, TypeTable.LoadDefaultTypeFiles()); lock (sync) connectingRunspace = remote; @@ -43,20 +46,18 @@ public async Task ConnectAsync(RunspaceConnectionInfo connection, CancellationTo { lock (sync) connectingRunspace = null; remote?.Dispose(); - if (!disposed && State != SessionState.Starting) SetState(SessionState.Ready); - gate.Release(); + ReleaseExecution(started); } } public async Task ExitRemoteSessionAsync() { - if (!await gate.WaitAsync(0)) throw new InvalidOperationException("Stop execution before leaving the remote session."); + await EnterExecutionAsync("Stop execution before leaving the remote session."); var started = false; try { EnsureReady(); if (!IsRunspacePushed) throw new InvalidOperationException("No interactive session is active."); - lock (sync) stopRequested = false; started = true; SetState(SessionState.Running); PopRunspace(); @@ -64,8 +65,7 @@ public async Task ExitRemoteSessionAsync() } finally { - if (started && !disposed) SetState(SessionState.Ready); - gate.Release(); + ReleaseExecution(started); } } diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs new file mode 100644 index 0000000..5088197 --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ManagedPowerShellSession.cs @@ -0,0 +1,620 @@ +using DebuggerResumeAction = System.Management.Automation.DebuggerResumeAction; +using SessionState = Iseberg.Core.SessionState; +using System.Management.Automation; +using System.Management.Automation.Runspaces; + +using Iseberg.Core; +namespace Iseberg.PowerShellHost; + +/// Owns a persistent PowerShell runspace with serialized execution, completion, and debugging. +public sealed partial class ManagedPowerShellSession : IAsyncDisposable +{ + private readonly SemaphoreSlim gate = new(1, 1); + private readonly object sync = new(); + private readonly AutoResetEvent debuggerWake = new(false); + private readonly Runspace localRunspace; + private readonly WorkbenchHost host; + private Runspace runspace => pushedRunspace ?? localRunspace; + private PowerShell? active; + private InputRequest? pendingInput; + private ShowCommandRequest? pendingShowCommand; + private CommandErrorRequest? pendingCommandError; + private DebuggerResumeAction resumeAction; + private bool disposed; + private Task? disposalTask; + private bool stopRequested; + private bool executionPending; + private object? iseObjectModel; + public IseSnippetService Snippets { get; } + + /// Formatted stream entries, including script errors; callbacks may occur on pipeline threads. + public event Action? Output; + /// Execution-state transitions; marshal callbacks before updating a UI. + public event Action? StateChanged; + /// Interactive input requiring a host response; do not block or re-enter the engine in the handler. + public event Action? InputRequested; + public event Action? ShowCommandRequested; + public event Action? CommandErrorRequested; + public event Action? ProgressChanged; + public event Action? DebuggerStopped; + public event Action? ConsoleCleared; + public SessionState State { get; private set; } = SessionState.Starting; + public string Prompt { get; private set; } = "PS> "; + public string Version => PSVersionInfo.PSVersion.ToString(); + public Guid LocalRunspaceId => localRunspace.InstanceId; + public void SetTerminalSize(int columns, int rows) => host.SetTerminalSize(columns, rows); + + /// Creates an unopened local runspace with optional custom snippet storage. + public ManagedPowerShellSession(string? snippetDirectory = null) + { + Snippets = new(snippetDirectory); + host = new WorkbenchHost(entry => Output?.Invoke(entry), ReadInput, + update => ProgressChanged?.Invoke(update), () => ConsoleCleared?.Invoke(), ReadShowCommand, + () => runspace, () => IsRunspacePushed, remote => PushRunspace(remote, false), PopRunspace, + EnterNestedPrompt, ExitNestedPrompt, ReadCommandError, Snippets); + var initialState = InitialSessionState.CreateDefault2(); + // The default Unix function clears a terminal instead of this graphical host. + initialState.Commands.Remove("Clear-Host", typeof(SessionStateFunctionEntry)); + initialState.Commands.Add(new SessionStateFunctionEntry("Clear-Host", """ + $rawUI = $Host.UI.RawUI + $rawUI.SetBufferContents( + [System.Management.Automation.Host.Rectangle]::new(-1, -1, -1, -1), + [System.Management.Automation.Host.BufferCell]::new( + ' ', + $rawUI.ForegroundColor, + $rawUI.BackgroundColor, + [System.Management.Automation.Host.BufferCellType]::Complete)) + """)); + initialState.Commands.Remove("clear", typeof(SessionStateAliasEntry)); + initialState.Commands.Add(new SessionStateAliasEntry("clear", "Clear-Host")); + // These binary cmdlets must be available even when Restricted prevents loading module type data. + initialState.Commands.Add(new SessionStateCmdletEntry("Set-ExecutionPolicy", typeof(Microsoft.PowerShell.Commands.SetExecutionPolicyCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Get-ExecutionPolicy", typeof(Microsoft.PowerShell.Commands.GetExecutionPolicyCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Show-IsebergCommand", typeof(ShowCommandCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Start-IsebergTerminal", typeof(StartTerminalCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("New-IseSnippet", typeof(NewIseSnippetCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Get-IseSnippet", typeof(GetIseSnippetCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Import-IseSnippet", typeof(ImportIseSnippetCommand), null)); + initialState.Commands.Add(new SessionStateCmdletEntry("Invoke-IsebergNotification", typeof(InvokeIsebergNotificationCommand), null)); + // A function keeps precedence when Utility is auto-imported by commands such as Get-Help. + initialState.Commands.Remove("Show-Command", typeof(SessionStateFunctionEntry)); + initialState.Commands.Add(new SessionStateFunctionEntry("Show-Command", """ + [CmdletBinding()] + param( + [Parameter(Position=0)] [ValidateNotNullOrEmpty()] [string] $Name, + [switch] $PassThru, + [switch] $NoCommonParameter, + [switch] $ErrorPopup, + [ValidateRange(300, [int]::MaxValue)] [int] $Width = 360, + [ValidateRange(300, [int]::MaxValue)] [int] $Height = 410 + ) + Show-IsebergCommand @PSBoundParameters + """)); + localRunspace = RunspaceFactory.CreateRunspace(host, initialState); + runspace.ThreadOptions = PSThreadOptions.ReuseThread; + if (OperatingSystem.IsWindows()) + runspace.ApartmentState = ApartmentState.STA; + } + + public void ConfigureIseObjectModel(object model) + { + ArgumentNullException.ThrowIfNull(model); + if (!gate.Wait(0)) throw new InvalidOperationException("Configure the ISE object model before executing commands."); + try + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State is not (SessionState.Starting or SessionState.Ready)) + throw new InvalidOperationException("Configure the ISE object model before executing commands."); + iseObjectModel = model; + if (localRunspace.RunspaceStateInfo.State == RunspaceState.Opened) + localRunspace.SessionStateProxy.SetVariable("psISE", model); + } + finally { gate.Release(); } + } + + /// Opens the local runspace and prepares its host. Profiles are not loaded automatically. + public async Task InitializeAsync() + { + await gate.WaitAsync(); + try + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State != SessionState.Starting || runspace.RunspaceStateInfo.State != RunspaceState.BeforeOpen) + throw new InvalidOperationException("This PowerShell tab has already been initialized."); + await Task.Run(() => + { + runspace.Open(); + runspace.Debugger.SetDebugMode(DebugModes.LocalScript); + runspace.Debugger.DebuggerStop += OnDebuggerStop; + var config = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "powershell"); + var profile = PSObject.AsPSObject(Path.Combine(config, "Devolutions.Terminal.Iseberg_profile.ps1")); + profile.Properties.Add(new PSNoteProperty("CurrentUserCurrentHost", profile.BaseObject)); + profile.Properties.Add(new PSNoteProperty("CurrentUserAllHosts", Path.Combine(config, "profile.ps1"))); + var engineHome = Path.GetDirectoryName(typeof(PSObject).Assembly.Location)!; + profile.Properties.Add(new PSNoteProperty("AllUsersCurrentHost", Path.Combine(engineHome, "Devolutions.Terminal.Iseberg_profile.ps1"))); + profile.Properties.Add(new PSNoteProperty("AllUsersAllHosts", Path.Combine(engineHome, "profile.ps1"))); + runspace.SessionStateProxy.SetVariable("PROFILE", profile); + if (iseObjectModel is not null) runspace.SessionStateProxy.SetVariable("psISE", iseObjectModel); + RefreshPrompt(); + }); + SetState(SessionState.Ready); + } + finally { gate.Release(); } + } + + public async Task SetWorkingDirectoryAsync(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Path.IsPathFullyQualified(path)) throw new ArgumentException("An absolute directory is required.", nameof(path)); + await gate.WaitAsync(); + try + { + EnsureReady(); + await Task.Run(() => + { + localRunspace.SessionStateProxy.Path.SetLocation(path); + RefreshPrompt(); + }); + } + finally { gate.Release(); } + } + + /// Executes text or a named script in the persistent session. Script errors are delivered through Output. + public Task ExecuteAsync(string script, string? filePath = null) => ExecuteAsync(script, filePath, null); + + public Task ExecuteMenuActionAsync(ScriptBlock action) + { + ArgumentNullException.ThrowIfNull(action); + if (IsRunspacePushed) throw new PSNotSupportedException("ISE Add-ons menu actions require their original local runspace. Exit the remote session first."); + return ExecuteAsync("ISE Add-ons menu action", null, action); + } + + private async Task ExecuteAsync(string script, string? filePath, ScriptBlock? action) + { + await EnterExecutionAsync("This PowerShell tab is busy."); + var started = false; + try + { + EnsureReady(); + started = true; + SetState(SessionState.Running); + Output?.Invoke(new(Prompt + (filePath ?? script) + Environment.NewLine, OutputKind.Command, Prompt.Length)); + await Task.Run(() => + { + if (IsRunspacePushed && filePath is null && IsExitSessionCommand(script)) + { + PopRunspace(); + RefreshPrompt(); + return; + } + using var shell = CreateShell(); + try + { + if (action is not null) + // Keep the original block, including module/session state and GetNewClosure(). + shell.AddScript(". $args[0]", useLocalScope: false).AddArgument(action); + else if (filePath is null) + shell.AddScript(script, useLocalScope: false); + else + shell.AddScript(". '" + filePath.Replace("'", "''") + "'", useLocalScope: false); + shell.AddCommand("Out-Default"); + using var output = new PSDataCollection(); + IAsyncResult invocation; + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + invocation = shell.BeginInvoke(null, output); + active = shell; + } + shell.EndInvoke(invocation); + } + catch (PipelineStoppedException) + { + Output?.Invoke(new("Execution stopped." + Environment.NewLine, OutputKind.Warning)); + } + catch (RuntimeException exception) + { + Output?.Invoke(new(exception.ErrorRecord.ToString() + Environment.NewLine, OutputKind.Error)); + } + finally + { + lock (sync) active = null; + DebuggerStopped?.Invoke(null); + RefreshPrompt(); + } + }); + } + finally + { + ReleaseExecution(started); + } + } + + private async Task EnterExecutionAsync(string busyMessage, CancellationToken cancellationToken = default) + { + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + if (executionPending || State != SessionState.Ready) + throw new InvalidOperationException(busyMessage); + executionPending = true; + stopRequested = false; + } + // Ready-state metadata/notification work is serialized, not another user execution. + try { await gate.WaitAsync(cancellationToken); } + catch + { + lock (sync) executionPending = false; + throw; + } + } + + private void ReleaseExecution(bool started) + { + lock (sync) + { + executionPending = false; + if (started && !disposed) State = SessionState.Ready; + gate.Release(); + if (started && !disposed) StateChanged?.Invoke(SessionState.Ready); + } + } + + /// Requests cancellation of execution, debugger evaluation, and pending interactive host requests. + public Task StopAsync() + { + lock (sync) + { + stopRequested = true; + pendingInput?.Response.TrySetCanceled(); + pendingShowCommand?.Response.TrySetCanceled(); + pendingCommandError?.Response.TrySetCanceled(); + foreach (var frame in nestedFrames) frame.Exit = true; + resumeAction = DebuggerResumeAction.Stop; + resumeRequested = true; + debuggerWake.Set(); + connectingRunspace?.CloseAsync(); + if (runspace.RunspaceStateInfo.State == RunspaceState.Opened) + runspace.Debugger?.StopProcessCommand(); + if (active is { } shell) + return shell.StopAsync(null, null); + } + return Task.CompletedTask; + } + + public void Resume(DebuggerResumeAction action) + { + lock (sync) + { + if (State != SessionState.Debugging) + throw new InvalidOperationException("The debugger is not paused."); + resumeAction = action; + resumeRequested = true; + debuggerWake.Set(); + } + } + + public Task SetBreakpointsAsync(string path, IEnumerable lines) => + SetLineBreakpointsAsync(path, lines.Select(line => new BreakpointSpec(BreakpointKind.Line, path, Line: line))); + + /// Completes text at a zero-based cursor offset in the current runspace or paused debugger context. + public Task CompleteAsync(string text, int cursor, CancellationToken cancellationToken = default) => + State == SessionState.NestedPrompt ? NestedQueryAsync(shell => + { + var result = CommandCompletion.CompleteInput(text, cursor, null, shell); + return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); + }, cancellationToken) : State == SessionState.Debugging ? CompletePausedAsync(text, cursor, cancellationToken) : QueryAsync(shell => + { + var result = CommandCompletion.CompleteInput(text, cursor, null, shell); + return new CompletionSet(result.ReplacementIndex, result.ReplacementLength, result.CompletionMatches.Select(ToCompletion).ToArray()); + }, cancellationToken); + + public Task> GetCommandsAsync(CancellationToken cancellationToken = default) => + QueryAsync>(shell => + { + shell.AddCommand("Get-Command"); + if (IsRemote) + shell.AddCommand("Microsoft.PowerShell.Utility\\Select-Object") + .AddParameter("Property", new[] { "Name", "ModuleName", "CommandType", "Definition" }); + var commands = shell.Invoke(); + ThrowQueryErrors(shell); + return commands.Select(command => new CommandDescription( + command.Properties["Name"].Value.ToString()!, command.Properties["ModuleName"].Value?.ToString() ?? "", + command.Properties["CommandType"].Value.ToString()!, command.Properties["Definition"].Value?.ToString() ?? "")) + .OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase).DistinctBy(command => command.Name).ToArray(); + }, cancellationToken, waitForGate: true); + + internal static IReadOnlyList DescribeCommands(IEnumerable commands) => + commands.OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase) + .Select(command => new CommandDescription(command.Name, command.ModuleName, + command.CommandType.ToString(), command.Definition)) + .DistinctBy(command => command.Name).ToArray(); + + public Task GetHelpAsync(string name) => QueryAsync(shell => + { + var output = shell.AddCommand("Get-Help").AddParameter("Name", name).AddParameter("Full") + .AddCommand("Out-String").AddParameter("Width", 100).Invoke(); + ThrowQueryErrors(shell); + return string.Join(Environment.NewLine, output); + }); + + public Task GetHelpDocumentAsync(string name) => QueryAsync(shell => + { + var help = shell.AddCommand("Get-Help").AddParameter("Name", name).AddParameter("Full").Invoke(); + ThrowQueryErrors(shell); + return HelpMetadata.FromHelp(name, help); + }); + + public Task GetCommandFormAsync(string name, string? module = null, + CancellationToken cancellationToken = default) => QueryAsync(shell => + { + if (IsRemote) return GetRemoteCommandForm(shell, name, module); + shell.AddCommand("Get-Command").AddParameter("Name", WildcardPattern.Escape(name)); + if (!string.IsNullOrEmpty(module)) shell.AddParameter("Module", module); + var commands = shell.Invoke(); + ThrowQueryErrors(shell); + var command = commands.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Command '{name}' was not found in this PowerShell tab."); + return CommandMetadata.Describe(command); + }, cancellationToken, waitForGate: true); + + public Task GetHelpUriAsync(string name) => QueryAsync(shell => + { + var help = shell.AddCommand("Get-Help").AddParameter("Name", name).Invoke(); + ThrowQueryErrors(shell); + return FindHelpUri(help); + }); + + internal static Uri? FindHelpUri(IEnumerable help) + { + foreach (var entry in help) + { + if (entry.Properties["RelatedLinks"]?.Value is not PSObject links) continue; + if (links.Properties["navigationLink"]?.Value is not System.Collections.IEnumerable navigation) continue; + foreach (var link in navigation) + { + var value = PSObject.AsPSObject(link).Properties["uri"]?.Value?.ToString(); + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") return uri; + } + } + return null; + } + + private async Task QueryAsync(Func query, CancellationToken cancellationToken = default, + bool waitForGate = false) + { + if (!waitForGate) + { + lock (sync) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (executionPending || State != SessionState.Ready) + throw new InvalidOperationException("Wait for the running command to finish."); + } + } + await gate.WaitAsync(cancellationToken); + try + { + EnsureReady(); + return await Task.Run(() => + { + using var shell = CreateShell(); + using var registration = cancellationToken.Register(() => shell.Stop()); + cancellationToken.ThrowIfCancellationRequested(); + var debugger = shell.Runspace.Debugger; + var debugMode = debugger.DebugMode; + debugger.SetDebugMode(DebugModes.None); + try + { + var result = query(shell); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + catch (PipelineStoppedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + finally { debugger.SetDebugMode(debugMode); } + }); + } + finally { gate.Release(); } + } + + private PowerShell CreateShell() + { + var shell = PowerShell.Create(); + shell.Runspace = runspace; + shell.Streams.Error.DataAdded += (_, e) => Output?.Invoke(new(shell.Streams.Error[e.Index].ToString() + Environment.NewLine, OutputKind.Error)); + return shell; + } + + private static void ThrowQueryErrors(PowerShell shell) + { + if (shell.HadErrors) + throw new InvalidOperationException(string.Join(Environment.NewLine, shell.Streams.Error.Select(e => e.ToString()))); + } + + private void EnsureReady() + { + ObjectDisposedException.ThrowIf(disposed, this); + if (State != SessionState.Ready) + throw new InvalidOperationException("The PowerShell tab is not ready."); + } + + private string ReadInput(InputRequest request) + { + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + pendingInput = request; + } + try + { + if (InputRequested is null) + throw new PSNotSupportedException("No input handler is attached to this host."); + InputRequested.Invoke(request); + return request.Response.Task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally { lock (sync) pendingInput = null; } + } + + private string? ReadShowCommand(ShowCommandRequest request) + { + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + pendingShowCommand = request; + } + try + { + if (ShowCommandRequested is null) + throw new PSNotSupportedException("No Show-Command handler is attached to this host."); + ShowCommandRequested.Invoke(request); + return request.Response.Task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally { lock (sync) pendingShowCommand = null; } + } + + private void OnDebuggerStop(object? sender, DebuggerStopEventArgs e) + { + var previousState = State; + lock (sync) + { + resumeRequested = stopRequested; + resumeAction = stopRequested ? DebuggerResumeAction.Stop : DebuggerResumeAction.Continue; + SetState(SessionState.Debugging); + } + + var invocation = e.InvocationInfo; + DebuggerStopped?.Invoke(new(invocation?.ScriptName, invocation?.ScriptLineNumber ?? 0, + invocation?.OffsetInLine ?? 0, invocation?.PositionMessage ?? "Execution paused without a script location.")); + try + { + while (true) + { + DebugWork? work; + lock (sync) + { + if (resumeRequested) break; + work = debugWork.Count > 0 ? debugWork.Dequeue() : null; + } + if (work is null) debuggerWake.WaitOne(); + else work.Execute(); + } + } + finally + { + lock (sync) + { + e.ResumeAction = resumeAction; + debugValues.Clear(); + while (debugWork.TryDequeue(out var work)) + work.Fail(new InvalidOperationException("The debugger has resumed.")); + } + } + SetState(previousState); + DebuggerStopped?.Invoke(null); + } + + private void ReadCommandError(string message) + { + var request = new CommandErrorRequest(message); + lock (sync) + { + if (stopRequested) throw new PipelineStoppedException(); + pendingCommandError = request; + } + try + { + if (CommandErrorRequested is null) + throw new PSNotSupportedException("No command error popup handler is attached to this host." + Environment.NewLine + message); + CommandErrorRequested.Invoke(request); + request.Response.Task.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { throw new PipelineStoppedException(); } + finally { lock (sync) pendingCommandError = null; } + } + + private void RefreshPrompt() + { + using var shell = PowerShell.Create(); + shell.Runspace = runspace; + shell.AddScript("prompt", useLocalScope: true); + var debugger = shell.Runspace.Debugger; + var debugMode = debugger.DebugMode; + debugger.SetDebugMode(DebugModes.None); + try + { + IAsyncResult invocation; + lock (sync) + { + if (stopRequested) + { + Prompt = IsRemote ? $"[{RemoteComputerName}]: PS> " : "PS> "; + return; + } + invocation = shell.BeginInvoke(); + active = shell; + } + var values = shell.EndInvoke(invocation); + if (shell.HadErrors) + { + Output?.Invoke(new("The prompt function failed: " + string.Join("; ", shell.Streams.Error) + Environment.NewLine, OutputKind.Error)); + Prompt = "PS> "; + } + else + Prompt = string.Concat(values.Select(v => v.ToString())); + } + catch (PipelineStoppedException) + { + Prompt = "PS> "; + Output?.Invoke(new("Prompt evaluation stopped." + Environment.NewLine, OutputKind.Warning)); + } + catch (RuntimeException exception) + { + Prompt = "PS> "; + Output?.Invoke(new("The prompt function failed: " + exception.Message + Environment.NewLine, OutputKind.Error)); + } + finally + { + lock (sync) active = null; + debugger.SetDebugMode(debugMode); + } + if (IsRemote) Prompt = $"[{RemoteComputerName}]: " + Prompt; + } + + private void SetState(SessionState state) + { + State = state; + StateChanged?.Invoke(state); + } + + /// Stops execution and releases the runspace. Repeated and concurrent calls await the same disposal. + public ValueTask DisposeAsync() + { + lock (sync) return new ValueTask(disposalTask ??= DisposeCoreAsync()); + } + + private async Task DisposeCoreAsync() + { + await StopAsync(); + await gate.WaitAsync(); + try + { + if (disposed) return; + disposed = true; + if (pushedRunspace is not null) PopRunspace(); + if (localRunspace.Debugger is { } debugger) debugger.DebuggerStop -= OnDebuggerStop; + await Task.Run(localRunspace.Dispose); + iseObjectModel = null; + debuggerWake.Dispose(); + SetState(SessionState.Disposed); + } + finally { gate.Release(); } + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/PowerShellEditorAnalysis.Contracts.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/PowerShellEditorAnalysis.Contracts.cs new file mode 100644 index 0000000..8192a5e --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/PowerShellEditorAnalysis.Contracts.cs @@ -0,0 +1,69 @@ +using System.Management.Automation.Language; +using Iseberg.Core; +using Token = System.Management.Automation.Language.Token; +using TokenKind = System.Management.Automation.Language.TokenKind; +using ParseError = System.Management.Automation.Language.ParseError; +using ScriptExtent = Iseberg.Core.ScriptExtent; + +namespace Iseberg.PowerShellHost; + +internal static partial class PowerShellEditorAnalysis +{ + private static ScriptExtent Extent(IScriptExtent extent) => new(extent.StartOffset, extent.EndOffset, + extent.StartLineNumber, extent.StartColumnNumber, extent.EndLineNumber, extent.EndColumnNumber, extent.Text); + + private static Iseberg.Core.Token ConvertToken(Token token) => + new((Iseberg.Core.TokenKind)(long)token.Kind, (Iseberg.Core.TokenFlags)(long)token.TokenFlags, Extent(token.Extent), token.Text) + { + NestedTokens = token is StringExpandableToken { NestedTokens: { } nested } + ? nested.Select(ConvertToken).ToArray() : null, + VariablePath = token is VariableToken variable + ? new(variable.VariablePath.UserPath, variable.VariablePath.IsUnqualified, variable.VariablePath.IsDriveQualified) : null + }; + + private static ScriptAnalysis ConvertAnalysis(ScriptBlockAst ast, Token[] tokens, ParseError[] errors, + IReadOnlyList<(int Start, int End)> folds) + { + var stack = new Stack<(TokenKind Kind, int Offset)>(); + var pairs = new List(); + foreach (var token in Flatten(tokens)) + { + var kind = token.Kind switch + { + TokenKind.AtCurly => TokenKind.LCurly, + TokenKind.AtParen or TokenKind.DollarParen => TokenKind.LParen, + _ => token.Kind + }; + if (kind is TokenKind.LCurly or TokenKind.LParen or TokenKind.LBracket) + stack.Push((kind, token.Extent.EndOffset - 1)); + else if (kind is TokenKind.RCurly or TokenKind.RParen or TokenKind.RBracket) + { + var expected = kind switch + { + TokenKind.RCurly => TokenKind.LCurly, + TokenKind.RParen => TokenKind.LParen, + _ => TokenKind.LBracket + }; + if (stack.TryPeek(out var open) && open.Kind == expected) + { + stack.Pop(); + pairs.Add(new(open.Offset, token.Extent.StartOffset)); + } + else stack.Clear(); + } + } + return new(tokens.Select(ConvertToken).ToArray(), + errors.Select(error => new Iseberg.Core.ParseError(error.ErrorId, error.Message, + Extent(error.Extent), error.IncompleteInput)).ToArray(), + folds.Distinct().OrderBy(fold => fold.Start).ThenByDescending(fold => fold.End) + .Select(fold => new ScriptSpan(fold.Start, fold.End)).ToArray()) + { + BracePairs = pairs, + Statements = ast.FindAll(node => node is StatementAst && node is not StatementBlockAst, true) + .Select(node => Extent(node.Extent)).ToArray(), + Commands = ast.FindAll(node => node is CommandAst, true).OfType() + .Select(command => new CommandSpan(command.Extent.StartOffset, command.Extent.EndOffset, + command.GetCommandName())).ToArray() + }; + } +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/PowerShellEditorAnalysis.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/PowerShellEditorAnalysis.cs new file mode 100644 index 0000000..fbf33cf --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/PowerShellEditorAnalysis.cs @@ -0,0 +1,79 @@ +using System.Management.Automation.Language; +using Iseberg.Core; +using Token = System.Management.Automation.Language.Token; +using TokenKind = System.Management.Automation.Language.TokenKind; +namespace Iseberg.PowerShellHost; +internal static partial class PowerShellEditorAnalysis +{ + public static ScriptAnalysis Analyze(string text, string? documentPath) + { + ArgumentNullException.ThrowIfNull(text); + if (EditorAnalysis.IsXmlDocument(documentPath)) throw new ArgumentException("XML analysis belongs to the parent."); + var ast = Parser.ParseInput(text, out var tokens, out var errors); + var stack = new Stack(); + var regions = new Stack(); + var regionFolds = new List<(int Start, int End)>(); + var folds = new List<(int Start, int End)>(); + foreach (var token in Flatten(tokens)) + { + if (token.Kind is TokenKind.LCurly or TokenKind.AtCurly) + stack.Push(token); + else if (token.Kind == TokenKind.RCurly && stack.TryPop(out var open) && + token.Extent.EndLineNumber > open.Extent.StartLineNumber) + folds.Add((open.Extent.StartOffset, token.Extent.EndOffset)); + } + foreach (var token in tokens) + { + if (token.Kind == TokenKind.Comment && !token.Text.StartsWith("<#", StringComparison.Ordinal) && + IsLineDirective(text, token)) + { + if (IsDirective(token.Text, "#region")) regions.Push(token); + else if (IsDirective(token.Text, "#endregion") && regions.TryPop(out var region) && + token.Extent.StartLineNumber > region.Extent.StartLineNumber) + regionFolds.Add((region.Extent.StartOffset, token.Extent.EndOffset)); + } + if (token.Extent.EndLineNumber <= token.Extent.StartLineNumber) continue; + var closed = token.Kind switch + { + TokenKind.Comment => token.Text.StartsWith("<#", StringComparison.Ordinal) && + token.Text.EndsWith("#>", StringComparison.Ordinal), + TokenKind.HereStringLiteral => token.Text.EndsWith("'@", StringComparison.Ordinal), + TokenKind.HereStringExpandable => token.Text.EndsWith("\"@", StringComparison.Ordinal), + TokenKind.StringLiteral => token.Text.EndsWith("'", StringComparison.Ordinal), + TokenKind.StringExpandable => token.Text.EndsWith("\"", StringComparison.Ordinal), + _ => false + }; + if (closed && !errors.Any(error => error.IncompleteInput && + error.Extent.StartOffset >= token.Extent.StartOffset && error.Extent.StartOffset < token.Extent.EndOffset)) + folds.Add((token.Extent.StartOffset, token.Extent.EndOffset)); + } + // Unclosed outer regions must not hide even otherwise matched nested directives. + var unmatchedRegion = regions.Count == 0 ? int.MaxValue : regions.Min(region => region.Extent.StartOffset); + folds.AddRange(regionFolds.Where(fold => fold.Start < unmatchedRegion)); + return ConvertAnalysis(ast, tokens, errors, folds); + } + + private static bool IsDirective(string text, string directive) => + text.StartsWith(directive, StringComparison.OrdinalIgnoreCase) && + (text.Length == directive.Length || char.IsWhiteSpace(text[directive.Length])); + + private static bool IsLineDirective(string text, Token token) + { + for (var index = token.Extent.StartOffset - 1; index >= 0 && text[index] is not ('\r' or '\n'); index--) + if (!char.IsWhiteSpace(text[index])) return false; + return true; + } + + private static IEnumerable Flatten(IEnumerable tokens) + { + foreach (var token in tokens) + { + if (token is StringExpandableToken { NestedTokens: { } nested }) + { + foreach (var child in Flatten(nested)) yield return child; + } + else yield return token; + } + } + +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ScriptingBridge.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ScriptingBridge.cs new file mode 100644 index 0000000..538a7ac --- /dev/null +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ScriptingBridge.cs @@ -0,0 +1,17 @@ +using System.Management.Automation; +using System.Text.Json; + +namespace Iseberg.PowerShellHost; + +public interface IIseScriptingBridge +{ + JsonElement Invoke(string operation, JsonElement arguments); + string RegisterCallback(ScriptBlock action); + void RemoveCallback(string handle); + void ScheduleNotification(Action notification); +} + +public interface IIseObjectProxyNotifications +{ + void Notify(string objectId, string propertyName); +} diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/ShowCommandCommand.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ShowCommandCommand.cs similarity index 90% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/ShowCommandCommand.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ShowCommandCommand.cs index a3d015e..84b4609 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/ShowCommandCommand.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/ShowCommandCommand.cs @@ -1,6 +1,7 @@ using System.Management.Automation; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; [Cmdlet(VerbsCommon.Show, "IsebergCommand")] [OutputType(typeof(string))] @@ -36,12 +37,12 @@ protected override void EndProcessing() { var commands = InvokeCommand.InvokeScript(false, ScriptBlock.Create("Get-Command"), null) .Select(entry => entry.BaseObject).OfType(); - name = host.ShowCommand(new() { Commands = PowerShellSession.DescribeCommands(commands) }); + name = host.ShowCommand(new() { Commands = ManagedPowerShellSession.DescribeCommands(commands) }); if (name is null) return; } var command = InvokeCommand.GetCommand(name, CommandTypes.All) ?? throw new CommandNotFoundException($"Command '{name}' was not found in this PowerShell tab."); - var description = CommandForm.Describe(command); + var description = CommandMetadata.Describe(command); if (NoCommonParameter) description = description with { @@ -55,13 +56,13 @@ protected override void EndProcessing() ScriptBlock.Create("Get-Help -Name $args[0] -Full"), null, name); var helpText = InvokeCommand.InvokeScript(false, ScriptBlock.Create("$args[0] | Out-String -Width 100"), null, new object[] { help }); - var helpDocument = CommandHelpDocument.FromHelp(description.Name, help); + var helpDocument = HelpMetadata.FromHelp(description.Name, help); var script = host.ShowCommand(new() { Command = description, PassThru = PassThru, Width = Width, Height = Height, HelpText = string.Join(Environment.NewLine, helpText.Select(entry => entry.ToString())), HelpDocument = helpDocument, - HelpUri = PowerShellSession.FindHelpUri(help) + HelpUri = ManagedPowerShellSession.FindHelpUri(help) }); if (script is null) return; if (PassThru) diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/StartTerminalCommand.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/StartTerminalCommand.cs similarity index 97% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/StartTerminalCommand.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/StartTerminalCommand.cs index 3564ad4..4160003 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/StartTerminalCommand.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/StartTerminalCommand.cs @@ -1,6 +1,7 @@ using System.Management.Automation; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; [Cmdlet(VerbsLifecycle.Start, "IsebergTerminal")] [OutputType(typeof(int))] diff --git a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchHost.cs b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/WorkbenchHost.cs similarity index 99% rename from src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchHost.cs rename to src/Devolutions.Terminal.Ise/Iseberg.PowerShell/WorkbenchHost.cs index 76b081c..add2e88 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg.Core/WorkbenchHost.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg.PowerShell/WorkbenchHost.cs @@ -5,7 +5,8 @@ using System.Management.Automation.Runspaces; using System.Security; -namespace Iseberg.Core; +using Iseberg.Core; +namespace Iseberg.PowerShellHost; internal sealed record WorkbenchHostServices(Func ShowCommand, Action ShowCommandError, Func IsRunspacePushed, IseSnippetService Snippets); diff --git a/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs b/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs index 653dfe6..aefdbe9 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg/BreakpointWindow.cs @@ -1,4 +1,3 @@ -using System.Management.Automation; using Avalonia; using Avalonia.Automation; using Avalonia.Controls; @@ -20,7 +19,7 @@ public sealed class BreakpointWindow : Window private readonly CheckBox enabled = new() { Name = "BreakpointEnabled", Content = UiText.Get("BreakpointEnabled") }; private readonly TextBlock error = new() { TextWrapping = TextWrapping.Wrap }; - public BreakpointWindow(BreakpointSpec spec) + public BreakpointWindow(BreakpointSpec spec, Func? validate = null) { Title = UiText.Get("BreakpointsTitle"); Width = 570; @@ -47,18 +46,26 @@ void Field(string key, Control control) var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 8 }; var ok = new Button { Name = "BreakpointOK", Content = UiText.Get("OK"), IsDefault = true }; var cancel = new Button { Content = UiText.Get("Cancel"), IsCancel = true }; - ok.Click += (_, _) => + var cancellation = new CancellationTokenSource(); + var cancellationToken = cancellation.Token; + Closed += (_, _) => { cancellation.Cancel(); cancellation.Dispose(); }; + ok.Click += async (_, _) => { try { + ok.IsEnabled = false; var value = ReadSpec(); value.Validate(); + if (validate is not null) await validate(value, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); Close(value); } - catch (Exception exception) when (exception is ArgumentException or RuntimeException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or IOException) { error.Text = exception.Message; } + finally { ok.IsEnabled = true; } }; cancel.Click += (_, _) => Close(null); buttons.Children.Add(ok); diff --git a/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs b/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs index 24bdf44..bc5ec16 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs +++ b/src/Devolutions.Terminal.Ise/Iseberg/ClassicDialog.cs @@ -1,6 +1,5 @@ using Avalonia; using Avalonia.Controls; -using Avalonia.Markup.Xaml.Styling; namespace Iseberg; @@ -10,10 +9,7 @@ public static void Apply(Window window) { DesktopTheme.ApplyWindow(window); window.Classes.Add("options"); - window.Styles.Add(new StyleInclude(new Uri("avares://Iseberg/")) - { - Source = new Uri("avares://Iseberg/CommandDialogStyles.axaml") - }); + window.Styles.Add(new CommandDialogStyles()); window.Icon = AppIcon.Create(); window.FontSize = 12 * DesktopTheme.TextScale; window.ShowInTaskbar = false; diff --git a/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml b/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml index 6707cd9..d27118e 100644 --- a/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml +++ b/src/Devolutions.Terminal.Ise/Iseberg/CommandDialogStyles.axaml @@ -1,4 +1,5 @@ - +