aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authordivens <divens@users.noreply.github.com>2026-03-04 07:29:16 +0100
committerGitHub <noreply@github.com>2026-03-04 07:29:16 +0100
commitf6bf1af1cef0138d42429ddcb1246ae4f2c6216b (patch)
treed2c2caefa891cd33ec79906f4892ff61c89f3294
parentVisual indication for terminal bells (#4781) (diff)
downloadsl0ppij-f6bf1af1cef0138d42429ddcb1246ae4f2c6216b.tar.gz
sl0ppij-f6bf1af1cef0138d42429ddcb1246ae4f2c6216b.tar.xz
sl0ppij-f6bf1af1cef0138d42429ddcb1246ae4f2c6216b.zip
[Windows port PR8] feature: add Windows support (#4768)
* feat: add Windows signal and process implementations - AsyncSignalListener: polls crossterm size at 100ms, tokio ctrl_c/break/close - BlockingSignalIterator: SetConsoleCtrlHandler + AtomicBool, polls size at 50ms - signal_process: GenerateConsoleCtrlEvent for Interrupt, TerminateProcess for Kill - Add windows-sys workspace dependency * feat: add Windows IPC support with named pipes and marker files - ipc_connect/ipc_bind/ipc_bind_async helpers with cfg gates for named pipes - Dual-pipe architecture on Windows: main pipe + reply pipe per connection to prevent IPC deadlock (half-duplex named pipe limitation) - is_ipc_socket returns is_file() on non-Unix for marker file discovery - ipc_bind creates empty marker file after binding named pipe on Windows - assert_socket split: cfg(unix) probes with ConnStatus, cfg(not(unix)) returns true to avoid deadlocking the server accept loop - Replace tokio UnixListener/UnixStream with interprocess tokio Listener/Stream in web server IPC for cross-platform support - Add interprocess tokio feature to workspace deps * feat: add Windows client support for input, server spawn, and stack size - Two-mode Windows input: crossterm event reader for native console, termwiz byte parser for terminal emulators (VT input mode) - cast_crossterm_key: convert crossterm KeyEvent to KeyWithModifier - ENABLE_VIRTUAL_TERMINAL_INPUT on Windows stdin for raw VT byte reading - Skip ANSI terminal query on Windows to unblock startup - spawn_server: CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP on Windows - 8MB stack trampoline in main() for Windows (default 1MB overflows) - 8MB stack for server worker threads (WASM compilation needs it) - Enable crossterm events and bracketed-paste features * test: make tests cross-platform for Windows - Socket tests: cross-platform IpcGuard/IpcName helpers with GenericNamespaced on Windows vs GenericFilePath/TempDir on Unix - Layout tests: normalize_layout_debug() replaces backslash separators in Debug output on Windows; gate env_var_expansion test with cfg(unix) - Setup tests: use PathBuf::join + display() instead of hardcoded / separators - Server OS tests: cross-platform command helpers (long_running_cmd, echo_cmd, stdin_reader_cmd) with CREATE_NO_WINDOW on Windows - zellij_exports: replace GenericFilePath with ipc_connect() helper * ci: add Windows build and test jobs with debug logging - Add build-windows job: cargo check --no-default-features on windows-latest - Add test-windows job: cargo test --no-default-features for utils/server/client - Enable manual workflow_dispatch trigger - Add debug log::info! calls in server startup, plugin loading, PTY spawning, and screen layout application for troubleshooting * feat: implement Windows stubs for shell, command hooks, and process listing Replace unimplemented!() stubs with working Windows implementations: - get_default_shell: reads $COMSPEC, falls back to cmd.exe - run_command_hook: uses cmd /C with RESURRECT_COMMAND env var - get_all_cmds_by_ppid: uses sysinfo to enumerate processes by parent PID * feat: implement Windows daemonize_web_server with detached process spawning Replace the `unimplemented!()` stub with a proper background process spawner that mirrors the existing `spawn_server` pattern: launch a foreground `zellij web --start` child with CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP, poll the TCP port to confirm it's listening, then exit the parent process. * feat: implement native ConPTY backend for Windows PTY Replace all WindowsPtyBackend stubs with a full ConPTY implementation: - CreatePseudoConsole with overlapped named pipes for IOCP-based async I/O (zero extra threads on the read path per pane) - CreateProcessW with PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE and proper UTF-16 command line quoting / environment block construction - ConPtyAsyncReader with lazy promotion from OwnedHandle to tokio NamedPipeServer (reactor not available at spawn time) - Per-terminal exit monitoring thread using WaitForSingleObject - ResizePseudoConsole for terminal resize, WriteFile for stdin, TerminateProcess / GenerateConsoleCtrlEvent for signals Note: for PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, the HPCON value must be passed directly as lpValue (not a pointer to it), matching the C convention where HPCON is void*. In Rust (windows-sys) HPCON is isize, requiring an explicit cast. See microsoft/terminal#6705. * chore: remove debug logging from ConPTY backend * fix: resolve Windows plugin directory and WASI mount errors Two Windows-specific fixes for the plugin system: 1. Replace colons with underscores in URL-derived plugin directory names (e.g. "zellij:tab-bar" -> "zellij_tab-bar"). Colons are illegal in Windows path components, causing OS error 123 on plugin dir creation. 2. Use FILE_FLAG_BACKUP_SEMANTICS when opening directories for WASI pre-opened mounts. Windows requires this flag to open directory handles, otherwise File::open() returns OS error 5 (access denied). Both fixes are behind #[cfg(windows)] — Unix behavior is unchanged. * fix: enable mouse support on Windows native console path Mouse events were silently dropped in the crossterm event loop, and enable_mouse() wrote VT escape sequences that don't set the required ENABLE_MOUSE_INPUT console mode flag. Add from_crossterm_mouse() converter, InputInstruction::MouseEvent variant, and use crossterm's EnableMouseCapture/DisableMouseCapture on Windows. * fix: Windows get_default_shell() now checks $SHELL before $COMSPEC * fix: resolve bare command names via PATH and PATHEXT on Windows CreateProcessW doesn't reliably resolve bare command names (e.g. "pwsh") through PATH the way Unix execvp does. Additionally, the existing command_exists() check didn't consider Windows PATHEXT extensions (.exe, .cmd, .bat, etc.). Add find_executable() and resolve_command() which search PATH with PATHEXT support on Windows, and use the resolved absolute path when spawning via CreateProcessW. * chore: run cargo fmt --all * fix: canonicalize expected paths in setup tests for Windows \\?\ prefix * fix: Windows CI test failures in spawn_and_read_output and snapshot test Use cmd /K instead of /C in spawn test so ConPTY has time to flush output before the pipe closes. Normalize backslashes in snapshot test to handle Windows path separators. * fix: strip redundant Shift and AltGr modifiers from Windows key events On the Windows native console path, crossterm reports physical modifier flags alongside the already-translated character. For example on French AZERTY, Shift+ù produces Char('%') with SHIFT, and AltGr+_ produces Char('\') with CTRL+ALT. These extra modifiers cause keybinding mismatches and prevent modified characters from being typed in plugin text inputs. Strip Shift for all Char events and Ctrl+Alt (AltGr) when the character is printable, matching Unix terminal behavior where only the resulting character is reported. Also fix raw byte synthesis for AltGr characters to emit plain UTF-8 instead of ESC-prefixed sequences. * fix: detect and clean up stale Windows sessions via PID-based liveness check Write server PID to marker files instead of creating empty files. assert_socket() on Windows now reads the PID and checks process liveness via OpenProcess, cleaning up stale marker files for dead sessions. * fix: connect reply pipe on Windows for kill-session and delete-session On Windows the server blocks on reply_listener.accept() after accepting the main pipe connection. Without a reply pipe connection, the route thread is never spawned and the KillSession message is never processed. * fix: wait for server Exit response in kill/delete session on Windows The previous fire-and-forget approach was unreliable: the client would disconnect before the server's route thread had time to read and process the KillSession message. Now we wait for the Exit response on the reply pipe, ensuring the server has fully processed the kill before we return. * fix: resolve Windows console mode race between VT input and mouse setup Two threads race over SetConsoleMode on the stdin console handle: - stdin_handler calls enable_vt_input() (read-modify-write) - input_handler calls enable_mouse() via crossterm's EnableMouseCapture (full SetConsoleMode overwrite to 0x0098) Depending on ordering, crossterm could clobber ENABLE_VIRTUAL_TERMINAL_INPUT (breaking the VT byte reader), or enable_vt_input could preserve ENABLE_QUICK_EDIT_MODE (intercepting mouse events at the console level). Fix enable_vt_input() to explicitly set the exact console mode needed (0x0298) instead of read-modify-write, clearing QUICK_EDIT_MODE and other line-editing flags. Fix enable_mouse()/disable_mouse() on the VT path (TERM is set) to use ANSI escape sequences instead of crossterm's Console API, avoiding the competing SetConsoleMode call entirely. Enable ENABLE_VIRTUAL_TERMINAL_PROCESSING on stdout first so ConPTY forwards the DEC private mode sequences to the terminal emulator. * fix: session-manager plugin kill session on Windows dual-pipe IPC The plugin's kill_sessions() only connected the main pipe and sent KillSession, but on Windows the server blocks on reply_listener.accept() before spawning the route thread. Without connecting the reply pipe the message was never processed. Add the same dual-pipe handling as the CLI kill_session(): connect the reply pipe first, send the message, then wait for the Exit response. * fix(windows): replace resize polling with event forwarding from stdin thread Forward crossterm Event::Resize from the stdin thread to the signal handler thread via an mpsc channel, replacing the 50ms polling loop. This makes terminal resizing much more responsive during mouse-driven resizes and eliminates wasteful polling when idle. When the VT reader path is active (terminal emulators with TERM set), the sender is dropped, causing the signal handler to automatically fall back to size-polling — preserving existing behavior for that path. Unix is unaffected: handle_signals receives None for the receiver and BlockingSignalIterator continues using signal_hook as before. * fix(windows): enable ANSI terminal queries on the VT reader path The startup ANSI query mechanism (pixel dimensions, colors, sync output) was blanket-skipped on Windows. The actual limitation is specific to the native console path, where crossterm's event::read() uses ReadConsoleInput (INPUT_RECORDs) and never reaches the byte-reader loop that parses ANSI responses. On the VT reader path (terminal emulators like Alacritty with TERM set), fill_buf() reads raw VT bytes via ReadFile — same as Unix — so ANSI query responses work normally. Move the use_vt_reader decision earlier and use it to gate the query, enabling it for the VT reader path while still skipping it on native console. * fix: handle early TerminalResize before client is fully registered On Windows, enable_mouse()'s SetConsoleMode call can generate a WINDOW_BUFFER_SIZE_EVENT that arrives as a resize before the server finishes processing the initial client connection. At that point set_client_data() hasn't been called yet, so set_client_size() silently no-ops on the None entry and min_client_terminal_size() returns None. Instead of panicking, skip the resize when the minimum size can't be determined — the server will query the terminal size once setup completes. * fix: remove unnecessary custom stack size Early versions of zelilj windows port crashed with stack overflow. Increasing stack size allowed proper startup and moving further with the porting effort. Current implementation no longer suffers from stack issues, so we can revert back to default stack size. * fix(windows): re-enable Ctrl+C handling inherited by ConPTY children The server is spawned with CREATE_NEW_PROCESS_GROUP, which disables Ctrl+C for the process. Children inherit this disabled state, so ConPTY child processes (shells, ping, etc.) silently ignore CTRL_C_EVENT signals generated by the pseudo-console. Call SetConsoleCtrlHandler(NULL, FALSE) early in start_server() to clear the inherited ignore flag before any ConPTY children are created. * refactor(windows): move inline cfg(windows) blocks to platform files Break out inline #[cfg(windows)] blocks from shared client code into the existing _unix.rs / _windows.rs platform files, per upstream feedback. - Split spawn_server into two #[cfg]-gated function definitions - Extract setup_ipc, enable_mouse_support, disable_mouse_support into os_input_output_unix.rs and os_input_output_windows.rs - Unify BlockingSignalIterator::new() signature across platforms - Extract native console stdin loop into stdin_handler_windows.rs * fix: remove sporadic info log statements * ci(windows): add Windows x64 release builds and upgrade CI - Add Windows x64 target to release.yml for both normal and no-web release matrices, with NASM, static CRT, .zip packaging, and PowerShell checksum steps - Upgrade build-windows in rust.yml from cargo check to cargo xtask build, add wasm32-wasip1 target and NASM - Add build_release() function and platform-aware e2e_build() to xtask/src/ci.rs - Add BuildRelease command definition to xtask/src/flags.rs * refactor(client): make resize channel unconditional across platforms Create the resize mpsc channel on all platforms instead of gating it behind #[cfg(windows)], avoiding conditionally-existing variables that are hard to follow. On Unix the sender is immediately dropped and the receiver is passed as None-equivalent to handle_signals. * refactor(windows): restructure signal iterator with if/else for channel vs poll Consolidate the quit check at the top of a single loop and use if/else for channel mode (native console) vs poll mode (VT reader). On channel disconnect, set resize_receiver to None and continue into the else branch instead of falling through. * docs(server): clarify comment about early resize before client init Explain why min_client_terminal_size() can return None even after set_client_size(): new_client() inserts None, and set_client_size() is a no-op on None entries since it uses as_mut().map(). * feat(web): add --server-startup-timeout flag for web server Make the hardcoded 10-second TCP-poll timeout in the non-Unix daemonize_web_server configurable via a CLI flag, defaulting to 10s. * fix(windows): use unique pipe names for ConPTY output on re-spawn When a held pane re-runs a command or drops to shell, do_spawn tried to create a named pipe with the same name as the previous spawn. The old ConPtyAsyncReader still held the read handle, so CreateNamedPipeW failed (FILE_FLAG_FIRST_PIPE_INSTANCE, max instances = 1) and the error was silently swallowed, leaving the pane blank. Add a monotonic AtomicU64 counter to the pipe name so each spawn gets a globally unique pipe, regardless of whether the previous one has been cleaned up yet. * ci(windows): enable static CRT for regular Windows release build The no-web Windows build already had RUSTFLAGS="-C target-feature=+crt-static" but the regular build was missing it, resulting in a dynamic CRT dependency. * docs(web): clarify --server-startup-timeout is Windows-only --------- Co-authored-by: divens <divens.dev@gmail.com>
-rw-r--r--.github/workflows/release.yml126
-rw-r--r--.github/workflows/rust.yml51
-rw-r--r--Cargo.lock25
-rw-r--r--Cargo.toml3
-rw-r--r--src/commands.rs3
-rw-r--r--src/main.rs1
-rw-r--r--xtask/src/ci.rs70
-rw-r--r--xtask/src/flags.rs16
-rw-r--r--zellij-client/Cargo.toml10
-rw-r--r--zellij-client/src/input_handler.rs7
-rw-r--r--zellij-client/src/lib.rs40
-rw-r--r--zellij-client/src/os_input_output.rs56
-rw-r--r--zellij-client/src/os_input_output_unix.rs39
-rw-r--r--zellij-client/src/os_input_output_windows.rs218
-rw-r--r--zellij-client/src/remote_attach/unit/remote_attach_tests.rs8
-rw-r--r--zellij-client/src/stdin_handler.rs133
-rw-r--r--zellij-client/src/stdin_handler_windows.rs64
-rw-r--r--zellij-client/src/unit/terminal_loop_tests.rs8
-rw-r--r--zellij-client/src/web_client/ipc_listener.rs14
-rw-r--r--zellij-client/src/web_client/mod.rs77
-rw-r--r--zellij-client/src/web_client/unit/web_client_tests.rs8
-rw-r--r--zellij-server/Cargo.toml12
-rw-r--r--zellij-server/src/lib.rs49
-rw-r--r--zellij-server/src/os_input_output.rs132
-rw-r--r--zellij-server/src/os_input_output_windows.rs636
-rw-r--r--zellij-server/src/plugins/plugin_loader.rs18
-rw-r--r--zellij-server/src/plugins/wasm_bridge.rs20
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs35
-rw-r--r--zellij-server/src/pty.rs17
-rw-r--r--zellij-server/src/route.rs31
-rw-r--r--zellij-server/src/screen.rs8
-rw-r--r--zellij-server/src/tab/unit/layout_applier_tests.rs8
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs8
-rw-r--r--zellij-server/src/tab/unit/tab_tests.rs8
-rw-r--r--zellij-server/src/unit/os_input_output_tests.rs153
-rw-r--r--zellij-server/src/unit/screen_tests.rs13
-rw-r--r--zellij-utils/Cargo.toml7
-rw-r--r--zellij-utils/src/cli.rs25
-rw-r--r--zellij-utils/src/consts.rs100
-rw-r--r--zellij-utils/src/input/mod.rs260
-rw-r--r--zellij-utils/src/input/unit/layout_test.rs120
-rw-r--r--zellij-utils/src/ipc/tests/mod.rs1
-rw-r--r--zellij-utils/src/ipc/tests/socket_tests.rs185
-rw-r--r--zellij-utils/src/sessions.rs115
-rw-r--r--zellij-utils/src/setup.rs76
-rw-r--r--zellij-utils/src/web_server_commands.rs5
46 files changed, 2630 insertions, 389 deletions
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index de892454..bd4f3f50 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -35,6 +35,7 @@ jobs:
- linux musl aarch64
- macos x64
- macos aarch64
+ - windows x64
include:
- build: linux musl x64
os: ubuntu-latest
@@ -48,8 +49,12 @@ jobs:
- build: macos aarch64
os: macos-latest
target: aarch64-apple-darwin
+ - build: windows x64
+ os: windows-latest
+ target: x86_64-pc-windows-msvc
steps:
- - name: Set release tag
+ - name: Set release tag (Unix)
+ if: runner.os != 'Windows'
run: |
if [ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]; then
echo "RELEASE_TAG=main" >> "$GITHUB_ENV"
@@ -57,6 +62,16 @@ jobs:
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV"
fi
+ - name: Set release tag (Windows)
+ if: runner.os == 'Windows'
+ run: |
+ if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') {
+ echo "RELEASE_TAG=main" >> $env:GITHUB_ENV
+ } else {
+ $tag = $env:GITHUB_REF -replace '^refs/tags/', ''
+ echo "RELEASE_TAG=$tag" >> $env:GITHUB_ENV
+ }
+
- name: Checkout repository
uses: actions/checkout@v3
@@ -76,18 +91,39 @@ jobs:
cache: false
rustflags: ""
- - name: Build release binary
+ - name: Install NASM (Windows)
+ if: runner.os == 'Windows'
+ uses: ilammy/setup-nasm@v1
+
+ - name: Build release binary (cross-compiled)
+ if: matrix.build != 'windows x64'
run: cargo xtask ci cross ${{ matrix.target }}
- - name: Create artifact
- id: make-artifact
+ - name: Build release binary (Windows native)
+ if: matrix.build == 'windows x64'
+ env:
+ RUSTFLAGS: "-C target-feature=+crt-static"
+ run: cargo xtask ci build-release
+
+ - name: Create artifact (Unix)
+ if: runner.os != 'Windows'
+ id: make-artifact-unix
run: |
asset_name="zellij-${{ matrix.target }}.tar.gz"
tar cvzf "${asset_name}" -C "target/${{ matrix.target }}/release" zellij
echo "asset_name=${asset_name}" >> "$GITHUB_OUTPUT"
- - name: Create checksum
- id: make-checksum
+ - name: Create artifact (Windows)
+ if: runner.os == 'Windows'
+ id: make-artifact-windows
+ run: |
+ $asset_name = "zellij-${{ matrix.target }}.zip"
+ Compress-Archive -Path "target/release/zellij.exe" -DestinationPath $asset_name
+ echo "asset_name=$asset_name" >> $env:GITHUB_OUTPUT
+
+ - name: Create checksum (Unix)
+ if: runner.os != 'Windows'
+ id: make-checksum-unix
run: |
checksum_name="zellij-${{ matrix.target }}.sha256sum"
if [[ "$RUNNER_OS" != "macOS" ]]; then
@@ -97,14 +133,23 @@ jobs:
fi
echo "checksum_name=${checksum_name}" >> "$GITHUB_OUTPUT"
+ - name: Create checksum (Windows)
+ if: runner.os == 'Windows'
+ id: make-checksum-windows
+ run: |
+ $checksum_name = "zellij-${{ matrix.target }}.sha256sum"
+ $hash = (Get-FileHash "target/release/zellij.exe" -Algorithm SHA256).Hash.ToLower()
+ "$hash target/release/zellij.exe" | Out-File -Encoding ascii $checksum_name
+ echo "checksum_name=$checksum_name" >> $env:GITHUB_OUTPUT
+
- name: Upload release archive
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
- asset_path: ${{ steps.make-artifact.outputs.asset_name }}
- asset_name: zellij-${{matrix.target}}.tar.gz
+ asset_path: ${{ steps.make-artifact-unix.outputs.asset_name || steps.make-artifact-windows.outputs.asset_name }}
+ asset_name: ${{ runner.os == 'Windows' && format('zellij-{0}.zip', matrix.target) || format('zellij-{0}.tar.gz', matrix.target) }}
asset_content_type: application/octet-stream
- name: Upload checksum
@@ -113,7 +158,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
- asset_path: ${{ steps.make-checksum.outputs.checksum_name }}
+ asset_path: ${{ steps.make-checksum-unix.outputs.checksum_name || steps.make-checksum-windows.outputs.checksum_name }}
asset_name: zellij-${{matrix.target}}.sha256sum
asset_content_type: text/plain
@@ -130,6 +175,7 @@ jobs:
- linux musl aarch64
- macos x64
- macos aarch64
+ - windows x64
include:
- build: linux musl x64
os: ubuntu-latest
@@ -143,8 +189,12 @@ jobs:
- build: macos aarch64
os: macos-latest
target: aarch64-apple-darwin
+ - build: windows x64
+ os: windows-latest
+ target: x86_64-pc-windows-msvc
steps:
- - name: Set release tag
+ - name: Set release tag (Unix)
+ if: runner.os != 'Windows'
run: |
if [ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]; then
echo "RELEASE_TAG=main" >> "$GITHUB_ENV"
@@ -152,6 +202,16 @@ jobs:
echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV"
fi
+ - name: Set release tag (Windows)
+ if: runner.os == 'Windows'
+ run: |
+ if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') {
+ echo "RELEASE_TAG=main" >> $env:GITHUB_ENV
+ } else {
+ $tag = $env:GITHUB_REF -replace '^refs/tags/', ''
+ echo "RELEASE_TAG=$tag" >> $env:GITHUB_ENV
+ }
+
- name: Checkout repository
uses: actions/checkout@v3
@@ -171,18 +231,39 @@ jobs:
cache: false
rustflags: ""
- - name: Build release binary (no-web)
+ - name: Install NASM (Windows)
+ if: runner.os == 'Windows'
+ uses: ilammy/setup-nasm@v1
+
+ - name: Build release binary (cross-compiled, no-web)
+ if: matrix.build != 'windows x64'
run: cargo xtask ci cross ${{ matrix.target }} --no-web
- - name: Create artifact
- id: make-artifact
+ - name: Build release binary (Windows native, no-web)
+ if: matrix.build == 'windows x64'
+ env:
+ RUSTFLAGS: "-C target-feature=+crt-static"
+ run: cargo xtask ci build-release --no-web
+
+ - name: Create artifact (Unix)
+ if: runner.os != 'Windows'
+ id: make-artifact-unix
run: |
asset_name="zellij-no-web-${{ matrix.target }}.tar.gz"
tar cvzf "${asset_name}" -C "target/${{ matrix.target }}/release" zellij
echo "asset_name=${asset_name}" >> "$GITHUB_OUTPUT"
- - name: Create checksum
- id: make-checksum
+ - name: Create artifact (Windows)
+ if: runner.os == 'Windows'
+ id: make-artifact-windows
+ run: |
+ $asset_name = "zellij-no-web-${{ matrix.target }}.zip"
+ Compress-Archive -Path "target/release/zellij.exe" -DestinationPath $asset_name
+ echo "asset_name=$asset_name" >> $env:GITHUB_OUTPUT
+
+ - name: Create checksum (Unix)
+ if: runner.os != 'Windows'
+ id: make-checksum-unix
run: |
checksum_name="zellij-no-web-${{ matrix.target }}.sha256sum"
if [[ "$RUNNER_OS" != "macOS" ]]; then
@@ -192,14 +273,23 @@ jobs:
fi
echo "checksum_name=${checksum_name}" >> "$GITHUB_OUTPUT"
+ - name: Create checksum (Windows)
+ if: runner.os == 'Windows'
+ id: make-checksum-windows
+ run: |
+ $checksum_name = "zellij-no-web-${{ matrix.target }}.sha256sum"
+ $hash = (Get-FileHash "target/release/zellij.exe" -Algorithm SHA256).Hash.ToLower()
+ "$hash target/release/zellij.exe" | Out-File -Encoding ascii $checksum_name
+ echo "checksum_name=$checksum_name" >> $env:GITHUB_OUTPUT
+
- name: Upload release archive
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
- asset_path: ${{ steps.make-artifact.outputs.asset_name }}
- asset_name: zellij-no-web-${{matrix.target}}.tar.gz
+ asset_path: ${{ steps.make-artifact-unix.outputs.asset_name || steps.make-artifact-windows.outputs.asset_name }}
+ asset_name: ${{ runner.os == 'Windows' && format('zellij-no-web-{0}.zip', matrix.target) || format('zellij-no-web-{0}.tar.gz', matrix.target) }}
asset_content_type: application/octet-stream
- name: Upload checksum
@@ -208,6 +298,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
- asset_path: ${{ steps.make-checksum.outputs.checksum_name }}
+ asset_path: ${{ steps.make-checksum-unix.outputs.checksum_name || steps.make-checksum-windows.outputs.checksum_name }}
asset_name: zellij-no-web-${{matrix.target}}.sha256sum
asset_content_type: text/plain
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index f1f0c1ac..cd05448b 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
+ workflow_dispatch:
env:
CARGO_TERM_COLOR: always
@@ -36,6 +37,30 @@ jobs:
- name: Build
run: cargo xtask build
+ build-windows:
+ name: Build (Windows)
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install Protoc
+ uses: arduino/setup-protoc@v3
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup toolchain
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ target: wasm32-wasip1
+ rustflags: ""
+
+ - name: Install NASM
+ uses: ilammy/setup-nasm@v1
+
+ - name: Build
+ run: cargo xtask build
+
test:
name: Test
@@ -62,6 +87,32 @@ jobs:
- name: Test
run: cargo xtask test
+ test-windows:
+ name: Test (Windows)
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install Protoc
+ uses: arduino/setup-protoc@v3
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup toolchain
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ rustflags: ""
+
+ - name: Test zellij-utils
+ run: cargo test --no-default-features -p zellij-utils --lib
+
+ - name: Test zellij-server
+ run: cargo test --no-default-features -p zellij-server --lib
+
+ - name: Test zellij-client
+ run: cargo test --no-default-features -p zellij-client --lib
+
test-no-web:
name: Test (No Web)
runs-on: ubuntu-latest
diff --git a/Cargo.lock b/Cargo.lock
index fe30634a..b491228d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -801,8 +801,11 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.5.0",
"crossterm_winapi",
+ "mio 1.0.3",
"parking_lot 0.12.1",
"rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
"winapi",
]
@@ -1871,8 +1874,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53bf2b0e0785c5394a7392f66d7c4fb9c653633c29b27a932280da3cb344c66a"
dependencies = [
"doctest-file",
+ "futures-core",
"libc",
"recvmsg",
+ "tokio",
"widestring",
"windows-sys 0.52.0",
]
@@ -2362,6 +2367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
+ "log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@@ -3483,15 +3489,26 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
-version = "0.3.14"
+version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio 1.0.3",
+ "signal-hook",
+]
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5220,6 +5237,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
+ "windows-sys 0.52.0",
"zellij-utils",
]
@@ -5269,6 +5287,7 @@ dependencies = [
"vte 0.11.0",
"wasmi",
"wasmi_wasi",
+ "windows-sys 0.52.0",
"zellij-utils",
]
@@ -5304,6 +5323,7 @@ dependencies = [
"colored",
"colorsys",
"crossbeam",
+ "crossterm",
"curl-sys",
"directories",
"expect-test",
@@ -5341,6 +5361,7 @@ dependencies = [
"unicode-width 0.1.10",
"url",
"uuid",
+ "windows-sys 0.52.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 1794dbdd..e5f89ab9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -79,7 +79,7 @@ crossterm = { version = "0.28", default-features = false, features = ["windows"]
daemonize = { version = "0.5", default-features = false }
dialoguer = { version = "0.10.4", default-features = false, features = ["password", "completion"] }
humantime = { version = "2.1.0", default-features = false }
-interprocess = { version = "2.2", default-features = false }
+interprocess = { version = "2.2", default-features = false, features = ["tokio"] }
isahc = { version = "1.7.2", default-features = false, features = ["http2", "text-decoding"] }
kdl = { version = "4.5.0", default-features = false, features = ["span"] }
lazy_static = { version = "1.4.0", default-features = false }
@@ -107,6 +107,7 @@ unicode-width = { version = "0.1.8", default-features = false }
url = { version = "2.2.2", default-features = false, features = ["serde"] }
uuid = { version = "1.4.1", default-features = false, features = ["serde", "v4", "std"] }
vte = { version = "0.11.0", default-features = false }
+windows-sys = { version = "0.52", default-features = false }
names = { version = "0.14.0", default-features = false }
include_dir = { version = "0.7.3", default-features = false }
sha2 = { version = "0.10", default-features = false }
diff --git a/src/commands.rs b/src/commands.rs
index 3f5e9aab..d00d1d04 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -172,6 +172,7 @@ pub(crate) fn start_web_server(
port: Option<u16>,
cert: Option<PathBuf>,
key: Option<PathBuf>,
+ startup_timeout: Option<u64>,
) {
// TODO: move this outside of this function
let (config, _layout, config_options, _config_without_layout, _config_options_without_layout) =
@@ -196,6 +197,7 @@ pub(crate) fn start_web_server(
port,
cert,
key,
+ startup_timeout,
);
}
@@ -207,6 +209,7 @@ pub(crate) fn start_web_server(
_port: Option<u16>,
_cert: Option<PathBuf>,
_key: Option<PathBuf>,
+ _startup_timeout: Option<u64>,
) {
log::error!(
"This version of Zellij was compiled without web server support, cannot run web server!"
diff --git a/src/main.rs b/src/main.rs
index f7fe74e6..581fff52 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -287,6 +287,7 @@ fn main() {
web_opts.port,
web_opts.cert.clone(),
web_opts.key.clone(),
+ web_opts.server_startup_timeout,
);
} else if web_opts.stop {
match commands::stop_web_server() {
diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs
index 36d51bc9..6f33ac31 100644
--- a/xtask/src/ci.rs
+++ b/xtask/src/ci.rs
@@ -1,7 +1,7 @@
//! Tasks related to zellij CI
use crate::{
build,
- flags::{self, CiCmd, Cross, E2e},
+ flags::{self, BuildRelease, CiCmd, Cross, E2e},
metadata,
};
use anyhow::Context;
@@ -40,6 +40,7 @@ pub fn main(sh: &Shell, flags: flags::Ci) -> anyhow::Result<()> {
args,
}) => e2e_test(sh, args),
CiCmd::Cross(Cross { triple, no_web }) => cross_compile(sh, &triple, no_web),
+ CiCmd::BuildRelease(BuildRelease { no_web }) => build_release(sh, no_web),
}
.context(err_context)
}
@@ -90,12 +91,67 @@ fn e2e_build(sh: &Shell) -> anyhow::Result<()> {
let _pd = sh.push_dir(project_root);
crate::cargo()
.and_then(|cargo| {
- cmd!(
- sh,
- "{cargo} build --release --target x86_64-unknown-linux-musl"
- )
- .run()
- .map_err(anyhow::Error::new)
+ // On Windows, build natively (MSVC). On Unix, cross-compile for musl.
+ if cfg!(windows) {
+ cmd!(sh, "{cargo} build --release")
+ .run()
+ .map_err(anyhow::Error::new)
+ } else {
+ cmd!(
+ sh,
+ "{cargo} build --release --target x86_64-unknown-linux-musl"
+ )
+ .run()
+ .map_err(anyhow::Error::new)
+ }
+ })
+ .context(err_context)
+}
+
+/// Native release build: builds plugins, generates protobufs, and runs
+/// `cargo build --release` without cross-compilation.
+fn build_release(sh: &Shell, no_web: bool) -> anyhow::Result<()> {
+ let err_context = "failed to perform native release build";
+
+ // Build plugins and generate protobufs
+ build::build(
+ sh,
+ flags::Build {
+ release: true,
+ no_plugins: false,
+ plugins_only: true,
+ no_web,
+ },
+ )
+ .context(err_context)?;
+
+ // Build the main binary natively
+ let _pd = sh.push_dir(crate::project_root());
+ crate::cargo()
+ .and_then(|cargo| {
+ if no_web {
+ match metadata::get_no_web_features(sh, ".")
+ .context("Failed to check web features for build-release")?
+ {
+ Some(features) => {
+ let mut cmd = cmd!(
+ sh,
+ "{cargo} build --verbose --release --no-default-features"
+ );
+ if !features.is_empty() {
+ cmd = cmd.arg("--features").arg(features);
+ }
+ cmd.run().map_err(anyhow::Error::new)
+ },
+ None => cmd!(sh, "{cargo} build --verbose --release")
+ .run()
+ .map_err(anyhow::Error::new),
+ }
+ } else {
+ cmd!(sh, "{cargo} build --verbose --release")
+ .run()
+ .map_err(anyhow::Error::new)
+ }
})
.context(err_context)
}
diff --git a/xtask/src/flags.rs b/xtask/src/flags.rs
index 080406bb..3814cd2c 100644
--- a/xtask/src/flags.rs
+++ b/xtask/src/flags.rs
@@ -31,6 +31,12 @@ xflags::xflags! {
/// Compile without web server support
optional --no-web
}
+
+ /// Native release build (plugins + binary, no cross-compilation)
+ cmd build-release {
+ /// Compile without web server support
+ optional --no-web
+ }
}
/// Build the manpage
@@ -150,6 +156,7 @@ pub struct Ci {
pub enum CiCmd {
E2e(E2e),
Cross(Cross),
+ BuildRelease(BuildRelease),
}
#[derive(Debug)]
@@ -163,6 +170,12 @@ pub struct E2e {
#[derive(Debug)]
pub struct Cross {
pub triple: OsString,
+
+ pub no_web: bool,
+}
+
+#[derive(Debug)]
+pub struct BuildRelease {
pub no_web: bool,
}
@@ -193,6 +206,7 @@ pub struct Make {
#[derive(Debug)]
pub struct Install {
pub destination: PathBuf,
+
pub no_web: bool,
}
@@ -214,6 +228,7 @@ pub struct Format {
#[derive(Debug)]
pub struct Test {
pub args: Vec<OsString>,
+
pub no_web: bool,
}
@@ -241,3 +256,4 @@ impl Xtask {
Self::from_vec_(args)
}
}
+// generated end
diff --git a/zellij-client/Cargo.toml b/zellij-client/Cargo.toml
index 7bb51e83..d8cb8f2f 100644
--- a/zellij-client/Cargo.toml
+++ b/zellij-client/Cargo.toml
@@ -21,7 +21,7 @@ tower-http = { version = "0.6.4", features = ["cors"], optional = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
-crossterm = { workspace = true }
+crossterm = { workspace = true, features = ["events", "bracketed-paste"] }
dialoguer = { workspace = true }
interprocess = { workspace = true }
lazy_static = { workspace = true }
@@ -51,6 +51,14 @@ signal-hook = { workspace = true }
daemonize = { workspace = true }
nix = { workspace = true }
+[target.'cfg(windows)'.dependencies]
+crossterm = { workspace = true }
+tokio = { workspace = true }
+windows-sys = { workspace = true, features = [
+ "Win32_Foundation",
+ "Win32_System_Console",
+] }
+
[dev-dependencies]
insta = "1.6.0"
serde_json = "1.0"
diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs
index 271eb33f..ae7b3210 100644
--- a/zellij-client/src/input_handler.rs
+++ b/zellij-client/src/input_handler.rs
@@ -229,10 +229,13 @@ impl InputHandler {
}
},
Ok((
- InputInstruction::KeyWithModifierEvent(key_with_modifier, raw_bytes),
+ InputInstruction::KeyWithModifierEvent(key_with_modifier, raw_bytes, is_kitty),
_error_context,
)) => {
- self.handle_key(&key_with_modifier, raw_bytes, true);
+ self.handle_key(&key_with_modifier, raw_bytes, is_kitty);
+ },
+ Ok((InputInstruction::MouseEvent(mouse_event), _error_context)) => {
+ self.handle_mouse_event(&mouse_event);
},
Ok((
InputInstruction::AnsiStdinInstructions(ansi_stdin_instructions),
diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs
index 4447c191..817a7338 100644
--- a/zellij-client/src/lib.rs
+++ b/zellij-client/src/lib.rs
@@ -16,6 +16,8 @@ pub mod old_config_converter;
pub mod remote_attach;
mod stdin_ansi_parser;
mod stdin_handler;
+#[cfg(windows)]
+mod stdin_handler_windows;
#[cfg(feature = "web_server_capability")]
pub mod web_client;
@@ -255,15 +257,18 @@ fn spawn_web_server(_cli_args: &CliArgs) -> Result<String, String> {
Ok("".to_owned())
}
+/// Spawn the Zellij server process.
+///
+/// On Unix the server daemonizes (double-fork) inside start_server(), so
+/// the intermediate child exits immediately and `cmd.status()` returns.
+#[cfg(not(windows))]
pub fn spawn_server(socket_path: &Path, debug: bool) -> io::Result<()> {
let mut cmd = Command::new(current_exe()?);
- cmd.arg("--server");
- cmd.arg(socket_path);
+ cmd.arg("--server").arg(socket_path);
if debug {
cmd.arg("--debug");
}
let status = cmd.status()?;
-
if status.success() {
Ok(())
} else {
@@ -276,6 +281,28 @@ pub fn spawn_server(socket_path: &Path, debug: bool) -> io::Result<()> {
}
}
+/// Spawn the Zellij server process.
+///
+/// On Windows there is no daemonize — we launch the server as a background
+/// process with a hidden console. We use CREATE_NO_WINDOW (not
+/// DETACHED_PROCESS) so the server gets valid standard handles;
+/// DETACHED_PROCESS leaves stdin/stdout/stderr as NULL, which breaks PTY
+/// creation, WASM plugin loading, and logging.
+#[cfg(windows)]
+pub fn spawn_server(socket_path: &Path, debug: bool) -> io::Result<()> {
+ use std::os::windows::process::CommandExt;
+ let mut cmd = Command::new(current_exe()?);
+ cmd.arg("--server").arg(socket_path);
+ if debug {
+ cmd.arg("--debug");
+ }
+ const CREATE_NO_WINDOW: u32 = 0x08000000;
+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
+ cmd.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
+ cmd.spawn()?;
+ Ok(())
+}
+
#[derive(Debug, Clone)]
pub enum ClientInfo {
Attach(String, Options),
@@ -311,7 +338,8 @@ impl ClientInfo {
#[derive(Debug, Clone)]
pub(crate) enum InputInstruction {
KeyEvent(InputEvent, Vec<u8>),
- KeyWithModifierEvent(KeyWithModifier, Vec<u8>),
+ KeyWithModifierEvent(KeyWithModifier, Vec<u8>, bool), // bool = is_kitty_keyboard_protocol
+ MouseEvent(zellij_utils::input::mouse::MouseEvent),
AnsiStdinInstructions(Vec<AnsiStdinInstruction>),
StartedParsing,
DoneParsing,
@@ -870,6 +898,8 @@ pub fn start_client(
let on_force_close = config_options.on_force_close.unwrap_or_default();
let stdin_ansi_parser = Arc::new(Mutex::new(StdinAnsiParser::new()));
+ let (resize_sender, resize_receiver) = std::sync::mpsc::channel::<()>();
+
let _stdin_thread = thread::Builder::new()
.name("stdin_handler".to_string())
.spawn({
@@ -882,6 +912,7 @@ pub fn start_client(
send_input_instructions,
stdin_ansi_parser,
explicitly_disable_kitty_keyboard_protocol,
+ Some(resize_sender),
)
}
});
@@ -931,6 +962,7 @@ pub fn start_client(
});
}
}),
+ Some(resize_receiver),
);
}
})
diff --git a/zellij-client/src/os_input_output.rs b/zellij-client/src/os_input_output.rs
index ee29403f..2522322b 100644
--- a/zellij-client/src/os_input_output.rs
+++ b/zellij-client/src/os_input_output.rs
@@ -1,14 +1,18 @@
-use anyhow::{Context, Result};
+use anyhow::Result;
use async_trait::async_trait;
-use interprocess;
use zellij_utils::pane_size::Size;
#[cfg(not(windows))]
-use crate::os_input_output_unix::{AsyncSignalListener, BlockingSignalIterator};
+use crate::os_input_output_unix::{
+ disable_mouse_support, enable_mouse_support, setup_ipc, AsyncSignalListener,
+ BlockingSignalIterator,
+};
#[cfg(windows)]
-use crate::os_input_output_windows::{AsyncSignalListener, BlockingSignalIterator};
+use crate::os_input_output_windows::{
+ disable_mouse_support, enable_mouse_support, setup_ipc, AsyncSignalListener,
+ BlockingSignalIterator,
+};
-use interprocess::local_socket::{prelude::*, GenericFilePath, Stream as LocalSocketStream};
use std::io::prelude::*;
use std::io::IsTerminal;
use std::path::Path;
@@ -23,9 +27,9 @@ use zellij_utils::{
const SIGWINCH_CB_THROTTLE_DURATION: time::Duration = time::Duration::from_millis(50);
-const ENABLE_MOUSE_SUPPORT: &str =
+pub(crate) const ENABLE_MOUSE_SUPPORT: &str =
"\u{1b}[?1000h\u{1b}[?1002h\u{1b}[?1003h\u{1b}[?1015h\u{1b}[?1006h";
-const DISABLE_MOUSE_SUPPORT: &str =
+pub(crate) const DISABLE_MOUSE_SUPPORT: &str =
"\u{1b}[?1006l\u{1b}[?1015l\u{1b}[?1003l\u{1b}[?1002l\u{1b}[?1000l";
/// Trait for async stdin reading, allowing for testable implementations
@@ -125,7 +129,12 @@ pub trait ClientOsApi: Send + Sync + std::fmt::Debug {
/// Receives a message on client-side IPC channel
// This should be called from the client-side router thread only.
fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)>;
- fn handle_signals(&self, sigwinch_cb: Box<dyn Fn()>, quit_cb: Box<dyn Fn()>);
+ fn handle_signals(
+ &self,
+ sigwinch_cb: Box<dyn Fn()>,
+ quit_cb: Box<dyn Fn()>,
+ resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
+ );
/// Establish a connection with the server socket.
fn connect_to_server(&self, path: &Path);
fn load_palette(&self) -> Palette;
@@ -236,9 +245,14 @@ impl ClientOsApi for ClientOsInputOutput {
.unwrap()
.recv_server_msg()
}
- fn handle_signals(&self, sigwinch_cb: Box<dyn Fn()>, quit_cb: Box<dyn Fn()>) {
+ fn handle_signals(
+ &self,
+ sigwinch_cb: Box<dyn Fn()>,
+ quit_cb: Box<dyn Fn()>,
+ resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
+ ) {
let mut sigwinch_cb_timestamp = time::Instant::now();
- let signals = BlockingSignalIterator::new().unwrap();
+ let signals = BlockingSignalIterator::new(resize_receiver).unwrap();
for event in signals {
match event {
SignalEvent::Resize => {
@@ -257,12 +271,9 @@ impl ClientOsApi for ClientOsInputOutput {
}
}
fn connect_to_server(&self, path: &Path) {
- let fs_name = path
- .to_fs_name::<GenericFilePath>()
- .expect("failed to convert path to socket name");
let socket;
loop {
- match LocalSocketStream::connect(fs_name.clone()) {
+ match zellij_utils::consts::ipc_connect(path) {
Ok(sock) => {
socket = sock;
break;
@@ -272,8 +283,7 @@ impl ClientOsApi for ClientOsInputOutput {
},
}
}
- let sender = IpcSenderWithContext::new(socket);
- let receiver = sender.get_receiver();
+ let (sender, receiver) = setup_ipc(socket, path);
*self.send_instructions_to_server.lock().unwrap() = Some(sender);
*self.receive_instructions_from_server.lock().unwrap() = Some(receiver);
}
@@ -292,23 +302,13 @@ impl ClientOsApi for ClientOsInputOutput {
default_palette()
}
fn enable_mouse(&self) -> Result<()> {
- let err_context = "failed to enable mouse mode";
let mut stdout = self.get_stdout_writer();
- stdout
- .write_all(ENABLE_MOUSE_SUPPORT.as_bytes())
- .context(err_context)?;
- stdout.flush().context(err_context)?;
- Ok(())
+ enable_mouse_support(&mut *stdout)
}
fn disable_mouse(&self) -> Result<()> {
- let err_context = "failed to enable mouse mode";
let mut stdout = self.get_stdout_writer();
- stdout
- .write_all(DISABLE_MOUSE_SUPPORT.as_bytes())
- .context(err_context)?;
- stdout.flush().context(err_context)?;
- Ok(())
+ disable_mouse_support(&mut *stdout)
}
fn env_variable(&self, name: &str) -> Option<String> {
diff --git a/zellij-client/src/os_input_output_unix.rs b/zellij-client/src/os_input_output_unix.rs
index d087eb7d..cf71b418 100644
--- a/zellij-client/src/os_input_output_unix.rs
+++ b/zellij-client/src/os_input_output_unix.rs
@@ -5,7 +5,11 @@ use signal_hook::consts::signal::*;
use signal_hook::iterator::Signals;
use tokio::signal::unix::{signal, SignalKind};
+use anyhow::{Context, Result};
use std::io;
+use std::io::Write;
+use std::path::Path;
+use zellij_utils::ipc::{IpcReceiverWithContext, IpcSenderWithContext};
/// Async signal listener that maps Unix signals to `SignalEvent` variants.
pub(crate) struct AsyncSignalListener {
@@ -48,7 +52,7 @@ pub(crate) struct BlockingSignalIterator {
}
impl BlockingSignalIterator {
- pub fn new() -> io::Result<Self> {
+ pub fn new(_resize_receiver: Option<std::sync::mpsc::Receiver<()>>) -> io::Result<Self> {
let signals = Signals::new([SIGWINCH, SIGTERM, SIGINT, SIGQUIT, SIGHUP])?;
Ok(Self { signals })
}
@@ -68,3 +72,36 @@ impl Iterator for BlockingSignalIterator {
None
}
}
+
+/// Set up client IPC channels from a connected socket.
+///
+/// On Unix a single socket is cloned for both send and receive directions.
+pub(crate) fn setup_ipc(
+ socket: interprocess::local_socket::Stream,
+ _path: &Path,
+) -> (
+ IpcSenderWithContext<zellij_utils::ipc::ClientToServerMsg>,
+ IpcReceiverWithContext<zellij_utils::ipc::ServerToClientMsg>,
+) {
+ let sender = IpcSenderWithContext::new(socket);
+ let receiver = sender.get_receiver();
+ (sender, receiver)
+}
+
+pub(crate) fn enable_mouse_support(stdout: &mut dyn Write) -> Result<()> {
+ let err_context = "failed to enable mouse mode";
+ stdout
+ .write_all(super::os_input_output::ENABLE_MOUSE_SUPPORT.as_bytes())
+ .context(err_context)?;
+ stdout.flush().context(err_context)?;
+ Ok(())
+}
+
+pub(crate) fn disable_mouse_support(stdout: &mut dyn Write) -> Result<()> {
+ let err_context = "failed to disable mouse mode";
+ stdout
+ .write_all(super::os_input_output::DISABLE_MOUSE_SUPPORT.as_bytes())
+ .context(err_context)?;
+ stdout.flush().context(err_context)?;
+ Ok(())
+}
diff --git a/zellij-client/src/os_input_output_windows.rs b/zellij-client/src/os_input_output_windows.rs
index bfced557..d71bc279 100644
--- a/zellij-client/src/os_input_output_windows.rs
+++ b/zellij-client/src/os_input_output_windows.rs
@@ -1,31 +1,115 @@
use crate::os_input_output::SignalEvent;
+use anyhow::{Context, Result};
use async_trait::async_trait;
use std::io;
+use std::io::Write;
+use std::path::Path;
+use zellij_utils::ipc::{IpcReceiverWithContext, IpcSenderWithContext};
-/// Windows async signal listener stub. Not yet implemented.
-pub(crate) struct AsyncSignalListener;
+/// Windows async signal listener.
+///
+/// Polls `crossterm::terminal::size()` at 100ms intervals for resize events,
+/// and listens to `tokio::signal::windows` for ctrl_c/ctrl_break/ctrl_close.
+pub(crate) struct AsyncSignalListener {
+ interval: tokio::time::Interval,
+ last_size: (u16, u16),
+ ctrl_c: tokio::signal::windows::CtrlC,
+ ctrl_break: tokio::signal::windows::CtrlBreak,
+ ctrl_close: tokio::signal::windows::CtrlClose,
+}
impl AsyncSignalListener {
pub fn new() -> io::Result<Self> {
- unimplemented!("Windows AsyncSignalListener not yet implemented")
+ let size = crossterm::terminal::size().unwrap_or((80, 24));
+ Ok(Self {
+ interval: tokio::time::interval(std::time::Duration::from_millis(100)),
+ last_size: size,
+ ctrl_c: tokio::signal::windows::ctrl_c()?,
+ ctrl_break: tokio::signal::windows::ctrl_break()?,
+ ctrl_close: tokio::signal::windows::ctrl_close()?,
+ })
}
}
#[async_trait]
impl crate::os_input_output::AsyncSignals for AsyncSignalListener {
async fn recv(&mut self) -> Option<SignalEvent> {
- unimplemented!("Windows AsyncSignalListener not yet implemented")
+ loop {
+ tokio::select! {
+ _ = self.interval.tick() => {
+ if let Ok(new_size) = crossterm::terminal::size() {
+ if new_size != self.last_size {
+ self.last_size = new_size;
+ return Some(SignalEvent::Resize);
+ }
+ }
+ }
+ result = self.ctrl_c.recv() => {
+ return result.map(|_| SignalEvent::Quit);
+ }
+ result = self.ctrl_break.recv() => {
+ return result.map(|_| SignalEvent::Quit);
+ }
+ result = self.ctrl_close.recv() => {
+ return result.map(|_| SignalEvent::Quit);
+ }
+ }
+ }
}
}
-/// Windows blocking signal iterator stub. Not yet implemented.
-pub(crate) struct BlockingSignalIterator;
+/// Windows blocking signal iterator.
+///
+/// Uses `SetConsoleCtrlHandler` with an `AtomicBool` for quit signals.
+/// For resize detection, operates in two modes:
+/// - **Channel mode**: receives resize notifications forwarded from the stdin
+/// thread (which gets `Event::Resize` from crossterm). Much more responsive
+/// than polling.
+/// - **Poll fallback**: polls `crossterm::terminal::size()` at 50ms intervals.
+/// Used when no receiver is provided or when the sender is dropped (VT reader
+/// path).
+pub(crate) struct BlockingSignalIterator {
+ last_size: (u16, u16),
+ resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
+}
+
+mod win_ctrl_handler {
+ use std::sync::atomic::{AtomicBool, Ordering};
+
+ use windows_sys::Win32::Foundation::BOOL;
+ use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT, CTRL_C_EVENT};
+
+ pub static CTRL_QUIT_RECEIVED: AtomicBool = AtomicBool::new(false);
+
+ pub unsafe extern "system" fn ctrl_handler(ctrl_type: u32) -> BOOL {
+ match ctrl_type {
+ CTRL_C_EVENT | CTRL_BREAK_EVENT | CTRL_CLOSE_EVENT => {
+ CTRL_QUIT_RECEIVED.store(true, Ordering::SeqCst);
+ 1 // TRUE — handled
+ },
+ _ => 0, // FALSE — not handled
+ }
+ }
+}
impl BlockingSignalIterator {
- pub fn new() -> io::Result<Self> {
- unimplemented!("Windows BlockingSignalIterator not yet implemented")
+ pub fn new(resize_receiver: Option<std::sync::mpsc::Receiver<()>>) -> io::Result<Self> {
+ use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
+
+ win_ctrl_handler::CTRL_QUIT_RECEIVED.store(false, std::sync::atomic::Ordering::SeqCst);
+
+ let ok = unsafe { SetConsoleCtrlHandler(Some(win_ctrl_handler::ctrl_handler), 1) };
+ if ok == 0 {
+ return Err(io::Error::last_os_error());
+ }
+
+ let size = crossterm::terminal::size().unwrap_or((80, 24));
+ Ok(Self {
+ last_size: size,
+ resize_receiver,
+ })
}
}
@@ -33,6 +117,122 @@ impl Iterator for BlockingSignalIterator {
type Item = SignalEvent;
fn next(&mut self) -> Option<SignalEvent> {
- unimplemented!("Windows BlockingSignalIterator not yet implemented")
+ use std::sync::mpsc::RecvTimeoutError;
+ use std::time::Duration;
+
+ loop {
+ if win_ctrl_handler::CTRL_QUIT_RECEIVED.load(std::sync::atomic::Ordering::SeqCst) {
+ return Some(SignalEvent::Quit);
+ }
+
+ if let Some(ref rx) = self.resize_receiver {
+ // Channel mode: the native console stdin loop sends resize
+ // notifications through this channel. Block with a timeout so
+ // we can periodically check the quit flag above.
+ match rx.recv_timeout(Duration::from_millis(100)) {
+ Ok(()) => return Some(SignalEvent::Resize),
+ Err(RecvTimeoutError::Timeout) => continue,
+ Err(RecvTimeoutError::Disconnected) => {
+ // Sender dropped (VT reader path) — switch to poll mode.
+ self.resize_receiver = None;
+ continue;
+ },
+ }
+ } else {
+ // Poll mode: used on the VT reader path where crossterm's
+ // event::read() isn't used, so resize events don't come through
+ // the channel. Periodically compare the terminal size instead.
+ if let Ok(new_size) = crossterm::terminal::size() {
+ if new_size != self.last_size {
+ self.last_size = new_size;
+ return Some(SignalEvent::Resize);
+ }
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ }
+ }
+}
+
+/// Set up client IPC channels from a connected socket.
+///
+/// On Windows we use two separate named pipes to avoid DuplicateHandle
+/// deadlock: the command pipe (socket) for client→server, and a reply pipe
+/// for server→client.
+pub(crate) fn setup_ipc(
+ socket: interprocess::local_socket::Stream,
+ path: &Path,
+) -> (
+ IpcSenderWithContext<zellij_utils::ipc::ClientToServerMsg>,
+ IpcReceiverWithContext<zellij_utils::ipc::ServerToClientMsg>,
+) {
+ let reply_socket;
+ loop {
+ match zellij_utils::consts::ipc_connect_reply(path) {
+ Ok(sock) => {
+ reply_socket = sock;
+ break;
+ },
+ Err(_) => {
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ },
+ }
+ }
+ let sender = IpcSenderWithContext::new(socket);
+ let receiver = IpcReceiverWithContext::new(reply_socket);
+ (sender, receiver)
+}
+
+/// Enable ENABLE_VIRTUAL_TERMINAL_PROCESSING on stdout so that ConPTY enters
+/// passthrough mode and forwards DEC private mode sequences (like mouse-enable)
+/// to the terminal emulator. Uses crossterm's safe wrapper which handles the
+/// GetConsoleMode/SetConsoleMode internally.
+fn enable_vt_processing_on_stdout() {
+ crossterm::ansi_support::supports_ansi();
+}
+
+/// Enable mouse support on Windows.
+///
+/// When TERM is set we're on the VT input path (terminal emulator like
+/// Alacritty via ConPTY). We must NOT use crossterm's EnableMouseCapture
+/// because it does a full SetConsoleMode() that would overwrite the mode
+/// set by enable_vt_input(), clobbering ENABLE_VIRTUAL_TERMINAL_INPUT.
+///
+/// Instead, we enable ENABLE_VIRTUAL_TERMINAL_PROCESSING on stdout so
+/// ConPTY enters passthrough mode, then write ANSI mouse-enable sequences.
+///
+/// When TERM is not set we're in a native console (cmd, PowerShell,
+/// Windows Terminal) and use crossterm's Console API approach.
+pub(crate) fn enable_mouse_support(stdout: &mut dyn Write) -> Result<()> {
+ let err_context = "failed to enable mouse mode";
+ if std::env::var("TERM").is_ok() {
+ enable_vt_processing_on_stdout();
+ stdout
+ .write_all(super::os_input_output::ENABLE_MOUSE_SUPPORT.as_bytes())
+ .context(err_context)?;
+ stdout.flush().context(err_context)?;
+ } else {
+ // crossterm::execute! requires Sized, so we use std::io::stdout()
+ // directly rather than the trait-object writer.
+ crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)
+ .context(err_context)?;
+ }
+ Ok(())
+}
+
+/// Disable mouse support on Windows.
+///
+/// See `enable_mouse_support()` for rationale on VT vs Console API paths.
+pub(crate) fn disable_mouse_support(stdout: &mut dyn Write) -> Result<()> {
+ let err_context = "failed to disable mouse mode";
+ if std::env::var("TERM").is_ok() {
+ stdout
+ .write_all(super::os_input_output::DISABLE_MOUSE_SUPPORT.as_bytes())
+ .context(err_context)?;
+ stdout.flush().context(err_context)?;
+ } else {
+ crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)
+ .context(err_context)?;
}
+ Ok(())
}
diff --git a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs
index 076e9ced..92c13cda 100644
--- a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs
+++ b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs
@@ -268,7 +268,13 @@ impl crate::os_input_output::ClientOsApi for MockClientOsApi {
None
}
- fn handle_signals(&self, _sigwinch_cb: Box<dyn Fn()>, _quit_cb: Box<dyn Fn()>) {}
+ fn handle_signals(
+ &self,
+ _sigwinch_cb: Box<dyn Fn()>,
+ _quit_cb: Box<dyn Fn()>,
+ _resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
+ ) {
+ }
fn connect_to_server(&self, _path: &std::path::Path) {}
diff --git a/zellij-client/src/stdin_handler.rs b/zellij-client/src/stdin_handler.rs
index a7ddf4e2..5eb1add5 100644
--- a/zellij-client/src/stdin_handler.rs
+++ b/zellij-client/src/stdin_handler.rs
@@ -20,14 +20,79 @@ fn send_done_parsing_after_query_timeout(
});
}
+/// On Windows, set the stdin console mode for raw VT input.
+///
+/// Instead of just ORing in ENABLE_VIRTUAL_TERMINAL_INPUT on top of whatever
+/// the current mode happens to be, we explicitly set the exact mode we need.
+/// This avoids a TOCTOU race with crossterm's EnableMouseCapture (which also
+/// does GetConsoleMode/SetConsoleMode) and ensures flags like
+/// ENABLE_QUICK_EDIT_MODE are always cleared — that flag intercepts mouse
+/// events at the console level, breaking application mouse support.
+#[cfg(windows)]
+fn enable_vt_input() -> bool {
+ use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
+ use windows_sys::Win32::System::Console::{
+ GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_EXTENDED_FLAGS, ENABLE_MOUSE_INPUT,
+ ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_WINDOW_INPUT, STD_INPUT_HANDLE,
+ };
+ unsafe {
+ let handle = GetStdHandle(STD_INPUT_HANDLE);
+ if handle == 0 || handle == INVALID_HANDLE_VALUE {
+ return false;
+ }
+ let mut mode: u32 = 0;
+ if GetConsoleMode(handle, &mut mode) == 0 {
+ return false;
+ }
+ // Explicitly set the mode we need rather than read-modify-write.
+ // This eliminates the race with crossterm's EnableMouseCapture which
+ // also calls GetConsoleMode/SetConsoleMode concurrently.
+ //
+ // Flags we set:
+ // ENABLE_WINDOW_INPUT (0x0008) - receive window resize events
+ // ENABLE_MOUSE_INPUT (0x0010) - receive mouse events; on ConPTY
+ // this signals the terminal emulator
+ // to capture and forward mouse input
+ // ENABLE_EXTENDED_FLAGS (0x0080) - required to clear QUICK_EDIT
+ // ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) - stdin returns raw VT bytes
+ //
+ // Flags we deliberately clear:
+ // ENABLE_PROCESSED_INPUT (0x0001) - let VT sequences through raw
+ // ENABLE_LINE_INPUT (0x0002) - no line buffering
+ // ENABLE_ECHO_INPUT (0x0004) - no echo
+ // ENABLE_QUICK_EDIT_MODE (0x0040) - would intercept mouse events
+ let new_mode = ENABLE_WINDOW_INPUT
+ | ENABLE_MOUSE_INPUT
+ | ENABLE_EXTENDED_FLAGS
+ | ENABLE_VIRTUAL_TERMINAL_INPUT;
+ if SetConsoleMode(handle, new_mode) == 0 {
+ return false;
+ }
+ true
+ }
+}
+
pub(crate) fn stdin_loop(
mut os_input: Box<dyn ClientOsApi>,
send_input_instructions: SenderWithContext<InputInstruction>,
stdin_ansi_parser: Arc<Mutex<StdinAnsiParser>>,
explicitly_disable_kitty_keyboard_protocol: bool,
+ resize_sender: Option<std::sync::mpsc::Sender<()>>,
) {
- let mut input_parser = InputParser::new();
- let mut current_buffer = vec![];
+ // On Windows, choose between two input strategies early — we need this
+ // decision before the startup ANSI query below.
+ //
+ // 1. Native console (no TERM env var): Use crossterm's event::read() which
+ // reads INPUT_RECORDs via ReadConsoleInput. Works in cmd.exe, PowerShell,
+ // and Windows Terminal where ALT is reported as a modifier flag.
+ //
+ // 2. Terminal emulator (TERM is set, e.g. Alacritty): Enable
+ // ENABLE_VIRTUAL_TERMINAL_INPUT so ReadFile on stdin returns raw VT bytes,
+ // bypassing conpty's lossy VT→INPUT_RECORD translation. Then use the
+ // termwiz byte parser (same as Unix) which understands ESC-prefixed ALT.
+ #[cfg(windows)]
+ let use_vt_reader = std::env::var("TERM").is_ok() && enable_vt_input();
+
{
// on startup we send a query to the terminal emulator for stuff like the pixel size and colors
// we get a response through STDIN, so it makes sense to do this here
@@ -41,23 +106,58 @@ pub(crate) fn stdin_loop(
.unwrap();
},
None => {
- send_input_instructions
- .send(InputInstruction::StartedParsing)
- .unwrap();
- let terminal_emulator_query_string =
- stdin_ansi_parser.terminal_emulator_query_string();
- let _ = os_input
- .get_stdout_writer()
- .write(terminal_emulator_query_string.as_bytes())
- .unwrap();
- let query_duration = stdin_ansi_parser.startup_query_duration();
- send_done_parsing_after_query_timeout(
- send_input_instructions.clone(),
- query_duration,
- );
+ // On Windows native console, the crossterm event::read() loop
+ // reads INPUT_RECORDs via ReadConsoleInput — not raw bytes — so
+ // ANSI query responses can never be read on that path. On the
+ // VT reader path (TERM is set), fill_buf() reads raw VT bytes
+ // just like Unix, so terminal queries work normally.
+ #[cfg(windows)]
+ let can_query_terminal = use_vt_reader;
+ #[cfg(not(windows))]
+ let can_query_terminal = true;
+
+ if can_query_terminal {
+ send_input_instructions
+ .send(InputInstruction::StartedParsing)
+ .unwrap();
+ let terminal_emulator_query_string =
+ stdin_ansi_parser.terminal_emulator_query_string();
+ let _ = os_input
+ .get_stdout_writer()
+ .write(terminal_emulator_query_string.as_bytes())
+ .unwrap();
+ let query_duration = stdin_ansi_parser.startup_query_duration();
+ send_done_parsing_after_query_timeout(
+ send_input_instructions.clone(),
+ query_duration,
+ );
+ } else {
+ let _ = send_input_instructions.send(InputInstruction::DoneParsing);
+ }
},
}
}
+
+ #[cfg(windows)]
+ if !use_vt_reader {
+ crate::stdin_handler_windows::native_console_stdin_loop(
+ send_input_instructions,
+ resize_sender,
+ );
+ return;
+ }
+
+ // Drop the resize sender so the signal handler thread falls back to
+ // polling. Only the Windows native console path (above) keeps it alive;
+ // the VT reader path and Unix don't produce crossterm resize events.
+ drop(resize_sender);
+
+ // Byte reader + termwiz/kitty parser path.
+ // Used on Unix always, and on Windows inside terminal emulators (Alacritty,
+ // etc.) with ENABLE_VIRTUAL_TERMINAL_INPUT enabled so stdin delivers raw VT
+ // byte sequences.
+ let mut input_parser = InputParser::new();
+ let mut current_buffer = vec![];
let mut ansi_stdin_events = vec![];
loop {
match os_input.read_from_stdin() {
@@ -96,6 +196,7 @@ pub(crate) fn stdin_loop(
.send(InputInstruction::KeyWithModifierEvent(
key_with_modifier,
current_buffer.drain(..).collect(),
+ true,
))
.unwrap();
continue;
diff --git a/zellij-client/src/stdin_handler_windows.rs b/zellij-client/src/stdin_handler_windows.rs
new file mode 100644
index 00000000..a2e57d47
--- /dev/null
+++ b/zellij-client/src/stdin_handler_windows.rs
@@ -0,0 +1,64 @@
+use crate::InputInstruction;
+use crossterm::event::{self, Event, KeyEventKind};
+use zellij_utils::channels::SenderWithContext;
+use zellij_utils::input::{cast_crossterm_key, from_crossterm_mouse};
+
+/// Windows native console event loop.
+///
+/// Uses crossterm's `event::read()` which reads INPUT_RECORDs via
+/// ReadConsoleInput. Works in cmd.exe, PowerShell, and Windows Terminal
+/// where ALT is reported as a modifier flag.
+///
+/// Resize events are forwarded to the signal handler thread via `resize_sender`.
+pub(crate) fn native_console_stdin_loop(
+ send_input_instructions: SenderWithContext<InputInstruction>,
+ resize_sender: Option<std::sync::mpsc::Sender<()>>,
+) {
+ loop {
+ match event::read() {
+ Ok(Event::Key(key_event)) => {
+ if key_event.kind != KeyEventKind::Press {
+ continue;
+ }
+ if let Some((key, bytes)) = cast_crossterm_key(key_event) {
+ if send_input_instructions
+ .send(InputInstruction::KeyWithModifierEvent(key, bytes, false))
+ .is_err()
+ {
+ break;
+ }
+ }
+ },
+ Ok(Event::Mouse(mouse_event)) => {
+ let mouse_event = from_crossterm_mouse(mouse_event);
+ if send_input_instructions
+ .send(InputInstruction::MouseEvent(mouse_event))
+ .is_err()
+ {
+ break;
+ }
+ },
+ Ok(Event::Paste(text)) => {
+ let raw_bytes = text.as_bytes().to_vec();
+ let paste_event = termwiz::input::InputEvent::Paste(text);
+ if send_input_instructions
+ .send(InputInstruction::KeyEvent(paste_event, raw_bytes))
+ .is_err()
+ {
+ break;
+ }
+ },
+ Ok(Event::Resize(..)) => {
+ if let Some(ref tx) = resize_sender {
+ let _ = tx.send(());
+ }
+ },
+ Ok(_) => {},
+ Err(e) => {
+ log::error!("Failed to read crossterm event: {}", e);
+ let _ = send_input_instructions.send(InputInstruction::Exit);
+ break;
+ },
+ }
+ }
+}
diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs
index e43adb4b..d48da54e 100644
--- a/zellij-client/src/unit/terminal_loop_tests.rs
+++ b/zellij-client/src/unit/terminal_loop_tests.rs
@@ -112,7 +112,13 @@ impl ClientOsApi for TestClientOsApi {
None
}
- fn handle_signals(&self, _sigwinch_cb: Box<dyn Fn()>, _quit_cb: Box<dyn Fn()>) {}
+ fn handle_signals(
+ &self,
+ _sigwinch_cb: Box<dyn Fn()>,
+ _quit_cb: Box<dyn Fn()>,
+ _resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
+ ) {
+ }
fn connect_to_server(&self, _path: &std::path::Path) {}
diff --git a/zellij-client/src/web_client/ipc_listener.rs b/zellij-client/src/web_client/ipc_listener.rs
index 62344ba8..0ba37002 100644
--- a/zellij-client/src/web_client/ipc_listener.rs
+++ b/zellij-client/src/web_client/ipc_listener.rs
@@ -1,8 +1,8 @@
use axum_server::Handle;
+use interprocess::local_socket::traits::tokio::Listener;
use std::net::IpAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
-use tokio::net::{UnixListener, UnixStream};
-use zellij_utils::consts::WEBSERVER_SOCKET_PATH;
+use zellij_utils::consts::{ipc_bind_async, WEBSERVER_SOCKET_PATH};
use zellij_utils::prost::Message;
use zellij_utils::web_server_commands::{InstructionForWebServer, VersionInfo, WebServerResponse};
use zellij_utils::web_server_contract::web_server_contract::InstructionForWebServer as ProtoInstructionForWebServer;
@@ -10,7 +10,7 @@ use zellij_utils::web_server_contract::web_server_contract::WebServerResponse as
pub async fn create_webserver_receiver(
id: &str,
-) -> Result<UnixStream, Box<dyn std::error::Error + Send + Sync>> {
+) -> Result<interprocess::local_socket::tokio::Stream, Box<dyn std::error::Error + Send + Sync>> {
std::fs::create_dir_all(&WEBSERVER_SOCKET_PATH.as_path())?;
let socket_path = WEBSERVER_SOCKET_PATH.join(format!("{}", id));
@@ -18,13 +18,13 @@ pub async fn create_webserver_receiver(
tokio::fs::remove_file(&socket_path).await?;
}
- let listener = UnixListener::bind(&socket_path)?;
- let (stream, _) = listener.accept().await?;
+ let listener = ipc_bind_async(&socket_path)?;
+ let stream = listener.accept().await?;
Ok(stream)
}
pub async fn receive_webserver_instruction(
- receiver: &mut UnixStream,
+ receiver: &mut interprocess::local_socket::tokio::Stream,
) -> std::io::Result<InstructionForWebServer> {
// Read length prefix (4 bytes)
let mut len_bytes = [0u8; 4];
@@ -46,7 +46,7 @@ pub async fn receive_webserver_instruction(
}
pub async fn send_webserver_response(
- sender: &mut UnixStream,
+ sender: &mut interprocess::local_socket::tokio::Stream,
response: WebServerResponse,
) -> std::io::Result<()> {
let proto_response: ProtoWebServerResponse = response.into();
diff --git a/zellij-client/src/web_client/mod.rs b/zellij-client/src/web_client/mod.rs
index 958de439..19a1c422 100644
--- a/zellij-client/src/web_client/mod.rs
+++ b/zellij-client/src/web_client/mod.rs
@@ -54,6 +54,8 @@ use utils::should_use_https;
use uuid::Uuid;
use websocket_handlers::{ws_handler_control, ws_handler_terminal};
+const DEFAULT_SERVER_STARTUP_TIMEOUT_SECS: u64 = 10;
+
pub fn start_web_client(
config: Config,
config_options: Options,
@@ -63,6 +65,7 @@ pub fn start_web_client(
custom_port: Option<u16>,
custom_server_cert: Option<PathBuf>,
custom_server_key: Option<PathBuf>,
+ startup_timeout: Option<u64>,
) {
std::panic::set_hook({
Box::new(move |info| {
@@ -108,6 +111,8 @@ pub fn start_web_client(
web_server_port,
web_server_cert,
web_server_key,
+ config_file_path.clone(),
+ startup_timeout,
)
} else {
let runtime = Runtime::new().unwrap();
@@ -258,6 +263,8 @@ fn daemonize_web_server(
web_server_port: u16,
web_server_cert: Option<PathBuf>,
web_server_key: Option<PathBuf>,
+ _config_file_path: Option<PathBuf>,
+ _startup_timeout: Option<u64>,
) -> (Runtime, std::net::TcpListener, Option<RustlsConfig>) {
let (mut exit_message_tx, exit_message_rx) = pipe().unwrap();
let (mut exit_status_tx, mut exit_status_rx) = pipe().unwrap();
@@ -356,12 +363,72 @@ fn daemonize_web_server(
#[cfg(not(unix))]
fn daemonize_web_server(
- _web_server_ip: IpAddr,
- _web_server_port: u16,
- _web_server_cert: Option<PathBuf>,
- _web_server_key: Option<PathBuf>,
+ web_server_ip: IpAddr,
+ web_server_port: u16,
+ web_server_cert: Option<PathBuf>,
+ web_server_key: Option<PathBuf>,
+ config_file_path: Option<PathBuf>,
+ startup_timeout: Option<u64>,
) -> (Runtime, std::net::TcpListener, Option<RustlsConfig>) {
- unimplemented!("Non-Unix web server not yet implemented")
+ use std::env::current_exe;
+ use std::net::TcpStream;
+ use std::process::{exit, Command};
+ use std::time::{Duration, Instant};
+
+ let exe = current_exe().unwrap_or_else(|e| {
+ eprintln!("Failed to determine executable path: {}", e);
+ exit(2);
+ });
+
+ let mut cmd = Command::new(&exe);
+ if let Some(ref config_path) = config_file_path {
+ cmd.arg("--config").arg(config_path);
+ }
+ cmd.arg("web").arg("--start");
+ cmd.arg("--ip").arg(web_server_ip.to_string());
+ cmd.arg("--port").arg(web_server_port.to_string());
+ if let Some(ref cert) = web_server_cert {
+ cmd.arg("--cert").arg(cert);
+ }
+ if let Some(ref key) = web_server_key {
+ cmd.arg("--key").arg(key);
+ }
+
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
+ const CREATE_NO_WINDOW: u32 = 0x08000000;
+ cmd.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
+ }
+
+ match cmd.spawn() {
+ Ok(_child) => {
+ let timeout_secs = startup_timeout.unwrap_or(DEFAULT_SERVER_STARTUP_TIMEOUT_SECS);
+ let deadline = Instant::now() + Duration::from_secs(timeout_secs);
+ let addr = format!("{}:{}", web_server_ip, web_server_port);
+ loop {
+ if TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(200))
+ .is_ok()
+ {
+ println!(
+ "Web Server started on {} port {}",
+ web_server_ip, web_server_port
+ );
+ exit(0);
+ }
+ if Instant::now() > deadline {
+ eprintln!("Timed out waiting for web server to start on {}", addr);
+ exit(2);
+ }
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ },
+ Err(e) => {
+ eprintln!("Failed to spawn web server: {}", e);
+ exit(2);
+ },
+ }
}
#[cfg(test)]
diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs
index 91daa699..542a7865 100644
--- a/zellij-client/src/web_client/unit/web_client_tests.rs
+++ b/zellij-client/src/web_client/unit/web_client_tests.rs
@@ -2258,7 +2258,13 @@ impl ClientOsApi for MockClientOsApi {
}
msg
}
- fn handle_signals(&self, _sigwinch_cb: Box<dyn Fn()>, _quit_cb: Box<dyn Fn()>) {}
+ fn handle_signals(
+ &self,
+ _sigwinch_cb: Box<dyn Fn()>,
+ _quit_cb: Box<dyn Fn()>,
+ _resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
+ ) {
+ }
fn connect_to_server(&self, _path: &std::path::Path) {}
fn load_palette(&self) -> Palette {
Palette::default()
diff --git a/zellij-server/Cargo.toml b/zellij-server/Cargo.toml
index 54f10a2c..c4b01314 100644
--- a/zellij-server/Cargo.toml
+++ b/zellij-server/Cargo.toml
@@ -31,7 +31,6 @@ regex = { workspace = true }
semver = { version = "0.11.0", default-features = false }
serde = { workspace = true }
serde_json = { workspace = true }
-signal-hook = { workspace = true }
sixel-image = { version = "0.1.0", default-features = false }
sixel-tokenizer = { version = "0.1.0", default-features = false }
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
@@ -57,6 +56,17 @@ close_fds = "0.3.2"
daemonize = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
+signal-hook = { workspace = true }
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { workspace = true, features = [
+ "Win32_Foundation",
+ "Win32_Security",
+ "Win32_Storage_FileSystem",
+ "Win32_System_Console",
+ "Win32_System_Pipes",
+ "Win32_System_Threading",
+] }
[dev-dependencies]
insta = "1.6.0"
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index fc699240..98888615 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -665,6 +665,20 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
.expect("could not daemonize the server process");
}
+ #[cfg(windows)]
+ {
+ // The server is spawned with CREATE_NEW_PROCESS_GROUP, which disables
+ // Ctrl+C handling for the process. Child processes inherit this
+ // disabled state, so ConPTY children (shells, commands) would silently
+ // ignore CTRL_C_EVENT signals. Re-enable Ctrl+C here so that
+ // descendants get the normal default handler (terminate on Ctrl+C).
+ //
+ use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
+ unsafe {
+ SetConsoleCtrlHandler(None, 0);
+ }
+ }
+
envs::set_zellij("0".to_string());
let (to_server, server_receiver): ChannelWithContext<ServerInstruction> = channels::bounded(50);
@@ -683,7 +697,9 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
let _ = thread::Builder::new()
.name("server_listener".to_string())
.spawn({
- use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions};
+ use interprocess::local_socket::prelude::*;
+ use zellij_utils::consts::ipc_bind;
+ #[cfg(unix)]
use zellij_utils::shared::set_permissions;
let os_input = os_input.clone();
@@ -693,26 +709,37 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
let socket_path = socket_path.clone();
move || {
drop(std::fs::remove_file(&socket_path));
- let listener = ListenerOptions::new()
- .name(
- socket_path
- .as_path()
- .to_fs_name::<GenericFilePath>()
- .unwrap(),
- )
- .create_sync()
- .unwrap();
+ let listener = ipc_bind(&socket_path).unwrap();
// set the sticky bit to avoid the socket file being potentially cleaned up
// https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html states that for XDG_RUNTIME_DIR:
// "To ensure that your files are not removed, they should have their access time timestamp modified at least once every 6 hours of monotonic time or the 'sticky' bit should be set on the file. "
// It is not guaranteed that all platforms allow setting the sticky bit on sockets!
+ #[cfg(unix)]
drop(set_permissions(&socket_path, 0o1700));
+
+ // On Windows, named pipes are half-duplex, so we need a separate
+ // reply pipe for server→client messages.
+ #[cfg(windows)]
+ let reply_listener = zellij_utils::consts::ipc_bind_reply(&socket_path).unwrap();
+
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let mut os_input = os_input.clone();
let client_id = session_state.write().unwrap().new_client();
+
+ #[cfg(windows)]
+ let reply_stream = reply_listener
+ .accept()
+ .expect("failed to accept reply connection");
+
+ #[cfg(windows)]
+ let receiver = os_input
+ .new_client_with_reply(client_id, stream, reply_stream)
+ .unwrap();
+ #[cfg(not(windows))]
let receiver = os_input.new_client(client_id, stream).unwrap();
+
let session_data = session_data.clone();
let session_state = session_state.clone();
let to_server = to_server.clone();
@@ -779,6 +806,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
},
};
+ info!("FirstClientConnected: initializing session");
let mut session = init_session(
os_input.clone(),
to_server.clone(),
@@ -790,6 +818,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
config.plugins.clone(),
client_id,
);
+ info!("FirstClientConnected: session initialized, spawning tabs");
let mut runtime_configuration = config.clone();
runtime_configuration.options = runtime_config_options.clone();
session
diff --git a/zellij-server/src/os_input_output.rs b/zellij-server/src/os_input_output.rs
index 19106388..dd7225ad 100644
--- a/zellij-server/src/os_input_output.rs
+++ b/zellij-server/src/os_input_output.rs
@@ -35,31 +35,62 @@ use std::{
pub use async_trait::async_trait;
-pub(crate) fn command_exists(cmd: &RunCommand) -> bool {
+/// Check whether a candidate path refers to an executable file, considering
+/// PATHEXT extensions on Windows (e.g. `.exe`, `.cmd`).
+fn find_executable(candidate: &std::path::Path) -> Option<PathBuf> {
+ if candidate.exists() && candidate.is_file() {
+ return Some(candidate.to_path_buf());
+ }
+ #[cfg(windows)]
+ {
+ if let Some(pathext) = env::var_os("PATHEXT") {
+ let pathext = pathext.to_string_lossy();
+ for ext in pathext.split(';') {
+ let ext = ext.trim();
+ if ext.is_empty() {
+ continue;
+ }
+ let mut with_ext = candidate.as_os_str().to_os_string();
+ with_ext.push(ext);
+ let with_ext_path = PathBuf::from(with_ext);
+ if with_ext_path.exists() && with_ext_path.is_file() {
+ return Some(with_ext_path);
+ }
+ }
+ }
+ }
+ None
+}
+
+/// Resolve a command to its absolute path, searching the working directory,
+/// then PATH (and PATHEXT on Windows).
+pub(crate) fn resolve_command(cmd: &RunCommand) -> Option<PathBuf> {
let command = &cmd.command;
match cmd.cwd.as_ref() {
Some(cwd) => {
- let full_command = cwd.join(&command);
- if full_command.exists() && full_command.is_file() {
- return true;
+ if let Some(resolved) = find_executable(&cwd.join(command)) {
+ return Some(resolved);
}
},
None => {
- if command.exists() && command.is_file() {
- return true;
+ if let Some(resolved) = find_executable(command) {
+ return Some(resolved);
}
},
}
-
if let Some(paths) = env::var_os("PATH") {
for path in env::split_paths(&paths) {
- let full_command = path.join(command);
- if full_command.exists() && full_command.is_file() {
- return true;
+ if let Some(resolved) = find_executable(&path.join(command)) {
+ return Some(resolved);
}
}
}
- false
+ None
+}
+
+#[cfg(not(windows))]
+pub(crate) fn command_exists(cmd: &RunCommand) -> bool {
+ resolve_command(cmd).is_some()
}
// this is a utility method to separate the arguments from a pathbuf before we turn it into a
@@ -298,6 +329,13 @@ pub trait ServerOsApi: Send + Sync {
client_id: ClientId,
stream: LocalSocketStream,
) -> Result<IpcReceiverWithContext<ClientToServerMsg>>;
+ /// Create a new client with a separate reply stream (Windows dual-pipe IPC).
+ fn new_client_with_reply(
+ &mut self,
+ client_id: ClientId,
+ stream: LocalSocketStream,
+ reply_stream: LocalSocketStream,
+ ) -> Result<IpcReceiverWithContext<ClientToServerMsg>>;
fn remove_client(&mut self, client_id: ClientId) -> Result<()>;
fn load_palette(&self) -> Palette;
/// Returns the current working directory for a given pid
@@ -421,6 +459,22 @@ impl ServerOsApi for ServerOsInputOutput {
Ok(receiver)
}
+ fn new_client_with_reply(
+ &mut self,
+ client_id: ClientId,
+ stream: LocalSocketStream,
+ reply_stream: LocalSocketStream,
+ ) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
+ let receiver = IpcReceiverWithContext::new(stream);
+ let sender = ClientSender::new(client_id, IpcSenderWithContext::new(reply_stream));
+ self.client_senders
+ .lock()
+ .to_anyhow()
+ .with_context(|| format!("failed to create new client {client_id}"))?
+ .insert(client_id, sender);
+ Ok(receiver)
+ }
+
fn remove_client(&mut self, client_id: ClientId) -> Result<()> {
let mut client_senders = self
.client_senders
@@ -540,8 +594,46 @@ impl ServerOsApi for ServerOsInputOutput {
}
#[cfg(not(unix))]
- fn get_all_cmds_by_ppid(&self, _post_hook: &Option<String>) -> HashMap<String, Vec<String>> {
- unimplemented!("Windows get_all_cmds_by_ppid not yet implemented")
+ fn get_all_cmds_by_ppid(&self, post_hook: &Option<String>) -> HashMap<String, Vec<String>> {
+ let mut system_info = System::new();
+ let refresh_kind = ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always);
+ system_info.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh_kind);
+ let mut cmds = HashMap::new();
+ for (_pid, process) in system_info.processes() {
+ if let Some(parent_pid) = process.parent() {
+ let ppid_str = format!("{}", parent_pid);
+ let command: Vec<String> = process
+ .cmd()
+ .iter()
+ .map(|s| s.to_string_lossy().into_owned())
+ .collect();
+ if command.is_empty() {
+ continue;
+ }
+ match post_hook {
+ Some(post_hook) => {
+ let stringified = command.join(" ");
+ let cmd = match run_command_hook(&stringified, post_hook) {
+ Ok(command) => command,
+ Err(e) => {
+ log::error!("Post command hook failed to run: {}", e);
+ stringified.to_owned()
+ },
+ };
+ let line_parts: Vec<String> = cmd
+ .trim()
+ .split_ascii_whitespace()
+ .map(|p| p.to_owned())
+ .collect();
+ cmds.insert(ppid_str, line_parts);
+ },
+ None => {
+ cmds.insert(ppid_str, command);
+ },
+ }
+ }
+ }
+ cmds
}
fn write_to_file(&mut self, buf: String, name: Option<String>) -> Result<()> {
@@ -653,10 +745,18 @@ fn run_command_hook(
#[cfg(windows)]
fn run_command_hook(
- _original_command: &str,
- _hook_script: &str,
+ original_command: &str,
+ hook_script: &str,
) -> Result<String, Box<dyn std::error::Error>> {
- unimplemented!("Windows run_command_hook not yet implemented")
+ let output = Command::new("cmd")
+ .arg("/C")
+ .arg(hook_script)
+ .env("RESURRECT_COMMAND", original_command)
+ .output()?;
+ if !output.status.success() {
+ return Err(format!("Hook failed: {}", String::from_utf8_lossy(&output.stderr)).into());
+ }
+ Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
#[cfg(test)]
diff --git a/zellij-server/src/os_input_output_windows.rs b/zellij-server/src/os_input_output_windows.rs
index cd9171c6..f3cb2266 100644
--- a/zellij-server/src/os_input_output_windows.rs
+++ b/zellij-server/src/os_input_output_windows.rs
@@ -1,69 +1,647 @@
-use crate::os_input_output::AsyncReader;
+use crate::os_input_output::{resolve_command, AsyncReader};
use crate::panes::PaneId;
-use std::{io, path::PathBuf};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ ffi::OsStr,
+ io,
+ os::windows::ffi::OsStrExt,
+ os::windows::io::{FromRawHandle, IntoRawHandle, OwnedHandle},
+ sync::{
+ atomic::{AtomicU64, Ordering},
+ Arc, Mutex,
+ },
+};
+
+use tokio::io::AsyncReadExt;
+use tokio::net::windows::named_pipe::NamedPipeServer;
+
+use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, S_OK};
+use windows_sys::Win32::Storage::FileSystem::{
+ CreateFileW, FlushFileBuffers, WriteFile, FILE_FLAG_OVERLAPPED, OPEN_EXISTING,
+};
+use windows_sys::Win32::System::Console::{
+ ClosePseudoConsole, CreatePseudoConsole, GenerateConsoleCtrlEvent, ResizePseudoConsole, COORD,
+ CTRL_C_EVENT, HPCON,
+};
+use windows_sys::Win32::System::Pipes::{CreateNamedPipeW, CreatePipe};
+use windows_sys::Win32::System::Threading::{
+ CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess,
+ InitializeProcThreadAttributeList, OpenProcess, TerminateProcess, UpdateProcThreadAttribute,
+ WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE,
+ PROCESS_INFORMATION, PROCESS_TERMINATE, STARTUPINFOEXW, STARTUPINFOW,
+};
use zellij_utils::{errors::prelude::*, input::command::RunCommand};
-/// Windows PTY backend stub. Not yet implemented.
+pub use async_trait::async_trait;
+
+// Not exported by windows-sys; value from the Windows SDK.
+const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016;
+const FILE_FLAG_FIRST_PIPE_INSTANCE: u32 = 0x00080000;
+const PIPE_ACCESS_INBOUND: u32 = 0x00000001;
+const PIPE_TYPE_BYTE: u32 = 0;
+const PIPE_WAIT: u32 = 0;
+const GENERIC_WRITE: u32 = 0x40000000;
+
+/// Monotonic counter so each ConPTY output pipe gets a unique name, even when
+/// re-spawning on the same `terminal_id` (the old async reader may still hold
+/// the previous pipe handle).
+static PIPE_SEQ: AtomicU64 = AtomicU64::new(0);
+
+/// Per-terminal ConPTY state.
+struct ConPtyTerminal {
+ hpcon: HPCON,
+ input_write_handle: HANDLE,
+}
+
+// HANDLE/HPCON are isize (plain integers), safe to send across threads.
+unsafe impl Send for ConPtyTerminal {}
+unsafe impl Sync for ConPtyTerminal {}
+
+impl Drop for ConPtyTerminal {
+ fn drop(&mut self) {
+ unsafe {
+ // Close the pseudo console first — it may write a final VT frame
+ // to the output pipe. The async reader task (if still alive) will
+ // drain it. Then close the remaining handle.
+ ClosePseudoConsole(self.hpcon);
+ CloseHandle(self.input_write_handle);
+ }
+ }
+}
+
+/// An `AsyncReader` backed by a named pipe connected to ConPTY output.
+///
+/// Construction stores the raw `OwnedHandle`. The first `read()` call promotes
+/// it to a `NamedPipeServer` (IOCP registration requires a live Tokio reactor,
+/// which is not available at `spawn_terminal` time).
+struct ConPtyAsyncReader {
+ pending: Option<OwnedHandle>,
+ pipe: Option<NamedPipeServer>,
+}
+
+// OwnedHandle is Send+Sync; NamedPipeServer is Send.
+// The reader is only ever used from a single async task (via &mut self),
+// so Sync is safe.
+unsafe impl Sync for ConPtyAsyncReader {}
+
+impl ConPtyAsyncReader {
+ fn new(handle: OwnedHandle) -> Self {
+ Self {
+ pending: Some(handle),
+ pipe: None,
+ }
+ }
+}
+
+#[async_trait]
+impl AsyncReader for ConPtyAsyncReader {
+ async fn read(&mut self, buf: &mut [u8]) -> Result<usize, io::Error> {
+ if let Some(handle) = self.pending.take() {
+ let pipe = unsafe { NamedPipeServer::from_raw_handle(handle.into_raw_handle()) }?;
+ self.pipe = Some(pipe);
+ }
+ let pipe = self
+ .pipe
+ .as_mut()
+ .expect("ConPtyAsyncReader used after init");
+ pipe.read(buf).await
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helper utilities
+// ---------------------------------------------------------------------------
+
+/// Encode a Rust string as null-terminated UTF-16.
+fn to_wide(s: &str) -> Vec<u16> {
+ OsStr::new(s)
+ .encode_wide()
+ .chain(std::iter::once(0))
+ .collect()
+}
+
+/// Build a Windows command-line string from a `RunCommand`, following the
+/// `CommandLineToArgvW` quoting convention.
+fn build_command_line(cmd: &RunCommand) -> Vec<u16> {
+ let mut cmdline = String::new();
+
+ // Executable — always quote to handle spaces in paths.
+ let exe = cmd.command.to_string_lossy();
+ cmdline.push('"');
+ cmdline.push_str(&exe);
+ cmdline.push('"');
+
+ for arg in &cmd.args {
+ cmdline.push(' ');
+ if arg.is_empty() || arg.contains(' ') || arg.contains('\t') || arg.contains('"') {
+ cmdline.push('"');
+ let mut backslashes: usize = 0;
+ for ch in arg.chars() {
+ if ch == '\\' {
+ backslashes += 1;
+ } else if ch == '"' {
+ // Double backslashes preceding a quote, then escape the quote.
+ for _ in 0..backslashes {
+ cmdline.push('\\');
+ }
+ backslashes = 0;
+ cmdline.push('\\');
+ cmdline.push('"');
+ } else {
+ backslashes = 0;
+ cmdline.push(ch);
+ }
+ }
+ // Double trailing backslashes before the closing quote.
+ for _ in 0..backslashes {
+ cmdline.push('\\');
+ }
+ cmdline.push('"');
+ } else {
+ cmdline.push_str(arg);
+ }
+ }
+
+ to_wide(&cmdline)
+}
+
+/// Build a UTF-16 environment block (each entry `KEY=VALUE\0`, terminated by
+/// an extra `\0`) from the current process environment, adding
+/// `ZELLIJ_PANE_ID`.
+fn build_environment_block(terminal_id: u32) -> Vec<u16> {
+ let mut block: Vec<u16> = Vec::new();
+ for (key, value) in std::env::vars() {
+ if key == "ZELLIJ_PANE_ID" {
+ continue;
+ }
+ let entry = format!("{}={}", key, value);
+ block.extend(OsStr::new(&entry).encode_wide());
+ block.push(0);
+ }
+ let pane_entry = format!("ZELLIJ_PANE_ID={}", terminal_id);
+ block.extend(OsStr::new(&pane_entry).encode_wide());
+ block.push(0);
+ block.push(0); // double-null terminator
+ block
+}
+
+/// Create an overlapped named-pipe pair for ConPTY output.
+///
+/// Returns `(server_read_handle, client_write_handle)` where the server
+/// (read) end has `FILE_FLAG_OVERLAPPED` for IOCP and the client (write) end
+/// is synchronous (required by ConPTY).
+fn create_overlapped_output_pipe(terminal_id: u32) -> io::Result<(HANDLE, HANDLE)> {
+ let seq = PIPE_SEQ.fetch_add(1, Ordering::Relaxed);
+ let name = format!(
+ r"\\.\pipe\zellij-pty-{}-{}-{}",
+ std::process::id(),
+ terminal_id,
+ seq
+ );
+ let wide_name = to_wide(&name);
+
+ let server = unsafe {
+ CreateNamedPipeW(
+ wide_name.as_ptr(),
+ PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE,
+ PIPE_TYPE_BYTE | PIPE_WAIT,
+ 1, // max instances
+ 0, // out buffer (we only read)
+ 65536, // in buffer
+ 0, // default timeout
+ std::ptr::null(),
+ )
+ };
+ if server == INVALID_HANDLE_VALUE {
+ return Err(io::Error::last_os_error());
+ }
+
+ let client = unsafe {
+ CreateFileW(
+ wide_name.as_ptr(),
+ GENERIC_WRITE,
+ 0, // no sharing
+ std::ptr::null(), // default security
+ OPEN_EXISTING, // pipe already exists
+ 0, // synchronous
+ 0, // no template
+ )
+ };
+ if client == INVALID_HANDLE_VALUE {
+ unsafe { CloseHandle(server) };
+ return Err(io::Error::last_os_error());
+ }
+
+ Ok((server, client))
+}
+
+/// Create a ConPTY pseudo console of the given size attached to the provided
+/// pipes.
+fn create_conpty(
+ cols: u16,
+ rows: u16,
+ input_read: HANDLE,
+ output_write: HANDLE,
+) -> io::Result<HPCON> {
+ let size = COORD {
+ X: cols as i16,
+ Y: rows as i16,
+ };
+ let mut hpcon: HPCON = 0;
+ let hr = unsafe { CreatePseudoConsole(size, input_read, output_write, 0, &mut hpcon) };
+ if hr != S_OK {
+ Err(io::Error::from_raw_os_error(hr))
+ } else {
+ Ok(hpcon)
+ }
+}
+
+/// Spawn a child process attached to the given ConPTY.
+///
+/// Returns `(process_handle, thread_handle, child_pid)`.
+fn spawn_child_process(
+ hpcon: HPCON,
+ cmd: &RunCommand,
+ terminal_id: u32,
+) -> io::Result<(HANDLE, HANDLE, u32)> {
+ // --- proc thread attribute list ---
+ let mut attr_size: usize = 0;
+ unsafe {
+ InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut attr_size);
+ }
+ let mut attr_buf = vec![0u8; attr_size];
+ let attr_list = attr_buf.as_mut_ptr().cast();
+
+ if unsafe { InitializeProcThreadAttributeList(attr_list, 1, 0, &mut attr_size) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+
+ // N.B. For PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, lpValue is the HPCON
+ // value itself (not a pointer to it). In C, HPCON is `void*` so passing
+ // it directly as PVOID is natural. In Rust, HPCON is `isize`, so we cast
+ // the value to a pointer. This matches the Microsoft ConPTY sample.
+ // See: https://github.com/microsoft/terminal/issues/6705
+ if unsafe {
+ UpdateProcThreadAttribute(
+ attr_list,
+ 0,
+ PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
+ hpcon as *const core::ffi::c_void,
+ std::mem::size_of::<HPCON>(),
+ std::ptr::null_mut(),
+ std::ptr::null_mut(),
+ )
+ } == 0
+ {
+ unsafe { DeleteProcThreadAttributeList(attr_list) };
+ return Err(io::Error::last_os_error());
+ }
+
+ // --- startup info ---
+ let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
+ si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
+ si.lpAttributeList = attr_list;
+
+ // --- command line & environment ---
+ let mut cmd_line = build_command_line(cmd);
+ let env_block = build_environment_block(terminal_id);
+
+ let cwd: Option<Vec<u16>> = cmd.cwd.as_ref().and_then(|p| {
+ if p.exists() && p.is_dir() {
+ Some(to_wide(&p.to_string_lossy()))
+ } else {
+ log::error!(
+ "CWD for new pane '{}' does not exist or is not a directory",
+ p.display()
+ );
+ None
+ }
+ });
+ let cwd_ptr = cwd.as_ref().map_or(std::ptr::null(), |v| v.as_ptr());
+
+ let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
+
+ let ok = unsafe {
+ CreateProcessW(
+ std::ptr::null(), // lpApplicationName
+ cmd_line.as_mut_ptr(), // lpCommandLine (mutable)
+ std::ptr::null(), // lpProcessAttributes
+ std::ptr::null(), // lpThreadAttributes
+ 0, // bInheritHandles = FALSE
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
+ env_block.as_ptr().cast(), // lpEnvironment
+ cwd_ptr, // lpCurrentDirectory
+ &si.StartupInfo as *const STARTUPINFOW, // lpStartupInfo
+ &mut pi, // lpProcessInformation
+ )
+ };
+
+ unsafe { DeleteProcThreadAttributeList(attr_list) };
+
+ if ok == 0 {
+ return Err(io::Error::last_os_error());
+ }
+
+ Ok((pi.hProcess, pi.hThread, pi.dwProcessId))
+}
+
+fn terminate_process(pid: u32) -> std::result::Result<(), std::io::Error> {
+ unsafe {
+ let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
+ if handle == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let ok = TerminateProcess(handle, 1);
+ CloseHandle(handle);
+ if ok == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ }
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// WindowsPtyBackend
+// ---------------------------------------------------------------------------
+
+/// Windows PTY backend using native ConPTY with IOCP-based async I/O.
#[derive(Clone)]
-pub(crate) struct WindowsPtyBackend;
+pub(crate) struct WindowsPtyBackend {
+ terminals: Arc<Mutex<BTreeMap<u32, Option<ConPtyTerminal>>>>,
+}
impl WindowsPtyBackend {
pub fn new() -> Result<Self, io::Error> {
- Ok(Self)
+ Ok(Self {
+ terminals: Arc::new(Mutex::new(BTreeMap::new())),
+ })
+ }
+
+ /// Core spawn logic — creates ConPTY, spawns child, sets up exit monitor.
+ fn do_spawn(
+ &self,
+ cmd: RunCommand,
+ quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
+ terminal_id: u32,
+ ) -> Result<(Box<dyn AsyncReader>, u32)> {
+ let err_context = |c: &RunCommand| {
+ format!(
+ "failed to spawn terminal for '{}'",
+ c.command.to_string_lossy()
+ )
+ };
+
+ // 1. Output pipe pair (named, overlapped read end for IOCP)
+ let (output_read, output_write) =
+ create_overlapped_output_pipe(terminal_id).with_context(|| err_context(&cmd))?;
+
+ // 2. Input pipe pair (anonymous, both synchronous)
+ let mut input_read: HANDLE = 0;
+ let mut input_write: HANDLE = 0;
+ if unsafe { CreatePipe(&mut input_read, &mut input_write, std::ptr::null(), 0) } == 0 {
+ unsafe {
+ CloseHandle(output_read);
+ CloseHandle(output_write);
+ }
+ return Err(io::Error::last_os_error()).with_context(|| err_context(&cmd));
+ }
+
+ // 3. Create pseudo console
+ let hpcon = match create_conpty(80, 24, input_read, output_write) {
+ Ok(h) => h,
+ Err(e) => {
+ unsafe {
+ CloseHandle(output_read);
+ CloseHandle(output_write);
+ CloseHandle(input_read);
+ CloseHandle(input_write);
+ }
+ return Err(e).with_context(|| err_context(&cmd));
+ },
+ };
+
+ // 4. ConPTY duplicated the pipe ends it needs; close our copies.
+ unsafe {
+ CloseHandle(input_read);
+ CloseHandle(output_write);
+ }
+
+ // 5. Spawn child process
+ let (process_handle, thread_handle, child_pid) =
+ match spawn_child_process(hpcon, &cmd, terminal_id) {
+ Ok(r) => r,
+ Err(e) => {
+ unsafe {
+ ClosePseudoConsole(hpcon);
+ CloseHandle(input_write);
+ CloseHandle(output_read);
+ }
+ return Err(e).with_context(|| err_context(&cmd));
+ },
+ };
+
+ // Thread handle is not needed after spawn.
+ unsafe { CloseHandle(thread_handle) };
+
+ // 6. Store per-terminal state
+ self.terminals.lock().unwrap().insert(
+ terminal_id,
+ Some(ConPtyTerminal {
+ hpcon,
+ input_write_handle: input_write,
+ }),
+ );
+
+ // 7. Exit-monitoring thread (zero CPU — spends all time in kernel wait)
+ let cmd_for_monitor = cmd.clone();
+ std::thread::spawn(move || {
+ let exit_code = unsafe {
+ WaitForSingleObject(process_handle, INFINITE);
+ let mut code: u32 = 0;
+ GetExitCodeProcess(process_handle, &mut code);
+ CloseHandle(process_handle);
+ code
+ };
+ quit_cb(
+ PaneId::Terminal(terminal_id),
+ Some(exit_code as i32),
+ cmd_for_monitor,
+ );
+ });
+
+ // 8. Wrap the output read handle in an async reader
+ let owned = unsafe { OwnedHandle::from_raw_handle(output_read as *mut core::ffi::c_void) };
+ let reader = Box::new(ConPtyAsyncReader::new(owned)) as Box<dyn AsyncReader>;
+
+ Ok((reader, child_pid))
}
pub fn spawn_terminal(
&self,
- _cmd: RunCommand,
- _failover_cmd: Option<RunCommand>,
- _quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
- _terminal_id: u32,
+ mut cmd: RunCommand,
+ failover_cmd: Option<RunCommand>,
+ quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
+ terminal_id: u32,
) -> Result<(Box<dyn AsyncReader>, u32)> {
- unimplemented!("Windows PTY not yet implemented")
+ if let Some(resolved) = resolve_command(&cmd) {
+ cmd.command = resolved;
+ return self.do_spawn(cmd, quit_cb, terminal_id);
+ }
+ if let Some(mut failover) = failover_cmd {
+ if let Some(resolved) = resolve_command(&failover) {
+ failover.command = resolved;
+ return self.do_spawn(failover, quit_cb, terminal_id);
+ }
+ }
+ Err(ZellijError::CommandNotFound {
+ terminal_id,
+ command: cmd.command.to_string_lossy().to_string(),
+ })
+ .context("failed to spawn terminal")
}
pub fn set_terminal_size(
&self,
- _terminal_id: u32,
- _cols: u16,
- _rows: u16,
+ terminal_id: u32,
+ cols: u16,
+ rows: u16,
_width_in_pixels: Option<u16>,
_height_in_pixels: Option<u16>,
) -> Result<()> {
- unimplemented!("Windows PTY not yet implemented")
+ let err_context = || {
+ format!(
+ "failed to set terminal {} to size ({}, {})",
+ terminal_id, cols, rows
+ )
+ };
+
+ match self
+ .terminals
+ .lock()
+ .to_anyhow()
+ .with_context(err_context)?
+ .get(&terminal_id)
+ {
+ Some(Some(term)) => {
+ if cols > 0 && rows > 0 {
+ let size = COORD {
+ X: cols as i16,
+ Y: rows as i16,
+ };
+ let hr = unsafe { ResizePseudoConsole(term.hpcon, size) };
+ if hr != S_OK {
+ Err::<(), _>(anyhow!("ResizePseudoConsole failed: HRESULT 0x{:08x}", hr))
+ .with_context(err_context)
+ .non_fatal();
+ }
+ }
+ },
+ _ => {
+ Err::<(), _>(anyhow!("no ConPTY terminal found for id {}", terminal_id))
+ .with_context(err_context)
+ .non_fatal();
+ },
+ }
+ Ok(())
}
- pub fn write_to_tty_stdin(&self, _terminal_id: u32, _buf: &[u8]) -> Result<usize> {
- unimplemented!("Windows PTY not yet implemented")
+ pub fn write_to_tty_stdin(&self, terminal_id: u32, buf: &[u8]) -> Result<usize> {
+ let err_context = || format!("failed to write to stdin of terminal {}", terminal_id);
+
+ match self
+ .terminals
+ .lock()
+ .to_anyhow()
+ .with_context(err_context)?
+ .get(&terminal_id)
+ {
+ Some(Some(term)) => {
+ let mut written: u32 = 0;
+ let ok = unsafe {
+ WriteFile(
+ term.input_write_handle,
+ buf.as_ptr(),
+ buf.len() as u32,
+ &mut written,
+ std::ptr::null_mut(),
+ )
+ };
+ if ok == 0 {
+ Err(io::Error::last_os_error()).with_context(err_context)
+ } else {
+ Ok(written as usize)
+ }
+ },
+ _ => Err(anyhow!("no ConPTY terminal found for id {}", terminal_id))
+ .with_context(err_context),
+ }
}
- pub fn tcdrain(&self, _terminal_id: u32) -> Result<()> {
- unimplemented!("Windows PTY not yet implemented")
+ pub fn tcdrain(&self, terminal_id: u32) -> Result<()> {
+ let err_context = || format!("failed to drain terminal {}", terminal_id);
+
+ match self
+ .terminals
+ .lock()
+ .to_anyhow()
+ .with_context(err_context)?
+ .get(&terminal_id)
+ {
+ Some(Some(term)) => {
+ let ok = unsafe { FlushFileBuffers(term.input_write_handle) };
+ if ok == 0 {
+ // FlushFileBuffers can legitimately fail on pipe handles
+ // (ERROR_INVALID_FUNCTION) — treat as non-fatal.
+ let e = io::Error::last_os_error();
+ log::debug!("FlushFileBuffers on terminal {}: {}", terminal_id, e);
+ }
+ Ok(())
+ },
+ _ => Err(anyhow!("no ConPTY terminal found for id {}", terminal_id))
+ .with_context(err_context),
+ }
}
- pub fn kill(&self, _pid: u32) -> Result<()> {
- unimplemented!("Windows signals not yet implemented")
+ pub fn kill(&self, pid: u32) -> Result<()> {
+ terminate_process(pid).with_context(|| format!("failed to kill pid {}", pid))?;
+ Ok(())
}
- pub fn force_kill(&self, _pid: u32) -> Result<()> {
- unimplemented!("Windows signals not yet implemented")
+ pub fn force_kill(&self, pid: u32) -> Result<()> {
+ terminate_process(pid).with_context(|| format!("failed to force-kill pid {}", pid))?;
+ Ok(())
}
- pub fn send_sigint(&self, _pid: u32) -> Result<()> {
- unimplemented!("Windows signals not yet implemented")
+ pub fn send_sigint(&self, pid: u32) -> Result<()> {
+ let ok = unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, pid) };
+ if ok != 0 {
+ Ok(())
+ } else {
+ terminate_process(pid)
+ .with_context(|| format!("failed to send SIGINT to pid {}", pid))?;
+ Ok(())
+ }
}
- pub fn reserve_terminal_id(&self, _terminal_id: u32) {
- unimplemented!("Windows PTY not yet implemented")
+ pub fn reserve_terminal_id(&self, terminal_id: u32) {
+ self.terminals.lock().unwrap().insert(terminal_id, None);
}
- pub fn clear_terminal_id(&self, _terminal_id: u32) {
- unimplemented!("Windows PTY not yet implemented")
+ pub fn clear_terminal_id(&self, terminal_id: u32) {
+ self.terminals.lock().unwrap().remove(&terminal_id);
}
pub fn next_terminal_id(&self) -> Option<u32> {
- unimplemented!("Windows PTY not yet implemented")
+ self.terminals
+ .lock()
+ .unwrap()
+ .keys()
+ .copied()
+ .collect::<BTreeSet<u32>>()
+ .last()
+ .map(|l| l + 1)
+ .or(Some(0))
}
}
diff --git a/zellij-server/src/plugins/plugin_loader.rs b/zellij-server/src/plugins/plugin_loader.rs
index c0b4ee60..31b54df7 100644
--- a/zellij-server/src/plugins/plugin_loader.rs
+++ b/zellij-server/src/plugins/plugin_loader.rs
@@ -36,6 +36,22 @@ use zellij_utils::{
pane_size::Size,
};
+/// Open a directory as a `File` handle for WASI pre-opening.
+/// On Windows, `FILE_FLAG_BACKUP_SEMANTICS` is required to open directories.
+#[cfg(not(windows))]
+fn open_dir(path: &std::path::Path) -> std::io::Result<std::fs::File> {
+ std::fs::File::open(path)
+}
+
+#[cfg(windows)]
+fn open_dir(path: &std::path::Path) -> std::io::Result<std::fs::File> {
+ use std::os::windows::fs::OpenOptionsExt;
+ std::fs::OpenOptions::new()
+ .read(true)
+ .custom_flags(0x02000000) // FILE_FLAG_BACKUP_SEMANTICS
+ .open(path)
+}
+
fn create_plugin_fs_entries(plugin_own_data_dir: &PathBuf, plugin_own_cache_dir: &PathBuf) {
// Create filesystem entries mounted into WASM.
// We create them here to get expressive error messages in case they fail.
@@ -452,7 +468,7 @@ impl<'a> PluginLoader<'a> {
// Mount directories using the builder
for (guest_path, host_path) in dirs {
- match std::fs::File::open(&host_path) {
+ match open_dir(&host_path) {
Ok(dir_file) => {
let dir = Dir::from_std_file(dir_file);
builder.preopened_dir(dir, guest_path)?;
diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs
index a83dbcc1..d82705ef 100644
--- a/zellij-server/src/plugins/wasm_bridge.rs
+++ b/zellij-server/src/plugins/wasm_bridge.rs
@@ -53,6 +53,18 @@ use zellij_utils::{
pane_size::Size,
};
+/// On Windows, colons in URL strings (e.g. `zellij:tab-bar`, `file:///...`)
+/// are illegal in path components. Replace them with underscores.
+#[cfg(windows)]
+fn make_plugin_url_path_safe(url: String) -> String {
+ url.replace(':', "_")
+}
+
+#[cfg(not(windows))]
+fn make_plugin_url_path_safe(url: String) -> String {
+ url
+}
+
#[derive(Debug, Clone)]
pub enum EventOrPipeMessage {
Event(Event),
@@ -115,10 +127,14 @@ impl LoadingContext {
size: Size,
) -> Self {
let plugin_own_data_dir = ZELLIJ_SESSION_CACHE_DIR
- .join(Url::from(&plugin_config.location).to_string())
+ .join(make_plugin_url_path_safe(
+ Url::from(&plugin_config.location).to_string(),
+ ))
.join(format!("{}-{}", plugin_id, client_id));
let plugin_own_cache_dir = ZELLIJ_CACHE_DIR
- .join(Url::from(&plugin_config.location).to_string())
+ .join(make_plugin_url_path_safe(
+ Url::from(&plugin_config.location).to_string(),
+ ))
.join(format!("plugin_cache"));
let default_mode = wasm_bridge
.base_modes
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index 2f42e91a..57cd2a44 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -6,7 +6,6 @@ use crate::plugins::wasm_bridge::handle_plugin_crash;
use crate::pty::{ClientTabIndexOrPaneId, PtyInstruction};
use crate::route::{route_action, wait_for_action_completion, NotificationEnd};
use crate::ServerInstruction;
-use interprocess::local_socket::{prelude::*, GenericFilePath, Stream as LocalSocketStream};
use log::warn;
use serde::Serialize;
use std::{
@@ -20,6 +19,7 @@ use std::{
};
use tokio::sync::oneshot;
use wasmi::{Caller, Linker};
+use zellij_utils::consts::ipc_connect;
use zellij_utils::data::{
BreakPanesToNewTabResponse, BreakPanesToTabWithIdResponse, BreakPanesToTabWithIndexResponse,
CommandType, ConnectToSession, DeleteLayoutResponse, EditLayoutResponse, Event,
@@ -3184,21 +3184,26 @@ fn disconnect_other_clients(env: &PluginEnv) {
fn kill_sessions(session_names: Vec<String>) {
for session_name in session_names {
let path = &*ZELLIJ_SOCK_DIR.join(&session_name);
- let fs_name = match path.to_fs_name::<GenericFilePath>() {
- Ok(name) => name,
- Err(e) => {
- log::error!(
- "Failed to convert path for session {}: {:?}",
- session_name,
- e
- );
- continue;
- },
- };
- match LocalSocketStream::connect(fs_name) {
+ match ipc_connect(path) {
Ok(stream) => {
- let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
- .send_client_msg(ClientToServerMsg::KillSession);
+ #[cfg(windows)]
+ {
+ use zellij_utils::consts::ipc_connect_reply;
+ use zellij_utils::ipc::{IpcReceiverWithContext, ServerToClientMsg};
+ let reply = ipc_connect_reply(path);
+ let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
+ .send_client_msg(ClientToServerMsg::KillSession);
+ if let Ok(reply_stream) = reply {
+ let mut receiver: IpcReceiverWithContext<ServerToClientMsg> =
+ IpcReceiverWithContext::new(reply_stream);
+ let _ = receiver.recv_server_msg();
+ }
+ }
+ #[cfg(not(windows))]
+ {
+ let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
+ .send_client_msg(ClientToServerMsg::KillSession);
+ }
},
Err(e) => {
log::error!("Failed to kill session {}: {:?}", session_name, e);
diff --git a/zellij-server/src/pty.rs b/zellij-server/src/pty.rs
index 90146a97..cd982209 100644
--- a/zellij-server/src/pty.rs
+++ b/zellij-server/src/pty.rs
@@ -488,6 +488,10 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> {
completion_tx,
) => {
let err_context = || "failed to open new tab";
+ log::info!(
+ "PtyInstruction::NewTab: spawning terminals for tab {}",
+ tab_index
+ );
let floating_panes_layout = if floating_panes_layout.is_empty() {
layout.new_tab().1
@@ -1279,6 +1283,11 @@ impl Pty {
(completion_tx, None)
};
+ log::info!(
+ "spawn_terminals_for_layout: {} tiled + {} floating panes created, sending ApplyLayout",
+ new_tab_pane_ids.len(),
+ new_tab_floating_pane_ids.len()
+ );
self.bus
.senders
.send_to_screen(ScreenInstruction::ApplyLayout(
@@ -2240,5 +2249,11 @@ pub fn get_default_shell() -> PathBuf {
#[cfg(windows)]
pub fn get_default_shell() -> PathBuf {
- unimplemented!("Windows get_default_shell not yet implemented")
+ if let Ok(shell) = std::env::var("SHELL") {
+ return PathBuf::from(shell);
+ }
+ PathBuf::from(std::env::var("COMSPEC").unwrap_or_else(|_| {
+ log::warn!("Cannot read SHELL or COMSPEC env, falling back to use cmd.exe");
+ "cmd.exe".to_string()
+ }))
}
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index d7d25527..a57873a3 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -2098,23 +2098,26 @@ pub(crate) fn route_thread_main(
.to_anyhow()
.with_context(err_context)?
.set_client_size(client_id, new_size);
- session_state
+ // min_client_terminal_size() skips clients whose
+ // entry is still None — i.e. new_client() was
+ // called but set_client_data() hasn't been yet.
+ // set_client_size() above is a no-op in that
+ // case (it doesn't upgrade None to Some). This
+ // can happen if a resize arrives before the
+ // initial connection setup completes; the server
+ // will query the terminal size once it does.
+ if let Some(min_size) = session_state
.read()
.to_anyhow()
- .and_then(|state| {
- state.min_client_terminal_size().ok_or(anyhow!(
- "failed to determine minimal client terminal size"
+ .with_context(err_context)?
+ .min_client_terminal_size()
+ {
+ let _ = senders.as_ref().map(|s| {
+ s.send_to_screen(ScreenInstruction::TerminalResize(
+ min_size,
))
- })
- .and_then(|min_size| {
- let _ = senders.as_ref().map(|s| {
- s.send_to_screen(ScreenInstruction::TerminalResize(
- min_size,
- ))
- });
- Ok(())
- })
- .with_context(err_context)?;
+ });
+ }
}
},
ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index fe50546a..9d2d258a 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -5484,6 +5484,10 @@ pub(crate) fn screen_thread_main(
mut completion_tx,
blocking_terminal,
) => {
+ log::info!(
+ "ScreenInstruction::ApplyLayout: applying layout for tab {}",
+ tab_id
+ );
// tab_id is a stable identifier from NewTab instruction
if let Some(first_terminal_pane) = new_pane_pids.iter().next() {
completion_tx
@@ -5565,6 +5569,10 @@ pub(crate) fn screen_thread_main(
// very short and cheap and shouldn't cause any trouble
if let Some(os_input) = &mut screen.bus.os_input {
for (client_id, _is_web_client) in screen.connected_clients.borrow().iter() {
+ log::info!(
+ "ApplyLayout: sending QueryTerminalSize to client {}",
+ client_id
+ );
let _ = os_input
.send_to_client(*client_id, ServerToClientMsg::QueryTerminalSize);
}
diff --git a/zellij-server/src/tab/unit/layout_applier_tests.rs b/zellij-server/src/tab/unit/layout_applier_tests.rs
index be5d6d6f..d404a2f9 100644
--- a/zellij-server/src/tab/unit/layout_applier_tests.rs
+++ b/zellij-server/src/tab/unit/layout_applier_tests.rs
@@ -80,6 +80,14 @@ impl ServerOsApi for FakeInputOutput {
) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
unimplemented!()
}
+ fn new_client_with_reply(
+ &mut self,
+ _client_id: ClientId,
+ _stream: LocalSocketStream,
+ _reply_stream: LocalSocketStream,
+ ) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
+ unimplemented!()
+ }
fn remove_client(&mut self, _client_id: ClientId) -> Result<()> {
unimplemented!()
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index 97f79025..bcd83279 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -100,6 +100,14 @@ impl ServerOsApi for FakeInputOutput {
) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
unimplemented!()
}
+ fn new_client_with_reply(
+ &mut self,
+ _client_id: ClientId,
+ _stream: LocalSocketStream,
+ _reply_stream: LocalSocketStream,
+ ) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
+ unimplemented!()
+ }
fn remove_client(&mut self, _client_id: ClientId) -> Result<()> {
unimplemented!()
}
diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs
index b299439c..a94758f2 100644
--- a/zellij-server/src/tab/unit/tab_tests.rs
+++ b/zellij-server/src/tab/unit/tab_tests.rs
@@ -71,6 +71,14 @@ impl ServerOsApi for FakeInputOutput {
) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
unimplemented!()
}
+ fn new_client_with_reply(
+ &mut self,
+ _client_id: ClientId,
+ _stream: LocalSocketStream,
+ _reply_stream: LocalSocketStream,
+ ) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
+ unimplemented!()
+ }
fn remove_client(&mut self, _client_id: ClientId) -> Result<()> {
unimplemented!()
}
diff --git a/zellij-server/src/unit/os_input_output_tests.rs b/zellij-server/src/unit/os_input_output_tests.rs
index 09b6dc6f..994ed984 100644
--- a/zellij-server/src/unit/os_input_output_tests.rs
+++ b/zellij-server/src/unit/os_input_output_tests.rs
@@ -1,9 +1,67 @@
use super::*;
+use zellij_utils::input::command::RunCommand;
fn make_server() -> ServerOsInputOutput {
get_server_os_input().expect("failed to create server os input")
}
+// --- Cross-platform command helpers ---
+
+#[allow(dead_code)]
+#[cfg(not(windows))]
+fn long_running_cmd() -> Command {
+ let mut cmd = Command::new("sleep");
+ cmd.arg("60");
+ cmd
+}
+
+#[allow(dead_code)]
+#[cfg(windows)]
+fn long_running_cmd() -> Command {
+ use std::os::windows::process::CommandExt;
+ let mut cmd = Command::new("timeout");
+ cmd.args(&["/T", "60"]);
+ cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ cmd
+}
+
+#[allow(dead_code)]
+#[cfg(not(windows))]
+fn echo_cmd(msg: &str) -> Command {
+ let mut cmd = Command::new("echo");
+ cmd.arg(msg);
+ cmd
+}
+
+#[allow(dead_code)]
+#[cfg(windows)]
+fn echo_cmd(msg: &str) -> Command {
+ use std::os::windows::process::CommandExt;
+ let mut cmd = Command::new("cmd");
+ cmd.args(&["/C", "echo", msg]);
+ cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ cmd
+}
+
+#[allow(dead_code)]
+#[cfg(not(windows))]
+fn stdin_reader_cmd() -> Command {
+ let mut cmd = Command::new("cat");
+ cmd.stdin(std::process::Stdio::piped());
+ cmd
+}
+
+#[allow(dead_code)]
+#[cfg(windows)]
+fn stdin_reader_cmd() -> Command {
+ use std::os::windows::process::CommandExt;
+ let mut cmd = Command::new("findstr");
+ cmd.arg("/R").arg(".*");
+ cmd.stdin(std::process::Stdio::piped());
+ cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ cmd
+}
+
#[test]
fn get_cwd() {
let server = make_server();
@@ -21,10 +79,9 @@ fn get_cwd() {
#[cfg(not(windows))]
#[test]
fn kill_sends_sighup_to_process() {
- let child = Command::new("sleep")
- .arg("60")
+ let child = long_running_cmd()
.spawn()
- .expect("failed to spawn sleep");
+ .expect("failed to spawn long-running process");
let pid = child.id();
let server = make_server();
@@ -38,10 +95,9 @@ fn kill_sends_sighup_to_process() {
#[cfg(not(windows))]
#[test]
fn force_kill_sends_sigkill_to_process() {
- let child = Command::new("sleep")
- .arg("60")
+ let child = long_running_cmd()
.spawn()
- .expect("failed to spawn sleep");
+ .expect("failed to spawn long-running process");
let pid = child.id();
let server = make_server();
@@ -54,10 +110,9 @@ fn force_kill_sends_sigkill_to_process() {
#[cfg(not(windows))]
#[test]
fn send_sigint_to_process() {
- let child = Command::new("cat")
- .stdin(std::process::Stdio::piped())
+ let child = stdin_reader_cmd()
.spawn()
- .expect("failed to spawn cat");
+ .expect("failed to spawn stdin-reader process");
let pid = child.id();
let server = make_server();
@@ -66,3 +121,83 @@ fn send_sigint_to_process() {
std::thread::sleep(std::time::Duration::from_millis(100));
}
+
+#[test]
+fn spawn_and_read_output() {
+ use crate::panes::PaneId;
+ use zellij_utils::input::command::TerminalAction;
+
+ let server = make_server();
+ let test_message = "hello_zellij_test";
+
+ #[cfg(not(windows))]
+ let cmd = RunCommand {
+ command: PathBuf::from("echo"),
+ args: vec![test_message.to_string()],
+ ..Default::default()
+ };
+ #[cfg(windows)]
+ let cmd = RunCommand {
+ command: PathBuf::from("cmd"),
+ args: vec![
+ "/K".to_string(),
+ "echo".to_string(),
+ test_message.to_string(),
+ ],
+ ..Default::default()
+ };
+
+ let action = TerminalAction::RunCommand(cmd);
+ let quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send> =
+ Box::new(|_pane_id, _exit_status, _run_command| {});
+
+ let (_terminal_id, mut reader, _child_pid) = server
+ .spawn_terminal(action, quit_cb, None)
+ .expect("spawn_terminal should succeed");
+
+ // Read output from the spawned terminal
+ let mut output = Vec::new();
+ let mut buf = [0u8; 4096];
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+
+ rt.block_on(async {
+ loop {
+ if std::time::Instant::now() > deadline {
+ break;
+ }
+ match tokio::time::timeout(std::time::Duration::from_millis(500), reader.read(&mut buf))
+ .await
+ {
+ Ok(Ok(0)) => break,
+ Ok(Ok(n)) => {
+ output.extend_from_slice(&buf[..n]);
+ let s = String::from_utf8_lossy(&output);
+ if s.contains(test_message) {
+ break;
+ }
+ },
+ Ok(Err(_)) => break,
+ Err(_) => {
+ // timeout — check if we already have enough
+ let s = String::from_utf8_lossy(&output);
+ if s.contains(test_message) {
+ break;
+ }
+ },
+ }
+ }
+ });
+
+ let output_str = String::from_utf8_lossy(&output);
+ assert!(
+ output_str.contains(test_message),
+ "expected output to contain '{}', got: '{}'",
+ test_message,
+ output_str
+ );
+}
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 6bd1db8c..3d021f43 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -213,6 +213,14 @@ impl ServerOsApi for FakeInputOutput {
) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
unimplemented!()
}
+ fn new_client_with_reply(
+ &mut self,
+ _client_id: ClientId,
+ _stream: LocalSocketStream,
+ _reply_stream: LocalSocketStream,
+ ) -> Result<IpcReceiverWithContext<ClientToServerMsg>> {
+ unimplemented!()
+ }
fn remove_client(&mut self, _client_id: ClientId) -> Result<()> {
unimplemented!()
}
@@ -3572,7 +3580,10 @@ pub fn send_cli_new_tab_action_with_name_and_layout() {
})
.unwrap()
.clone();
- assert_snapshot!(format!("{:#?}", new_tab_instruction));
+ let output = format!("{:#?}", new_tab_instruction);
+ // Normalize Windows path separators for cross-platform snapshot consistency
+ let output = output.replace("\\\\", "/");
+ assert_snapshot!(output);
}
#[test]
diff --git a/zellij-utils/Cargo.toml b/zellij-utils/Cargo.toml
index 92cd3593..0269a022 100644
--- a/zellij-utils/Cargo.toml
+++ b/zellij-utils/Cargo.toml
@@ -58,6 +58,13 @@ notify = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { workspace = true }
+[target.'cfg(windows)'.dependencies]
+crossterm = { workspace = true, features = ["events"] }
+windows-sys = { workspace = true, features = [
+ "Win32_Foundation",
+ "Win32_System_Threading",
+] }
+
[dev-dependencies]
insta = { version = "1.6.0", features = ["backtrace"] }
expect-test = "1.4.1"
diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs
index f8bf79ac..7b984fd1 100644
--- a/zellij-utils/src/cli.rs
+++ b/zellij-utils/src/cli.rs
@@ -143,15 +143,20 @@ pub struct WebCli {
display_order = 5
)]
pub daemonize: bool,
+ /// Timeout in seconds waiting for the server to start (default: 10).
+ /// Only used on Windows where the daemonized server is polled via TCP.
+ /// On Unix, startup signaling uses pipes and this option is ignored.
+ #[clap(long, value_parser, display_order = 6)]
+ pub server_startup_timeout: Option<u64>,
/// Create a login token for the web interface, will only be displayed once and cannot later be
/// retrieved. Returns the token name and the token.
- #[clap(long, value_parser, exclusive(true), display_order = 6)]
+ #[clap(long, value_parser, exclusive(true), display_order = 7)]
pub create_token: bool,
/// Optional name for the token
- #[clap(long, value_parser, value_name = "TOKEN_NAME", display_order = 7)]
+ #[clap(long, value_parser, value_name = "TOKEN_NAME", display_order = 8)]
pub token_name: Option<String>,
/// Create a read-only login token (can only attach to existing sessions as watcher)
- #[clap(long, value_parser, exclusive(true), display_order = 8)]
+ #[clap(long, value_parser, exclusive(true), display_order = 9)]
pub create_read_only_token: bool,
/// Revoke a login token by its name
#[clap(
@@ -159,21 +164,21 @@ pub struct WebCli {
value_parser,
exclusive(true),
value_name = "TOKEN NAME",
- display_order = 9
+ display_order = 10
)]
pub revoke_token: Option<String>,
/// Revoke all login tokens
- #[clap(long, value_parser, exclusive(true), display_order = 10)]
+ #[clap(long, value_parser, exclusive(true), display_order = 11)]
pub revoke_all_tokens: bool,
/// List token names and their creation dates (cannot show actual tokens)
- #[clap(long, value_parser, exclusive(true), display_order = 11)]
+ #[clap(long, value_parser, exclusive(true), display_order = 12)]
pub list_tokens: bool,
/// The ip address to listen on locally for connections (defaults to 127.0.0.1)
#[clap(
long,
value_parser,
conflicts_with_all(&["stop", "status", "create-token", "revoke-token", "revoke-all-tokens"]),
- display_order = 12
+ display_order = 13
)]
pub ip: Option<IpAddr>,
/// The port to listen on locally for connections (defaults to 8082)
@@ -181,7 +186,7 @@ pub struct WebCli {
long,
value_parser,
conflicts_with_all(&["stop", "status", "create-token", "revoke-token", "revoke-all-tokens"]),
- display_order = 13
+ display_order = 14
)]
pub port: Option<u16>,
/// The path to the SSL certificate (required if not listening on 127.0.0.1)
@@ -189,7 +194,7 @@ pub struct WebCli {
long,
value_parser,
conflicts_with_all(&["stop", "status", "create-token", "revoke-token", "revoke-all-tokens"]),
- display_order = 14
+ display_order = 15
)]
pub cert: Option<PathBuf>,
/// The path to the SSL key (required if not listening on 127.0.0.1)
@@ -197,7 +202,7 @@ pub struct WebCli {
long,
value_parser,
conflicts_with_all(&["stop", "status", "create-token", "revoke-token", "revoke-all-tokens"]),
- display_order = 15
+ display_order = 16
)]
pub key: Option<PathBuf>,
}
diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs
index 12cc5c2a..1f039c72 100644
--- a/zellij-utils/src/consts.rs
+++ b/zellij-utils/src/consts.rs
@@ -147,7 +147,8 @@ mod not_wasm {
/// Check if a filesystem entry is an IPC socket.
///
/// On Unix, this checks `FileTypeExt::is_socket()`. On non-Unix platforms,
-/// this returns `false` — session discovery uses a different mechanism.
+/// this checks `is_file()` to detect marker files created by `ipc_bind()`
+/// and `ipc_bind_async()` alongside kernel-level named pipes.
#[cfg(unix)]
pub fn is_ipc_socket(file_type: &std::fs::FileType) -> bool {
use std::os::unix::fs::FileTypeExt;
@@ -155,8 +156,101 @@ pub fn is_ipc_socket(file_type: &std::fs::FileType) -> bool {
}
#[cfg(not(unix))]
-pub fn is_ipc_socket(_file_type: &std::fs::FileType) -> bool {
- false
+pub fn is_ipc_socket(file_type: &std::fs::FileType) -> bool {
+ file_type.is_file()
+}
+
+/// Connect to an IPC socket at the given path.
+///
+/// On Unix, this uses Unix domain sockets via `GenericFilePath`.
+/// On Windows, this uses named pipes via `GenericNamespaced`.
+#[cfg(unix)]
+pub fn ipc_connect(path: &std::path::Path) -> std::io::Result<interprocess::local_socket::Stream> {
+ use interprocess::local_socket::{prelude::*, GenericFilePath, Stream as LocalSocketStream};
+ let fs_name = path.to_fs_name::<GenericFilePath>()?;
+ LocalSocketStream::connect(fs_name)
+}
+
+#[cfg(windows)]
+pub fn ipc_connect(path: &std::path::Path) -> std::io::Result<interprocess::local_socket::Stream> {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream as LocalSocketStream};
+ let name = path.to_string_lossy().to_string();
+ let ns_name = name.to_ns_name::<GenericNamespaced>()?;
+ LocalSocketStream::connect(ns_name)
+}
+
+/// Create an IPC listener bound to the given path.
+///
+/// On Unix, this uses Unix domain sockets via `GenericFilePath`.
+/// On Windows, this uses named pipes via `GenericNamespaced` and creates
+/// a marker file for session discovery.
+#[cfg(unix)]
+pub fn ipc_bind(path: &std::path::Path) -> std::io::Result<interprocess::local_socket::Listener> {
+ use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions};
+ let fs_name = path.to_fs_name::<GenericFilePath>()?;
+ ListenerOptions::new().name(fs_name).create_sync()
+}
+
+#[cfg(windows)]
+pub fn ipc_bind(path: &std::path::Path) -> std::io::Result<interprocess::local_socket::Listener> {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
+ let name = path.to_string_lossy().to_string();
+ let ns_name = name.to_ns_name::<GenericNamespaced>()?;
+ let listener = ListenerOptions::new().name(ns_name).create_sync()?;
+ std::fs::write(path, std::process::id().to_string())?;
+ Ok(listener)
+}
+
+/// Create an async (tokio) IPC listener bound to the given path.
+///
+/// On Unix, this uses Unix domain sockets via `GenericFilePath`.
+/// On Windows, this uses named pipes via `GenericNamespaced` and creates
+/// a marker file for session discovery.
+#[cfg(unix)]
+pub fn ipc_bind_async(
+ path: &std::path::Path,
+) -> std::io::Result<interprocess::local_socket::tokio::Listener> {
+ use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions};
+ let fs_name = path.to_fs_name::<GenericFilePath>()?;
+ ListenerOptions::new().name(fs_name).create_tokio()
+}
+
+#[cfg(windows)]
+pub fn ipc_bind_async(
+ path: &std::path::Path,
+) -> std::io::Result<interprocess::local_socket::tokio::Listener> {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
+ let name = path.to_string_lossy().to_string();
+ let ns_name = name.to_ns_name::<GenericNamespaced>()?;
+ let listener = ListenerOptions::new().name(ns_name).create_tokio()?;
+ std::fs::write(path, std::process::id().to_string())?;
+ Ok(listener)
+}
+
+/// Connect to the reply pipe for a given IPC path (Windows only).
+///
+/// Uses `path-reply` as the named pipe for the server→client direction.
+#[cfg(windows)]
+pub fn ipc_connect_reply(
+ path: &std::path::Path,
+) -> std::io::Result<interprocess::local_socket::Stream> {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream as LocalSocketStream};
+ let name = format!("{}-reply", path.to_string_lossy());
+ let ns_name = name.to_ns_name::<GenericNamespaced>()?;
+ LocalSocketStream::connect(ns_name)
+}
+
+/// Create an IPC listener for the reply pipe (Windows only).
+///
+/// Binds to `path-reply` as the named pipe for the server→client direction.
+#[cfg(windows)]
+pub fn ipc_bind_reply(
+ path: &std::path::Path,
+) -> std::io::Result<interprocess::local_socket::Listener> {
+ use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
+ let name = format!("{}-reply", path.to_string_lossy());
+ let ns_name = name.to_ns_name::<GenericNamespaced>()?;
+ ListenerOptions::new().name(ns_name).create_sync()
}
#[cfg(unix)]
diff --git a/zellij-utils/src/input/mod.rs b/zellij-utils/src/input/mod.rs
index 69520fe2..5ff101d6 100644
--- a/zellij-utils/src/input/mod.rs
+++ b/zellij-utils/src/input/mod.rs
@@ -147,4 +147,264 @@ mod not_wasm {
_ => KeyWithModifier::new(BareKey::Esc),
}
}
+
+ /// Convert a crossterm `MouseEvent` into a zellij `MouseEvent`.
+ ///
+ /// Crossterm's mouse events are richer than termwiz's (they distinguish
+ /// Down/Up/Drag/Moved directly), so no state tracking is needed.
+ #[cfg(windows)]
+ pub fn from_crossterm_mouse(event: crossterm::event::MouseEvent) -> super::mouse::MouseEvent {
+ use super::mouse;
+ use crossterm::event::{KeyModifiers, MouseButton as CButton, MouseEventKind};
+
+ let position = crate::position::Position::new(event.row as i32, event.column);
+ let modifiers = event.modifiers;
+ let shift = modifiers.contains(KeyModifiers::SHIFT);
+ let alt = modifiers.contains(KeyModifiers::ALT);
+ let ctrl = modifiers.contains(KeyModifiers::CONTROL);
+
+ let (event_type, left, right, middle, wheel_up, wheel_down) = match event.kind {
+ MouseEventKind::Down(CButton::Left) => (
+ mouse::MouseEventType::Press,
+ true,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Down(CButton::Right) => (
+ mouse::MouseEventType::Press,
+ false,
+ true,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Down(CButton::Middle) => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ true,
+ false,
+ false,
+ ),
+ MouseEventKind::Up(CButton::Left) => (
+ mouse::MouseEventType::Release,
+ true,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Up(CButton::Right) => (
+ mouse::MouseEventType::Release,
+ false,
+ true,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Up(CButton::Middle) => (
+ mouse::MouseEventType::Release,
+ false,
+ false,
+ true,
+ false,
+ false,
+ ),
+ MouseEventKind::Drag(CButton::Left) => (
+ mouse::MouseEventType::Motion,
+ true,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Drag(CButton::Right) => (
+ mouse::MouseEventType::Motion,
+ false,
+ true,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Drag(CButton::Middle) => (
+ mouse::MouseEventType::Motion,
+ false,
+ false,
+ true,
+ false,
+ false,
+ ),
+ MouseEventKind::Moved => (
+ mouse::MouseEventType::Motion,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::ScrollUp => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ false,
+ true,
+ false,
+ ),
+ MouseEventKind::ScrollDown => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ false,
+ false,
+ true,
+ ),
+ MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => (
+ mouse::MouseEventType::Motion,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ };
+
+ mouse::MouseEvent {
+ event_type,
+ left,
+ right,
+ middle,
+ wheel_up,
+ wheel_down,
+ shift,
+ alt,
+ ctrl,
+ position,
+ }
+ }
+
+ /// Convert a crossterm `KeyEvent` into a zellij `KeyWithModifier` plus synthesized raw VT
+ /// bytes suitable for PTY pass-through. Returns `None` for key codes we don't handle
+ /// (e.g. media keys, bare modifier presses).
+ #[cfg(windows)]
+ pub fn cast_crossterm_key(
+ event: crossterm::event::KeyEvent,
+ ) -> Option<(KeyWithModifier, Vec<u8>)> {
+ use crossterm::event::{KeyCode as CKeyCode, KeyModifiers};
+
+ let ct_mods = event.modifiers;
+ let mut modifiers = BTreeSet::new();
+ if ct_mods.contains(KeyModifiers::CONTROL) {
+ modifiers.insert(KeyModifier::Ctrl);
+ }
+ if ct_mods.contains(KeyModifiers::ALT) {
+ modifiers.insert(KeyModifier::Alt);
+ }
+ if ct_mods.contains(KeyModifiers::SHIFT) {
+ modifiers.insert(KeyModifier::Shift);
+ }
+ if ct_mods.contains(KeyModifiers::SUPER) {
+ modifiers.insert(KeyModifier::Super);
+ }
+
+ let has_ctrl = ct_mods.contains(KeyModifiers::CONTROL);
+ let has_alt = ct_mods.contains(KeyModifiers::ALT);
+
+ let (bare_key, raw_bytes) = match event.code {
+ CKeyCode::Char(c) => {
+ // On Windows, the console reports physical modifier flags alongside
+ // the already-translated character. For example, on French AZERTY:
+ // Shift+ù → Char('%') + SHIFT
+ // AltGr+_ → Char('\\') + CTRL+ALT (AltGr = Ctrl+Alt on Windows)
+ //
+ // Strip modifiers that "produced" the character so the resulting
+ // KeyWithModifier matches what Unix terminals report (just the
+ // character, no redundant modifiers):
+ // - Shift is always redundant for Char events
+ // - Ctrl+Alt together indicates AltGr; strip both when the
+ // character is printable (not a control code)
+ modifiers.remove(&KeyModifier::Shift);
+ let is_altgr = has_ctrl && has_alt && !c.is_ascii_control();
+ if is_altgr {
+ modifiers.remove(&KeyModifier::Ctrl);
+ modifiers.remove(&KeyModifier::Alt);
+ }
+
+ let bytes = if is_altgr {
+ let mut buf = [0u8; 4];
+ c.encode_utf8(&mut buf).as_bytes().to_vec()
+ } else if has_ctrl && has_alt && c.is_ascii_alphabetic() {
+ vec![0x1b, (c.to_ascii_lowercase() as u8) & 0x1f]
+ } else if has_ctrl && c.is_ascii_alphabetic() {
+ vec![(c.to_ascii_lowercase() as u8) & 0x1f]
+ } else if has_alt {
+ let mut b = vec![0x1b];
+ let mut buf = [0u8; 4];
+ b.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
+ b
+ } else {
+ let mut buf = [0u8; 4];
+ c.encode_utf8(&mut buf).as_bytes().to_vec()
+ };
+ (BareKey::Char(c), bytes)
+ },
+ CKeyCode::Enter => (BareKey::Enter, vec![0x0d]),
+ CKeyCode::Tab => (BareKey::Tab, vec![0x09]),
+ CKeyCode::BackTab => {
+ modifiers.insert(KeyModifier::Shift);
+ (BareKey::Tab, vec![0x1b, b'[', b'Z'])
+ },
+ CKeyCode::Backspace => (BareKey::Backspace, vec![0x7f]),
+ CKeyCode::Esc => (BareKey::Esc, vec![0x1b]),
+ CKeyCode::Left => (BareKey::Left, vec![0x1b, b'[', b'D']),
+ CKeyCode::Right => (BareKey::Right, vec![0x1b, b'[', b'C']),
+ CKeyCode::Up => (BareKey::Up, vec![0x1b, b'[', b'A']),
+ CKeyCode::Down => (BareKey::Down, vec![0x1b, b'[', b'B']),
+ CKeyCode::Home => (BareKey::Home, vec![0x1b, b'[', b'H']),
+ CKeyCode::End => (BareKey::End, vec![0x1b, b'[', b'F']),
+ CKeyCode::PageUp => (BareKey::PageUp, b"\x1b[5~".to_vec()),
+ CKeyCode::PageDown => (BareKey::PageDown, b"\x1b[6~".to_vec()),
+ CKeyCode::Delete => (BareKey::Delete, b"\x1b[3~".to_vec()),
+ CKeyCode::Insert => (BareKey::Insert, b"\x1b[2~".to_vec()),
+ CKeyCode::F(n) => {
+ let bytes = match n {
+ 1 => b"\x1bOP".to_vec(),
+ 2 => b"\x1bOQ".to_vec(),
+ 3 => b"\x1bOR".to_vec(),
+ 4 => b"\x1bOS".to_vec(),
+ 5 => b"\x1b[15~".to_vec(),
+ 6 => b"\x1b[17~".to_vec(),
+ 7 => b"\x1b[18~".to_vec(),
+ 8 => b"\x1b[19~".to_vec(),
+ 9 => b"\x1b[20~".to_vec(),
+ 10 => b"\x1b[21~".to_vec(),
+ 11 => b"\x1b[23~".to_vec(),
+ 12 => b"\x1b[24~".to_vec(),
+ _ => vec![],
+ };
+ (BareKey::F(n), bytes)
+ },
+ CKeyCode::CapsLock => (BareKey::CapsLock, vec![]),
+ CKeyCode::ScrollLock => (BareKey::ScrollLock, vec![]),
+ CKeyCode::NumLock => (BareKey::NumLock, vec![]),
+ CKeyCode::PrintScreen => (BareKey::PrintScreen, vec![]),
+ CKeyCode::Pause => (BareKey::Pause, vec![]),
+ CKeyCode::Menu => (BareKey::Menu, vec![]),
+ CKeyCode::Null => {
+ // ctrl-space
+ return Some((
+ KeyWithModifier::new(BareKey::Char(' ')).with_ctrl_modifier(),
+ vec![0x00],
+ ));
+ },
+ // Media keys, bare modifier presses, KeypadBegin — skip
+ _ => return None,
+ };
+
+ Some((
+ KeyWithModifier::new_with_modifiers(bare_key, modifiers),
+ raw_bytes,
+ ))
+ }
}
diff --git a/zellij-utils/src/input/unit/layout_test.rs b/zellij-utils/src/input/unit/layout_test.rs
index f0cbae7b..4ba33c12 100644
--- a/zellij-utils/src/input/unit/layout_test.rs
+++ b/zellij-utils/src/input/unit/layout_test.rs
@@ -1,6 +1,19 @@
use super::super::layout::*;
use insta::assert_snapshot;
+#[cfg(not(windows))]
+fn normalize_layout_debug(s: String) -> String {
+ s
+}
+
+#[cfg(windows)]
+fn normalize_layout_debug(s: String) -> String {
+ // On Windows, PathBuf's Debug output uses `\\` (escaped backslash).
+ // Replace `\\\\` (two escaped backslashes in Debug repr) with `/`
+ // so that snapshots match Unix-recorded baselines.
+ s.replace("\\\\", "/")
+}
+
#[test]
fn empty_layout() {
let kdl_layout = "layout";
@@ -302,7 +315,7 @@ fn layout_with_tabs_and_floating_panes() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -508,7 +521,7 @@ fn layout_with_command_panes_and_close_on_exit() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -521,7 +534,7 @@ fn layout_with_command_panes_and_start_suspended() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -816,7 +829,7 @@ fn layout_with_default_tab_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -841,7 +854,7 @@ fn layout_with_new_tab_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -868,7 +881,7 @@ fn layout_with_pane_templates() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -889,7 +902,7 @@ fn layout_with_tab_and_pane_templates() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -910,7 +923,7 @@ fn layout_with_nested_pane_templates() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -936,7 +949,7 @@ fn layout_with_nested_branched_pane_templates() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -982,7 +995,7 @@ fn children_not_as_first_child_of_tab_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1030,7 +1043,7 @@ fn children_not_as_first_child_of_pane_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1082,7 +1095,7 @@ fn combined_tab_and_pane_template_both_with_children() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1093,7 +1106,7 @@ fn layout_with_pane_excluded_from_sync() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1232,7 +1245,7 @@ fn error_on_multiple_layout_nodes_in_file() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1248,7 +1261,7 @@ fn error_on_unknown_layout_node() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1262,7 +1275,7 @@ fn error_on_unknown_layout_pane_property() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1276,7 +1289,7 @@ fn error_on_unknown_layout_pane_template_property() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1290,7 +1303,7 @@ fn error_on_unknown_layout_tab_property() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1304,7 +1317,7 @@ fn error_on_unknown_layout_tab_template_property() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1322,7 +1335,7 @@ fn error_on_pane_templates_without_a_name() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1340,7 +1353,7 @@ fn error_on_tab_templates_without_a_name() {
);
let layout_error =
Layout::from_kdl(&kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1354,7 +1367,7 @@ fn error_on_more_than_one_focused_tab() {
"#;
let layout_error =
Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap_err();
- assert_snapshot!(format!("{:?}", layout_error));
+ assert_snapshot!(normalize_layout_debug(format!("{:?}", layout_error)));
}
#[test]
@@ -1372,7 +1385,7 @@ fn args_override_args_in_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1390,7 +1403,7 @@ fn close_on_exit_overrides_close_on_exit_in_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1407,7 +1420,7 @@ fn args_added_to_args_in_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1424,7 +1437,7 @@ fn close_on_exit_added_to_close_on_exit_in_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1442,7 +1455,7 @@ fn cwd_override_cwd_in_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1459,7 +1472,7 @@ fn cwd_added_to_cwd_in_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1559,7 +1572,7 @@ fn pane_template_command_with_cwd_overriden_by_its_consumers_command_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1576,7 +1589,7 @@ fn pane_template_command_with_cwd_remains_when_its_consumer_command_does_not_hav
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1594,7 +1607,7 @@ fn pane_template_command_without_cwd_is_overriden_by_its_consumers_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1613,7 +1626,7 @@ fn pane_template_command_with_cwd_is_overriden_by_its_consumers_bare_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1631,7 +1644,7 @@ fn pane_template_command_without_cwd_receives_its_consumers_bare_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1649,7 +1662,7 @@ fn pane_template_with_bare_cwd_overriden_by_its_consumers_bare_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1665,7 +1678,7 @@ fn pane_template_with_bare_propagated_to_its_consumer_command_without_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1683,7 +1696,7 @@ fn pane_template_with_bare_propagated_to_its_consumer_command_with_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1699,7 +1712,7 @@ fn pane_template_with_bare_propagated_to_its_consumer_edit() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1715,7 +1728,7 @@ fn pane_template_with_command_propagated_to_its_consumer_edit() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1729,7 +1742,7 @@ fn global_cwd_given_to_panes_without_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1742,7 +1755,7 @@ fn global_cwd_prepended_to_panes_with_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1762,7 +1775,7 @@ fn global_cwd_passed_from_layout_constructor() {
Some(PathBuf::from("/tmp")),
)
.unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1783,7 +1796,7 @@ fn global_cwd_passed_from_layout_constructor_overrides_global_cwd_in_layout_file
Some(PathBuf::from("/tmp")),
)
.unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1799,7 +1812,7 @@ fn global_cwd_with_tab_cwd_given_to_panes_without_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1814,7 +1827,7 @@ fn tab_cwd_given_to_panes_without_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1829,7 +1842,7 @@ fn tab_cwd_prepended_to_panes_with_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1844,7 +1857,7 @@ fn global_cwd_and_tab_cwd_prepended_to_panes_with_and_without_cwd() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1865,7 +1878,7 @@ fn global_cwd_and_tab_cwd_prepended_to_panes_with_and_without_cwd_in_pane_templa
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1884,7 +1897,7 @@ fn global_cwd_and_tab_cwd_prepended_to_panes_with_and_without_cwd_in_tab_templat
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1935,7 +1948,7 @@ fn can_load_swap_layouts_from_a_different_file() {
None,
)
.unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1949,7 +1962,7 @@ fn can_define_stacked_children_for_pane_node() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1966,7 +1979,7 @@ fn can_define_stacked_children_for_pane_template() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -1981,7 +1994,7 @@ fn can_define_a_stack_with_an_expanded_pane() {
}
"#;
let layout = Layout::from_kdl(kdl_layout, Some("layout_file_name".into()), None, None).unwrap();
- assert_snapshot!(format!("{:#?}", layout));
+ assert_snapshot!(normalize_layout_debug(format!("{:#?}", layout)));
}
#[test]
@@ -2186,6 +2199,7 @@ fn run_plugin_location_parsing() {
assert_eq!(layout, expected_layout);
}
+#[cfg(unix)]
#[test]
fn env_var_expansion() {
let raw_layout = r#"
@@ -2227,7 +2241,7 @@ fn env_var_expansion() {
}
}
let layout = layout.unwrap();
- assert_snapshot!(format!("{layout:#?}"));
+ assert_snapshot!(normalize_layout_debug(format!("{layout:#?}")));
}
#[test]
diff --git a/zellij-utils/src/ipc/tests/mod.rs b/zellij-utils/src/ipc/tests/mod.rs
index b6883b96..d3eaefb9 100644
--- a/zellij-utils/src/ipc/tests/mod.rs
+++ b/zellij-utils/src/ipc/tests/mod.rs
@@ -1,2 +1,3 @@
mod roundtrip_tests;
+mod socket_tests;
mod test_framework;
diff --git a/zellij-utils/src/ipc/tests/socket_tests.rs b/zellij-utils/src/ipc/tests/socket_tests.rs
index 5181f851..d0e9ce65 100644
--- a/zellij-utils/src/ipc/tests/socket_tests.rs
+++ b/zellij-utils/src/ipc/tests/socket_tests.rs
@@ -2,27 +2,104 @@ use crate::ipc::{
ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg,
};
use crate::pane_size::Size;
-use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions, Stream as LocalSocketStream};
-#[cfg(not(windows))]
-use std::os::unix::fs::FileTypeExt;
-use std::path::PathBuf;
-use tempfile::TempDir;
+use interprocess::local_socket::{prelude::*, ListenerOptions};
-fn socket_path() -> (TempDir, PathBuf) {
- let dir = TempDir::new().expect("failed to create temp dir");
- let path = dir.path().join("test.sock");
+use std::sync::atomic::{AtomicU32, Ordering};
+
+static IPC_COUNTER: AtomicU32 = AtomicU32::new(0);
+
+// --- Cross-platform IPC helpers ---
+// On Unix: use filesystem-based sockets (TempDir + PathBuf + GenericFilePath)
+// On Windows: use named pipes (no guard needed, String name + GenericNamespaced)
+
+#[cfg(unix)]
+type IpcGuard = tempfile::TempDir;
+#[cfg(unix)]
+type IpcName = std::path::PathBuf;
+
+#[cfg(windows)]
+type IpcGuard = ();
+#[cfg(windows)]
+type IpcName = String;
+
+#[cfg(unix)]
+fn new_ipc() -> (IpcGuard, IpcName) {
+ let dir = tempfile::TempDir::new().expect("failed to create temp dir");
+ let id = IPC_COUNTER.fetch_add(1, Ordering::SeqCst);
+ let path = dir
+ .path()
+ .join(format!("test-{}-{}.sock", std::process::id(), id));
(dir, path)
}
+#[cfg(windows)]
+fn new_ipc() -> (IpcGuard, IpcName) {
+ let id = IPC_COUNTER.fetch_add(1, Ordering::SeqCst);
+ let name = format!(
+ "@zellij-test-{}-{}-{}",
+ std::process::id(),
+ id,
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ ((), name)
+}
+
+#[cfg(unix)]
+fn bind_listener(name: &IpcName) -> interprocess::local_socket::Listener {
+ use interprocess::local_socket::GenericFilePath;
+ ListenerOptions::new()
+ .name(name.as_path().to_fs_name::<GenericFilePath>().unwrap())
+ .create_sync()
+ .expect("bind failed")
+}
+
+#[cfg(windows)]
+fn bind_listener(name: &IpcName) -> interprocess::local_socket::Listener {
+ use interprocess::local_socket::GenericNamespaced;
+ ListenerOptions::new()
+ .name(name.as_str().to_ns_name::<GenericNamespaced>().unwrap())
+ .create_sync()
+ .expect("bind failed")
+}
+
+#[cfg(unix)]
+fn connect_stream(name: &IpcName) -> interprocess::local_socket::Stream {
+ use interprocess::local_socket::{GenericFilePath, Stream as LocalSocketStream};
+ LocalSocketStream::connect(name.as_path().to_fs_name::<GenericFilePath>().unwrap())
+ .expect("connect failed")
+}
+
+#[cfg(windows)]
+fn connect_stream(name: &IpcName) -> interprocess::local_socket::Stream {
+ use interprocess::local_socket::{GenericNamespaced, Stream as LocalSocketStream};
+ LocalSocketStream::connect(name.as_str().to_ns_name::<GenericNamespaced>().unwrap())
+ .expect("connect failed")
+}
+
+#[cfg(unix)]
+fn try_connect(name: &IpcName) -> std::io::Result<interprocess::local_socket::Stream> {
+ use interprocess::local_socket::{GenericFilePath, Stream as LocalSocketStream};
+ LocalSocketStream::connect(name.as_path().to_fs_name::<GenericFilePath>().unwrap())
+}
+
+#[cfg(windows)]
+fn try_connect(name: &IpcName) -> std::io::Result<interprocess::local_socket::Stream> {
+ use interprocess::local_socket::{GenericNamespaced, Stream as LocalSocketStream};
+ LocalSocketStream::connect(name.as_str().to_ns_name::<GenericNamespaced>().unwrap())
+}
+
#[test]
fn client_to_server_message_over_socket() {
- let (_dir, path) = socket_path();
- let listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let (_guard, name) = new_ipc();
+ let listener = bind_listener(&name);
let client = std::thread::spawn({
- let path = path.clone();
+ let name = name.clone();
move || {
- let stream = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).expect("connect failed");
+ let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> =
IpcSenderWithContext::new(stream);
sender
@@ -49,19 +126,18 @@ fn client_to_server_message_over_socket() {
#[test]
fn server_to_client_message_over_socket() {
- let (_dir, path) = socket_path();
- let listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let (_guard, name) = new_ipc();
+ let listener = bind_listener(&name);
let server = std::thread::spawn(move || {
let stream = listener.incoming().next().unwrap().expect("accept failed");
- let mut sender: IpcSenderWithContext<ServerToClientMsg> =
- IpcSenderWithContext::new(stream);
+ let mut sender: IpcSenderWithContext<ServerToClientMsg> = IpcSenderWithContext::new(stream);
sender
.send_server_msg(ServerToClientMsg::Connected)
.expect("send failed");
});
- let stream = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).expect("connect failed");
+ let stream = connect_stream(&name);
let mut receiver: IpcReceiverWithContext<ServerToClientMsg> =
IpcReceiverWithContext::new(stream);
@@ -79,13 +155,12 @@ fn server_to_client_message_over_socket() {
#[test]
fn bidirectional_communication_via_fd_duplication() {
- let (_dir, path) = socket_path();
- let listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let (_guard, name) = new_ipc();
+ let listener = bind_listener(&name);
let server = std::thread::spawn(move || {
let stream = listener.incoming().next().unwrap().expect("accept failed");
- let mut sender: IpcSenderWithContext<ServerToClientMsg> =
- IpcSenderWithContext::new(stream);
+ let mut sender: IpcSenderWithContext<ServerToClientMsg> = IpcSenderWithContext::new(stream);
// Create a receiver from the same socket via dup()
let mut receiver: IpcReceiverWithContext<ClientToServerMsg> = sender.get_receiver();
@@ -103,7 +178,7 @@ fn bidirectional_communication_via_fd_duplication() {
);
});
- let stream = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).expect("connect failed");
+ let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> = IpcSenderWithContext::new(stream);
// Create a receiver from the same socket via dup()
@@ -126,13 +201,13 @@ fn bidirectional_communication_via_fd_duplication() {
#[test]
fn multiple_messages_in_sequence() {
- let (_dir, path) = socket_path();
- let listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let (_guard, name) = new_ipc();
+ let listener = bind_listener(&name);
let client = std::thread::spawn({
- let path = path.clone();
+ let name = name.clone();
move || {
- let stream = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).expect("connect failed");
+ let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> =
IpcSenderWithContext::new(stream);
@@ -141,7 +216,10 @@ fn multiple_messages_in_sequence() {
.expect("send 1 failed");
sender
.send_client_msg(ClientToServerMsg::TerminalResize {
- new_size: Size { rows: 50, cols: 120 },
+ new_size: Size {
+ rows: 50,
+ cols: 120,
+ },
})
.expect("send 2 failed");
sender
@@ -174,13 +252,13 @@ fn multiple_messages_in_sequence() {
#[test]
fn receiver_returns_none_on_closed_connection() {
- let (_dir, path) = socket_path();
- let listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let (_guard, name) = new_ipc();
+ let listener = bind_listener(&name);
let client = std::thread::spawn({
- let path = path.clone();
+ let name = name.clone();
move || {
- let stream = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).expect("connect failed");
+ let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> =
IpcSenderWithContext::new(stream);
sender
@@ -209,23 +287,25 @@ fn receiver_returns_none_on_closed_connection() {
// FileTypeExt::is_socket() for identifying socket files, and the assert_socket
// probing pattern (connect, send ConnStatus, expect Connected).
-#[cfg(not(windows))]
+#[cfg(unix)]
#[test]
fn is_socket_identifies_bound_unix_socket() {
- let (_dir, path) = socket_path();
- let _listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ use std::os::unix::fs::FileTypeExt;
+ let (_guard, name) = new_ipc();
+ let _listener = bind_listener(&name);
- let metadata = std::fs::metadata(&path).expect("metadata failed");
+ let metadata = std::fs::metadata(&name).expect("metadata failed");
assert!(
metadata.file_type().is_socket(),
"a bound LocalSocketListener path should be identified as a socket"
);
}
-#[cfg(not(windows))]
+#[cfg(unix)]
#[test]
fn is_socket_rejects_regular_file() {
- let dir = TempDir::new().expect("failed to create temp dir");
+ use std::os::unix::fs::FileTypeExt;
+ let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let file_path = dir.path().join("not_a_socket");
std::fs::write(&file_path, b"regular file").expect("write failed");
@@ -240,8 +320,8 @@ fn is_socket_rejects_regular_file() {
fn session_probe_accepts_responding_socket() {
// Simulates the assert_socket() pattern from sessions.rs:
// A real Zellij server responds to ConnStatus with Connected.
- let (_dir, path) = socket_path();
- let listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let (_guard, name) = new_ipc();
+ let listener = bind_listener(&name);
// Spawn a fake "server" that responds to ConnStatus with Connected
let server = std::thread::spawn(move || {
@@ -251,10 +331,7 @@ fn session_probe_accepts_responding_socket() {
let mut sender: IpcSenderWithContext<ServerToClientMsg> = receiver.get_sender();
let msg = receiver.recv_client_msg();
- assert!(matches!(
- msg,
- Some((ClientToServerMsg::ConnStatus, _))
- ));
+ assert!(matches!(msg, Some((ClientToServerMsg::ConnStatus, _))));
sender
.send_server_msg(ServerToClientMsg::Connected)
@@ -262,7 +339,7 @@ fn session_probe_accepts_responding_socket() {
});
// Client-side probing (mirrors assert_socket in sessions.rs)
- let stream = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).expect("connect failed");
+ let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> = IpcSenderWithContext::new(stream);
sender
.send_client_msg(ClientToServerMsg::ConnStatus)
@@ -282,30 +359,36 @@ fn session_probe_accepts_responding_socket() {
fn session_probe_rejects_dead_socket() {
// Simulates discovering a stale socket file with no listener.
// get_sessions() filters these out via assert_socket() which tries to connect.
- let (_dir, path) = socket_path();
+ let (_guard, name) = new_ipc();
// Bind and immediately drop the listener to create a stale socket file
{
- let _listener = ListenerOptions::new().name(path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let _listener = bind_listener(&name);
}
- // Listener is dropped — the socket file may still exist but nobody is listening
+ // Listener is dropped -- the socket file may still exist but nobody is listening
- let result = LocalSocketStream::connect(path.as_path().to_fs_name::<GenericFilePath>().unwrap());
+ let result = try_connect(&name);
assert!(
result.is_err(),
"connecting to a dead socket should fail (no listener)"
);
}
-#[cfg(not(windows))]
+#[cfg(unix)]
#[test]
fn socket_directory_enumeration_finds_sockets() {
+ use interprocess::local_socket::GenericFilePath;
+ use std::os::unix::fs::FileTypeExt;
+
// Simulates the readdir + is_socket() filtering pattern from get_sessions().
- let dir = TempDir::new().expect("failed to create temp dir");
+ let dir = tempfile::TempDir::new().expect("failed to create temp dir");
// Create a socket
let sock_path = dir.path().join("test-session");
- let _listener = ListenerOptions::new().name(sock_path.as_path().to_fs_name::<GenericFilePath>().unwrap()).create_sync().expect("bind failed");
+ let _listener = ListenerOptions::new()
+ .name(sock_path.as_path().to_fs_name::<GenericFilePath>().unwrap())
+ .create_sync()
+ .expect("bind failed");
// Create a regular file (should be filtered out)
let file_path = dir.path().join("not-a-session");
diff --git a/zellij-utils/src/sessions.rs b/zellij-utils/src/sessions.rs
index bbac6afe..0f84aa4b 100644
--- a/zellij-utils/src/sessions.rs
+++ b/zellij-utils/src/sessions.rs
@@ -9,7 +9,6 @@ use crate::{
};
use anyhow;
use humantime::format_duration;
-use interprocess::local_socket::{prelude::*, GenericFilePath, Stream as LocalSocketStream};
use std::collections::HashMap;
use std::time::{Duration, SystemTime};
use std::{fs, io, process};
@@ -139,13 +138,15 @@ pub fn get_sessions_sorted_by_mtime() -> anyhow::Result<Vec<String>> {
}
}
+/// Probe a session socket to check if a server is alive.
+///
+/// On Unix, connects and sends a `ConnStatus` message to verify the server responds.
+/// On Windows, reads the server PID from the marker file and checks process liveness.
+#[cfg(unix)]
fn assert_socket(name: &str) -> bool {
+ use crate::consts::ipc_connect;
let path = &*ZELLIJ_SOCK_DIR.join(name);
- let fs_name = match path.to_fs_name::<GenericFilePath>() {
- Ok(name) => name,
- Err(_) => return false,
- };
- match LocalSocketStream::connect(fs_name) {
+ match ipc_connect(path) {
Ok(stream) => {
let mut sender: IpcSenderWithContext<ClientToServerMsg> =
IpcSenderWithContext::new(stream);
@@ -164,6 +165,50 @@ fn assert_socket(name: &str) -> bool {
}
}
+/// On Windows, reads the server PID from the marker file and checks whether
+/// the process is still alive via `OpenProcess`. Cleans up stale marker files.
+#[cfg(windows)]
+fn assert_socket(name: &str) -> bool {
+ use windows_sys::Win32::Foundation::CloseHandle;
+ use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
+
+ let path = &*ZELLIJ_SOCK_DIR.join(name);
+ let pid_str = match fs::read_to_string(path) {
+ Ok(s) => s,
+ Err(_) => {
+ drop(fs::remove_file(path));
+ return false;
+ },
+ };
+ let pid: u32 = match pid_str.trim().parse() {
+ Ok(p) => p,
+ Err(_) => {
+ // Marker file exists but has no valid PID (e.g. empty from old version).
+ // Treat as stale.
+ drop(fs::remove_file(path));
+ return false;
+ },
+ };
+ let alive = unsafe {
+ let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
+ if handle == 0 {
+ false
+ } else {
+ CloseHandle(handle);
+ true
+ }
+ };
+ if !alive {
+ drop(fs::remove_file(path));
+ }
+ alive
+}
+
+#[cfg(not(any(unix, windows)))]
+fn assert_socket(_name: &str) -> bool {
+ true
+}
+
pub fn print_sessions(
mut sessions: Vec<(String, Duration, bool)>,
no_formatting: bool,
@@ -246,18 +291,33 @@ pub fn get_active_session() -> ActiveSession {
}
pub fn kill_session(name: &str) {
+ use crate::consts::ipc_connect;
let path = &*ZELLIJ_SOCK_DIR.join(name);
- let fs_name = match path.to_fs_name::<GenericFilePath>() {
- Ok(name) => name,
- Err(e) => {
- eprintln!("Error occurred: {:?}", e);
- process::exit(1);
- },
- };
- match LocalSocketStream::connect(fs_name) {
+ match ipc_connect(path) {
Ok(stream) => {
- let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
- .send_client_msg(ClientToServerMsg::KillSession);
+ // On Windows, the server uses a dual-pipe architecture: the main pipe
+ // for client→server and a reply pipe for server→client. We must:
+ // 1. Connect to the reply pipe (so the server unblocks from
+ // reply_listener.accept() and spawns the route thread)
+ // 2. Send KillSession on the main pipe
+ // 3. Wait for the Exit response on the reply pipe (so we don't
+ // disconnect before the server processes the message)
+ #[cfg(windows)]
+ {
+ let reply = crate::consts::ipc_connect_reply(path);
+ let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
+ .send_client_msg(ClientToServerMsg::KillSession);
+ if let Ok(reply_stream) = reply {
+ let mut receiver: IpcReceiverWithContext<ServerToClientMsg> =
+ IpcReceiverWithContext::new(reply_stream);
+ let _ = receiver.recv_server_msg();
+ }
+ }
+ #[cfg(not(windows))]
+ {
+ let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
+ .send_client_msg(ClientToServerMsg::KillSession);
+ }
},
Err(e) => {
eprintln!("Error occurred: {:?}", e);
@@ -268,16 +328,27 @@ pub fn kill_session(name: &str) {
pub fn delete_session(name: &str, force: bool) {
if force {
+ use crate::consts::ipc_connect;
let path = &*ZELLIJ_SOCK_DIR.join(name);
- let _ = path
- .to_fs_name::<GenericFilePath>()
- .ok()
- .and_then(|fs_name| LocalSocketStream::connect(fs_name).ok())
- .map(|stream| {
+ let _ = ipc_connect(path).ok().map(|stream| {
+ #[cfg(windows)]
+ {
+ let reply = crate::consts::ipc_connect_reply(path);
+ let _ = IpcSenderWithContext::<ClientToServerMsg>::new(stream)
+ .send_client_msg(ClientToServerMsg::KillSession);
+ if let Ok(reply_stream) = reply {
+ let mut receiver: IpcReceiverWithContext<ServerToClientMsg> =
+ IpcReceiverWithContext::new(reply_stream);
+ let _ = receiver.recv_server_msg();
+ }
+ }
+ #[cfg(not(windows))]
+ {
IpcSenderWithContext::<ClientToServerMsg>::new(stream)
.send_client_msg(ClientToServerMsg::KillSession)
.ok();
- });
+ }
+ });
}
if let Err(e) = std::fs::remove_dir_all(session_info_folder_for_session(name)) {
if e.kind() == std::io::ErrorKind::NotFound {
diff --git a/zellij-utils/src/setup.rs b/zellij-utils/src/setup.rs
index 13838740..f4fd6f47 100644
--- a/zellij-utils/src/setup.rs
+++ b/zellij-utils/src/setup.rs
@@ -867,55 +867,55 @@ mod setup_test {
}
#[test]
fn cli_config_dir_overrides_defaults() {
+ let config_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("src")
+ .join("test-fixtures")
+ .join("config-dirs")
+ .join("layout-upside-down");
let cli_args = CliArgs {
- config_dir: Some(PathBuf::from(format!(
- "{}/src/test-fixtures/config-dirs/layout-upside-down",
- env!("CARGO_MANIFEST_DIR")
- ))),
+ config_dir: Some(config_dir.clone()),
..Default::default()
};
let (_, layout_info, _, _, _) = Setup::from_cli_args(&cli_args).unwrap();
let Some(LayoutInfo::File(layout_path, _)) = layout_info else {
panic!("layout info has unexpected format");
};
- assert_eq!(
- layout_path,
- format!(
- "{}/src/test-fixtures/config-dirs/layout-upside-down/layouts/upside-down.kdl",
- env!("CARGO_MANIFEST_DIR")
- )
- );
+ let expected = config_dir
+ .join("layouts")
+ .join("upside-down.kdl")
+ .canonicalize()
+ .unwrap();
+ assert_eq!(layout_path, expected.display().to_string());
}
#[test]
fn cli_config_dir_finds_custom_default() {
+ let config_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("src")
+ .join("test-fixtures")
+ .join("config-dirs")
+ .join("custom-default-layout");
let cli_args = CliArgs {
- config_dir: Some(PathBuf::from(format!(
- "{}/src/test-fixtures/config-dirs/custom-default-layout",
- env!("CARGO_MANIFEST_DIR")
- ))),
+ config_dir: Some(config_dir.clone()),
..Default::default()
};
let (_, layout_info, _, _, _) = Setup::from_cli_args(&cli_args).unwrap();
let Some(LayoutInfo::File(layout_path, _)) = layout_info else {
panic!("layout info has unexpected format");
};
- assert_eq!(
- layout_path,
- format!(
- "{}/src/test-fixtures/config-dirs/custom-default-layout/layouts/default.kdl",
- env!("CARGO_MANIFEST_DIR")
- )
- );
+ let expected = config_dir
+ .join("layouts")
+ .join("default.kdl")
+ .canonicalize()
+ .unwrap();
+ assert_eq!(layout_path, expected.display().to_string());
}
#[test]
fn cli_with_relative_layout_and_extension() {
// NOTE: We assume to be in `zellij-utils` root directory. If this doesn't hold, path
// resolution cannot work (as it actually reads path to ensure they exist).
- assert_eq!(
- std::env::current_dir().unwrap(),
- PathBuf::from(env!("CARGO_MANIFEST_DIR"))
- );
+ let cwd = std::env::current_dir().unwrap();
+ assert_eq!(cwd, PathBuf::from(env!("CARGO_MANIFEST_DIR")));
let cli_args = CliArgs {
layout: Some(PathBuf::from("assets/layouts/compact.kdl")),
@@ -925,23 +925,16 @@ mod setup_test {
let Some(LayoutInfo::File(layout_path, _)) = layout_info else {
panic!("layout info has unexpected format: {:?}", &layout_info);
};
- assert_eq!(
- layout_path,
- format!(
- "{}/assets/layouts/compact.kdl",
- std::env::current_dir().unwrap().display()
- ),
- );
+ let expected = cwd.join("assets/layouts/compact.kdl");
+ assert_eq!(layout_path, expected.display().to_string());
}
#[test]
fn cli_with_relative_layout_and_separator() {
// NOTE: We assume to be in `zellij-utils` root directory. If this doesn't hold, path
// resolution cannot work (as it actually reads path to ensure they exist).
- assert_eq!(
- std::env::current_dir().unwrap(),
- PathBuf::from(env!("CARGO_MANIFEST_DIR"))
- );
+ let cwd = std::env::current_dir().unwrap();
+ assert_eq!(cwd, PathBuf::from(env!("CARGO_MANIFEST_DIR")));
let cli_args = CliArgs {
layout: Some(PathBuf::from("assets/layouts/compact")),
@@ -951,12 +944,7 @@ mod setup_test {
let Some(LayoutInfo::File(layout_path, _)) = layout_info else {
panic!("layout info has unexpected format");
};
- assert_eq!(
- layout_path,
- format!(
- "{}/assets/layouts/compact",
- std::env::current_dir().unwrap().display()
- ),
- );
+ let expected = cwd.join("assets/layouts/compact");
+ assert_eq!(layout_path, expected.display().to_string());
}
}
diff --git a/zellij-utils/src/web_server_commands.rs b/zellij-utils/src/web_server_commands.rs
index 2ce8de44..a7682889 100644
--- a/zellij-utils/src/web_server_commands.rs
+++ b/zellij-utils/src/web_server_commands.rs
@@ -3,7 +3,7 @@ use crate::consts::WEBSERVER_SOCKET_PATH;
use crate::errors::prelude::*;
use crate::web_server_contract::web_server_contract::InstructionForWebServer as ProtoInstructionForWebServer;
use crate::web_server_contract::web_server_contract::WebServerResponse as ProtoWebServerResponse;
-use interprocess::local_socket::{prelude::*, GenericFilePath, Stream as LocalSocketStream};
+use interprocess::local_socket::Stream as LocalSocketStream;
use prost::Message;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -60,8 +60,7 @@ pub enum WebServerResponse {
}
pub fn create_webserver_sender(path: &str) -> Result<BufWriter<LocalSocketStream>> {
- let fs_name = path.to_fs_name::<GenericFilePath>()?;
- let stream = LocalSocketStream::connect(fs_name)?;
+ let stream = crate::consts::ipc_connect(std::path::Path::new(path))?;
Ok(BufWriter::new(stream))
}