diff --git a/.github/actions/spelling/allow/allow.txt b/.github/actions/spelling/allow/allow.txt index 68c5a275e..35b398bd1 100644 --- a/.github/actions/spelling/allow/allow.txt +++ b/.github/actions/spelling/allow/allow.txt @@ -12,12 +12,14 @@ commandlines consvc copyable dcs +diskpart deselection diffing Dimidium downsides Emacspeak Fitt +FNF flac FTCS gantt @@ -45,6 +47,7 @@ notwrapped NTMTo overlined perlw +PNF postmodern Powerline ptys diff --git a/.github/workflows/winterm-full-build.yml b/.github/workflows/winterm-full-build.yml index 2cb9da31b..97c44f83d 100644 --- a/.github/workflows/winterm-full-build.yml +++ b/.github/workflows/winterm-full-build.yml @@ -32,6 +32,10 @@ jobs: shell: pwsh run: .\scripts\winterm\test.ps1 -Suite Smoke -Configuration ${{ matrix.configuration }} -Platform x64 + - name: Test Shell Experience assets + shell: pwsh + run: .\scripts\winterm\test-shell-integration.ps1 -Shell All + - name: Build x64 shell: pwsh run: .\scripts\winterm\build.ps1 -Configuration ${{ matrix.configuration }} -Platform x64 -IncludeTests diff --git a/.github/workflows/winterm-validation.yml b/.github/workflows/winterm-validation.yml index 79d0df3a0..3aeb4ccc2 100644 --- a/.github/workflows/winterm-validation.yml +++ b/.github/workflows/winterm-validation.yml @@ -29,3 +29,13 @@ jobs: - name: Verify winTerm identity, scripts, and profile foundations shell: powershell run: .\scripts\winterm\test.ps1 -Suite Smoke + + - name: Validate Shell Experience assets and safety boundaries + shell: powershell + run: | + .\scripts\winterm\package-shell-assets.ps1 + .\scripts\winterm\test-paste-protection.ps1 + + - name: Test PowerShell and Command Prompt integration + shell: powershell + run: .\scripts\winterm\test-shell-integration.ps1 -Shell All diff --git a/README.md b/README.md index 0a92fa240..92c52dbf6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,21 @@ # winTerm +[![CI](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml/badge.svg)](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml) +[![License](https://img.shields.io/github/license/HelloThisWorld/winTerm)](LICENSE) + winTerm is an independent open-source terminal application based on Microsoft Windows Terminal. winTerm is not affiliated with or endorsed by Microsoft. -winTerm v0.1 is a foundation release. It keeps the mature Microsoft Terminal engine and adds an independent package identity, application-data boundary, user-visible branding, original placeholder artwork, reproducible wrappers, validation, CI, and an upstream synchronization workflow. +winTerm v0.3 is a development release for Shell Experience foundations. It keeps the mature Microsoft Terminal engine and adds an independent package identity, application-data boundary, user-visible branding, original placeholder artwork, reproducible wrappers, validation, CI, an upstream synchronization workflow, Appearance source foundations, and packaged PowerShell/CMD shell-integration assets. -## v0.1 scope +## Current development scope The foundation preserves upstream tabs, panes, multiple windows, command palette, settings UI, keyboard shortcuts, clipboard, search, scrollback, ANSI color, Unicode, CJK, emoji fallback, and font-dependent Powerline rendering. It uses the upstream profile generators for PowerShell 7, Windows PowerShell 5.1, Command Prompt, and installed WSL distributions. -It does not add command translation, completion engines, bundled shells, themes or fonts, renderer changes, extended session restoration, workspaces, docking, AI features, or remote process persistence. +v0.3 adds a conservative local-shell integration layer: a PowerShell 7/Windows PowerShell 5.1 module, CMD initialization assets, OSC 9;9 and OSC 133 marks reused from upstream, Safe `ll`, `la`, `which`, `touch`, and `open` mappings, a Windows API helper for CMD, diagnostics, and a non-persisting paste-risk analyser. + +It does not add a full GNU compatibility layer, bundled GNU utilities or Clink, global PowerShell profile changes, CMD AutoRun changes, renderer changes, extended session restoration, workspaces, docking, AI features, cloud history, remote persistence, or command/clipboard telemetry. ## Prerequisites @@ -29,6 +34,7 @@ Run these commands from a PowerShell 7 prompt: ```powershell .\scripts\winterm\build.ps1 -Configuration Debug -Platform x64 .\scripts\winterm\test.ps1 -Suite Relevant -Configuration Debug -Platform x64 +.\scripts\winterm\test-shell-integration.ps1 -Shell All .\scripts\winterm\build.ps1 -Configuration Release -Platform x64 .\scripts\winterm\package.ps1 -Platform x64 ``` @@ -53,6 +59,9 @@ winTerm does not bundle PowerShell, WSL, or a Linux distribution. ## Current limitations - This checkout has passed source-level smoke validation, but the local environment used for the v0.1 work did not contain PowerShell 7, Visual Studio/MSBuild, or Windows SDK 10.0.22621.0. No local binary or MSIX was produced here. +- The current local environment has Windows PowerShell 5.1 and CMD, but not PowerShell 7, Visual Studio/MSBuild, or the required SDK. Its execution policy also blocks script execution, so runtime module, build, package, launch, UI, clipboard, and rendering acceptance remain open. +- v0.2 Appearance remains source-level only: compilation, package payload, launch, Settings UI, Theme Gallery, font loading, and manual rendering have not been verified. Those facts are not treated as v0.3 passes. +- v0.3 packages the shell module, CMD assets, and helper source/project, but automatic profile launcher injection, Shell Experience settings UI, right-click behavior, paste confirmation UI, and built-app validation remain tracked acceptance work. - Installation, launch, shell execution, tabs, panes, input, and rendering still require manual verification on a correctly provisioned Windows 11 x64 machine. - The manifest publisher `CN=winTerm Development` is a development placeholder and must be replaced together with the signing certificate for a public release. - `winterm.exe` is the packaged execution alias. A `winterm:` URI protocol is intentionally not registered in v0.1 because the upstream application has no matching URI activation handler to reuse safely. @@ -60,12 +69,22 @@ winTerm does not bundle PowerShell, WSL, or a Linux distribution. The evidence and outstanding checks are tracked in [v0.1 acceptance](docs/v0.1-acceptance.md). +The current baseline and v0.3 evidence are tracked in [v0.2 acceptance](docs/v0.2-acceptance.md) and [v0.3 acceptance](docs/v0.3-acceptance.md). + ## Architecture and maintenance - [Architecture and ownership](docs/architecture.md) - [Brand and package identity](docs/branding.md) - [Upstream synchronization](docs/upstream-sync.md) - [Release process](docs/release-process.md) +- [Shell Experience architecture](docs/shell-experience-architecture.md) +- [Shell protocol](docs/shell-protocol.md) +- [PowerShell integration](docs/powershell-integration.md) +- [Command Prompt integration](docs/cmd-integration.md) +- [Linux command compatibility](docs/linux-compatibility.md) +- [Completion](docs/completion.md) +- [Clipboard and paste protection](docs/clipboard-and-paste-protection.md) +- [Shell diagnostics](docs/shell-diagnostics.md) The source baseline is Microsoft Terminal `release-1.25` at commit `1cea42d433253d95c4487a3037db48197b5e72f4`. The `upstream` remote points to `https://github.com/microsoft/terminal.git`. diff --git a/docs/branding.md b/docs/branding.md index e25ea941c..e3d09b469 100644 --- a/docs/branding.md +++ b/docs/branding.md @@ -10,7 +10,7 @@ | Execution alias | `winterm.exe` | | Forbidden alias | `wt.exe` | | Development publisher | `CN=winTerm Development` | -| Package version | `0.2.0.0` | +| Package version | `0.3.0.0` | | Release status | `0.2.0-dev` until v0.2 acceptance is complete | | Package description | `Independent open-source terminal based on Microsoft Windows Terminal` | diff --git a/docs/clipboard-and-paste-protection.md b/docs/clipboard-and-paste-protection.md new file mode 100644 index 000000000..ef5170fd9 --- /dev/null +++ b/docs/clipboard-and-paste-protection.md @@ -0,0 +1,9 @@ +# Clipboard and paste protection + +The v0.3 source foundation contains a pure `PasteRiskAnalyzer`. It examines only the paste candidate in memory and returns decision data: line count, character count, final newline, unexpected control characters, command separators, and a small set of suspicious patterns. It does not modify text, save it, send it over the network, or execute it. + +The intended default confirmation triggers are multiple non-empty lines, a final newline, configured large text, unexpected control characters, multiple command separators, and suspicious destructive patterns. Detection is advisory: it can have false positives and false negatives and must never claim to recognize every harmful command. + +The pending terminal-control UI will offer Paste, Paste without final newline, Copy to editor or clipboard, and Cancel. A preview must be truncated safely and must not expose a complete large or secret-containing paste. Cancel must send no input. + +Right-click behavior remains an application integration acceptance item: selected text should copy and clear selection, no selection should paste, Shift+right-click should open the context menu, and terminal mouse-reporting mode must take precedence for full-screen programs. The analyser is designed to be invoked before bracketed-paste wrapping so the exact clipboard text stays unchanged. diff --git a/docs/cmd-integration.md b/docs/cmd-integration.md new file mode 100644 index 000000000..22b296e14 --- /dev/null +++ b/docs/cmd-integration.md @@ -0,0 +1,15 @@ +# Command Prompt integration + +The package contains `ShellAssets\cmd\winterm-init.cmd`. A profile may explicitly launch it with: + +```cmd +cmd.exe /K "call \"\ShellAssets\cmd\winterm-init.cmd\"" +``` + +Initialization is process-local and idempotent. It sets `WINTERM_SESSION_ID`, `WINTERM_INTEGRATION_VERSION`, and a Safe compatibility default when those variables are absent. It does not require elevation, modify the AutoRun registry value, or write a global setting. + +The script wraps the existing `PROMPT` template with inherited OSC 9;9 and OSC 133 marks. CMD has no safe pre-execution hook, so it relies on the inherited `autoMarkPrompts` behavior for the command-executed transition and cannot claim PowerShell-equivalent exit-code reporting or completion. + +`winterm-doskey.cmd` adds `ll`, `la`, `clear`, `pwd`, `which`, `cat`, `ls`, `touch`, and `open` only when no same-named DOSKEY macro and no same-named `.exe` already exists. It does not overwrite user macros. `touch` and `open` use `winterm-shim.exe`, which accepts Unicode arguments and uses Windows APIs rather than calling PowerShell or composing `cmd /c` strings. + +The helper supports `touch `, `open `, `version`, and `doctor`. It returns `0` on success, `1` on a general error, `2` for invalid arguments, `3` for a missing target, `4` for access denied, and `5` for an unsupported operation. diff --git a/docs/completion.md b/docs/completion.md new file mode 100644 index 000000000..d0fec4a99 --- /dev/null +++ b/docs/completion.md @@ -0,0 +1,7 @@ +# Shell completion + +PowerShell retains its native command, parameter, and path completion. The Safe `ll`, `touch`, and `open` functions use normal path parameters, so they receive the same relative-path, quoted-path, directory, and path-with-space completion behavior as other PowerShell commands. `which` registers a command-name completer only when winTerm owns the function. + +If PSReadLine is loaded, diagnostics reports it as available. winTerm does not set PSReadLine key bindings, edit mode, prediction source, history behavior, or any global option. If PSReadLine is unavailable, the module still loads and basic compatibility commands remain usable. + +CMD keeps its native Tab path completion. DOSKEY does not provide a PowerShell-class completion protocol, and winTerm does not bundle or inject Clink. `winterm-shim.exe` supplies normal command-line help for its supported operations. diff --git a/docs/linux-compatibility.md b/docs/linux-compatibility.md new file mode 100644 index 000000000..a9a753600 --- /dev/null +++ b/docs/linux-compatibility.md @@ -0,0 +1,17 @@ +# Linux and macOS command compatibility + +winTerm v0.3 is not a Bash translator or GNU utility bundle. It offers a small, shell-aware Safe mode for local PowerShell and CMD sessions only. WSL, Git Bash, SSH, and other Unix-like profiles should default to `Off`, because those shells already provide their own command semantics. + +| Command | PowerShell Safe mode | CMD Safe mode | Notes | +| --- | --- | --- | --- | +| `ll`, `la` | `Get-ChildItem -Force` | `dir /a` | `-a` and `--all` accepted in PowerShell. Other GNU flags fail clearly. | +| `which name` | Friendly `Get-Command` result | `where` | Reports name, command type, and source/path in PowerShell. | +| `touch path...` | Create or update `LastWriteTime` | `winterm-shim.exe touch` | Does not create missing parent directories or truncate content. | +| `open target` | `Invoke-Item` for existing files or HTTP(S) URLs | `winterm-shim.exe open` | Uses Windows file/URL associations; no download occurs. | +| `clear`, `cls`, `pwd`, `ls`, `cat`, `history` | Native behavior retained | Safe DOSKEY mapping where appropriate | No PowerShell native alias is overwritten. | + +The precedence rule is: an explicit user command, a native shell command or real executable, then a winTerm mapping, then normal command-not-found behavior. CMD checks existing DOSKEY macros and `.exe` files before adding a mapping. PowerShell does not export a mapping when a command already exists and also defers to a real application found later. + +The following are intentionally not translated: `rm`, `rm -rf`, `sudo`, `chmod`, `chown`, `grep`, `sed`, `awk`, `find`, `xargs`, arbitrary `tar` flags, shell redirection, command substitution, Bash variables, loops, and conditionals. A missing command keeps the normal shell error; winTerm does not generate a destructive alternative. + +`Off` disables winTerm mappings. `Safe` is the default. `Extended` is an experimental label for future explicitly-reviewed additions; it currently behaves like Safe and does not promise broader compatibility. diff --git a/docs/powershell-integration.md b/docs/powershell-integration.md new file mode 100644 index 000000000..547827f69 --- /dev/null +++ b/docs/powershell-integration.md @@ -0,0 +1,29 @@ +# PowerShell integration + +The packaged module is `ShellAssets\powershell\winTerm.Shell\winTerm.Shell.psd1`, version `0.3.0`. It supports PowerShell 7 and Windows PowerShell 5.1 with the same syntax. + +An explicit winTerm profile launcher must set these process-local variables before importing the module: + +```powershell +$env:WINTERM_SESSION_ID = '' +$env:WINTERM_INTEGRATION_VERSION = '1' +Import-Module '\ShellAssets\powershell\winTerm.Shell\winTerm.Shell.psd1' +``` + +The module does not add this block to `$PROFILE`. A launcher must preserve normal PowerShell execution policy; the module neither uses nor recommends `-ExecutionPolicy Bypass`. If policy prevents importing a module, PowerShell must still launch and diagnostics should report the failure and recommend a user-reviewed policy or installation remedy. + +## Prompt and marks + +On an eligible session, the module captures the current `prompt` function, sends prompt/CWD marks around its output, and calls the original script block. A second import detects its own wrapper instead of nesting it. Removing `winTerm.Shell` restores the captured prompt when the wrapper is still active. This preserves common profile customizations, including prompt frameworks loaded before the module. + +The module emits `OSC 133;A`, `OSC 9;9`, `OSC 133;B`, and then `OSC 133;D;` on the next prompt. The inherited `autoMarkPrompts` behavior supplies the command-executed transition. Command duration is intentionally not guessed from prompt idle time. + +## Compatibility and completion + +Compatibility mode resolves in this order: session override, `WINTERM_PROFILE_COMPATIBILITY_MODE`, `WINTERM_COMPATIBILITY_MODE`, then `Safe`. `Off` disables winTerm compatibility functions; `Extended` is labelled experimental and currently provides only the Safe command set. + +The module never exports a compatibility function over an existing user command. Each function additionally defers to a real application with the same name. Native PowerShell commands and aliases such as `cls`, `pwd`, `ls`, `cat`, `clear`, and `history` are not replaced. + +Path parameters use PowerShell's native completion. `which` adds a command completer only when winTerm owns that function. PSReadLine is detected but its key bindings, edit mode, prediction source, and option values are not modified. + +`Get-WinTermShellDiagnostics` reports the shell version, activation state, protocol, marker status, prompt wrapper, completion provider, PSReadLine status, compatibility mode, and a redacted failure category. It does not expose a full current path, command history, environment, or terminal output. diff --git a/docs/release-process.md b/docs/release-process.md index 0e7cf8b7d..05dd2ecc5 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -15,7 +15,7 @@ Run source validation first: ## 2. Set release identity -For a local development package, the v0.2 manifest uses version `0.2.0.0` and publisher `CN=winTerm Development`. +For a local development package, the v0.3 manifest uses version `0.3.0.0` and publisher `CN=winTerm Development`. For a public release: diff --git a/docs/shell-diagnostics.md b/docs/shell-diagnostics.md new file mode 100644 index 000000000..1ac990a97 --- /dev/null +++ b/docs/shell-diagnostics.md @@ -0,0 +1,15 @@ +# Shell integration diagnostics + +PowerShell exposes `Get-WinTermShellDiagnostics` and `Test-WinTermShellIntegration`. They report: + +- shell type and version; +- integration and protocol versions; +- session marker and prompt wrapper state; +- current-directory and command-mark providers; +- PSReadLine and completion availability; +- current compatibility mode; and +- a concise last integration failure category. + +Diagnostics intentionally omit command history, command text, arguments, clipboard data, terminal output, environment dumps, tokens, passwords, and full user paths. CMD diagnostics are limited to its process markers, available helper, prompt configuration, and DOSKEY conflicts. + +An integration failure must leave the shell usable. The usual resolution is to verify that the package shell assets exist, launch an explicit profile that sets the session marker, and import the installed module under the user's existing execution policy. Diagnostics must not write a profile or registry entry to repair an installation. diff --git a/docs/shell-experience-architecture.md b/docs/shell-experience-architecture.md new file mode 100644 index 000000000..2e5f5c1b0 --- /dev/null +++ b/docs/shell-experience-architecture.md @@ -0,0 +1,20 @@ +# Shell Experience architecture + +winTerm keeps command interpretation at the shell boundary. It does not inspect keystrokes to replace text, infer a prompt from rendered cells, or translate input inside SSH, WSL, Vim, Python, or other interactive applications. + +```text +winTerm profile with explicit session marker + -> PowerShell module or CMD initialization asset + -> inherited OSC 9;9 and OSC 133 handling + -> upstream command marks, navigation, and working-directory state +``` + +The PowerShell module activates its prompt integration only when `WINTERM_SESSION_ID` is present and the host is an interactive ConsoleHost with unredirected input and output. It stores a captured prompt script block only for the current process and restores it when removed. No `$PROFILE`, registry AutoRun value, or execution policy is changed. + +`ShellSessionMetadata` is an in-memory, per-session model with a session ID, profile ID, shell type, capabilities, current-directory kind, command state, last exit code, duration, and health. It deliberately excludes command history, terminal output, environment dumps, clipboard data, passwords, and tokens. Remote and WSL directories are tagged separately from trusted local paths. + +The paste analyser is also pure and in-memory. It reports reasons such as multiline text, a final newline, text size, control characters, command separators, and suspicious patterns. It never changes text, records clipboard content, blocks all text containing a word, or executes a command. UI confirmation and terminal-control wiring remain acceptance items until a built application can be exercised. + +## Upstream boundaries + +The v0.3 foundation reuses the inherited TerminalCore parser and command-mark navigation. It does not modify ConPTY, the VT parser, text buffer, renderer core, Unicode width engine, input protocol parser, or OpenConsole internals. The new source under `src/winterm` is limited to diagnostic/session models and paste-risk analysis. diff --git a/docs/shell-protocol.md b/docs/shell-protocol.md new file mode 100644 index 000000000..6997beb60 --- /dev/null +++ b/docs/shell-protocol.md @@ -0,0 +1,24 @@ +# winTerm Shell protocol + +Protocol version: `1`. + +winTerm v0.3 reuses the terminal parser inherited from the Microsoft Terminal `release-1.25` baseline. The shell assets emit only sequences that this checkout already handles. `src/winterm/Shell/Protocol` classifies payloads for diagnostics and tests; it never consumes terminal output or executes an action. + +| Purpose | Sequence | Notes | +| --- | --- | --- | +| Current working directory | `OSC 9 ; 9 ; "C:\Path" ST` | The baseline accepts a quoted or unquoted Windows path. It does not yet implement OSC 7. | +| Prompt start | `OSC 133 ; A ST` | Starts the prompt portion of a command mark. | +| Command start | `OSC 133 ; B ST` | Ends the prompt portion. | +| Command executed | `OSC 133 ; C ST` | Provided by the inherited `autoMarkPrompts` path when available. | +| Command finished | `OSC 133 ; D ; exitCode ST` | PowerShell reports `0` for success and a nonzero value for failure. CMD sends `D` without an exit code. | + +`ST` is `ESC \\`. The PowerShell module sends OSC 9;9 only for the FileSystem provider. It reports local drive and UNC paths, but does not represent a WSL, SSH, or other remote path as a local Windows filesystem path. + +## Safety and compatibility + +- A payload longer than 8,192 characters, containing a control character, or having an unknown shape is ignored by the winTerm diagnostic classifier. +- The inherited terminal dispatcher also ignores unsupported actions. No sequence can start a process, write a file, open a URL, change settings, or change shell compatibility mode. +- Command text is not included in a winTerm-specific protocol message and is not persisted by the v0.3 metadata types. +- The protocol version is independent from the application version. Version `1` remains compatible with a v0.3 module; future versions must preserve these messages or negotiate a different explicit capability. + +For the baseline parser behavior, see `src/terminal/adapter/adaptDispatch.cpp` and the inherited mark specification in `doc/specs/#11000 - Marks/Shell-Integration-Marks.md`. diff --git a/docs/v0.3-acceptance.md b/docs/v0.3-acceptance.md new file mode 100644 index 000000000..54a98d4c1 --- /dev/null +++ b/docs/v0.3-acceptance.md @@ -0,0 +1,83 @@ +# winTerm v0.3 acceptance + +## Verdict + +Last updated: 2026-07-14. + +**Not accepted.** v0.3 provides source, package, test, and documentation foundations for Shell Experience, but this machine cannot build, package, launch, or manually exercise winTerm. A source check, syntax parse, CMD macro inspection, or an asset manifest is not a runtime pass. + +`[x]` means the exact check was run or directly established in this checkout. `[ ]` means not run, unavailable, blocked, or not integrated. + +## Baseline record + +| Item | Result | +| --- | --- | +| Current branch before v0.3 work | `main` | +| Current commit before v0.3 work | `63546ad44495bfb8a36954f3738ab17468c648c0` | +| v0.2 baseline | `3fcd63bd0ebe846095f5a773717a1e9f3927e5bf` | +| Microsoft Terminal source baseline | `release-1.25@1cea42d433253d95c4487a3037db48197b5e72f4` | +| Git remotes | `origin` exists; `upstream` is absent | +| Uncommitted files before v0.3 work | None observed | +| Available shells | Windows PowerShell `5.1.26100.8655`; `cmd.exe`; PowerShell 7 unavailable | +| Existing shell integration | OSC 9;9, OSC 133 A/B/C/D, command marks, and autoMarkPrompts in inherited source | + +- [x] v0.1 and v0.2 acceptance documents exist. +- [x] Package identity remains `Kaname.winTerm`; executable alias remains `winterm.exe`. +- [x] Repository state was inspected before changes. +- [ ] `upstream` remote exists. The local `.git` metadata is not writable in this environment, so no remote was added. +- [ ] Debug x64 build, Release x64 build, package, launch, profiles, panes, theme UI, fonts, ANSI, and emoji runtime verification. + +The v0.2 acceptance document remains authoritative for its unverified Appearance work. v0.3 does not reinterpret those unchecked results as passes or attempt a Theme/renderer refactor. + +## Implemented source foundations + +- Packaged `winTerm.Shell` module, version `0.3.0`, with explicit session-marker activation, prompt preservation/restoration, CWD reporting, command marks, diagnostics, Safe compatibility mode, and native completion boundaries. +- Packaged CMD initialization, non-overriding DOSKEY macros, prompt/CWD marks, and no AutoRun registry operation. +- `winterm-shim.exe` source and project for Unicode `touch`, `open`, `version`, and `doctor` operations using Windows APIs. +- Protocol classifier and session metadata types that reuse rather than replace the inherited OSC dispatcher. +- In-memory paste risk analyser and non-executing unit-test fixture. +- Package validation, source tests, CI coverage, version `0.3.0.0`, and documentation. + +## Executed checks + +| Command or check | Result | +| --- | --- | +| PowerShell parser over `shell/powershell/winTerm.Shell/**/*.ps1` | Passed | +| PowerShell parser over `scripts/winterm/*.ps1` | Passed | +| CMD DOSKEY initialization with a space-containing shim path | Passed; `touch`/`open` macros kept quotes | +| CMD existing `ll` macro collision | Passed; existing macro was preserved | +| `scripts/winterm/test.ps1 -Suite Smoke` without policy override | Blocked by local execution policy | +| PowerShell 7 module test | Unavailable; `pwsh.exe` is not installed | +| C++ unit tests, Debug/Release build, MSIX package | Unavailable; Visual Studio/MSBuild and SDK are absent | + +## Shell capability matrix + +| Capability | PowerShell 7 | Windows PowerShell 5.1 | CMD | WSL | Git Bash | +| --- | --- | --- | --- | --- | +| CWD reporting | Not tested | Source implemented | Source implemented | Not supported | Not supported | +| Command marks | Not tested | Source implemented | Partial source implementation | Not supported | Not supported | +| Exit code | Not tested | Source implemented | Partial | Not supported | Not supported | +| Safe compatibility | Not tested | Source implemented | Source implemented | Disabled by design | Disabled by design | +| Completion | Not tested | Source implemented | Partial/native only | Not supported | Not supported | +| Paste protection | Not tested | Source analyser only | Source analyser only | Not supported | Not supported | + +## Security record + +- No `$PROFILE` modification, execution-policy bypass, remote module import, dynamic download, command telemetry, clipboard telemetry, command-history upload, or registry AutoRun operation was added. +- OSC handling is limited to the inherited safe dispatcher. The winTerm classifier validates length and shape for diagnostics and cannot execute a command, open a URL, write a file, or change settings. +- Safe mappings are shell-level only. No terminal input transformation is added, so commands typed in SSH, REPL applications, editors, password prompts, and full-screen applications are not rewritten. +- `winterm-shim` does not spawn PowerShell, does not compose a `cmd /c` string, does not read history, and does not use the network. + +## Known limitations + +- The profile launcher still needs built-app/runtime integration to resolve a package asset path automatically for generated PowerShell and CMD profiles. +- Shell Experience Settings UI, profile-specific persistent settings, status indicators, right-click behavior, paste confirmation dialog, and mouse-reporting integration are not complete. +- The inherited baseline handles OSC 9;9 rather than OSC 7; PowerShell emits the supported path form. +- PowerShell 7, PSReadLine variants, Oh My Posh, Starship, WSL, Git Bash, SSH, Vim, Python REPL, bracketed paste, and full-screen interaction have not been manually tested. +- CMD has no reliable pre-execution hook, so its exit status and command mark detail are inherently partial. +- Paste risk detection is advisory and may have false positives or false negatives. +- Appearance runtime verification and the missing `upstream` remote remain v0.2 baseline limitations. + +## Required follow-up acceptance run + +On a Windows 11 x64 machine with PowerShell 7, Visual Studio 2022, Windows SDK 10.0.22621.0, and a writable Git configuration, run the source/asset checks, Debug and Release builds, compiled protocol/paste tests, package validation, install/side-by-side launch, and manual profile tests. Record actual commands and results for PowerShell 7, Windows PowerShell 5.1, CMD, WSL, Git Bash where installed, SSH, interactive applications, clipboard behavior, and renderer/Appearance regression checks before changing this verdict. diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 new file mode 100644 index 000000000..6f85ce9b8 --- /dev/null +++ b/scripts/winterm/package-shell-assets.ps1 @@ -0,0 +1,63 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param( + [Parameter()] + [string]$PackageRoot +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$sourceAssets = @( + 'shell\shared\version.json', + 'shell\powershell\winTerm.Shell\winTerm.Shell.psd1', + 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1', + 'shell\cmd\winterm-init.cmd', + 'shell\cmd\winterm-doskey.cmd', + 'shell\cmd\winterm-prompt.cmd', + 'src\winterm-tools\winterm-shim\main.cpp', + 'src\winterm-tools\winterm-shim\winterm-shim.vcxproj' +) + +foreach ($relativePath in $sourceAssets) +{ + $path = Join-Path $repositoryRoot $relativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) + { + throw "Required shell asset '$relativePath' is missing." + } +} + +$version = Get-Content -LiteralPath (Join-Path $repositoryRoot 'shell\shared\version.json') -Raw | ConvertFrom-Json +if ($version.moduleVersion -ne '0.3.0' -or $version.protocolVersion -ne 1) +{ + throw 'The winTerm Shell asset version metadata is invalid.' +} + +if ([string]::IsNullOrWhiteSpace($PackageRoot)) +{ + Write-Host 'winTerm Shell source assets are complete.' -ForegroundColor Green + return +} + +$packageAssets = @( + 'ShellAssets\version.json', + 'ShellAssets\powershell\winTerm.Shell\winTerm.Shell.psd1', + 'ShellAssets\powershell\winTerm.Shell\winTerm.Shell.psm1', + 'ShellAssets\cmd\winterm-init.cmd', + 'ShellAssets\cmd\winterm-doskey.cmd', + 'ShellAssets\cmd\winterm-prompt.cmd', + 'ShellAssets\winterm-shim.exe' +) +foreach ($relativePath in $packageAssets) +{ + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $relativePath) -PathType Leaf)) + { + throw "The package is missing shell asset '$relativePath'." + } +} + +Write-Host 'winTerm Shell package assets are complete.' -ForegroundColor Green diff --git a/scripts/winterm/package.ps1 b/scripts/winterm/package.ps1 index 3a115e3a9..371e80fdc 100644 --- a/scripts/winterm/package.ps1 +++ b/scripts/winterm/package.ps1 @@ -41,9 +41,9 @@ function Assert-WinTermManifest { throw "Package '$Path' must claim winterm.exe and must not claim wt.exe." } - if ($identity.Version -ne '0.2.0.0') + if ($identity.Version -ne '0.3.0.0') { - throw "Package '$Path' must use the winTerm v0.2 package version." + throw "Package '$Path' must use the winTerm v0.3 package version." } } @@ -174,6 +174,11 @@ try Assert-WinTermManifest -Path (Join-Path $temporaryDirectory 'AppxManifest.xml') Assert-AppearancePayload -Path $temporaryDirectory -RepositoryRoot $repositoryRoot + & (Join-Path $PSScriptRoot 'package-shell-assets.ps1') -PackageRoot $temporaryDirectory + if (-not $?) + { + throw 'Shell asset package validation failed.' + } $signature = Get-AuthenticodeSignature -LiteralPath $artifact.FullName Write-Host "Package: $($artifact.FullName)" diff --git a/scripts/winterm/test-compatibility.ps1 b/scripts/winterm/test-compatibility.ps1 new file mode 100644 index 000000000..cfa1394a2 --- /dev/null +++ b/scripts/winterm/test-compatibility.ps1 @@ -0,0 +1,14 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot 'test-shell-integration.ps1') -Shell WindowsPowerShell +if ($LASTEXITCODE -ne 0) +{ + exit $LASTEXITCODE +} diff --git a/scripts/winterm/test-paste-protection.ps1 b/scripts/winterm/test-paste-protection.ps1 new file mode 100644 index 000000000..6a75b838c --- /dev/null +++ b/scripts/winterm/test-paste-protection.ps1 @@ -0,0 +1,19 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$analyzer = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\winterm\Clipboard\PasteRiskAnalyzer.cpp') -Raw +$unitTests = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\cascadia\UnitTests_SettingsModel\WinTermShellTests.cpp') -Raw + +if (-not ($analyzer.Contains('AnalyzePasteRisk') -and $analyzer.Contains('SuspiciousCommand') -and $unitTests.Contains('PasteRiskAnalyzerClassifiesWithoutChangingText'))) +{ + throw 'Paste protection source or its non-executing unit test is missing.' +} + +Write-Host 'PASS: Paste protection source and non-executing unit test are present.' -ForegroundColor Green diff --git a/scripts/winterm/test-shell-integration.ps1 b/scripts/winterm/test-shell-integration.ps1 new file mode 100644 index 000000000..04db5d18e --- /dev/null +++ b/scripts/winterm/test-shell-integration.ps1 @@ -0,0 +1,210 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('PowerShell7', 'WindowsPowerShell', 'CMD', 'All')] + [string]$Shell = 'All' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-Condition +{ + param( + [Parameter(Mandatory)] + [bool]$Condition, + + [Parameter(Mandatory)] + [string]$Message + ) + + if (-not $Condition) + { + throw $Message + } +} + +function Test-PowerShellModule +{ + param( + [Parameter(Mandatory)] + [string]$ModulePath + ) + + $originalSessionId = $env:WINTERM_SESSION_ID + $originalPath = $env:PATH + $env:WINTERM_SESSION_ID = 'test-shell-integration' + $env:PATH = Join-Path $env:SystemRoot 'System32' + $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('winterm-shell-' + [guid]::NewGuid().ToString('N')) + + try + { + Import-Module $ModulePath -Force + Set-WinTermCompatibilityMode -Mode Safe | Out-Null + New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null + + $createdFile = Join-Path $temporaryDirectory 'created.txt' + $touchCommand = Get-Command -Name touch -ErrorAction Stop + if ($touchCommand.ModuleName -eq 'winTerm.Shell') + { + touch $createdFile + Assert-Condition -Condition (Test-Path -LiteralPath $createdFile -PathType Leaf) -Message 'touch did not create a file.' + + Set-Content -LiteralPath $createdFile -Value 'preserved' + touch $createdFile + Assert-Condition -Condition ((Get-Content -LiteralPath $createdFile -Raw).Trim() -eq 'preserved') -Message 'touch truncated an existing file.' + } + else + { + Write-Host "SKIP: touch resolves to the existing $($touchCommand.CommandType) command." + } + + $listing = @(ll $temporaryDirectory) + Assert-Condition -Condition ($listing.Count -gt 0) -Message 'll did not list the requested directory.' + $llCommand = Get-Command -Name ll -ErrorAction Stop + Assert-Condition -Condition ($llCommand.ModuleName -eq 'winTerm.Shell') -Message 'll was not provided by winTerm Shell.' + + $whichCommand = Get-Command -Name which -ErrorAction Stop + $found = @(which Get-ChildItem) + Assert-Condition -Condition ($found.Count -gt 0) -Message 'which did not find Get-ChildItem.' + if ($whichCommand.ModuleName -eq 'winTerm.Shell') + { + Assert-Condition -Condition ($found[0].Name -eq 'Get-ChildItem') -Message 'winTerm which did not return the requested command.' + } + else + { + $nativeOutput = ($found | ForEach-Object { [string]$_ }) -join [Environment]::NewLine + Assert-Condition -Condition ($nativeOutput -match '(?i)Get-ChildItem') -Message 'The native which command did not report Get-ChildItem.' + } + + Set-WinTermCompatibilityMode -Mode Off | Out-Null + $disabled = $false + try + { + ll $temporaryDirectory -ErrorAction Stop + } + catch + { + $disabled = $true + } + Assert-Condition -Condition $disabled -Message 'Compatibility mode Off did not disable ll.' + + Set-WinTermCompatibilityMode -Mode Safe | Out-Null + $diagnostics = Get-WinTermShellDiagnostics + Assert-Condition -Condition ($diagnostics.ProtocolVersion -eq 1) -Message 'Shell diagnostics did not report protocol version 1.' + } + finally + { + Remove-Module -Name winTerm.Shell -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $temporaryDirectory) + { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } + $env:WINTERM_SESSION_ID = $originalSessionId + $env:PATH = $originalPath + } +} + +function Test-PowerShellNativeCommandPrecedence +{ + param( + [Parameter(Mandatory)] + [string]$ModulePath + ) + + $nativeTouch = Get-Command -Name touch -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $nativeTouch) + { + Write-Host 'SKIP: No native touch application is installed.' -ForegroundColor Yellow + return + } + + try + { + Import-Module $ModulePath -Force + $resolvedTouch = Get-Command -Name touch -ErrorAction Stop + Assert-Condition -Condition ($resolvedTouch.CommandType -eq 'Application') -Message 'winTerm Shell overrode a native touch application.' + } + finally + { + Remove-Module -Name winTerm.Shell -Force -ErrorAction SilentlyContinue + } +} + +function Test-CmdInitialization +{ + param( + [Parameter(Mandatory)] + [string]$InitScript + ) + + $doskeyScript = Join-Path (Split-Path -Parent $InitScript) 'winterm-doskey.cmd' + $originalShim = $env:WINTERM_SHIM + $originalMode = $env:WINTERM_COMPATIBILITY_MODE + $env:WINTERM_SHIM = 'C:\Program Files\winTerm\ShellAssets\winterm-shim.exe' + $env:WINTERM_COMPATIBILITY_MODE = 'Safe' + + try + { + $output = & cmd.exe /d /c ('call "{0}" & doskey /macros' -f $doskeyScript) + $outputText = $output -join [Environment]::NewLine + Assert-Condition -Condition ($outputText -match 'll=dir /a \$\*') -Message 'CMD initialization did not register ll.' + + & cmd.exe /d /c 'where touch.exe >nul 2>nul' + $nativeTouchExitCode = $LASTEXITCODE + if ($nativeTouchExitCode -eq 0) + { + Assert-Condition -Condition ($outputText -notmatch '(?m)^touch=') -Message 'CMD initialization overrode a native touch executable.' + } + else + { + Assert-Condition -Condition ($outputText -match 'touch="C:\\Program Files\\winTerm\\ShellAssets\\winterm-shim.exe" touch \$\*') -Message 'CMD touch did not preserve a quoted helper path.' + } + + & cmd.exe /d /c ('call "{0}" & if not defined WINTERM_CMD_INITIALIZED exit 1 & if not defined WINTERM_INTEGRATION_VERSION exit 1' -f $InitScript) + Assert-Condition -Condition ($LASTEXITCODE -eq 0) -Message 'CMD initialization did not set its process-local markers.' + } + finally + { + $env:WINTERM_SHIM = $originalShim + $env:WINTERM_COMPATIBILITY_MODE = $originalMode + } +} + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$modulePath = Join-Path $repositoryRoot 'shell\powershell\winTerm.Shell\winTerm.Shell.psd1' +$cmdInitPath = Join-Path $repositoryRoot 'shell\cmd\winterm-init.cmd' + +if ($Shell -in @('WindowsPowerShell', 'All')) +{ + Test-PowerShellModule -ModulePath $modulePath + Test-PowerShellNativeCommandPrecedence -ModulePath $modulePath + Write-Host 'PASS: Windows PowerShell shell module' -ForegroundColor Green +} + +if ($Shell -in @('PowerShell7', 'All')) +{ + $pwsh = Get-Command pwsh.exe -ErrorAction SilentlyContinue + if ($null -eq $pwsh) + { + Write-Host 'SKIP: PowerShell 7 is not installed.' -ForegroundColor Yellow + } + else + { + & $pwsh.Source -NoLogo -NoProfile -File $PSCommandPath -Shell WindowsPowerShell + if ($LASTEXITCODE -ne 0) + { + throw "PowerShell 7 shell module test failed with exit code $LASTEXITCODE." + } + Write-Host 'PASS: PowerShell 7 shell module' -ForegroundColor Green + } +} + +if ($Shell -in @('CMD', 'All')) +{ + Test-CmdInitialization -InitScript $cmdInitPath + Write-Host 'PASS: Command Prompt initialization' -ForegroundColor Green +} diff --git a/scripts/winterm/test.ps1 b/scripts/winterm/test.ps1 index a7b27b4e6..4fdf3b038 100644 --- a/scripts/winterm/test.ps1 +++ b/scripts/winterm/test.ps1 @@ -29,7 +29,7 @@ function Test-PowerShellSyntax [string]$Directory ) - foreach ($scriptFile in Get-ChildItem -LiteralPath $Directory -File -Filter '*.ps1') + foreach ($scriptFile in Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter '*.ps1') { $tokens = $null $parseErrors = $null @@ -80,6 +80,68 @@ function Test-ProfileFoundations Write-Host 'PASS: WSL dynamic discovery and missing-WSL guard' -ForegroundColor Green } +function Test-ShellExperienceFoundations +{ + param( + [Parameter(Mandatory)] + [string]$RepositoryRoot + ) + + $moduleManifest = Join-Path $RepositoryRoot 'shell\powershell\winTerm.Shell\winTerm.Shell.psd1' + $moduleScript = Join-Path $RepositoryRoot 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1' + $cmdInit = Join-Path $RepositoryRoot 'shell\cmd\winterm-init.cmd' + $protocol = Join-Path $RepositoryRoot 'src\winterm\Shell\Protocol\ShellIntegrationProtocol.cpp' + + foreach ($path in @($moduleManifest, $moduleScript, $cmdInit, $protocol)) + { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) + { + throw "Shell Experience foundation is missing '$path'." + } + } + + $manifest = Import-PowerShellDataFile -LiteralPath $moduleManifest + if ($manifest.ModuleVersion -ne '0.3.0' -or $manifest.PowerShellVersion -ne '5.1') + { + throw 'The winTerm PowerShell module manifest does not declare the supported version boundary.' + } + + $moduleContent = Get-Content -LiteralPath $moduleScript -Raw + $cmdContent = Get-Content -LiteralPath $cmdInit -Raw + $protocolContent = Get-Content -LiteralPath $protocol -Raw + if ($moduleContent -match 'ExecutionPolicy|Bypass' -or $cmdContent -match 'AutoRun' -or $protocolContent -notmatch 'MaximumShellProtocolPayloadLength') + { + throw 'Shell Experience safety boundaries are missing or contain a forbidden policy bypass.' + } + + $shellScriptFiles = Get-ChildItem -LiteralPath (Split-Path -Parent $moduleScript) -Recurse -File -Include '*.ps1', '*.psm1' + foreach ($shellScriptFile in $shellScriptFiles) + { + $tokens = $null + $parseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile($shellScriptFile.FullName, [ref]$tokens, [ref]$parseErrors) | Out-Null + if ($parseErrors.Count -gt 0) + { + $details = $parseErrors | ForEach-Object { "line $($_.Extent.StartLineNumber): $($_.Message)" } + throw "PowerShell syntax failed for '$($shellScriptFile.FullName)': $($details -join '; ')" + } + } + + & (Join-Path $PSScriptRoot 'package-shell-assets.ps1') + if (-not $?) + { + throw 'Shell asset validation failed.' + } + + & (Join-Path $PSScriptRoot 'test-paste-protection.ps1') + if (-not $?) + { + throw 'Paste protection source validation failed.' + } + + Write-Host 'PASS: Shell Experience source foundations' -ForegroundColor Green +} + $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $originalLocation = Get-Location @@ -107,6 +169,7 @@ try } Test-ProfileFoundations -RepositoryRoot $repositoryRoot + Test-ShellExperienceFoundations -RepositoryRoot $repositoryRoot if ($Suite -eq 'Smoke') { diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 355598b6e..087cf4ccb 100644 --- a/scripts/winterm/verify-branding.ps1 +++ b/scripts/winterm/verify-branding.ps1 @@ -73,7 +73,7 @@ function Test-Manifest Test-Requirement -Condition ($null -ne $identity -and $identity.Name -eq 'Kaname.winTerm') -Message "$Path uses package identity Kaname.winTerm" Test-Requirement -Condition ($null -ne $identity -and $identity.Name -notmatch '^Microsoft\.') -Message "$Path does not use a Microsoft package name" Test-Requirement -Condition ($null -ne $identity -and $identity.Publisher -eq 'CN=winTerm Development') -Message "$Path uses the documented development publisher placeholder" - Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '0.2.0.0') -Message "$Path uses package version 0.2.0.0" + Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '0.3.0.0') -Message "$Path uses package version 0.3.0.0" Test-Requirement -Condition ($null -ne $properties -and $properties.DisplayName -eq 'winTerm') -Message "$Path package display name is winTerm" Test-Requirement -Condition ($null -ne $application -and $application.Id -eq 'winTerm') -Message "$Path application ID is winTerm" Test-Requirement -Condition ($null -ne $visualElements -and $visualElements.DisplayName -eq 'winTerm') -Message "$Path application display name is winTerm" diff --git a/shell/cmd/README.md b/shell/cmd/README.md new file mode 100644 index 000000000..4e2ef65ba --- /dev/null +++ b/shell/cmd/README.md @@ -0,0 +1,13 @@ +# winTerm Command Prompt integration + +`winterm-init.cmd` is designed for an explicit winTerm profile command such as: + +```cmd +cmd.exe /K "call \"\ShellAssets\cmd\winterm-init.cmd\"" +``` + +It sets only process-local environment variables, configures prompt marks using the inherited `OSC 9;9` and `OSC 133` handlers, and adds DOSKEY macros only when neither a user macro nor a real `.exe` has the same name. It never modifies the AutoRun registry value. + +CMD supplies no dependable pre-execution hook. The integration therefore relies on the upstream `autoMarkPrompts` behavior for command execution marks and reports CWD and prompt boundaries on each prompt. It does not claim full command completion or reliable exit-code reporting. + +The `touch` and `open` macros call `winterm-shim.exe`. If that helper is missing, CMD remains usable and the affected macro reports a normal command failure. diff --git a/shell/cmd/winterm-doskey.cmd b/shell/cmd/winterm-doskey.cmd new file mode 100644 index 000000000..18fd41a31 --- /dev/null +++ b/shell/cmd/winterm-doskey.cmd @@ -0,0 +1,44 @@ +@echo off +rem Copyright (c) winTerm contributors. +rem Licensed under the MIT license. + +if /i "%WINTERM_COMPATIBILITY_MODE%"=="Off" exit /b 0 + +call :AddMacro ll "dir /a $*" +call :AddMacro la "dir /a $*" +call :AddMacro clear "cls" +call :AddMacro pwd "cd" +call :AddMacro which "where $*" +call :AddMacro cat "type $*" +call :AddShimMacro touch touch +call :AddShimMacro open open +call :AddMacro ls "dir $*" +exit /b 0 + +:AddMacro +call :HasMacro "%~1" +if not errorlevel 1 exit /b 0 + +call :HasApplication "%~1" +if not errorlevel 1 exit /b 0 + +doskey %~1=%~2 +exit /b 0 + +:AddShimMacro +call :HasMacro "%~1" +if not errorlevel 1 exit /b 0 + +call :HasApplication "%~1" +if not errorlevel 1 exit /b 0 + +doskey %~1="%WINTERM_SHIM%" %~2 $* +exit /b 0 + +:HasMacro +doskey /macros | findstr /r /i /c:"^%~1=" >nul +exit /b %ERRORLEVEL% + +:HasApplication +where "%~1.exe" >nul 2>nul +exit /b %ERRORLEVEL% diff --git a/shell/cmd/winterm-init.cmd b/shell/cmd/winterm-init.cmd new file mode 100644 index 000000000..290b9dea3 --- /dev/null +++ b/shell/cmd/winterm-init.cmd @@ -0,0 +1,15 @@ +@echo off +rem Copyright (c) winTerm contributors. +rem Licensed under the MIT license. + +if defined WINTERM_CMD_INITIALIZED exit /b 0 +set "WINTERM_CMD_INITIALIZED=1" +set "WINTERM_INTEGRATION_VERSION=1" +if not defined WINTERM_SESSION_ID set "WINTERM_SESSION_ID=cmd-%RANDOM%%RANDOM%" +if not defined WINTERM_COMPATIBILITY_MODE set "WINTERM_COMPATIBILITY_MODE=Safe" +set "WINTERM_SHELL_ASSET_ROOT=%~dp0.." +set "WINTERM_SHIM=%WINTERM_SHELL_ASSET_ROOT%\winterm-shim.exe" + +call "%~dp0winterm-doskey.cmd" +call "%~dp0winterm-prompt.cmd" +exit /b 0 diff --git a/shell/cmd/winterm-prompt.cmd b/shell/cmd/winterm-prompt.cmd new file mode 100644 index 000000000..af72a06ee --- /dev/null +++ b/shell/cmd/winterm-prompt.cmd @@ -0,0 +1,12 @@ +@echo off +rem Copyright (c) winTerm contributors. +rem Licensed under the MIT license. + +if defined WINTERM_CMD_PROMPT_INITIALIZED exit /b 0 +set "WINTERM_CMD_PROMPT_INITIALIZED=1" +if not defined WINTERM_ORIGINAL_PROMPT set "WINTERM_ORIGINAL_PROMPT=%PROMPT%" + +rem cmd.exe has no reliable pre-execution hook. autoMarkPrompts supplies the +rem command-executed transition while this prompt supplies CWD and prompt marks. +prompt $E]133;D$E\$E]133;A$E\$E]9;9;"$P"$E\$E]133;B$E\%WINTERM_ORIGINAL_PROMPT% +exit /b 0 diff --git a/shell/powershell/winTerm.Shell/Completion/CompatibilityCompletion.ps1 b/shell/powershell/winTerm.Shell/Completion/CompatibilityCompletion.ps1 new file mode 100644 index 000000000..af24ed9af --- /dev/null +++ b/shell/powershell/winTerm.Shell/Completion/CompatibilityCompletion.ps1 @@ -0,0 +1,29 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +function Register-WinTermCompatibilityCompletion +{ + [CmdletBinding()] + param() + + if ($null -eq (Get-Command -Name Register-ArgumentCompleter -ErrorAction SilentlyContinue)) + { + $script:WinTermCompletionProvider = 'PowerShell native completion only' + return + } + + Register-ArgumentCompleter -CommandName which -ParameterName Name -ScriptBlock { + param($commandName, $parameterName, $wordToComplete) + + Get-Command -Name ($wordToComplete + '*') -ErrorAction SilentlyContinue | + Sort-Object Name -Unique | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.CommandType.ToString()) + } + } + + if ($null -ne (Get-Module -Name PSReadLine | Select-Object -First 1)) + { + $script:WinTermCompletionProvider = 'PowerShell native completion with PSReadLine' + } +} diff --git a/shell/powershell/winTerm.Shell/Private/Prompt.ps1 b/shell/powershell/winTerm.Shell/Private/Prompt.ps1 new file mode 100644 index 000000000..678d9c993 --- /dev/null +++ b/shell/powershell/winTerm.Shell/Private/Prompt.ps1 @@ -0,0 +1,99 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +function Invoke-WinTermPrompt +{ + [CmdletBinding()] + param() + + $lastSuccess = $? + if ($script:WinTermHasPrompted) + { + Write-WinTermOsc -Payload ('133;D;' + (Get-WinTermExitCode -LastSuccess $lastSuccess)) + } + + Write-WinTermOsc -Payload '133;A' + Send-WinTermCurrentDirectory + + try + { + $promptText = if ($null -ne $script:WinTermOriginalPrompt) { & $script:WinTermOriginalPrompt } else { 'PS> ' } + } + catch + { + $script:WinTermLastIntegrationError = 'The existing PowerShell prompt function failed.' + $promptText = 'PS> ' + } + + Write-WinTermOsc -Payload '133;B' + $script:WinTermHasPrompted = $true + return $promptText +} + +function Test-WinTermPromptWrapper +{ + [CmdletBinding()] + param() + + if ($null -eq $script:WinTermPromptWrapper) + { + return $false + } + + $prompt = Get-Command -Name prompt -CommandType Function -ErrorAction SilentlyContinue | Select-Object -First 1 + return $null -ne $prompt -and $prompt.ScriptBlock.ToString() -eq $script:WinTermPromptWrapper.ToString() +} + +function Enable-WinTermShellIntegration +{ + [CmdletBinding()] + param( + [switch]$Force + ) + + if (-not $Force -and -not (Test-WinTermInteractiveSession)) + { + $script:WinTermLastIntegrationError = 'The winTerm session marker or an interactive console host was not detected.' + return $false + } + + if (Test-WinTermPromptWrapper) + { + $script:WinTermIntegrationEnabled = $true + $script:WinTermPromptWrapped = $true + return $true + } + + $originalPrompt = Get-Command -Name prompt -CommandType Function -ErrorAction SilentlyContinue | Select-Object -First 1 + $script:WinTermOriginalPrompt = if ($null -ne $originalPrompt) { $originalPrompt.ScriptBlock } else { { 'PS> ' } } + $script:WinTermPromptWrapper = { + $module = Get-Module -Name 'winTerm.Shell' + if ($null -ne $module) + { + return & $module { Invoke-WinTermPrompt } + } + return 'PS> ' + } + + Set-Item -Path Function:\global:prompt -Value $script:WinTermPromptWrapper -Force + $script:WinTermIntegrationEnabled = $true + $script:WinTermPromptWrapped = $true + $script:WinTermLastIntegrationError = $null + return $true +} + +function Disable-WinTermShellIntegration +{ + [CmdletBinding()] + param() + + if ((Test-WinTermPromptWrapper) -and $null -ne $script:WinTermOriginalPrompt) + { + Set-Item -Path Function:\global:prompt -Value $script:WinTermOriginalPrompt -Force + } + + $script:WinTermIntegrationEnabled = $false + $script:WinTermPromptWrapped = $false + $script:WinTermHasPrompted = $false + return $true +} diff --git a/shell/powershell/winTerm.Shell/Private/Protocol.ps1 b/shell/powershell/winTerm.Shell/Private/Protocol.ps1 new file mode 100644 index 000000000..5498335ad --- /dev/null +++ b/shell/powershell/winTerm.Shell/Private/Protocol.ps1 @@ -0,0 +1,74 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +function Write-WinTermOsc +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Payload + ) + + if ($Payload.Length -eq 0 -or $Payload.Length -gt 8192 -or $Payload -match '[\x00-\x1F\x7F]') + { + $script:WinTermLastIntegrationError = 'An invalid shell integration payload was ignored.' + return + } + + try + { + [Console]::Out.Write((([char]27).ToString() + ']' + $Payload + [char]27 + '\\')) + } + catch + { + $script:WinTermLastIntegrationError = 'The terminal did not accept a shell integration sequence.' + } +} + +function Send-WinTermCurrentDirectory +{ + [CmdletBinding()] + param() + + try + { + $location = Get-Location + if ($location.Provider.Name -ne 'FileSystem') + { + return + } + + $path = $location.ProviderPath + if ([string]::IsNullOrWhiteSpace($path) -or $path -match '[\x00-\x1F\x7F"]') + { + return + } + + Write-WinTermOsc -Payload ('9;9;"' + $path + '"') + } + catch + { + $script:WinTermLastIntegrationError = 'The current directory could not be reported.' + } +} + +function Get-WinTermExitCode +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [bool]$LastSuccess + ) + + if ($LastSuccess) + { + return 0 + } + + if ($null -ne $global:LASTEXITCODE -and [int]$global:LASTEXITCODE -ne 0) + { + return [int]$global:LASTEXITCODE + } + + return 1 +} diff --git a/shell/powershell/winTerm.Shell/Private/State.ps1 b/shell/powershell/winTerm.Shell/Private/State.ps1 new file mode 100644 index 000000000..17241c402 --- /dev/null +++ b/shell/powershell/winTerm.Shell/Private/State.ps1 @@ -0,0 +1,74 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +function Test-WinTermInteractiveSession +{ + [CmdletBinding()] + param() + + if ([string]::IsNullOrWhiteSpace($env:WINTERM_SESSION_ID)) + { + return $false + } + + if ($Host.Name -ne 'ConsoleHost') + { + return $false + } + + return -not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected +} + +function Test-WinTermExistingCommand +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Name + ) + + $commands = @(Get-Command -Name $Name -All -ErrorAction SilentlyContinue | + Where-Object { $_.ModuleName -ne 'winTerm.Shell' -and $_.Source -ne 'winTerm.Shell' }) + return $commands.Count -gt 0 +} + +function Get-WinTermCompatibilityModeInternal +{ + [CmdletBinding()] + param() + + foreach ($candidate in @($script:WinTermSessionCompatibilityMode, $env:WINTERM_PROFILE_COMPATIBILITY_MODE, $env:WINTERM_COMPATIBILITY_MODE, 'Safe')) + { + if ($candidate -in @('Off', 'Safe', 'Extended')) + { + return $candidate + } + } + + return 'Safe' +} + +function Assert-WinTermSafeCompatibilityEnabled +{ + [CmdletBinding()] + param() + + if ((Get-WinTermCompatibilityModeInternal) -eq 'Off') + { + Write-Error -Category InvalidOperation -Message 'winTerm Safe Compatibility mode is disabled for this session.' + return $false + } + return $true +} + +function Find-WinTermNativeCommand +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Name + ) + + return Get-Command -Name $Name -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 +} diff --git a/shell/powershell/winTerm.Shell/Public/Compatibility.ps1 b/shell/powershell/winTerm.Shell/Public/Compatibility.ps1 new file mode 100644 index 000000000..acdbc6969 --- /dev/null +++ b/shell/powershell/winTerm.Shell/Public/Compatibility.ps1 @@ -0,0 +1,243 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +function Invoke-WinTermList +{ + [CmdletBinding()] + param( + [string[]]$ArgumentList, + + [Parameter(Mandatory)] + [string]$CommandName + ) + + if (-not (Assert-WinTermSafeCompatibilityEnabled)) + { + return + } + + $native = Find-WinTermNativeCommand -Name $CommandName + if ($null -ne $native) + { + & $native.Source @ArgumentList + return + } + + $path = '.' + foreach ($argument in @($ArgumentList)) + { + if ($argument -in @('-a', '--all')) + { + continue + } + if ($argument.StartsWith('-')) + { + Write-Error -Category InvalidArgument -Message "Unsupported option '$argument'. Supported options are -a and --all." + return + } + if ($path -ne '.') + { + Write-Error -Category InvalidArgument -Message 'll and la accept one path argument.' + return + } + $path = $argument + } + + Get-ChildItem -LiteralPath $path -Force +} + +function ll +{ + <# + .SYNOPSIS + Lists a directory including hidden items when Safe Compatibility mode is enabled. + .EXAMPLE + ll C:\Projects + #> + [CmdletBinding()] + param( + [Parameter(ValueFromRemainingArguments = $true, Position = 0)] + [string[]]$ArgumentList + ) + + Invoke-WinTermList -ArgumentList $ArgumentList -CommandName 'll' +} + +function la +{ + <# + .SYNOPSIS + Lists a directory including hidden items when Safe Compatibility mode is enabled. + .EXAMPLE + la --all + #> + [CmdletBinding()] + param( + [Parameter(ValueFromRemainingArguments = $true, Position = 0)] + [string[]]$ArgumentList + ) + + Invoke-WinTermList -ArgumentList $ArgumentList -CommandName 'la' +} + +function which +{ + <# + .SYNOPSIS + Displays the command type and source for a command. + .EXAMPLE + which git + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)] + [ValidateNotNullOrEmpty()] + [string]$Name + ) + + if (-not (Assert-WinTermSafeCompatibilityEnabled)) + { + return + } + + $native = Find-WinTermNativeCommand -Name 'which' + if ($null -ne $native) + { + & $native.Source $Name + return + } + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $command) + { + Write-Error -Category ObjectNotFound -Message "Command '$Name' was not found." + return + } + + $source = if (-not [string]::IsNullOrWhiteSpace($command.Source)) { $command.Source } elseif (-not [string]::IsNullOrWhiteSpace($command.Path)) { $command.Path } else { $command.Definition } + [PSCustomObject]@{ + Name = $command.Name + CommandType = $command.CommandType.ToString() + Source = $source + } +} + +function touch +{ + <# + .SYNOPSIS + Creates an empty file or updates the last-write time without truncating content. + .EXAMPLE + touch notes.txt + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromRemainingArguments = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string[]]$Path + ) + + if (-not (Assert-WinTermSafeCompatibilityEnabled)) + { + return + } + + $native = Find-WinTermNativeCommand -Name 'touch' + if ($null -ne $native) + { + & $native.Source @Path + return + } + + foreach ($candidate in $Path) + { + $uri = $null + if ([Uri]::TryCreate($candidate, [UriKind]::Absolute, [ref]$uri) -and -not $uri.IsFile) + { + Write-Error -Category InvalidArgument -Message "touch accepts file system paths only: '$candidate'." + continue + } + + try + { + $fileSystemPath = [System.IO.Path]::GetFullPath($candidate) + } + catch + { + Write-Error -Category InvalidArgument -Message "touch accepts valid file system paths only: '$candidate'." + continue + } + + if (Test-Path -LiteralPath $fileSystemPath) + { + $item = Get-Item -LiteralPath $fileSystemPath -Force + $item.LastWriteTime = Get-Date + continue + } + + $parent = Split-Path -Path $fileSystemPath -Parent + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent -PathType Container)) + { + Write-Error -Category ObjectNotFound -Message "The parent directory for '$candidate' does not exist." + continue + } + + try + { + $stream = [System.IO.File]::Open($fileSystemPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite) + $stream.Dispose() + } + catch + { + Write-Error -Category WriteError -Message "The file '$candidate' could not be created." + } + } +} + +function open +{ + <# + .SYNOPSIS + Opens an existing file system target or HTTP(S) URL through its registered Windows handler. + .EXAMPLE + open . + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)] + [ValidateNotNullOrEmpty()] + [string]$Target + ) + + if (-not (Assert-WinTermSafeCompatibilityEnabled)) + { + return + } + + $native = Find-WinTermNativeCommand -Name 'open' + if ($null -ne $native) + { + & $native.Source $Target + return + } + + $uri = $null + if ([Uri]::TryCreate($Target, [UriKind]::Absolute, [ref]$uri) -and -not $uri.IsFile) + { + if ($uri.Scheme -notin @('http', 'https')) + { + Write-Error -Category InvalidArgument -Message "The URL scheme '$($uri.Scheme)' is not supported." + return + } + Invoke-Item -Path $uri.AbsoluteUri + return + } + + if (-not (Test-Path -LiteralPath $Target)) + { + Write-Error -Category ObjectNotFound -Message "The target '$Target' does not exist." + return + } + + Invoke-Item -LiteralPath $Target +} diff --git a/shell/powershell/winTerm.Shell/Public/Diagnostics.ps1 b/shell/powershell/winTerm.Shell/Public/Diagnostics.ps1 new file mode 100644 index 000000000..d92c5ff70 --- /dev/null +++ b/shell/powershell/winTerm.Shell/Public/Diagnostics.ps1 @@ -0,0 +1,79 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +function Get-WinTermShellDiagnostics +{ + [CmdletBinding()] + param() + + $psReadLine = Get-Module -Name PSReadLine | Select-Object -First 1 + $shellType = if ($PSVersionTable.PSEdition -eq 'Core') { 'PowerShell' } else { 'Windows PowerShell' } + + return [PSCustomObject]@{ + ShellType = $shellType + ShellVersion = $PSVersionTable.PSVersion.ToString() + IntegrationEnabled = $script:WinTermIntegrationEnabled + IntegrationVersion = $script:WinTermModuleVersion + ProtocolVersion = $script:WinTermProtocolVersion + SessionMarkerDetected = -not [string]::IsNullOrWhiteSpace($env:WINTERM_SESSION_ID) + PromptWrapped = $script:WinTermPromptWrapped + CurrentDirectoryReporting = if ($script:WinTermIntegrationEnabled) { 'OSC 9;9' } else { 'Disabled' } + CommandMarks = if ($script:WinTermIntegrationEnabled) { 'OSC 133 A/B/D with upstream autoMarkPrompts' } else { 'Disabled' } + CompletionProvider = $script:WinTermCompletionProvider + PSReadLineStatus = if ($null -ne $psReadLine) { 'Loaded' } else { 'Not loaded' } + CompatibilityMode = Get-WinTermCompatibilityModeInternal + LastIntegrationError = $script:WinTermLastIntegrationError + } +} + +function Test-WinTermShellIntegration +{ + [CmdletBinding()] + param() + + $diagnostics = Get-WinTermShellDiagnostics + return [PSCustomObject]@{ + Healthy = $diagnostics.IntegrationEnabled -and $diagnostics.PromptWrapped -and $diagnostics.SessionMarkerDetected + Diagnostics = $diagnostics + Resolution = if ($diagnostics.SessionMarkerDetected) { $null } else { 'Launch this shell from a winTerm profile that sets WINTERM_SESSION_ID.' } + } +} + +function Get-WinTermCompatibilityMode +{ + [CmdletBinding()] + param() + + return Get-WinTermCompatibilityModeInternal +} + +function Set-WinTermCompatibilityMode +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('Off', 'Safe', 'Extended')] + [string]$Mode, + + [ValidateSet('Session', 'Profile', 'Global')] + [string]$Scope = 'Session' + ) + + switch ($Scope) + { + 'Session' + { + $script:WinTermSessionCompatibilityMode = $Mode + } + 'Profile' + { + $env:WINTERM_PROFILE_COMPATIBILITY_MODE = $Mode + } + 'Global' + { + $env:WINTERM_COMPATIBILITY_MODE = $Mode + } + } + + return Get-WinTermCompatibilityModeInternal +} diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 new file mode 100644 index 000000000..faf4a3e2b --- /dev/null +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 @@ -0,0 +1,20 @@ +@{ + RootModule = 'winTerm.Shell.psm1' + ModuleVersion = '0.3.0' + GUID = 'f65cd8f4-5d25-4a2a-a0d4-58df1ab3dc5a' + Author = 'winTerm contributors' + CompanyName = 'winTerm' + Copyright = '(c) winTerm contributors. Licensed under the MIT license.' + Description = 'Shell integration and safe command compatibility for winTerm.' + PowerShellVersion = '5.1' + FunctionsToExport = @('Get-WinTermShellDiagnostics', 'Test-WinTermShellIntegration', 'Enable-WinTermShellIntegration', 'Disable-WinTermShellIntegration', 'Get-WinTermCompatibilityMode', 'Set-WinTermCompatibilityMode', 'll', 'la', 'which', 'touch', 'open') + CmdletsToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('winTerm', 'terminal', 'shell-integration') + ProjectUri = 'https://github.com/HelloThisWorld/winTerm' + LicenseUri = 'https://github.com/HelloThisWorld/winTerm/blob/main/LICENSE' + } + } +} diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 new file mode 100644 index 000000000..2568c1922 --- /dev/null +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 @@ -0,0 +1,61 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +Set-StrictMode -Version Latest + +$script:WinTermModuleVersion = '0.3.0' +$script:WinTermProtocolVersion = 1 +$script:WinTermIntegrationEnabled = $false +$script:WinTermPromptWrapped = $false +$script:WinTermHasPrompted = $false +$script:WinTermOriginalPrompt = $null +$script:WinTermPromptWrapper = $null +$script:WinTermSessionCompatibilityMode = $null +$script:WinTermLastIntegrationError = $null +$script:WinTermCompletionProvider = 'PowerShell native completion' + +foreach ($relativePath in @( + 'Private\State.ps1', + 'Private\Protocol.ps1', + 'Private\Prompt.ps1', + 'Public\Diagnostics.ps1', + 'Public\Compatibility.ps1', + 'Completion\CompatibilityCompletion.ps1' + )) +{ + . (Join-Path $PSScriptRoot $relativePath) +} + +$script:WinTermExportedCompatibilityCommands = @() +foreach ($commandName in @('ll', 'la', 'which', 'touch', 'open')) +{ + if (-not (Test-WinTermExistingCommand -Name $commandName)) + { + $script:WinTermExportedCompatibilityCommands += $commandName + } +} + +Register-WinTermCompatibilityCompletion + +if (Test-WinTermInteractiveSession) +{ + Enable-WinTermShellIntegration | Out-Null +} + +$ExecutionContext.SessionState.Module.OnRemove = { + Disable-WinTermShellIntegration | Out-Null +} + +Export-ModuleMember -Function @( + 'Get-WinTermShellDiagnostics', + 'Test-WinTermShellIntegration', + 'Enable-WinTermShellIntegration', + 'Disable-WinTermShellIntegration', + 'Get-WinTermCompatibilityMode', + 'Set-WinTermCompatibilityMode' +) -Variable @() + +if ($script:WinTermExportedCompatibilityCommands.Count -gt 0) +{ + Export-ModuleMember -Function $script:WinTermExportedCompatibilityCommands -Variable @() +} diff --git a/shell/shared/version.json b/shell/shared/version.json new file mode 100644 index 000000000..084f832a8 --- /dev/null +++ b/shell/shared/version.json @@ -0,0 +1,5 @@ +{ + "applicationVersion": "0.3.0-dev", + "moduleVersion": "0.3.0", + "protocolVersion": 1 +} diff --git a/src/cascadia/CascadiaPackage/CascadiaPackage.wapproj b/src/cascadia/CascadiaPackage/CascadiaPackage.wapproj index 4fe7e9f56..8884f7455 100644 --- a/src/cascadia/CascadiaPackage/CascadiaPackage.wapproj +++ b/src/cascadia/CascadiaPackage/CascadiaPackage.wapproj @@ -93,9 +93,13 @@ {9CBD7DFA-1754-4A9D-93D7-857A9D17CB1B} - - {416fd703-baa7-4f6e-9361-64f550ec8fca} - + + {416fd703-baa7-4f6e-9361-64f550ec8fca} + + + {3C5DE684-ADFD-4630-BA91-67A1A3B6AF7A} + false + {71CC9D78-BA29-4D93-946F-BEF5D9A3A6EF} diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index dd1e4636b..73f928ee4 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="0.3.0.0" /> winTerm diff --git a/src/cascadia/CascadiaResources.build.items b/src/cascadia/CascadiaResources.build.items index 42bfb7b3c..8153c18f9 100644 --- a/src/cascadia/CascadiaResources.build.items +++ b/src/cascadia/CascadiaResources.build.items @@ -45,6 +45,23 @@ true AppearanceAssets\samples\%(RecursiveDir)%(FileName)%(Extension) + + + true + ShellAssets\powershell\winTerm.Shell\%(RecursiveDir)%(FileName)%(Extension) + + + true + ShellAssets\cmd\%(RecursiveDir)%(FileName)%(Extension) + + + true + ShellAssets\version.json + + + true + ShellAssets\winterm-shim.exe + true THIRD_PARTY_NOTICES.md diff --git a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj index 30ebf8640..9b0fc2e71 100644 --- a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj +++ b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj @@ -236,6 +236,9 @@ + + + @@ -263,6 +266,9 @@ + + + diff --git a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj index 9e9e646a8..6d5c24909 100644 --- a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj +++ b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj @@ -46,6 +46,7 @@ + diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp new file mode 100644 index 000000000..e4f20dbf4 --- /dev/null +++ b/src/cascadia/UnitTests_SettingsModel/WinTermShellTests.cpp @@ -0,0 +1,60 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" + +#include + +#include "../../winterm/Clipboard/PasteRiskAnalyzer.h" +#include "../../winterm/Shell/Protocol/ShellIntegrationProtocol.h" +#include "../../winterm/Shell/Sessions/ShellSessionMetadata.h" + +using namespace WEX::TestExecution; +using namespace winTerm::Clipboard; +using namespace winTerm::Shell; + +namespace SettingsModelUnitTests +{ + class WinTermShellTests + { + TEST_CLASS(WinTermShellTests); + + TEST_METHOD(ProtocolClassifierAcceptsKnownSafePayloads); + TEST_METHOD(PasteRiskAnalyzerClassifiesWithoutChangingText); + }; + + void WinTermShellTests::ProtocolClassifierAcceptsKnownSafePayloads() + { + const auto directory = ClassifyShellIntegrationPayload(L"9;9;\"C:\\Projects\\中文\""); + VERIFY_IS_TRUE(directory.has_value()); + VERIFY_ARE_EQUAL(ShellEventType::CurrentDirectory, directory->type); + VERIFY_ARE_EQUAL(std::wstring{ L"C:\\Projects\\中文" }, directory->currentDirectory); + + const auto completed = ClassifyShellIntegrationPayload(L"133;D;42"); + VERIFY_IS_TRUE(completed.has_value()); + VERIFY_ARE_EQUAL(ShellEventType::CommandFinished, completed->type); + VERIFY_ARE_EQUAL(uint32_t{ 42 }, *completed->exitCode); + + VERIFY_IS_FALSE(ClassifyShellIntegrationPayload(L"133;D;not-a-number").has_value()); + VERIFY_IS_FALSE(ClassifyShellIntegrationPayload(std::wstring(MaximumShellProtocolPayloadLength + 1, L'x')).has_value()); + + ShellSessionRegistry registry; + ShellSessionMetadata metadata; + metadata.sessionId = L"pane-1"; + metadata.currentDirectory = { CurrentDirectoryKind::Remote, L"/remote/app" }; + registry.Upsert(metadata); + VERIFY_IS_FALSE(registry.Find(L"pane-1")->currentDirectory.IsTrustedLocalPath()); + } + + void WinTermShellTests::PasteRiskAnalyzerClassifiesWithoutChangingText() + { + const std::wstring content{ L"Remove-Item -Recurse -Force .\n" }; + const auto analysis = AnalyzePasteRisk(content); + + VERIFY_ARE_EQUAL(content.size(), analysis.characterCount); + VERIFY_IS_TRUE(analysis.endsWithNewline); + VERIFY_IS_TRUE(analysis.RequiresConfirmation()); + VERIFY_IS_TRUE(std::find(analysis.reasons.begin(), analysis.reasons.end(), PasteRiskReason::SuspiciousCommand) != analysis.reasons.end()); + VERIFY_ARE_EQUAL(std::wstring{ L"Remove-Item -Recurse -Force .\n" }, content); + } +} diff --git a/src/winterm-tools/winterm-shim/main.cpp b/src/winterm-tools/winterm-shim/main.cpp new file mode 100644 index 000000000..2776e1651 --- /dev/null +++ b/src/winterm-tools/winterm-shim/main.cpp @@ -0,0 +1,158 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include +#include + +#include +#include + +namespace +{ + constexpr int Success{ 0 }; + constexpr int GeneralError{ 1 }; + constexpr int InvalidArguments{ 2 }; + constexpr int TargetNotFound{ 3 }; + constexpr int AccessDenied{ 4 }; + constexpr int UnsupportedOperation{ 5 }; + + int ExitCodeForWin32Error(const DWORD error) noexcept + { + switch (error) + { + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + return TargetNotFound; + case ERROR_ACCESS_DENIED: + case ERROR_SHARING_VIOLATION: + return AccessDenied; + default: + return GeneralError; + } + } + + void WriteWin32Error(const std::wstring_view operation, const DWORD error) + { + std::wcerr << L"winterm-shim: " << operation << L" failed (Windows error " << error << L").\n"; + } + + int TouchPath(const wchar_t* const path) + { + const auto handle = CreateFileW(path, + FILE_WRITE_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_BACKUP_SEMANTICS, + nullptr); + if (handle != INVALID_HANDLE_VALUE) + { + FILETIME now{}; + GetSystemTimeAsFileTime(&now); + if (!SetFileTime(handle, nullptr, nullptr, &now)) + { + const auto error = GetLastError(); + CloseHandle(handle); + WriteWin32Error(L"updating the target timestamp", error); + return ExitCodeForWin32Error(error); + } + CloseHandle(handle); + return Success; + } + + const auto existingError = GetLastError(); + if (existingError != ERROR_FILE_NOT_FOUND && existingError != ERROR_PATH_NOT_FOUND) + { + WriteWin32Error(L"opening the target", existingError); + return ExitCodeForWin32Error(existingError); + } + + const auto created = CreateFileW(path, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + nullptr); + if (created == INVALID_HANDLE_VALUE) + { + const auto error = GetLastError(); + WriteWin32Error(L"creating the target", error); + return ExitCodeForWin32Error(error); + } + CloseHandle(created); + return Success; + } + + int OpenTarget(const wchar_t* const target) + { + const auto result = reinterpret_cast(ShellExecuteW(nullptr, L"open", target, nullptr, nullptr, SW_SHOWNORMAL)); + if (result > 32) + { + return Success; + } + + std::wcerr << L"winterm-shim: Windows could not open the requested target (ShellExecute error " << result << L").\n"; + return result == SE_ERR_FNF || result == SE_ERR_PNF ? TargetNotFound : GeneralError; + } + + void PrintUsage() + { + std::wcerr << L"Usage: winterm-shim.exe [arguments]\n"; + } +} + +int wmain(const int argc, wchar_t* argv[]) +{ + if (argc < 2) + { + PrintUsage(); + return InvalidArguments; + } + + const std::wstring_view command{ argv[1] }; + if (command == L"touch") + { + if (argc < 3) + { + PrintUsage(); + return InvalidArguments; + } + + auto result = Success; + for (auto index = 2; index < argc; ++index) + { + const auto touchResult = TouchPath(argv[index]); + if (touchResult != Success) + { + result = touchResult; + } + } + return result; + } + + if (command == L"open") + { + if (argc != 3) + { + PrintUsage(); + return InvalidArguments; + } + return OpenTarget(argv[2]); + } + + if (command == L"version") + { + std::wcout << L"winterm-shim 0.3.0\n"; + return Success; + } + + if (command == L"doctor") + { + std::wcout << L"winterm-shim 0.3.0 is available. No network checks were performed.\n"; + return Success; + } + + PrintUsage(); + return UnsupportedOperation; +} diff --git a/src/winterm-tools/winterm-shim/winterm-shim.vcxproj b/src/winterm-tools/winterm-shim/winterm-shim.vcxproj new file mode 100644 index 000000000..0a243d293 --- /dev/null +++ b/src/winterm-tools/winterm-shim/winterm-shim.vcxproj @@ -0,0 +1,34 @@ + + + + {3C5DE684-ADFD-4630-BA91-67A1A3B6AF7A} + Win32Proj + winTermShim + winterm-shim + winterm-shim + Application + winTerm Shell Integration Helper + + + + + + + + + NotUsing + + + + + + stdcpp17 + + + onecore.lib;shell32.lib;%(AdditionalDependencies) + + + + + + diff --git a/src/winterm/Clipboard/PasteRiskAnalyzer.cpp b/src/winterm/Clipboard/PasteRiskAnalyzer.cpp new file mode 100644 index 000000000..225bee7aa --- /dev/null +++ b/src/winterm/Clipboard/PasteRiskAnalyzer.cpp @@ -0,0 +1,86 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PasteRiskAnalyzer.h" + +#include +#include +#include + +using namespace winTerm::Clipboard; + +namespace +{ + bool Contains(const std::wstring& text, const std::wstring_view value) + { + return text.find(value) != std::wstring::npos; + } + + bool ContainsUnexpectedControlCharacter(const std::wstring_view text) + { + return std::any_of(text.begin(), text.end(), [](const wchar_t character) { + return character == L'\0' || (character < 0x20 && character != L'\r' && character != L'\n' && character != L'\t'); + }); + } + + bool ContainsSuspiciousPattern(const std::wstring& normalized) + { + static constexpr std::array patterns{ + L"rm -rf", + L"remove-item -recurse -force", + L"format ", + L"diskpart", + L"shutdown ", + L"stop-computer", + L"del /s /q", + L"rmdir /s /q", + L"invoke-expression", + }; + + return std::any_of(patterns.begin(), patterns.end(), [&](const auto pattern) { return Contains(normalized, pattern); }); + } +} + +bool PasteRiskAnalysis::RequiresConfirmation() const noexcept +{ + return !reasons.empty(); +} + +PasteRiskAnalysis winTerm::Clipboard::AnalyzePasteRisk(const std::wstring_view text, const PasteRiskSettings& settings) +{ + PasteRiskAnalysis analysis; + analysis.characterCount = text.size(); + analysis.lineCount = text.empty() ? 0 : 1 + static_cast(std::count(text.begin(), text.end(), L'\n')); + analysis.endsWithNewline = !text.empty() && (text.back() == L'\n' || text.back() == L'\r'); + + if (settings.warnForMultiline && analysis.lineCount > 1) + { + analysis.reasons.push_back(PasteRiskReason::MultipleLines); + } + if (settings.warnForTrailingNewline && analysis.endsWithNewline) + { + analysis.reasons.push_back(PasteRiskReason::TrailingNewline); + } + if (settings.warnForLargePaste && analysis.characterCount > settings.largePasteCharacterThreshold) + { + analysis.reasons.push_back(PasteRiskReason::LargeText); + } + if (ContainsUnexpectedControlCharacter(text)) + { + analysis.reasons.push_back(PasteRiskReason::ControlCharacter); + } + + std::wstring normalized{ text }; + std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](const wchar_t character) { return static_cast(std::towlower(character)); }); + if (Contains(normalized, L"&&") || Contains(normalized, L"||") || Contains(normalized, L";")) + { + analysis.reasons.push_back(PasteRiskReason::CommandSeparator); + } + if (settings.warnForSuspiciousCommands && ContainsSuspiciousPattern(normalized)) + { + analysis.reasons.push_back(PasteRiskReason::SuspiciousCommand); + } + + return analysis; +} diff --git a/src/winterm/Clipboard/PasteRiskAnalyzer.h b/src/winterm/Clipboard/PasteRiskAnalyzer.h new file mode 100644 index 000000000..1dfa0aedc --- /dev/null +++ b/src/winterm/Clipboard/PasteRiskAnalyzer.h @@ -0,0 +1,43 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include +#include +#include +#include + +namespace winTerm::Clipboard +{ + enum class PasteRiskReason + { + MultipleLines, + TrailingNewline, + LargeText, + ControlCharacter, + CommandSeparator, + SuspiciousCommand, + }; + + struct PasteRiskSettings + { + size_t largePasteCharacterThreshold{ 4096 }; + bool warnForMultiline{ true }; + bool warnForTrailingNewline{ true }; + bool warnForLargePaste{ true }; + bool warnForSuspiciousCommands{ true }; + }; + + struct PasteRiskAnalysis + { + size_t lineCount{}; + size_t characterCount{}; + bool endsWithNewline{}; + std::vector reasons; + + bool RequiresConfirmation() const noexcept; + }; + + PasteRiskAnalysis AnalyzePasteRisk(std::wstring_view text, const PasteRiskSettings& settings = {}); +} diff --git a/src/winterm/Shell/Protocol/ShellIntegrationProtocol.cpp b/src/winterm/Shell/Protocol/ShellIntegrationProtocol.cpp new file mode 100644 index 000000000..63e859188 --- /dev/null +++ b/src/winterm/Shell/Protocol/ShellIntegrationProtocol.cpp @@ -0,0 +1,103 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "ShellIntegrationProtocol.h" + +#include +#include +#include +#include + +using namespace winTerm::Shell; + +namespace +{ + bool ContainsUnsafeControlCharacter(const std::wstring_view value) noexcept + { + return std::any_of(value.begin(), value.end(), [](const wchar_t character) { + return character == L'\0' || (character < 0x20 && character != L'\t'); + }); + } + + bool HasPrefix(const std::wstring_view value, const std::wstring_view prefix) noexcept + { + return value.size() >= prefix.size() && value.substr(0, prefix.size()) == prefix; + } + + std::optional ParseExitCode(const std::wstring_view value) noexcept + { + if (value.empty() || !std::all_of(value.begin(), value.end(), [](const wchar_t character) { return std::iswdigit(character) != 0; })) + { + return std::nullopt; + } + + const std::wstring copiedValue{ value }; + wchar_t* end = nullptr; + const auto parsed = std::wcstoul(copiedValue.c_str(), &end, 10); + if (end != copiedValue.c_str() + copiedValue.size() || parsed > std::numeric_limits::max()) + { + return std::nullopt; + } + return static_cast(parsed); + } +} + +std::optional winTerm::Shell::ClassifyShellIntegrationPayload(const std::wstring_view payload) +{ + if (payload.empty() || payload.size() > MaximumShellProtocolPayloadLength || ContainsUnsafeControlCharacter(payload)) + { + return std::nullopt; + } + + constexpr std::wstring_view directoryPrefix{ L"9;9;" }; + if (HasPrefix(payload, directoryPrefix)) + { + auto directory = payload.substr(directoryPrefix.size()); + if (directory.size() >= 2 && directory.front() == L'"' && directory.back() == L'"') + { + directory = directory.substr(1, directory.size() - 2); + } + if (directory.empty() || ContainsUnsafeControlCharacter(directory)) + { + return std::nullopt; + } + return ShellProtocolEvent{ ShellEventType::CurrentDirectory, std::nullopt, std::wstring{ directory } }; + } + + constexpr std::wstring_view marksPrefix{ L"133;" }; + if (!HasPrefix(payload, marksPrefix) || payload.size() < marksPrefix.size() + 1) + { + return std::nullopt; + } + + const auto mark = payload[marksPrefix.size()]; + if (payload.size() == marksPrefix.size() + 1) + { + switch (mark) + { + case L'A': + return ShellProtocolEvent{ ShellEventType::PromptStart, std::nullopt, {} }; + case L'B': + return ShellProtocolEvent{ ShellEventType::CommandStart, std::nullopt, {} }; + case L'C': + return ShellProtocolEvent{ ShellEventType::CommandExecuted, std::nullopt, {} }; + case L'D': + return ShellProtocolEvent{ ShellEventType::CommandFinished, std::nullopt, {} }; + default: + return std::nullopt; + } + } + + if (mark != L'D' || payload[marksPrefix.size() + 1] != L';') + { + return std::nullopt; + } + + const auto exitCode = ParseExitCode(payload.substr(marksPrefix.size() + 2)); + if (!exitCode) + { + return std::nullopt; + } + return ShellProtocolEvent{ ShellEventType::CommandFinished, exitCode, {} }; +} diff --git a/src/winterm/Shell/Protocol/ShellIntegrationProtocol.h b/src/winterm/Shell/Protocol/ShellIntegrationProtocol.h new file mode 100644 index 000000000..becde00fb --- /dev/null +++ b/src/winterm/Shell/Protocol/ShellIntegrationProtocol.h @@ -0,0 +1,35 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include +#include +#include +#include + +namespace winTerm::Shell +{ + inline constexpr uint32_t ShellProtocolVersion{ 1 }; + inline constexpr size_t MaximumShellProtocolPayloadLength{ 8192 }; + + enum class ShellEventType + { + CurrentDirectory, + PromptStart, + CommandStart, + CommandExecuted, + CommandFinished, + }; + + struct ShellProtocolEvent + { + ShellEventType type; + std::optional exitCode; + std::wstring currentDirectory; + }; + + // This classifier is for diagnostics and tests only. Terminal data is parsed by + // the inherited Microsoft Terminal dispatch implementation. + std::optional ClassifyShellIntegrationPayload(std::wstring_view payload); +} diff --git a/src/winterm/Shell/Sessions/ShellSessionMetadata.cpp b/src/winterm/Shell/Sessions/ShellSessionMetadata.cpp new file mode 100644 index 000000000..89c504b32 --- /dev/null +++ b/src/winterm/Shell/Sessions/ShellSessionMetadata.cpp @@ -0,0 +1,32 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "ShellSessionMetadata.h" + +using namespace winTerm::Shell; + +bool CurrentDirectoryState::IsTrustedLocalPath() const noexcept +{ + return kind == CurrentDirectoryKind::LocalWindows || kind == CurrentDirectoryKind::Unc; +} + +void ShellSessionRegistry::Upsert(ShellSessionMetadata metadata) +{ + if (metadata.sessionId.empty()) + { + return; + } + _sessions.insert_or_assign(metadata.sessionId, std::move(metadata)); +} + +bool ShellSessionRegistry::Remove(const std::wstring_view sessionId) +{ + return _sessions.erase(std::wstring{ sessionId }) != 0; +} + +const ShellSessionMetadata* ShellSessionRegistry::Find(const std::wstring_view sessionId) const +{ + const auto found = _sessions.find(std::wstring{ sessionId }); + return found == _sessions.end() ? nullptr : &found->second; +} diff --git a/src/winterm/Shell/Sessions/ShellSessionMetadata.h b/src/winterm/Shell/Sessions/ShellSessionMetadata.h new file mode 100644 index 000000000..64716f4e5 --- /dev/null +++ b/src/winterm/Shell/Sessions/ShellSessionMetadata.h @@ -0,0 +1,83 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include +#include +#include +#include +#include + +namespace winTerm::Shell +{ + enum class ShellType + { + Unknown, + PowerShell, + WindowsPowerShell, + CommandPrompt, + Wsl, + GitBash, + Ssh, + }; + + enum class CurrentDirectoryKind + { + Unknown, + LocalWindows, + Unc, + Wsl, + Remote, + }; + + enum class CommandExecutionState + { + Unknown, + AtPrompt, + Running, + Finished, + }; + + enum class IntegrationHealth + { + Disabled, + Pending, + Healthy, + Degraded, + Failed, + }; + + struct CurrentDirectoryState + { + CurrentDirectoryKind kind{ CurrentDirectoryKind::Unknown }; + std::wstring value; + + bool IsTrustedLocalPath() const noexcept; + }; + + struct ShellSessionMetadata + { + std::wstring sessionId; + std::wstring profileId; + ShellType shellType{ ShellType::Unknown }; + uint32_t integrationVersion{}; + uint32_t capabilities{}; + CurrentDirectoryState currentDirectory; + CommandExecutionState commandState{ CommandExecutionState::Unknown }; + std::optional lastExitCode; + std::optional lastCommandDuration; + IntegrationHealth integrationHealth{ IntegrationHealth::Disabled }; + }; + + class ShellSessionRegistry + { + public: + void Upsert(ShellSessionMetadata metadata); + bool Remove(std::wstring_view sessionId); + const ShellSessionMetadata* Find(std::wstring_view sessionId) const; + + private: + std::map> _sessions; + }; +}