diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..8bb0b94e561c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "mcp__sequential-thinking__sequentialthinking", + "Bash(git add:*)", + "Bash(git push:*)", + "mcp__context7__resolve-library-id", + "mcp__context7__get-library-docs", + "Bash(pip install:*)", + "Bash(python scripts/ipc_test.py:*)", + "Bash(find:*)", + "Bash(python:*)", + "Bash(powershell:*)", + "Bash(gh pr list:*)", + "Bash(gh pr checks:*)", + "Bash(gh run view:*)", + "Bash(gh api graphql:*)", + "Bash(gh pr view:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000000..002dff11f459 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,66 @@ +# GitHub Workflows for Fork + +## Available Workflows + +### 1. `ci-fork.yml` (Simplified CI) +- **Purpose**: Basic build validation without requiring secrets +- **Triggers**: Push to main/feature/fix branches, Pull Requests +- **What it does**: + - Builds the solution in Debug and Release modes + - Runs tests (continues on error) + - Uploads build artifacts + - Checks code formatting + - Runs code analysis + +### 2. `ci.yml` (Original CI) +- **Purpose**: Full CI with all checks +- **Note**: Modified to work with `primeinc` fork +- **Requirements**: Some jobs may need secrets configured + +## Setting Up Secrets (Optional) + +If you want to run the full CI/CD workflows, you'll need to configure these secrets in your repository settings: + +### For Sideload builds: +- `SIDELOAD_PUBLISHER_SECRET` +- `BING_MAPS_SECRET` +- `SENTRY_SECRET` +- `GH_OAUTH_CLIENT_ID` + +### For Store builds: +- `STORE_PUBLISHER_SECRET` +- `AZURE_TENANT_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` + +### For Code Signing: +- `SIGNING_ACCOUNT_NAME` +- `SIGNING_PROFILE_NAME` + +## Disabling Workflows + +If you don't need certain workflows, you can disable them: + +1. Go to Actions tab in your repository +2. Click on the workflow you want to disable +3. Click "..." menu → "Disable workflow" + +## Recommended Setup for Fork + +1. Use `ci-fork.yml` for basic build validation +2. Disable CD workflows unless you plan to deploy +3. Disable `format-xaml.yml` unless you have the bot configured + +## Troubleshooting + +### Build Fails with Missing Secrets +- Use `ci-fork.yml` instead which doesn't require secrets +- Or add placeholder values for the required secrets + +### Tests Fail +- Tests are set to `continue-on-error: true` in fork CI +- Check test output for actual issues + +### Workflow Not Running +- Check if the branch/path triggers match your changes +- Ensure workflows are enabled in your fork's Actions settings \ No newline at end of file diff --git a/.github/workflows/ci-fork.yml b/.github/workflows/ci-fork.yml new file mode 100644 index 000000000000..b9354e5c16bf --- /dev/null +++ b/.github/workflows/ci-fork.yml @@ -0,0 +1,221 @@ +# Simplified CI for Fork +# This workflow runs basic build validation without requiring secrets + +name: Fork CI + +on: + pull_request: + branches: + - main + paths-ignore: + - 'assets/**' + - 'builds/**' + - 'docs/**' + - '*.md' + +# Auto-cancel previous runs when a new commit is pushed to the same PR +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write # Allow pushing formatting fixes + pull-requests: write # Allow PR comments + +env: + SOLUTION_PATH: '${{ github.workspace }}\Files.slnx' + PACKAGE_PROJECT_PATH: '${{ github.workspace }}\src\Files.App (Package)\Files.Package.wapproj' + PACKAGE_PROJECT_DIR: '${{ github.workspace }}\src\Files.App (Package)' + APPX_PACKAGE_DIR: '${{ github.workspace }}\artifacts\AppxPackages' + APPX_SELFSIGNED_CERT_PATH: '${{ github.workspace }}\.github\workflows\FilesApp_SelfSigned.pfx' + ARTIFACTS_STAGING_DIR: '${{ github.workspace }}\artifacts' + AUTOMATED_TESTS_ASSEMBLY_DIR: '${{ github.workspace }}\artifacts\TestsAssembly' + AUTOMATED_TESTS_PROJECT_DIR: '${{ github.workspace }}\tests\Files.InteractionTests' + AUTOMATED_TESTS_PROJECT_PATH: '${{ github.workspace }}\tests\Files.InteractionTests\Files.InteractionTests.csproj' + WINAPPDRIVER_EXE86_PATH: 'C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe' + +jobs: + check-and-fix-formatting: + runs-on: windows-latest + + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 2 + token: ${{ github.token }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + + - name: Install XamlStyler.Console + run: 'dotnet tool install --global XamlStyler.Console' + + - name: Check and fix XAML formatting + id: format-xaml + shell: pwsh + run: | + $changedFiles = (git diff --diff-filter=d --name-only HEAD~1) -split "\n" | Where-Object {$_ -like "*.xaml"} + $hasChanges = $false + foreach ($file in $changedFiles) + { + Write-Host "Checking: $file" + xstyler -f $file + if (git diff --name-only $file) { + $hasChanges = $true + Write-Host " ✓ Fixed formatting" + } + } + if ($hasChanges) { + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add *.xaml + git commit -m "Auto-fix XAML formatting [skip ci]" + git push + echo "::notice::XAML files were auto-formatted and committed" + } else { + echo "::notice::All XAML files are properly formatted" + } + + build: + needs: [check-and-fix-formatting] + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + - name: Setup NuGet + uses: NuGet/setup-nuget@v2 + + - name: Restore Files + shell: pwsh + run: | + msbuild $env:SOLUTION_PATH ` + -t:Restore ` + -p:Platform=x64 ` + -p:Configuration=Release ` + -p:PublishReadyToRun=true ` + -v:quiet + + - name: Create self signed cert + run: ./.github/scripts/Generate-SelfCertPfx.ps1 -Destination "$env:APPX_SELFSIGNED_CERT_PATH" + + - name: Build & package Files + run: | + msbuild ` + $env:PACKAGE_PROJECT_PATH ` + -t:Build ` + -t:_GenerateAppxPackage ` + -p:Configuration=Release ` + -p:Platform=x64 ` + -p:AppxBundlePlatforms=x64 ` + -p:AppxBundle=Always ` + -p:UapAppxPackageBuildMode=SideloadOnly ` + -p:AppxPackageDir=$env:APPX_PACKAGE_DIR ` + -p:AppxPackageSigningEnabled=true ` + -p:PackageCertificateKeyFile=$env:APPX_SELFSIGNED_CERT_PATH ` + -p:PackageCertificatePassword="" ` + -p:PackageCertificateThumbprint="" ` + -v:quiet + + - name: Build interaction tests + run: | + msbuild $env:AUTOMATED_TESTS_PROJECT_PATH ` + -t:Build ` + -p:Configuration=Release ` + -p:Platform=x64 ` + -v:quiet + + - name: Copy tests to artifacts + shell: pwsh + run: | + Copy-Item ` + -Path "$env:AUTOMATED_TESTS_PROJECT_DIR\bin" ` + -Destination "$env:AUTOMATED_TESTS_ASSEMBLY_DIR" -Recurse + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: 'Fork-Package-Release-x64' + path: ${{ env.ARTIFACTS_STAGING_DIR }} + retention-days: 7 + + test: + needs: [build] + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: 'Fork-Package-Release-x64' + path: ${{ env.ARTIFACTS_STAGING_DIR }} + + - name: Install Files + shell: powershell + run: | + Set-Location "$env:APPX_PACKAGE_DIR" + $AppxPackageBundleDir = Get-ChildItem -Filter Files.Package_*_Test -Name + Set-Location $AppxPackageBundleDir + ./Install.ps1 -Force + + - name: Set display resolution + run: Set-DisplayResolution -Width 1920 -Height 1080 -Force + + - name: Start WinAppDriver + shell: pwsh + run: Start-Process -FilePath "$env:WINAPPDRIVER_EXE86_PATH" + + - name: Run interaction tests + uses: nick-fields/retry@v3 + with: + timeout_minutes: 15 + max_attempts: 2 + shell: pwsh + command: | + dotnet test ` + $env:AUTOMATED_TESTS_ASSEMBLY_DIR\**\Files.InteractionTests.dll ` + --logger "console;verbosity=detailed" + + code-quality: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Check Format + run: | + dotnet format --verify-no-changes --verbosity diagnostic + continue-on-error: true + + - name: Run Code Analysis + run: | + dotnet build $env:SOLUTION_PATH ` + /p:RunAnalyzersDuringBuild=true ` + /p:Configuration=Release ` + /p:Platform=x64 ` + /p:TreatWarningsAsErrors=false + continue-on-error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index fab72c112dd9..2751a9bf54f7 100644 --- a/.gitignore +++ b/.gitignore @@ -409,3 +409,4 @@ FodyWeavers.xsd *.sln.iml .idea/ src/Files.App/Assets/FilesOpenDialog/Files.App.Launcher.exe.sha256 +.claude/ \ No newline at end of file diff --git a/docs/remote-control/README.md b/docs/remote-control/README.md new file mode 100644 index 000000000000..aa4b5804d05d --- /dev/null +++ b/docs/remote-control/README.md @@ -0,0 +1,147 @@ +# Remote Control / IPC — hardened (final candidate) + +This revision hardens the IPC subsystem for Files to address resource, security, and correctness issues: +- Strict JSON-RPC 2.0 validation and shape enforcement (includes IsInvalidRequest). +- Encrypted token storage (DPAPI) with epoch-based rotation that invalidates existing sessions. +- Centralized RpcMethodRegistry used everywhere (transports + adapter). +- WebSocket receive caps, per-method caps, per-client queue caps, lossy coalescing by method and per-client token bucket applied for both requests and notifications. +- Named Pipe per-user ACL and per-session randomized pipe name; length-prefixed framing. +- getMetadata: capped by items and timeout; runs off UI thread and honors client cancellation. +- Selection notifications are capped and include truncated flag. +- UIOperationQueue required to be passed a DispatcherQueue; all UI-affecting operations serialized. + +## Merge checklist +- [ ] Settings UI: "Enable Remote Control" (ProtectedTokenStore.SetEnabled), "Rotate Token" (RotateTokenAsync), "Enable Long Paths" toggle and display of current pipe name/port only when enabled. +- [ ] ShellViewModel: wire ExecuteActionById / NavigateToPathNormalized or expose small interface for adapter. +- [ ] Packaging decision: Document Kestrel + URLACL if WS is desired in Store/MSIX; default recommended for Store builds is NamedPipe-only. +- [ ] Tests: WS/pipe oversize, slow-consumer (lossy/coalesce), JSON-RPC conformance, getMetadata timeout & cancellation, token rotation invalidation. +- [ ] Telemetry hooks: auth failures, slow-client disconnects, queue drops. + +## JSON-RPC error codes used +- -32700 Parse error +- -32600 Invalid Request +- -32601 Method not found +- -32602 Invalid params +- -32001 Authentication required +- -32002 Invalid token +- -32003 Rate limit exceeded +- -32004 Session expired +- -32000 Internal server error + +## Architecture + +### Core Components + +#### JsonRpcMessage +Strict JSON-RPC 2.0 implementation with helpers for creating responses and validating message shapes. Preserves original ID types and enforces result XOR error semantics. + +#### ProtectedTokenStore +DPAPI-backed encrypted token storage in LocalSettings with epoch-based rotation. When tokens are rotated, the epoch increments and invalidates all existing client sessions. + +#### ClientContext +Per-client state management including: +- Token bucket rate limiting (configurable burst and refill rate) +- Lossy message queue with method-based coalescing +- Authentication state and epoch tracking +- Connection lifecycle management + +#### RpcMethodRegistry +Centralized registry for RPC method definitions including: +- Authentication requirements +- Notification permissions +- Per-method payload size limits +- Custom authorization policies + +#### Transport Services +- **WebSocketAppCommunicationService**: HTTP listener on loopback with WebSocket upgrade +- **NamedPipeAppCommunicationService**: Per-user ACL with randomized pipe names + +#### ShellIpcAdapter +Application logic adapter that: +- Enforces method allowlists and security policies +- Provides path normalization and validation +- Implements resource-bounded operations (metadata with timeouts) +- Serializes UI operations through UIOperationQueue + +## Security Features + +### Authentication & Authorization +- DPAPI-encrypted token storage +- Per-session token validation with epoch checking +- Method-level authorization policies +- Per-user ACL on named pipes + +### Resource Protection +- Configurable message size limits per transport +- Per-client queue size limits with lossy behavior +- Rate limiting with token bucket algorithm +- Operation timeouts and cancellation support + +### Attack Mitigation +- Strict JSON-RPC validation prevents malformed requests +- Path normalization rejects device paths and traversal attempts +- Selection notifications capped to prevent resource exhaustion +- Automatic cleanup of inactive/stale connections + +## Configuration + +All limits are configurable via `IpcConfig`: +```csharp +IpcConfig.WebSocketMaxMessageBytes = 16 * 1024 * 1024; // 16 MB +IpcConfig.NamedPipeMaxMessageBytes = 10 * 1024 * 1024; // 10 MB +IpcConfig.PerClientQueueCapBytes = 2 * 1024 * 1024; // 2 MB +IpcConfig.RateLimitPerSecond = 20; +IpcConfig.RateLimitBurst = 60; +IpcConfig.SelectionNotificationCap = 200; +IpcConfig.GetMetadataMaxItems = 500; +IpcConfig.GetMetadataTimeoutSec = 30; +``` + +## Supported Methods + +### Authentication +- `handshake` - Authenticate with token and establish session + +### State Query +- `getState` - Get current navigation state +- `listActions` - Get available actions + +### Operations +- `navigate` - Navigate to path (with normalization) +- `executeAction` - Execute registered action by ID +- `getMetadata` - Get file/folder metadata (batched, with timeout) + +### Notifications (Broadcast) +- `workingDirectoryChanged` - Current directory changed +- `selectionChanged` - File selection changed (with truncation) +- `ping` - Keepalive heartbeat + +## Usage + +**DO NOT enable IPC by default** — StartAsync refuses to start unless the user explicitly enables Remote Control via Settings. See merge checklist above. + +### Enabling Remote Control +```csharp +// In Settings UI +await ProtectedTokenStore.SetEnabled(true); +var token = await ProtectedTokenStore.GetOrCreateTokenAsync(); +``` + +### Starting Services +```csharp +// Only starts if enabled +await webSocketService.StartAsync(); +await namedPipeService.StartAsync(); +``` + +### Token Rotation +```csharp +// Invalidates all existing sessions +var newToken = await ProtectedTokenStore.RotateTokenAsync(); +``` + +## Implementation Status + +✅ **Complete**: Core IPC framework, security model, transport services +🔄 **Pending**: Settings UI integration, ShellViewModel method wiring +📋 **TODO**: Comprehensive tests, telemetry integration, Kestrel option \ No newline at end of file diff --git a/global.json b/global.json index 6b2ebefd9cc0..5ce2e6ef2fcf 100644 --- a/global.json +++ b/global.json @@ -3,4 +3,4 @@ "version": "9.0.200", "rollForward": "latestMajor" } -} \ No newline at end of file +} diff --git a/scripts/.claude/settings.local.json b/scripts/.claude/settings.local.json new file mode 100644 index 000000000000..543f7c6a0424 --- /dev/null +++ b/scripts/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(git show:*)", + "Bash(gh pr list:*)", + "Bash(git log:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/scripts/test_ipc_unified.py b/scripts/test_ipc_unified.py new file mode 100644 index 000000000000..6630441f9958 --- /dev/null +++ b/scripts/test_ipc_unified.py @@ -0,0 +1,596 @@ +#!/usr/bin/env python3 +""" +Unified IPC test suite that tests BOTH WebSocket and Named Pipe transports +with identical test scenarios to ensure 1:1 parity. + +Usage: + python scripts/test_ipc_unified.py [--transport ws|pipe|both] [--test ] +""" + +import argparse +import asyncio +import json +import os +import struct +import sys +import time +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +# Transport-specific imports +import websockets +import win32pipe +import win32file +import pywintypes + + +def discover_ipc_config() -> Tuple[str, int, str]: + """Discover IPC configuration from rendezvous file.""" + rendezvous_path = Path(os.environ['LOCALAPPDATA']) / 'FilesIPC' / 'ipc.info' + + if not rendezvous_path.exists(): + raise FileNotFoundError(f"Rendezvous file not found at {rendezvous_path}") + + with open(rendezvous_path, 'r') as f: + data = json.load(f) + + token = data.get('token') + port = data.get('webSocketPort', 52345) + pipe = data.get('pipeName') + + if not token: + raise ValueError("No token found in rendezvous file") + + print(f"[Discovery] Found IPC config:") + print(f" Token: {token[:8]}...") + print(f" WebSocket Port: {port}") + print(f" Named Pipe: {pipe}") + + return token, port, pipe + + +class IpcClient(ABC): + """Abstract base class for IPC clients.""" + + @abstractmethod + async def connect(self, token: str): + """Connect and authenticate.""" + pass + + @abstractmethod + async def call(self, method: str, params: Dict[str, Any] = None) -> Any: + """Make an RPC call.""" + pass + + @abstractmethod + async def close(self): + """Close the connection.""" + pass + + +class WebSocketClient(IpcClient): + """WebSocket IPC client.""" + + def __init__(self, port: int): + self.port = port + self.ws = None + self._id = 1 + self._responses = {} + self._response_events = {} + self._receiver_task = None + + async def connect(self, token: str): + """Connect and authenticate via WebSocket.""" + self.ws = await websockets.connect(f"ws://127.0.0.1:{self.port}/") + self._receiver_task = asyncio.create_task(self._receive_loop()) + + # Handshake + result = await self.call("handshake", {"token": token, "clientInfo": "unified-test-ws"}) + if result.get("status") != "authenticated": + raise RuntimeError(f"WebSocket handshake failed: {result}") + + async def _receive_loop(self): + """Continuously receive messages.""" + try: + async for msg in self.ws: + data = json.loads(msg) + if "id" in data and data["id"] is not None: + self._responses[data["id"]] = data + # Set event if waiting for this response + if data["id"] in self._response_events: + self._response_events[data["id"]].set() + except (websockets.exceptions.ConnectionClosed, + websockets.exceptions.ConnectionClosedOK, + asyncio.CancelledError): + # Expected disconnections - connection closed normally + pass + except Exception as e: + print(f"[ERROR] Unexpected exception in receive loop: {e}") + import traceback + traceback.print_exc() + + async def call(self, method: str, params: Dict[str, Any] = None) -> Any: + """Make an RPC call via WebSocket.""" + msg_id = self._id + self._id += 1 + + msg = {"jsonrpc": "2.0", "id": msg_id, "method": method} + if params: + msg["params"] = params + + # Create event for this response + event = asyncio.Event() + self._response_events[msg_id] = event + + await self.ws.send(json.dumps(msg)) + + # Wait for response with timeout + try: + await asyncio.wait_for(event.wait(), timeout=5.0) + except asyncio.TimeoutError: + self._response_events.pop(msg_id, None) + raise TimeoutError(f"No response for {method} (id: {msg_id})") + + # Get and return response + self._response_events.pop(msg_id, None) + resp = self._responses.pop(msg_id) + if "error" in resp and resp["error"]: + raise RuntimeError(f"RPC error: {resp['error']}") + return resp.get("result") + + async def close(self): + """Close WebSocket connection.""" + if self._receiver_task: + self._receiver_task.cancel() + if self.ws: + await self.ws.close() + + +class NamedPipeClient(IpcClient): + """Named Pipe IPC client (async wrapper).""" + + def __init__(self, pipe_name: str): + self.pipe_name = f"\\\\.\\pipe\\{pipe_name}" + self.pipe_handle = None + self._id = 1 + + async def connect(self, token: str): + """Connect and authenticate via Named Pipe.""" + await asyncio.get_event_loop().run_in_executor(None, self._connect_sync, token) + + def _connect_sync(self, token: str): + """Synchronous connection.""" + self.pipe_handle = win32file.CreateFile( + self.pipe_name, + win32file.GENERIC_READ | win32file.GENERIC_WRITE, + 0, None, + win32file.OPEN_EXISTING, + 0, None + ) + + # Handshake + result = self._call_sync("handshake", {"token": token, "clientInfo": "unified-test-pipe"}) + if result.get("status") != "authenticated": + raise RuntimeError(f"Named pipe handshake failed: {result}") + + async def call(self, method: str, params: Dict[str, Any] = None) -> Any: + """Make an RPC call via Named Pipe.""" + return await asyncio.get_event_loop().run_in_executor( + None, self._call_sync, method, params + ) + + def _call_sync(self, method: str, params: Dict[str, Any] = None) -> Any: + """Synchronous RPC call.""" + msg_id = self._id + self._id += 1 + + msg = {"jsonrpc": "2.0", "id": msg_id, "method": method} + if params: + msg["params"] = params + + # Send with length prefix + json_bytes = json.dumps(msg).encode('utf-8') + length_bytes = struct.pack(' 0, "No actions available" + + # Print available actions for debugging + print(f"\n[INFO] Available IPC actions:") + for action in actions["actions"]: + print(f" - {action['id']}: {action.get('description', 'No description')}") + + async def test_navigation(self): + """Test navigation operations.""" + # Navigate to a valid path + result = await self.client.call("navigate", {"path": "C:\\Windows"}) + assert result is not None, "Navigate returned None" + + # Verify navigation + state = await self.client.call("getState") + assert state["currentPath"] == "C:\\Windows", f"Navigation failed: {state['currentPath']}" + + # Navigate back + result = await self.client.call("navigate", {"path": "C:\\Users"}) + assert result is not None, "Navigate back failed" + + async def test_invalid_paths(self): + """Test handling of invalid paths.""" + # Test 1: Non-existent path should return successfully but indicate the path doesn't exist + result = await self.client.call("navigate", {"path": "Z:\\NonExistent\\Path"}) + assert result is not None, "Navigate returned None for non-existent path" + assert isinstance(result, dict), f"Navigate should return dict, got {type(result)}" + # Should either return success status (navigated to closest valid parent) or indicate path issue + assert "status" in result, "Missing status field in navigation response" + assert result["status"] == "ok", "Navigation should handle non-existent paths gracefully" + + # Test 2: Very long path (exceeds Windows MAX_PATH) + long_path = "C:\\" + "\\VeryLongFolderName" * 50 # Creates path > 260 chars + assert len(long_path) > 260, "Test path should exceed Windows MAX_PATH limit" + + try: + result = await self.client.call("navigate", {"path": long_path}) + # If it succeeds, verify the response structure + assert result is not None, "Navigate returned None for long path" + assert isinstance(result, dict), f"Navigate should return dict, got {type(result)}" + assert "status" in result, "Missing status field in response" + # The path should be normalized/truncated or handled appropriately + assert result["status"] == "ok", "Long path should be handled without error" + except RuntimeError as e: + # If it fails, ensure it's a proper JSON-RPC error + error_str = str(e) + assert "RPC error" in error_str, f"Expected RPC error format, got: {error_str}" + # Should contain either InvalidParams error or a descriptive message + assert any(x in error_str.lower() for x in ["invalid", "path", "long", "exceed"]), \ + f"Error should describe the path issue, got: {error_str}" + + # Test 3: Path with invalid characters + invalid_char_path = "C:\\Invalid<>Path|Name?.txt" + try: + result = await self.client.call("navigate", {"path": invalid_char_path}) + # Should handle gracefully even with invalid chars + assert result is not None, "Navigate returned None for path with invalid chars" + assert isinstance(result, dict), f"Navigate should return dict, got {type(result)}" + assert "status" in result, "Missing status field" + except RuntimeError as e: + # Verify proper error handling + error_str = str(e) + assert "RPC error" in error_str, f"Expected RPC error format, got: {error_str}" + assert any(x in error_str.lower() for x in ["invalid", "character", "path"]), \ + f"Error should indicate invalid characters, got: {error_str}" + + # IMPORTANT: Reset to a valid path to ensure shell is in good state for next tests + import os + pictures_path = os.path.expanduser("~\\Pictures") + result = await self.client.call("navigate", {"path": pictures_path}) + assert result["status"] == "ok", "Failed to reset to valid Pictures path" + print(f" [DEBUG] Reset shell to valid path: {pictures_path}") + + async def test_metadata(self): + """Test metadata retrieval.""" + paths = ["C:\\Windows", "C:\\Users", "C:\\Program Files"] + result = await self.client.call("getMetadata", {"paths": paths}) + + assert "items" in result, "Missing items in metadata" + assert len(result["items"]) > 0, "No metadata returned" + + for item in result["items"]: + assert "Path" in item, "Missing Path in metadata" + assert "Exists" in item, "Missing Exists in metadata" + + async def test_list_shells(self): + """Test shell enumeration.""" + result = await self.client.call("listShells", {}) + + assert "shells" in result, "Missing shells in response" + assert isinstance(result["shells"], list), "Shells should be a list" + assert len(result["shells"]) > 0, "Should have at least one shell" + + # Check first shell has required fields + shell = result["shells"][0] + assert "shellId" in shell, "Missing shellId" + assert "windowId" in shell, "Missing windowId" + assert "tabId" in shell, "Missing tabId" + assert "isActive" in shell, "Missing isActive flag" + assert "window" in shell, "Missing window info" + assert "currentPath" in shell, "Missing currentPath" + assert "availableActions" in shell, "Missing availableActions" + + # Verify window info structure + window = shell["window"] + assert "pid" in window, "Missing PID in window info" + assert "title" in window, "Missing title in window info" + assert "isFocused" in window, "Missing isFocused in window info" + assert "bounds" in window, "Missing bounds in window info" + + print(f" [INFO] Found {len(result['shells'])} shell(s):") + for s in result["shells"]: + print(f" - Shell {s['shellId'][:8]}... in window {s['windowId']}, path: {s['currentPath']}") + + async def test_actions(self): + """Test ALL available actions dynamically.""" + # Navigate to valid directory first + await self.client.call("navigate", {"path": "C:\\Users"}) + + # Wait for navigation to complete and UI to fully initialize + for i in range(10): # Try up to 10 times + await asyncio.sleep(0.5) + state = await self.client.call("getState", {}) + if not state.get("isLoading", True) and state.get("itemCount", 0) > 0: + print(f"[DEBUG] Navigation complete after {(i+1)*0.5}s: {state}") + break + else: + print(f"[WARNING] Navigation may not be complete: {state}") + + # Debug: Check what shells are available and their state + shells = await self.client.call("listShells", {}) + print(f"\n[DEBUG] Available shells BEFORE actions: {json.dumps(shells, indent=2)}") + + state = await self.client.call("getState", {}) + print(f"\n[DEBUG] Current state: {json.dumps(state, indent=2)}") + + # Get ALL available actions from the API + actions_response = await self.client.call("listActions") + available_actions = actions_response.get('actions', []) + + print(f"\n[INFO] Found {len(available_actions)} available actions") + + failed_actions = [] + succeeded_actions = [] + + # Test EVERY SINGLE ACTION that the API says is available + for action in available_actions: + action_id = action['id'] + + try: + # Special handling for toggledualpane - check shells after + if action_id == 'toggledualpane': + print(f" [TESTING] {action_id}: Testing dual pane toggle...") + result = await self.client.call("executeAction", {"actionId": action_id}) + print(f" [OK] {action_id}: {result}") + + # Give UI time to create new pane + await asyncio.sleep(1.0) + + # Check shells after toggle + shells_after = await self.client.call("listShells", {}) + print(f" [DEBUG] Shells AFTER toggledualpane: {len(shells_after['shells'])} shell(s)") + for s in shells_after['shells']: + print(f" - Shell {s['shellId'][:8]}... active={s['isActive']}, path={s['currentPath']}") + else: + result = await self.client.call("executeAction", {"actionId": action_id}) + print(f" [OK] {action_id}: {result}") + + succeeded_actions.append(action_id) + except RuntimeError as e: + print(f" [FAILED] {action_id}: {e}") + failed_actions.append((action_id, str(e))) + + # Report results + print(f"\n[SUMMARY] {len(succeeded_actions)}/{len(available_actions)} actions succeeded") + if failed_actions: + print(f"[FAILED ACTIONS]: {[f[0] for f in failed_actions]}") + + # ALL actions that are advertised as available MUST work + assert len(failed_actions) == 0, f"{len(failed_actions)} actions failed out of {len(available_actions)}: {[f[0] for f in failed_actions]}" + + async def test_invalid_action(self): + """Test that invalid actions fail properly.""" + try: + await self.client.call("executeAction", {"actionId": "nonExistentAction"}) + assert False, "Should have failed with invalid action" + except RuntimeError: + pass # Expected to fail + + async def test_error_handling(self): + """Test error handling.""" + # Missing required parameter + try: + await self.client.call("navigate", {}) + assert False, "Should have failed with missing parameter" + except RuntimeError: + pass # Expected + + # Invalid method + try: + await self.client.call("invalidMethod", {}) + assert False, "Should have failed with invalid method" + except RuntimeError: + pass # Expected + + async def test_large_payload(self): + """Test handling of large payloads.""" + # Request metadata for many paths + paths = [f"C:\\TestPath{i}" for i in range(100)] + result = await self.client.call("getMetadata", {"paths": paths}) + assert result is not None, "Large payload failed" + + +async def test_transport(transport: str, token: str, port: int, pipe_name: str) -> bool: + """Test a specific transport.""" + if transport == "ws": + client = WebSocketClient(port) + elif transport == "pipe": + client = NamedPipeClient(pipe_name) + else: + raise ValueError(f"Unknown transport: {transport}") + + try: + await client.connect(token) + tester = UnifiedIpcTester(client, transport.upper()) + success = await tester.run_all_tests() + return success + finally: + await client.close() + + +async def test_multi_client(transport: str, token: str, port: int, pipe_name: str): + """Test multiple simultaneous clients.""" + print(f"\n{'='*60}") + print(f"Multi-Client Test ({transport.upper()})") + print(f"{'='*60}") + + clients = [] + try: + # Create 3 clients + for i in range(3): + if transport == "ws": + client = WebSocketClient(port) + else: + client = NamedPipeClient(pipe_name) + + await client.connect(token) + clients.append(client) + print(f"[OK] Client {i+1} connected") + + # Test concurrent operations + tasks = [client.call("getState") for client in clients] + results = await asyncio.gather(*tasks) + + for i, result in enumerate(results): + assert "currentPath" in result, f"Client {i+1} failed" + print(f"[OK] Client {i+1} got state: {result['currentPath']}") + + print(f"[OK] Multi-client test passed") + return True + + except Exception as e: + print(f"[FAIL] Multi-client test failed: {e}") + return False + + finally: + for client in clients: + await client.close() + + +async def main(): + parser = argparse.ArgumentParser(description="Unified IPC test suite") + parser.add_argument("--transport", choices=["ws", "pipe", "both"], default="both", + help="Which transport to test") + parser.add_argument("--test", choices=["basic", "multi", "all"], default="all", + help="Which tests to run") + args = parser.parse_args() + + # Discover configuration + try: + token, port, pipe_name = discover_ipc_config() + except Exception as e: + print(f"Discovery failed: {e}") + return 1 + + # Determine which transports to test + if args.transport == "both": + transports = ["ws", "pipe"] + else: + transports = [args.transport] + + # Run tests + all_passed = True + + if args.test in ["basic", "all"]: + for transport in transports: + success = await test_transport(transport, token, port, pipe_name) + all_passed = all_passed and success + + if args.test in ["multi", "all"]: + for transport in transports: + success = await test_multi_client(transport, token, port, pipe_name) + all_passed = all_passed and success + + # Final summary + print(f"\n{'='*60}") + print("FINAL SUMMARY") + print(f"{'='*60}") + + if all_passed: + print("[OK] ALL TESTS PASSED - Both transports have 1:1 parity!") + return 0 + else: + print("[FAIL] SOME TESTS FAILED - Transports do not have full parity") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) \ No newline at end of file diff --git a/src/Files.App/Actions/Show/ToggleDualPaneAction.cs b/src/Files.App/Actions/Show/ToggleDualPaneAction.cs index 87c63ef5ed09..8a4482787dd6 100644 --- a/src/Files.App/Actions/Show/ToggleDualPaneAction.cs +++ b/src/Files.App/Actions/Show/ToggleDualPaneAction.cs @@ -29,9 +29,17 @@ public ToggleDualPaneAction() public Task ExecuteAsync(object? parameter = null) { if (IsOn) + { ContentPageContext.ShellPage?.PaneHolder.CloseOtherPane(); - else - ContentPageContext.ShellPage?.PaneHolder.OpenSecondaryPane(ContentPageContext.ShellPage!.ShellViewModel.WorkingDirectory, generalSettingsService.ShellPaneArrangementOption); + } + else if (ContentPageContext.IsMultiPaneAvailable) + { + ContentPageContext.ShellPage?.PaneHolder.OpenSecondaryPane( + ContentPageContext.ShellPage!.ShellViewModel.WorkingDirectory, + generalSettingsService.ShellPaneArrangementOption); + } + // If multi-pane is not available (window too narrow), the action is disabled + // The IsExecutable property handles UI state, no additional feedback needed return Task.CompletedTask; } diff --git a/src/Files.App/Communication/AppCommunicationServiceBase.cs b/src/Files.App/Communication/AppCommunicationServiceBase.cs new file mode 100644 index 000000000000..1ccd5940fdf4 --- /dev/null +++ b/src/Files.App/Communication/AppCommunicationServiceBase.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Files.App.Communication +{ + /// + /// Base class providing common JSON-RPC IPC service functionality for multiple transports + /// (WebSocket, Named Pipes, etc.). Handles: + /// - Token / epoch management + /// - Client registry & lifecycle + /// - Periodic keepalive pings (30s) + /// - Periodic stale client cleanup (60s interval, >5 min inactivity) + /// - Handshake ("handshake" method) authentication + /// - Rate limiting & basic JSON-RPC validation + /// - Unified request dispatch via OnRequestReceived + /// Transport specific subclasses are only responsible for: + /// - Accepting connections & constructing ClientContext + /// - Running per-client send / receive loops + /// - Implementing raw send in + /// + public abstract class AppCommunicationServiceBase : IAppCommunicationService, IDisposable + { + // Dependencies + protected readonly RpcMethodRegistry MethodRegistry; // shared method registry + protected readonly ILogger Logger; + + // Auth / runtime identity + protected string? CurrentToken { get; private set; } + protected int CurrentEpoch { get; private set; } + + // State + private bool _started; + protected readonly ConcurrentDictionary Clients = new(); + protected readonly CancellationTokenSource Cancellation = new(); + + // Timers + private readonly Timer _keepAliveTimer; + private readonly Timer _cleanupTimer; + + // Events + public event Func? OnRequestReceived; + + protected AppCommunicationServiceBase(RpcMethodRegistry methodRegistry, ILogger logger) + { + MethodRegistry = methodRegistry ?? throw new ArgumentNullException(nameof(methodRegistry)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _keepAliveTimer = new Timer(_ => SendKeepalive(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _cleanupTimer = new Timer(_ => CleanupInactiveClients(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + /// Starts the service (idempotent). + public async Task StartAsync() + { + if (!ProtectedTokenStore.IsEnabled()) + { + Logger.LogWarning("Remote control is not enabled, refusing to start {Service}", GetType().Name); + return; + } + if (_started) + return; + + try + { + CurrentToken = IpcRendezvousFile.GetOrCreateToken(); + CurrentEpoch = ProtectedTokenStore.GetEpoch(); + + await StartTransportAsync(); + + // Start timers AFTER transport to avoid ping before clients can connect + _keepAliveTimer.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + _cleanupTimer.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); + _started = true; + Logger.LogInformation("IPC transport {Service} started", GetType().Name); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed starting transport {Service}: {Message}", GetType().Name, ex.Message); + throw; + } + } + + /// Stops the service (idempotent). + public async Task StopAsync() + { + if (!_started) + return; + try + { + Cancellation.Cancel(); + await StopTransportAsync(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Errors stopping {Service}: {Message}", GetType().Name, ex.Message); + } + finally + { + foreach (var kv in Clients) + { + kv.Value.Dispose(); + } + Clients.Clear(); + _keepAliveTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _cleanupTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _started = false; + } + } + + /// Process a raw already-parsed JSON-RPC message instance (transport calls this). + protected async Task ProcessIncomingMessageAsync(ClientContext client, JsonRpcMessage? message) + { + if (message is null) + return; + + client.LastSeenUtc = DateTime.UtcNow; + + // Basic validation + if (!JsonRpcMessage.ValidJsonRpc(message) || JsonRpcMessage.IsInvalidRequest(message)) + { + if (!message.IsNotification) + await EnqueueResponseAsync(client, JsonRpcMessage.MakeError(message.Id, -32600, "Invalid Request")); + return; + } + + // Handshake + if (await HandleHandshakeAsync(client, message)) + return; // fully handled + + // Unknown method + if (!MethodRegistry.TryGet(message.Method ?? string.Empty, out var methodDef)) + { + if (!message.IsNotification) + await EnqueueResponseAsync(client, JsonRpcMessage.MakeError(message.Id, -32601, "Method not found")); + return; + } + + // Auth required + if (methodDef.RequiresAuth && !client.IsAuthenticated) + { + if (!message.IsNotification) + await EnqueueResponseAsync(client, JsonRpcMessage.MakeError(message.Id, -32001, "Authentication required")); + return; + } + + // Rate limiting + if (!client.TryConsumeToken()) + { + if (!message.IsNotification) + await EnqueueResponseAsync(client, JsonRpcMessage.MakeError(message.Id, -32003, "Rate limit exceeded")); + return; + } + + // Notifications allowed? + if (message.IsNotification && !methodDef.AllowNotifications) + { + Logger.LogDebug("Dropping unauthorized notification {Method} from {Client}", message.Method, client.Id); + return; + } + + // Dispatch + try + { + await (OnRequestReceived?.Invoke(client, message) ?? Task.CompletedTask); + } + catch (Exception ex) + { + Logger.LogError(ex, "Handler error for method {Method} from client {ClientId}: {Message}", + message.Method, client.Id, ex.Message); + } + } + + /// Handle handshake if applicable. + private async Task HandleHandshakeAsync(ClientContext client, JsonRpcMessage message) + { + if (!string.Equals(message.Method, "handshake", StringComparison.Ordinal)) + return false; + + try + { + if (message.Params?.TryGetProperty("token", out var tokenProp) != true) + { + await EnqueueResponseAsync(client, JsonRpcMessage.MakeError(message.Id, -32602, "Missing token parameter")); + return true; + } + if (tokenProp.GetString() != CurrentToken) + { + await EnqueueResponseAsync(client, JsonRpcMessage.MakeError(message.Id, -32002, "Invalid token")); + return true; + } + + client.IsAuthenticated = true; + client.AuthEpoch = CurrentEpoch; + if (message.Params?.TryGetProperty("clientInfo", out var clientInfo) == true) + client.ClientInfo = clientInfo.GetString(); + + if (!message.IsNotification) + { + await EnqueueResponseAsync(client, JsonRpcMessage.MakeResult(message.Id, new + { + status = "authenticated", + epoch = CurrentEpoch, + serverInfo = "Files IPC Server" + })); + } + Logger.LogInformation("Client {ClientId} authenticated (epoch {Epoch})", client.Id, CurrentEpoch); + } + catch (Exception ex) + { + Logger.LogError(ex, "Handshake failure for client {ClientId} - Token validation failed: {Message}", + client.Id, ex.Message); + } + return true; + } + + /// Queues a response (non-notification) for a client. + private Task EnqueueResponseAsync(ClientContext client, JsonRpcMessage response) + { + if (response.IsNotification) + { + Logger.LogWarning("Attempted to queue notification as response"); + return Task.CompletedTask; + } + try + { + client.TryEnqueue(response.ToJson(), false, response.Method); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed enqueue response to {Client} - Queue full or client disconnected: {Message}", + client.Id, ex.Message); + } + return Task.CompletedTask; + } + + public Task SendResponseAsync(ClientContext client, JsonRpcMessage response) => EnqueueResponseAsync(client, response); + + public Task BroadcastAsync(JsonRpcMessage notification) + { + if (!notification.IsNotification) + { + Logger.LogWarning("Attempted to broadcast non-notification message"); + return Task.CompletedTask; + } + + var json = notification.ToJson(); + var method = notification.Method; + + // O(n) iteration is acceptable here because: + // 1. Broadcasts are infrequent (navigation changes, selections) + // 2. Client count is typically small (<10 concurrent connections) + // 3. Alternative designs (pub/sub, selective broadcast) add complexity without measurable benefit + // 4. Each client operation is O(1) with early-exit conditions + foreach (var c in Clients.Values) + { + if (!c.IsAuthenticated) continue; + if (!c.TryConsumeToken()) continue; // protect from floods + c.TryEnqueue(json, true, method); + } + return Task.CompletedTask; + } + + private void SendKeepalive() + { + if (!_started || Cancellation.IsCancellationRequested) + return; + try + { + var notif = new JsonRpcMessage + { + Method = "ping", + Params = JsonSerializer.SerializeToElement(new { timestamp = DateTime.UtcNow }) + }; + _ = BroadcastAsync(notif); // fire & forget + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Keepalive failure"); + } + } + + private void CleanupInactiveClients() + { + if (!_started || Cancellation.IsCancellationRequested) + return; + var cutoff = DateTime.UtcNow - TimeSpan.FromMinutes(5); + + // O(n) iteration is acceptable for cleanup because: + // 1. Runs every 60 seconds (infrequent) + // 2. Client count remains small in practice + // 3. Removing stale clients prevents memory leaks + // 4. Cannot use LINQ ToArray() as it would prevent removal during iteration + foreach (var kv in Clients) + { + var c = kv.Value; + if (c.LastSeenUtc < cutoff || c.Cancellation?.IsCancellationRequested == true) + { + if (Clients.TryRemove(kv.Key, out var removed)) + { + try { removed.Dispose(); } catch { } + Logger.LogDebug("Removed stale client {ClientId}", kv.Key); + } + } + } + } + + /// Registers a newly connected client. Caller starts its send loop. + protected void RegisterClient(ClientContext client) => Clients[client.Id] = client; + protected void UnregisterClient(ClientContext client) + { + if (Clients.TryRemove(client.Id, out var removed)) + { + try { removed.Dispose(); } catch { } + } + } + + /// Dequeues payloads and invokes . Subclasses can reuse. + protected async Task RunSendLoopAsync(ClientContext client) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + Cancellation.Token, + client.Cancellation?.Token ?? CancellationToken.None); + + try + { + while (!linkedCts.Token.IsCancellationRequested) + { + // Async wait for messages - no polling needed + var item = await client.DequeueAsync(linkedCts.Token); + await SendToClientAsync(client, item.payload); + client.DecreaseQueuedBytes(Encoding.UTF8.GetByteCount(item.payload)); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Logger.LogDebug(ex, "Send loop exited for client {Client}", client.Id); + } + } + + /// Implement raw transport write for a textual JSON payload. + protected abstract Task SendToClientAsync(ClientContext client, string payload); + protected abstract Task StartTransportAsync(); + protected abstract Task StopTransportAsync(); + + public void Dispose() + { + try { Cancellation.Cancel(); } catch { } + _keepAliveTimer.Dispose(); + _cleanupTimer.Dispose(); + foreach (var c in Clients.Values) { try { c.Dispose(); } catch { } } + Cancellation.Dispose(); + } + } +} diff --git a/src/Files.App/Communication/ClientContext.cs b/src/Files.App/Communication/ClientContext.cs new file mode 100644 index 000000000000..09268be84db3 --- /dev/null +++ b/src/Files.App/Communication/ClientContext.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using System.IO; // added for BinaryWriter + +namespace Files.App.Communication +{ + // Per-client state with token-bucket, dual-priority queues and LastSeenUtc tracked. + // Optimized for performance: O(1) dequeue, intelligent notification coalescing + public sealed class ClientContext : IDisposable + { + // Fields + private readonly object _rateLock = new(); + // Separate queues for responses (high priority) and notifications (low priority) + private readonly ConcurrentQueue<(string payload, string? method)> _responseQueue = new(); + private readonly ConcurrentQueue<(string payload, string method)> _notificationQueue = new(); + // Track method counts for efficient duplicate dropping + private readonly ConcurrentDictionary _notificationMethodCounts = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _messageAvailable = new(0); // Signal when messages are available + private long _queuedBytes = 0; + private int _tokens; + private DateTime _lastRefill; + private bool _disposed; + + // Added: lock for pipe writer operations + internal object? PipeWriteLock { get; set; } + + // Properties + public Guid Id { get; } = Guid.NewGuid(); + + public string? ClientInfo { get; set; } + + public bool IsAuthenticated { get; set; } + + public int AuthEpoch { get; set; } = 0; // set at handshake + + public DateTime LastSeenUtc { get; set; } = DateTime.UtcNow; + + public long MaxQueuedBytes { get; set; } = IpcConfig.PerClientQueueCapBytes; + + public CancellationTokenSource? Cancellation { get; set; } + + public WebSocket? WebSocket { get; set; } + + public object? TransportHandle { get; set; } // can store session id, pipe name, etc. + + // Async wait for messages to become available + internal async Task<(string payload, bool isNotification, string? method)> DequeueAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await _messageAvailable.WaitAsync(cancellationToken); + if (TryDequeueInternal(out var item)) + return item; + } + throw new OperationCanceledException(); + } + + // Efficient dequeue: responses first (high priority), then notifications (low priority) + // O(1) operation with no scanning required + internal bool TryDequeue(out (string payload, bool isNotification, string? method) item) + { + var result = TryDequeueInternal(out item); + if (!result && _messageAvailable.CurrentCount > 0) + { + // Drain any excess semaphore count to prevent buildup + while (_messageAvailable.CurrentCount > 0) + _messageAvailable.Wait(0); + } + return result; + } + + private bool TryDequeueInternal(out (string payload, bool isNotification, string? method) item) + { + // Always dequeue responses first (high priority) + if (_responseQueue.TryDequeue(out var response)) + { + var bytes = System.Text.Encoding.UTF8.GetByteCount(response.payload); + System.Threading.Interlocked.Add(ref _queuedBytes, -bytes); + item = (response.payload, false, response.method); + return true; + } + + // Then dequeue notifications (low priority) + if (_notificationQueue.TryDequeue(out var notification)) + { + var bytes = System.Text.Encoding.UTF8.GetByteCount(notification.payload); + System.Threading.Interlocked.Add(ref _queuedBytes, -bytes); + + // Decrement method count + if (_notificationMethodCounts.TryGetValue(notification.method, out var count)) + { + if (count <= 1) + _notificationMethodCounts.TryRemove(notification.method, out _); + else + _notificationMethodCounts.TryUpdate(notification.method, count - 1, count); + } + + item = (notification.payload, true, notification.method); + return true; + } + + item = default; + return false; + } + + // Added: BinaryWriter for named pipe responses/notifications + public BinaryWriter? PipeWriter { get; set; } + + // Constructor + public ClientContext() + { + _tokens = IpcConfig.RateLimitBurst; + _lastRefill = DateTime.UtcNow; + } + + // Public methods + public void RefillTokens() + { + lock (_rateLock) + { + var now = DateTime.UtcNow; + var delta = (now - _lastRefill).TotalSeconds; + if (delta <= 0) + return; + + var add = (int)(delta * IpcConfig.RateLimitPerSecond); + if (add > 0) + { + _tokens = Math.Min(IpcConfig.RateLimitBurst, _tokens + add); + _lastRefill = now; + } + } + } + + public bool TryConsumeToken() + { + lock (_rateLock) + { + RefillTokens(); + if (_tokens > 0) + { + _tokens--; + return true; + } + return false; + } + } + + // Optimized TryEnqueue with dual-priority queues + // Responses always get through, notifications use intelligent coalescing + public bool TryEnqueue(string payload, bool isNotification, string? method = null) + { + var bytes = System.Text.Encoding.UTF8.GetByteCount(payload); + + // Responses always get enqueued (critical for protocol) + if (!isNotification) + { + // Make room if needed by dropping notifications + while (System.Threading.Interlocked.Read(ref _queuedBytes) + bytes > MaxQueuedBytes) + { + if (!DropOldestNotification()) + break; // No more notifications to drop + } + + System.Threading.Interlocked.Add(ref _queuedBytes, bytes); + _responseQueue.Enqueue((payload, method)); + _messageAvailable.Release(); // Signal message available + return true; + } + + // For notifications: check if we have room + var newVal = System.Threading.Interlocked.Add(ref _queuedBytes, bytes); + if (newVal <= MaxQueuedBytes) + { + // Fast path: under limit, just enqueue + if (method != null) + { + _notificationQueue.Enqueue((payload, method)); + _notificationMethodCounts.AddOrUpdate(method, 1, (_, count) => count + 1); + _messageAvailable.Release(); // Signal message available + } + return true; + } + + // Revert the byte count + System.Threading.Interlocked.Add(ref _queuedBytes, -bytes); + + // Try intelligent dropping for notifications + if (method != null) + { + // If we have duplicates of this method, drop the oldest one (coalescing) + if (_notificationMethodCounts.TryGetValue(method, out var existingCount) && existingCount > 0) + { + // This is O(n) but n is bounded and typically small + if (DropOldestNotificationOfMethod(method)) + { + // Now we should have room, enqueue the new notification + System.Threading.Interlocked.Add(ref _queuedBytes, bytes); + _notificationQueue.Enqueue((payload, method)); + _notificationMethodCounts.AddOrUpdate(method, 1, (_, c) => c + 1); + _messageAvailable.Release(); // Signal message available + return true; + } + } + + // No duplicate to drop, try dropping any notification + if (DropOldestNotification()) + { + System.Threading.Interlocked.Add(ref _queuedBytes, bytes); + _notificationQueue.Enqueue((payload, method)); + _notificationMethodCounts.AddOrUpdate(method, 1, (_, c) => c + 1); + _messageAvailable.Release(); // Signal message available + return true; + } + } + + // Can't make room + return false; + } + + // Helper: Drop oldest notification of specific method + // DESIGN DECISION: O(n) Complexity is Intentional + // ================================================ + // This method has O(n) complexity where n is the queue size, as it must rebuild + // the queue to remove an item from the middle. This is a deliberate trade-off: + // + // WHY WE ACCEPT O(n): + // 1. ConcurrentQueue provides thread-safety which is critical for our multi-threaded IPC + // 2. This operation only occurs when the queue is full (rare in normal operation) + // 3. Queue size is bounded by MaxQueuedBytes (2MB), keeping n relatively small (~100-1000 items) + // 4. Alternative data structures (e.g., LinkedList) would require complex synchronization + // + // ALTERNATIVES CONSIDERED: + // - LinkedList: Would allow O(1) removal but requires manual locking, increasing complexity + // - Priority queue: Overkill for our use case, adds unnecessary overhead + // - Custom circular buffer: More complex, harder to maintain + // + // CONCLUSION: The simplicity and thread-safety of ConcurrentQueue outweighs the + // occasional O(n) operation when the queue is full. + private bool DropOldestNotificationOfMethod(string targetMethod) + { + var tempQueue = new System.Collections.Generic.List<(string payload, string method)>(); + var dropped = false; + + while (_notificationQueue.TryDequeue(out var item)) + { + if (!dropped && item.method.Equals(targetMethod, StringComparison.OrdinalIgnoreCase)) + { + // Drop this one + var droppedBytes = System.Text.Encoding.UTF8.GetByteCount(item.payload); + System.Threading.Interlocked.Add(ref _queuedBytes, -droppedBytes); + _notificationMethodCounts.AddOrUpdate(targetMethod, 0, (_, c) => Math.Max(0, c - 1)); + dropped = true; + } + else + { + tempQueue.Add(item); + } + } + + // Re-enqueue the kept items + foreach (var item in tempQueue) + _notificationQueue.Enqueue(item); + + return dropped; + } + + // Helper: Drop any oldest notification + private bool DropOldestNotification() + { + if (_notificationQueue.TryDequeue(out var dropped)) + { + var droppedBytes = System.Text.Encoding.UTF8.GetByteCount(dropped.payload); + System.Threading.Interlocked.Add(ref _queuedBytes, -droppedBytes); + + // Update method count + if (_notificationMethodCounts.TryGetValue(dropped.method, out var count)) + { + if (count <= 1) + _notificationMethodCounts.TryRemove(dropped.method, out _); + else + _notificationMethodCounts.TryUpdate(dropped.method, count - 1, count); + } + return true; + } + return false; + } + + // Internal methods + internal void DecreaseQueuedBytes(int sentBytes) => System.Threading.Interlocked.Add(ref _queuedBytes, -sentBytes); + + // Dispose + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + Cancellation?.Cancel(); + Cancellation?.Dispose(); + + if (WebSocket?.State == WebSocketState.Open) + { + try + { + WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None).Wait(1000); + } + catch { } + } + + WebSocket?.Dispose(); + PipeWriter?.Dispose(); + _messageAvailable?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/EphemeralPortAllocator.cs b/src/Files.App/Communication/EphemeralPortAllocator.cs new file mode 100644 index 000000000000..2a7d0fcc237e --- /dev/null +++ b/src/Files.App/Communication/EphemeralPortAllocator.cs @@ -0,0 +1,18 @@ +using System.Net; +using System.Net.Sockets; + +namespace Files.App.Communication +{ + public static class EphemeralPortAllocator + { + public static int GetEphemeralTcpPort() + { + // Bind to port 0 to have OS assign an ephemeral port, then release immediately + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + } +} diff --git a/src/Files.App/Communication/EphemeralTokenHelper.cs b/src/Files.App/Communication/EphemeralTokenHelper.cs new file mode 100644 index 000000000000..96bd69bde617 --- /dev/null +++ b/src/Files.App/Communication/EphemeralTokenHelper.cs @@ -0,0 +1,24 @@ +using System; +using System.Security.Cryptography; + +namespace Files.App.Communication +{ + public static class EphemeralTokenHelper + { + public static string GenerateToken(int bytes = 32) + { + Span buf = stackalloc byte[bytes]; + RandomNumberGenerator.Fill(buf); + return ToUrlSafeBase64(buf); + } + + // Converts raw bytes to a URL-safe Base64 string without padding per RFC 4648 §5 + private static string ToUrlSafeBase64(ReadOnlySpan data) + { + return Convert.ToBase64String(data) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + } +} diff --git a/src/Files.App/Communication/IAppCommunicationService.cs b/src/Files.App/Communication/IAppCommunicationService.cs new file mode 100644 index 000000000000..a9c946fa7947 --- /dev/null +++ b/src/Files.App/Communication/IAppCommunicationService.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; + +namespace Files.App.Communication +{ + /// + /// Represents a communication service for handling JSON-RPC messages between clients and the application. + /// Implementations provide transport-specific functionality (WebSocket, Named Pipe, etc.) + /// + public interface IAppCommunicationService + { + /// + /// Occurs when a JSON-RPC request is received from a client. + /// + event Func? OnRequestReceived; + + /// + /// Starts the communication service and begins listening for client connections. + /// + /// A task that represents the asynchronous start operation. + Task StartAsync(); + + /// + /// Stops the communication service and closes all client connections. + /// + /// A task that represents the asynchronous stop operation. + Task StopAsync(); + + /// + /// Sends a JSON-RPC response message to a specific client. + /// + /// The client context to send the response to. + /// The JSON-RPC response message to send. + /// A task that represents the asynchronous send operation. + Task SendResponseAsync(ClientContext client, JsonRpcMessage response); + + /// + /// Broadcasts a JSON-RPC notification message to all connected clients. + /// + /// The JSON-RPC notification message to broadcast. + /// A task that represents the asynchronous broadcast operation. + Task BroadcastAsync(JsonRpcMessage notification); + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/IIpcShellRegistry.cs b/src/Files.App/Communication/IIpcShellRegistry.cs new file mode 100644 index 000000000000..91140a616c71 --- /dev/null +++ b/src/Files.App/Communication/IIpcShellRegistry.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Files.App.Communication +{ + /// + /// Registry for tracking active shell instances and their IPC adapters. + /// + public interface IIpcShellRegistry + { + /// + /// Registers a new shell instance with its adapter. + /// + void Register(IpcShellDescriptor descriptor); + + /// + /// Unregisters a shell instance when it's disposed. + /// + void Unregister(Guid shellId); + + /// + /// Gets the active shell for a specific window. + /// + IpcShellDescriptor? GetActiveForWindow(uint appWindowId); + + /// + /// Gets a specific shell by its ID. + /// + IpcShellDescriptor? GetById(Guid shellId); + + /// + /// Marks a shell as active (called on tab focus). + /// + void SetActive(Guid shellId); + + /// + /// Lists all registered shells. + /// + IReadOnlyCollection List(); + } + + /// + /// Describes a registered shell instance with its IPC adapter. + /// + public sealed record IpcShellDescriptor( + Guid ShellId, + uint AppWindowId, + Guid TabId, + ShellIpcAdapter Adapter, + bool IsActive); +} \ No newline at end of file diff --git a/src/Files.App/Communication/IWindowResolver.cs b/src/Files.App/Communication/IWindowResolver.cs new file mode 100644 index 000000000000..f45f8ebbef99 --- /dev/null +++ b/src/Files.App/Communication/IWindowResolver.cs @@ -0,0 +1,13 @@ +namespace Files.App.Communication +{ + /// + /// Resolves the active window for IPC routing. + /// + public interface IWindowResolver + { + /// + /// Gets the ID of the currently active window. + /// + uint GetActiveWindowId(); + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/IpcActionAdapter.cs b/src/Files.App/Communication/IpcActionAdapter.cs new file mode 100644 index 000000000000..5bfb7680b566 --- /dev/null +++ b/src/Files.App/Communication/IpcActionAdapter.cs @@ -0,0 +1,263 @@ +using Files.App.Data.Commands; +using Files.App.Data.Contracts; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.App.Communication +{ + /// + /// Adapter that bridges IPC action strings to the existing UI CommandManager. + /// This ensures IPC actions execute exactly the same code as UI actions. + /// + public sealed class IpcActionAdapter + { + private readonly ICommandManager _commandManager; + private readonly ILogger _logger; + + // SECURITY: Limited Action Whitelist for IPC + // ========================================== + // This whitelist intentionally restricts which commands can be executed via IPC for security: + // + // SECURITY RATIONALE: + // 1. **Defense in Depth**: Even if IPC authentication is bypassed, damage is limited + // 2. **Principle of Least Privilege**: Only expose necessary functionality to external processes + // 3. **Attack Surface Reduction**: Prevents exploitation of sensitive file operations + // + // THREATS MITIGATED: + // - Malicious processes executing destructive file operations (delete, move, format) + // - Unauthorized access to sensitive system folders or files + // - Exploitation of file management commands for privilege escalation + // - Automation of harmful batch operations + // + // CRITERIA FOR ADDING NEW ACTIONS: + // ✓ Read-only or safe operations (view, copy path, refresh) + // ✓ Non-destructive UI actions (toggle panes, show properties) + // ✗ File system modifications (delete, move, rename, create) + // ✗ System-level operations (format, partition, registry access) + // ✗ Security-sensitive actions (permissions, encryption, sharing) + // + // SECURITY REVIEW PROCESS FOR NEW ACTIONS: + // ======================================== + // Before adding any new action to this whitelist, complete ALL steps: + // + // 1. THREAT ANALYSIS: + // - Could this action delete or modify user data? + // - Could this action access sensitive information? + // - Could this action be used for privilege escalation? + // - Could this action be chained with others maliciously? + // + // 2. VALIDATION REQUIREMENTS: + // - Does the action validate all inputs? + // - Are path traversal attacks prevented? + // - Are injection attacks (command, SQL, etc.) prevented? + // - Is user consent required for sensitive operations? + // + // 3. AUDIT LOGGING: + // - Is the action logged with sufficient detail? + // - Can malicious use be detected from logs? + // - Are failed attempts logged? + // + // 4. TESTING CHECKLIST: + // - Test with malformed inputs + // - Test with path traversal attempts (../, ..\) + // - Test with extremely long inputs + // - Test with special characters and Unicode + // - Test rate limiting behavior + // + // 5. APPROVAL PROCESS: + // - Document the security rationale in PR description + // - Get security team review for any filesystem operations + // - Update this comment with new action's security notes + // + // Each addition MUST be security-reviewed against ALL criteria above. + // When in doubt, err on the side of caution and REJECT the action. + private readonly Dictionary _actionMap = new(StringComparer.OrdinalIgnoreCase) + { + ["refresh"] = CommandCodes.RefreshItems, + ["copypath"] = CommandCodes.CopyPath, + ["toggledualpane"] = CommandCodes.ToggleDualPane, + ["showproperties"] = CommandCodes.OpenProperties, + // Add more mappings as needed - MUST follow security review process above + }; + + public IpcActionAdapter(ICommandManager commandManager, ILogger logger) + { + _commandManager = commandManager ?? throw new ArgumentNullException(nameof(commandManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Checks if an action is allowed via IPC. + /// + public bool CanExecute(string actionId) + { + if (string.IsNullOrEmpty(actionId)) + return false; + + return _actionMap.ContainsKey(actionId); + } + + /// + /// Gets all allowed IPC action IDs. + /// + public IEnumerable GetAllowedActions() + { + return _actionMap.Keys; + } + + /// + /// Executes an IPC action by delegating to the UI CommandManager. + /// + public async Task ExecuteActionAsync(string actionId, IShellPage? targetShell = null) + { + if (!_actionMap.TryGetValue(actionId, out var commandCode)) + { + _logger.LogWarning("IPC action '{ActionId}' not found or not allowed", actionId); + throw new InvalidOperationException($"Action '{actionId}' is not allowed via IPC"); + } + + _logger.LogInformation("Executing IPC action '{ActionId}' via CommandCode '{CommandCode}'", + actionId, commandCode); + + var command = _commandManager[commandCode]; + + if (command.Code == CommandCodes.None) + { + _logger.LogError("CommandCode '{CommandCode}' not found in CommandManager", commandCode); + throw new InvalidOperationException($"Command '{commandCode}' not found"); + } + + // WORKAROUND: Actions use ContentPageContext (singleton) which always looks at the UI's active pane. + // When IPC executes an action on a non-active shell (e.g., after dual-pane toggle creates new shells), + // the action's IsExecutable check fails because it's checking the wrong shell's context. + // + // Solution: Temporarily focus the target shell so ContentPageContext.ShellPage points to it. + // This ensures actions check the correct shell's state (navigation history, current folder, etc.) + // + // FUTURE FIX: Refactor actions to accept an IShellPage parameter instead of using singleton DI, + // or use scoped DI containers per shell instance. This would eliminate the need for focus manipulation. + + // Local function to handle focus operations (DRY principle) + async Task SetFocusWithEventAsync(Microsoft.UI.Xaml.UIElement element, int timeoutMs, string operation, int elementId) + { + var focusCompleted = new TaskCompletionSource(); + + void OnFocusReceived(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + focusCompleted.TrySetResult(true); + } + + element.GotFocus += OnFocusReceived; + try + { + var focusResult = element.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + if (!focusResult) + { + _logger.LogWarning("{Operation} Focus() returned false for element {ElementId}, action may fail", + operation, elementId); + } + + // Wait for focus event with timeout + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs)); + try + { + await focusCompleted.Task.WaitAsync(cts.Token); + _logger.LogDebug("{Operation} focus completed for element {ElementId}", operation, elementId); + } + catch (OperationCanceledException) + { + _logger.LogWarning("{Operation} focus did not complete within timeout for element {ElementId}", + operation, elementId); + // Continue anyway - focus might still be processing + } + } + finally + { + element.GotFocus -= OnFocusReceived; + } + } + + IShellPage? previousActivePane = null; + bool shouldRestoreFocus = false; + + if (targetShell != null && targetShell.PaneHolder != null) + { + previousActivePane = targetShell.PaneHolder.ActivePane; + + // Only change focus if different from current + if (previousActivePane != targetShell) + { + _logger.LogDebug("Temporarily focusing target shell {ShellId} for action execution", + targetShell.GetHashCode()); + + // Focus the shell's UI element to make it the active pane + if (targetShell is Microsoft.UI.Xaml.UIElement uiElement) + { + shouldRestoreFocus = true; + + // Wait for focus to complete using event-driven approach + // 500ms should be plenty even on slow systems + await SetFocusWithEventAsync(uiElement, 500, "Setting", targetShell.GetHashCode()); + } + } + } + + try + { + if (!command.IsExecutable) + { + // Gather context information to help diagnose why the command isn't executable + var contextInfo = new System.Text.StringBuilder(); + + if (targetShell != null) + { + contextInfo.AppendLine($"Target Shell: {targetShell.GetHashCode()}"); + contextInfo.AppendLine($"Is Current Pane: {targetShell.IsCurrentPane}"); + contextInfo.AppendLine($"Page Type: {targetShell.CurrentPageType?.Name ?? "null"}"); + + if (targetShell.ShellViewModel != null) + { + contextInfo.AppendLine($"Working Directory: {targetShell.ShellViewModel.WorkingDirectory ?? "null"}"); + contextInfo.AppendLine($"Has Selection: {targetShell.SlimContentPage?.SelectedItems?.Count > 0}"); + } + } + else + { + contextInfo.AppendLine("Target Shell: null"); + } + + _logger.LogWarning("Command '{CommandCode}' is not executable. Context: {Context}", + commandCode, contextInfo.ToString()); + + throw new InvalidOperationException( + $"Command '{commandCode}' cannot be executed in the current context. " + + $"Page type: {targetShell?.CurrentPageType?.Name ?? "unknown"}, " + + $"Has selection: {targetShell?.SlimContentPage?.SelectedItems?.Count > 0}"); + } + + await command.ExecuteAsync(); + + return new { status = "ok", command = commandCode.ToString() }; + } + finally + { + // Restore original focus to prevent UI state changes from IPC operations + if (shouldRestoreFocus && previousActivePane != null) + { + _logger.LogDebug("Restoring focus to previous pane {PaneId}", + previousActivePane.GetHashCode()); + + if (previousActivePane is Microsoft.UI.Xaml.UIElement previousUiElement) + { + // Use same event-driven approach for restore + // Shorter timeout for restore since it's less critical (200ms) + await SetFocusWithEventAsync(previousUiElement, 200, "Restoring", previousActivePane.GetHashCode()); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/IpcConfig.cs b/src/Files.App/Communication/IpcConfig.cs new file mode 100644 index 000000000000..7b6a57afb868 --- /dev/null +++ b/src/Files.App/Communication/IpcConfig.cs @@ -0,0 +1,25 @@ +namespace Files.App.Communication +{ + // Runtime configuration for IPC system - uses constants from Constants.IpcSettings as defaults + public static class IpcConfig + { + public static long WebSocketMaxMessageBytes { get; set; } = Constants.IpcSettings.WebSocketMaxMessageBytes; + + public static long NamedPipeMaxMessageBytes { get; set; } = Constants.IpcSettings.NamedPipeMaxMessageBytes; + + public static long PerClientQueueCapBytes { get; set; } = Constants.IpcSettings.PerClientQueueCapBytes; + + public static int RateLimitPerSecond { get; set; } = Constants.IpcSettings.RateLimitPerSecond; + + public static int RateLimitBurst { get; set; } = Constants.IpcSettings.RateLimitBurst; + + public static int SelectionNotificationCap { get; set; } = Constants.IpcSettings.SelectionNotificationCap; + + public static int GetMetadataMaxItems { get; set; } = Constants.IpcSettings.GetMetadataMaxItems; + + public static int GetMetadataTimeoutSec { get; set; } = Constants.IpcSettings.GetMetadataTimeoutSec; + + + public static int SendLoopPollingIntervalMs { get; set; } = 10; // milliseconds between queue checks + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/IpcCoordinator.cs b/src/Files.App/Communication/IpcCoordinator.cs new file mode 100644 index 000000000000..6a0a6dcef095 --- /dev/null +++ b/src/Files.App/Communication/IpcCoordinator.cs @@ -0,0 +1,528 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Files.App.ViewModels; +using Files.App.Data.Contracts; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using System.Text; + +namespace Files.App.Communication +{ + /// + /// Routes IPC requests to appropriate shell adapters. No UI code. + /// + public sealed partial class IpcCoordinator + { + // Standard error codes for consistent error handling + private const int ERROR_NO_SHELL = -32001; // No shell available to handle request + private const int ERROR_DISPATCH = -32002; // Failed to dispatch to adapter + private const int ERROR_SHELL_LIST = -32003; // Failed to enumerate shells + + // Source-generated regex patterns for stack trace sanitization (compile-time optimization for .NET 7+) + // Windows absolute paths (C:\path\file.cs:line N) + [GeneratedRegex(@"[A-Z]:\\[^:""<>|]+\.cs:line \d+", RegexOptions.IgnoreCase)] + private static partial Regex WindowsPathRegex(); + + // Unix-style paths (/path/file.cs:line N) + [GeneratedRegex(@"/[^:""<>|]+\.cs:line \d+")] + private static partial Regex UnixPathRegex(); + + // UNC network paths (\\server\share\file.cs:line N) + [GeneratedRegex(@"\\\\[^\\:""<>|]+\\[^:""<>|]+\.cs:line \d+", RegexOptions.IgnoreCase)] + private static partial Regex UncPathRegex(); + + // Matches GUIDs in standard format + [GeneratedRegex(@"\b[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\b")] + private static partial Regex GuidRegex(); + + // Matches potential base64 tokens (JWT segments, API keys, etc.) - more specific pattern + // Looks for base64-like strings that are 20+ chars, bounded by non-base64 chars + [GeneratedRegex(@"\b(?:ey[A-Za-z0-9_-]{18,}|[A-Za-z0-9+/]{32,}={0,2}|[A-Za-z0-9_-]{32,})\b")] + private static partial Regex Base64TokenRegex(); + + // Matches method signatures with namespaces for sanitization + [GeneratedRegex(@"\bat [A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+\.")] + private static partial Regex MethodSignatureRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + // Win32 API for window bounds retrieval + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + private readonly IIpcShellRegistry _registry; + private readonly IAppCommunicationService _comm; + private readonly IWindowResolver _windows; + private readonly ILogger _logger; + + public IpcCoordinator( + IIpcShellRegistry registry, + IAppCommunicationService comm, + IWindowResolver windows, + ILogger logger) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _comm = comm ?? throw new ArgumentNullException(nameof(comm)); + _windows = windows ?? throw new ArgumentNullException(nameof(windows)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Initialize() + { + _comm.OnRequestReceived += HandleRequestAsync; + _logger.LogInformation("IPC coordinator initialized - routing enabled"); + } + + private static string SanitizeException(Exception ex) + { + // Sanitize the exception message first to remove any sensitive data + var sanitizedMessage = SanitizeMessage(ex.Message); + + // Early return for exceptions we don't want to expose stack traces for + var sensitiveExceptions = new[] { "UnauthorizedAccessException", "SecurityException", "CryptographicException" }; + if (sensitiveExceptions.Contains(ex.GetType().Name)) + { + return $"{ex.GetType().Name}: Access denied"; + } + + var stack = ex.StackTrace ?? string.Empty; + if (string.IsNullOrWhiteSpace(stack)) + { + return $"{ex.GetType().Name}: {sanitizedMessage}"; + } + + try + { + // Remove all file paths (Windows, Linux, Mac, UNC) - these reveal directory structure + stack = WindowsPathRegex().Replace(stack, "[path]:[line]"); + stack = UnixPathRegex().Replace(stack, "[path]:[line]"); + stack = UncPathRegex().Replace(stack, "[path]:[line]"); + + // Remove GUIDs which might be correlation IDs or sensitive identifiers + stack = GuidRegex().Replace(stack, "[guid]"); + + // Remove potential tokens (JWTs start with 'ey', API keys, base64 strings) + stack = Base64TokenRegex().Replace(stack, "[token]"); + + // Sanitize method signatures to remove full namespace paths + stack = MethodSignatureRegex().Replace(stack, "at [namespace]."); + + // Remove any remaining absolute paths that might have been missed + stack = Regex.Replace(stack, @"[A-Z]:\\[^\s]+", "[path]", RegexOptions.IgnoreCase); + stack = Regex.Replace(stack, @"/(?:home|usr|var|opt|Users|Applications)/[^\s]+", "[path]"); + stack = Regex.Replace(stack, @"\\\\[^\\\s]+\\[^\s]+", "[unc-path]"); + + // Remove port numbers which might reveal internal infrastructure + stack = Regex.Replace(stack, @":\d{2,5}\b", ":[port]"); + + // Remove IP addresses + stack = Regex.Replace(stack, @"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[ip]"); + + // Collapse excessive whitespace + stack = WhitespaceRegex().Replace(stack, " ").Trim(); + + // Truncate if too long, but keep it useful for debugging + if (stack.Length > Constants.IpcSettings.StackTraceSanitizationMaxLength) + { + // Try to keep the most relevant part (usually the top of the stack) + // Cut at word boundary for cleaner truncation + var maxLength = Constants.IpcSettings.StackTraceSanitizationMaxLength; + var lastSpace = stack.LastIndexOf(' ', maxLength - 1); + var cutPoint = lastSpace > maxLength * 0.8 ? lastSpace : maxLength; // Use word boundary if not too far back + stack = stack[..cutPoint].TrimEnd() + "... [truncated]"; + } + } + catch + { + // If sanitization fails, don't expose the original stack + stack = "[sanitization failed]"; + } + + return $"{ex.GetType().Name}: {sanitizedMessage}" + + (string.IsNullOrWhiteSpace(stack) ? string.Empty : $" | Stack: {stack}"); + } + + private static string SanitizeMessage(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return "[no message]"; + + // Remove file paths from messages + message = Regex.Replace(message, @"[A-Z]:\\[^\s""]+", "[path]", RegexOptions.IgnoreCase); + message = Regex.Replace(message, @"/(?:home|usr|var|opt|Users|Applications)/[^\s""]+", "[path]"); + + // Remove potential tokens from error messages + message = Regex.Replace(message, @"\b[A-Za-z0-9+/=_-]{32,}\b", "[token]"); + + // Remove GUIDs + message = Regex.Replace(message, @"\b[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\b", "[guid]"); + + // Limit message length + if (message.Length > 200) + message = message[..197] + "..."; + + return message; + } + + private async Task HandleRequestAsync(ClientContext client, JsonRpcMessage request) + { + var startTime = DateTime.UtcNow; + + try + { + _logger.LogDebug("IPC request: {Method} from {ClientId}", request.Method, client.Id); + + // Resolve target shell + var targetShell = ResolveShell(request); + if (targetShell == null) + { + var shellCount = _registry.List().Count; + _logger.LogWarning("No shell available for request {Method}. Total registered shells: {Count}", + request.Method, shellCount); + if (!request.IsNotification) + { + await _comm.SendResponseAsync(client, + JsonRpcMessage.MakeError(request.Id, ERROR_NO_SHELL, + $"No shell available to handle request. Registered shells: {shellCount}")); + } + return; + } + + _logger.LogDebug("Routing {Method} to shell {ShellId}", request.Method, targetShell.ShellId); + + // Dispatch to adapter (adapter handles UI marshaling) + var result = await DispatchToAdapterAsync(targetShell.Adapter, request); + + if (!request.IsNotification) + { + var safeResult = result ?? new { status = "ok" }; + await _comm.SendResponseAsync(client, JsonRpcMessage.MakeResult(request.Id, safeResult)); + } + + var elapsed = DateTime.UtcNow - startTime; + _logger.LogDebug("IPC request {Method} completed in {ElapsedMs}ms", + request.Method, elapsed.TotalMilliseconds); + } + catch (JsonRpcException jre) + { + _logger.LogWarning("JSON-RPC error {Code} for {Method}: {Message}", + jre.Code, request.Method, jre.Message); + + if (!request.IsNotification) + { + await _comm.SendResponseAsync(client, + JsonRpcMessage.MakeError(request.Id, jre.Code, jre.Message)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error handling IPC request {Method}", request.Method); + + if (!request.IsNotification) + { + var sanitized = SanitizeException(ex); + + // Use specific error code if it's a dispatch failure + var errorCode = ex is InvalidOperationException ? ERROR_DISPATCH : JsonRpcException.InternalError; + + await _comm.SendResponseAsync(client, JsonRpcMessage.MakeError(request.Id, errorCode, sanitized)); + } + } + } + + private IpcShellDescriptor? ResolveShell(JsonRpcMessage request) + { + try + { + // 1. Check for explicit targetShellId in params + if (request.Params.HasValue && request.Params.Value.TryGetProperty("targetShellId", out var shellIdElem) && + shellIdElem.ValueKind == JsonValueKind.String && + Guid.TryParse(shellIdElem.GetString(), out var shellId)) + { + var shell = _registry.GetById(shellId); + if (shell != null) + { + _logger.LogDebug("Resolved shell by explicit ID: {ShellId}", shellId); + return shell; + } + } + + // 2. Check for explicit windowId in params + if (request.Params.HasValue && request.Params.Value.TryGetProperty("windowId", out var windowIdElem) && + windowIdElem.TryGetUInt32(out var windowId)) + { + var shell = _registry.GetActiveForWindow(windowId); + if (shell != null) + { + _logger.LogDebug("Resolved shell by window ID: {WindowId}", windowId); + return shell; + } + } + + // 3. Fallback: use active shell in active window + var activeWindowId = _windows.GetActiveWindowId(); + var activeShell = _registry.GetActiveForWindow(activeWindowId); + + if (activeShell != null) + { + _logger.LogDebug("Resolved shell from active window {WindowId}", activeWindowId); + return activeShell; + } + + // 4. Last resort: any available shell + var anyShell = _registry.List().FirstOrDefault(); + if (anyShell != null) + { + _logger.LogDebug("Using any available shell: {ShellId}", anyShell.ShellId); + return anyShell; + } + + _logger.LogWarning("No shells available in registry"); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resolving shell for request"); + return null; + } + } + + private async Task ListShellsAsync() + { + try + { + var allShells = _registry.List(); + var shellInfos = new List(); + + foreach (var shell in allShells) + { + try + { + // Get shell state from the adapter + dynamic state = await shell.Adapter.GetStateAsync(); + dynamic actions = await shell.Adapter.ListActionsAsync(); + + // Get window information + var windowInfo = GetWindowInfo(shell.AppWindowId); + + var shellInfo = new + { + // Shell identifiers + shellId = shell.ShellId.ToString(), + windowId = shell.AppWindowId, + tabId = shell.TabId.ToString(), + isActive = shell.IsActive, + + // Window information + window = new + { + pid = System.Diagnostics.Process.GetCurrentProcess().Id, // Current process + title = windowInfo.Title, + isFocused = windowInfo.IsFocused, + bounds = windowInfo.Bounds + }, + + // Shell state information + currentPath = (string)(state?.currentPath ?? "Unknown"), + canNavigateBack = (bool)(state?.canNavigateBack ?? false), + canNavigateForward = (bool)(state?.canNavigateForward ?? false), + isLoading = (bool)(state?.isLoading ?? false), + itemCount = (int)(state?.itemCount ?? 0), + + // Available actions for this shell + availableActions = actions?.actions ?? new object[0] + }; + + shellInfos.Add(shellInfo); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get info for shell {ShellId}", shell.ShellId); + // Include shell even if we can't get full info + shellInfos.Add(new + { + shellId = shell.ShellId.ToString(), + windowId = shell.AppWindowId, + tabId = shell.TabId.ToString(), + isActive = shell.IsActive, + error = "Failed to retrieve shell information" + }); + } + } + + return new { shells = shellInfos }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list shells: {ExceptionType}", ex.GetType().Name); + throw new JsonRpcException(ERROR_SHELL_LIST, $"Failed to enumerate shells: {ex.GetType().Name}"); + } + } + + private (string Title, bool IsFocused, object Bounds) GetWindowInfo(uint appWindowId) + { + try + { + // Get the actual window handle from MainWindow + var hWnd = MainWindow.Instance?.WindowHandle ?? IntPtr.Zero; + if (hWnd == IntPtr.Zero) + { + return ( + Title: "Files", + IsFocused: false, + Bounds: new { x = 0, y = 0, width = 0, height = 0, error = "No window handle" } + ); + } + + // Get actual window title using Win32 API + var titleBuilder = new StringBuilder(256); + GetWindowText(hWnd, titleBuilder, titleBuilder.Capacity); + var title = titleBuilder.ToString(); + if (string.IsNullOrEmpty(title)) + title = "Files"; + + // Get actual window bounds using Win32 API + if (!GetWindowRect(hWnd, out RECT rect)) + { + return ( + Title: title, + IsFocused: appWindowId == _windows.GetActiveWindowId(), + Bounds: new { x = 0, y = 0, width = 0, height = 0, error = "GetWindowRect failed" } + ); + } + + // Check if this window is currently focused + var foregroundWindow = GetForegroundWindow(); + var isFocused = (hWnd == foregroundWindow) || (appWindowId == _windows.GetActiveWindowId()); + + return ( + Title: title, + IsFocused: isFocused, + Bounds: new + { + x = rect.Left, + y = rect.Top, + width = rect.Right - rect.Left, + height = rect.Bottom - rect.Top + } + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get window info for window ID {WindowId}", appWindowId); + return ("Files", false, new { x = 0, y = 0, width = 0, height = 0, error = ex.Message }); + } + } + + private async Task ExecuteActionOnTargetShellAsync(string actionId, string? targetShellId, ShellIpcAdapter fallbackAdapter) + { + ShellIpcAdapter targetAdapter = fallbackAdapter; + + // If a specific shell is requested, find it + if (!string.IsNullOrEmpty(targetShellId) && Guid.TryParse(targetShellId, out var shellGuid)) + { + var targetShell = _registry.GetById(shellGuid); + if (targetShell == null) + { + _logger.LogWarning("Target shell {ShellId} not found", targetShellId); + throw new JsonRpcException(JsonRpcException.InvalidParams, $"Shell '{targetShellId}' not found"); + } + targetAdapter = targetShell.Adapter; + _logger.LogInformation("Executing action '{ActionId}' on targeted shell {ShellId}", actionId, targetShellId); + } + else + { + _logger.LogInformation("Executing action '{ActionId}' on default shell", actionId); + } + + return await targetAdapter.ExecuteActionAsync(actionId); + } + + private async Task DispatchToAdapterAsync(ShellIpcAdapter adapter, JsonRpcMessage request) + { + // Call the adapter's public methods directly + + switch (request.Method) + { + case "getState": + return await adapter.GetStateAsync(); + + case "listActions": + return await adapter.ListActionsAsync(); + + case "listShells": + return await ListShellsAsync(); + + case "navigate": + if (request.Params.HasValue && request.Params.Value.TryGetProperty("path", out var pathProp)) + { + var path = pathProp.GetString(); + if (string.IsNullOrWhiteSpace(path)) + throw new JsonRpcException(JsonRpcException.InvalidParams, "Missing path parameter"); + return await adapter.NavigateAsync(path); + } + throw new JsonRpcException(JsonRpcException.InvalidParams, "Missing path parameter"); + + case "getMetadata": + var paths = new List(); + if (request.Params.HasValue && request.Params.Value.TryGetProperty("paths", out var pathsElem) && + pathsElem.ValueKind == JsonValueKind.Array) + { + foreach (var p in pathsElem.EnumerateArray()) + { + if (p.ValueKind == JsonValueKind.String) + { + var s = p.GetString(); + if (!string.IsNullOrWhiteSpace(s)) + paths.Add(s); + } + } + } + return await adapter.GetMetadataAsync(paths); + + case "executeAction": + if (request.Params.HasValue && request.Params.Value.TryGetProperty("actionId", out var actionIdProp)) + { + var actionId = actionIdProp.GetString(); + if (string.IsNullOrWhiteSpace(actionId)) + throw new JsonRpcException(JsonRpcException.InvalidParams, "Missing actionId parameter"); + + // Check if a specific shell is targeted + string? targetShellId = null; + if (request.Params.Value.TryGetProperty("targetShellId", out var shellIdProp)) + { + targetShellId = shellIdProp.GetString(); + } + + return await ExecuteActionOnTargetShellAsync(actionId, targetShellId, adapter); + } + throw new JsonRpcException(JsonRpcException.InvalidParams, "Missing actionId parameter"); + + default: + _logger.LogWarning("Unknown IPC method requested: {Method}", request.Method); + throw new JsonRpcException(JsonRpcException.MethodNotFound, + $"Method '{request.Method}' not implemented"); + } + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/IpcRendezvousFile.cs b/src/Files.App/Communication/IpcRendezvousFile.cs new file mode 100644 index 000000000000..35fc574c6fdf --- /dev/null +++ b/src/Files.App/Communication/IpcRendezvousFile.cs @@ -0,0 +1,183 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Files.App.Communication +{ + public static class IpcRendezvousFile + { + private const string FileName = "ipc.info"; // single instance (multi-instance support can suffix pid later) + private static readonly object _gate = new(); + private static bool _deleted; + private static string? _cachedToken; + + private sealed record Model(int? webSocketPort, string? pipeName, string? token, int epoch, int serverPid, DateTime createdUtc) + { + public Model Merge(Model newer) + => new( + newer.webSocketPort ?? webSocketPort, + newer.pipeName ?? pipeName, + token, // token is stable for runtime + epoch, + serverPid, + createdUtc); + } + + // Public accessor for tests/clients + public static string GetCurrentPath() + { + var baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FilesIPC"); + Directory.CreateDirectory(baseDir); + return Path.Combine(baseDir, FileName); + } + + public static string GetOrCreateToken() + { + lock (_gate) + { + if (!string.IsNullOrEmpty(_cachedToken)) + return _cachedToken!; + + // If file exists try read token + try + { + var path = GetCurrentPath(); + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var existing = JsonSerializer.Deserialize(json); + if (existing?.token is string t && t.Length > 0) + { + _cachedToken = t; + return t; + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[IPC] Failed reading rendezvous file for token reuse: {ex.Message}"); + } + + _cachedToken = EphemeralTokenHelper.GenerateToken(); + return _cachedToken!; + } + } + + public static async Task UpdateAsync(int? webSocketPort = null, string? pipeName = null, int epoch = 0) + { + try + { + lock (_gate) + { + if (_deleted) return; // do not resurrect after deletion + + var path = GetCurrentPath(); + Model? existing = null; + if (File.Exists(path)) + { + try + { + var jsonOld = File.ReadAllText(path); + existing = JsonSerializer.Deserialize(jsonOld); + } + catch (Exception ex) + { + Debug.WriteLine($"[IPC] Unable to parse existing rendezvous file: {ex.Message}"); + } + } + + var token = GetOrCreateToken(); + var now = DateTime.UtcNow; + var incoming = new Model(webSocketPort, pipeName, token, epoch, Environment.ProcessId, existing?.createdUtc ?? now); + var final = existing is null ? incoming : existing.Merge(incoming); + + var json = JsonSerializer.Serialize(final, new JsonSerializerOptions { WriteIndented = false }); + + // Atomic write via temp file + replace to avoid readers seeing partial content + var dir = Path.GetDirectoryName(path) ?? "."; + var tmp = Path.Combine(dir, Path.GetRandomFileName()); + + try + { + File.WriteAllText(tmp, json); + // Try atomic move first (works if destination doesn't exist) + try + { + File.Move(tmp, path); + } + catch (IOException ex) + { + // Only handle "file exists" error, rethrow otherwise + const int ERROR_FILE_EXISTS = 0x50; // 80 decimal + const int ERROR_ALREADY_EXISTS = 0xB7; // 183 decimal + int errorCode = ex.HResult & 0xFFFF; + + if (errorCode == ERROR_FILE_EXISTS || errorCode == ERROR_ALREADY_EXISTS) + { + // If move failed because file exists, use Replace for atomic update + File.Replace(tmp, path, null); // null backup means no backup + } + else + { + throw; + } + } + Secure(path); + } + finally + { + // Ensure temp file is always cleaned up if it still exists + if (File.Exists(tmp)) + { + try { File.Delete(tmp); } catch { } + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[IPC] Rendezvous update failed: {ex.Message}"); + } + await Task.CompletedTask; + } + + public static async Task TryDeleteAsync() + { + try + { + lock (_gate) _deleted = true; + var path = GetCurrentPath(); + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + Debug.WriteLine($"[IPC] Failed deleting rendezvous file: {ex.Message}"); + } + await Task.CompletedTask; + } + + private static void Secure(string filePath) + { + try + { + var current = WindowsIdentity.GetCurrent(); + if (current?.User is null) return; + + var security = new FileSecurity(); + security.SetOwner(current.User); + security.SetAccessRuleProtection(true, false); + security.AddAccessRule(new FileSystemAccessRule(current.User, FileSystemRights.FullControl, AccessControlType.Allow)); + + new FileInfo(filePath).SetAccessControl(security); + } + catch (Exception ex) + { + Debug.WriteLine($"[IPC] Failed securing rendezvous file: {ex.Message}"); + } + } + } +} diff --git a/src/Files.App/Communication/IpcShellRegistry.cs b/src/Files.App/Communication/IpcShellRegistry.cs new file mode 100644 index 000000000000..b86bf3ed7838 --- /dev/null +++ b/src/Files.App/Communication/IpcShellRegistry.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Files.App.Communication +{ + /// + /// Thread-safe registry implementation for shell IPC adapters. + /// + public sealed class IpcShellRegistry : IIpcShellRegistry + { + private readonly ConcurrentDictionary _shells = new(); + private readonly ILogger _logger; + + public IpcShellRegistry(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Register(IpcShellDescriptor descriptor) + { + if (descriptor == null) + throw new ArgumentNullException(nameof(descriptor)); + + if (_shells.TryAdd(descriptor.ShellId, descriptor)) + { + _logger.LogInformation("Registered shell {ShellId} for window {WindowId}, tab {TabId}", + descriptor.ShellId, descriptor.AppWindowId, descriptor.TabId); + } + else + { + _logger.LogWarning("Failed to register duplicate shell {ShellId}", descriptor.ShellId); + } + } + + public void Unregister(Guid shellId) + { + if (_shells.TryRemove(shellId, out var descriptor)) + { + _logger.LogInformation("Unregistered shell {ShellId} for window {WindowId}, tab {TabId}", + descriptor.ShellId, descriptor.AppWindowId, descriptor.TabId); + } + } + + public IpcShellDescriptor? GetActiveForWindow(uint appWindowId) + { + return _shells.Values + .Where(d => d.AppWindowId == appWindowId && d.IsActive) + .FirstOrDefault(); + } + + public IpcShellDescriptor? GetById(Guid shellId) + { + return _shells.TryGetValue(shellId, out var descriptor) ? descriptor : null; + } + + public void SetActive(Guid shellId) + { + if (!_shells.TryGetValue(shellId, out var descriptor)) + { + _logger.LogWarning("Cannot set active - shell {ShellId} not found", shellId); + return; + } + + // Deactivate other shells in the same window + var windowId = descriptor.AppWindowId; + foreach (var kvp in _shells) + { + if (kvp.Value.AppWindowId == windowId) + { + var isActive = kvp.Key == shellId; + if (kvp.Value.IsActive != isActive) + { + _shells.TryUpdate(kvp.Key, kvp.Value with { IsActive = isActive }, kvp.Value); + } + } + } + + _logger.LogDebug("Set shell {ShellId} as active for window {WindowId}", shellId, windowId); + } + + public IReadOnlyCollection List() + { + return _shells.Values.ToList().AsReadOnly(); + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/JsonRpcException.cs b/src/Files.App/Communication/JsonRpcException.cs new file mode 100644 index 000000000000..761a800a5a6d --- /dev/null +++ b/src/Files.App/Communication/JsonRpcException.cs @@ -0,0 +1,29 @@ +using System; + +namespace Files.App.Communication +{ + /// + /// Exception for JSON-RPC errors with proper error codes. + /// + public sealed class JsonRpcException : Exception + { + public int Code { get; } + + public JsonRpcException(int code, string message) : base(message) + { + Code = code; + } + + public JsonRpcException(int code, string message, Exception innerException) + : base(message, innerException) + { + Code = code; + } + + // Standard JSON-RPC error codes + public const int InvalidRequest = -32600; + public const int MethodNotFound = -32601; + public const int InvalidParams = -32602; + public const int InternalError = -32000; + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/JsonRpcMessage.cs b/src/Files.App/Communication/JsonRpcMessage.cs new file mode 100644 index 000000000000..d3a0adac8639 --- /dev/null +++ b/src/Files.App/Communication/JsonRpcMessage.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Files.App.Communication +{ + // Strict JSON-RPC 2.0 model with helpers that preserve original id types and enforce result XOR error. + public sealed record JsonRpcMessage + { + private const string JsonRpcVersion = "2.0"; + + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = JsonRpcVersion; + + [JsonPropertyName("id")] + public JsonElement? Id { get; init; } // omitted => notification + + [JsonPropertyName("method")] + public string? Method { get; init; } + + [JsonPropertyName("params")] + public JsonElement? Params { get; init; } + + [JsonPropertyName("result")] + public JsonElement? Result { get; init; } + + [JsonPropertyName("error")] + public JsonElement? Error { get; init; } + + public bool IsNotification => Id is null || (Id.HasValue && Id.Value.ValueKind == JsonValueKind.Null); + + public static JsonRpcMessage? FromJson(string json) + { + try { return JsonSerializer.Deserialize(json); } + catch { return null; } + } + + public string ToJson() => JsonSerializer.Serialize(this); + + public static JsonRpcMessage MakeError(JsonElement? id, int code, string message) + { + var errObj = new { code, message }; + var doc = JsonSerializer.SerializeToElement(errObj); + return new JsonRpcMessage { Id = id, Error = doc }; + } + + public static JsonRpcMessage MakeResult(JsonElement? id, object result) + { + var doc = JsonSerializer.SerializeToElement(result); + return new JsonRpcMessage { Id = id, Result = doc }; + } + + public static bool ValidJsonRpc(JsonRpcMessage? msg) => msg is not null && msg.JsonRpc == JsonRpcVersion; + + // Validate that incoming message is a legal JSON-RPC request/notification/response shape + public static bool IsInvalidRequest(JsonRpcMessage m) + { + var hasMethod = !string.IsNullOrEmpty(m.Method); + var hasResult = m.Result is not null && m.Result.Value.ValueKind != JsonValueKind.Undefined; + var hasError = m.Error is not null && m.Error.Value.ValueKind != JsonValueKind.Undefined; + + // result and error are mutually exclusive + if (hasResult && hasError) + return true; + + // request or notification: method present; NO result/error + if (hasMethod && (hasResult || hasError)) + return true; + + // response: no method; need exactly one of result or error + if (!hasMethod && !(hasResult ^ hasError)) + return true; + + return false; + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/Models/ItemDto.cs b/src/Files.App/Communication/Models/ItemDto.cs new file mode 100644 index 000000000000..8e4c8cf3f0d9 --- /dev/null +++ b/src/Files.App/Communication/Models/ItemDto.cs @@ -0,0 +1,21 @@ +namespace Files.App.Communication.Models +{ + public sealed class ItemDto + { + public string Path { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public bool IsDirectory { get; set; } + + public long SizeBytes { get; set; } + + public string DateModified { get; set; } = string.Empty; + + public string DateCreated { get; set; } = string.Empty; + + public string? MimeType { get; set; } + + public bool Exists { get; set; } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/MultiTransportCommunicationService.cs b/src/Files.App/Communication/MultiTransportCommunicationService.cs new file mode 100644 index 000000000000..5ca06bdac057 --- /dev/null +++ b/src/Files.App/Communication/MultiTransportCommunicationService.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Files.App.Communication +{ + public sealed class MultiTransportCommunicationService : IAppCommunicationService, IDisposable + { + private readonly WebSocketAppCommunicationService _websocket; + private readonly NamedPipeAppCommunicationService _pipes; + private readonly ILogger _logger; + + public event Func? OnRequestReceived; + + public MultiTransportCommunicationService( + WebSocketAppCommunicationService ws, + NamedPipeAppCommunicationService pipes, + ILogger logger) + { + _websocket = ws; + _pipes = pipes; + _logger = logger; + _websocket.OnRequestReceived += RelayAsync; + _pipes.OnRequestReceived += RelayAsync; + } + + private Task RelayAsync(ClientContext ctx, JsonRpcMessage msg) + { + return OnRequestReceived?.Invoke(ctx, msg) ?? Task.CompletedTask; + } + + public async Task StartAsync() + { + try + { + await _websocket.StartAsync(); + await _pipes.StartAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed starting multi transport IPC"); + throw; + } + } + + public async Task StopAsync() + { + try + { + await _websocket.StopAsync(); + await _pipes.StopAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Errors stopping transports"); + } + } + + public Task SendResponseAsync(ClientContext client, JsonRpcMessage response) + { + if (client.WebSocket != null) + return _websocket.SendResponseAsync(client, response); + if (client.PipeWriter != null) + return _pipes.SendResponseAsync(client, response); + return Task.CompletedTask; + } + + public async Task BroadcastAsync(JsonRpcMessage notification) + { + await _websocket.BroadcastAsync(notification); + await _pipes.BroadcastAsync(notification); + } + + public void Dispose() + { + (_websocket as IDisposable)?.Dispose(); + (_pipes as IDisposable)?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/NamedPipeAppCommunicationService.cs b/src/Files.App/Communication/NamedPipeAppCommunicationService.cs new file mode 100644 index 000000000000..ec8ebcb88b6c --- /dev/null +++ b/src/Files.App/Communication/NamedPipeAppCommunicationService.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Buffers.Binary; +using System.IO; +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.App.Communication +{ + public sealed class NamedPipeAppCommunicationService : AppCommunicationServiceBase + { + private string? _pipeName; + private bool _transportStarted; + private Task? _acceptTask; + + public NamedPipeAppCommunicationService( + RpcMethodRegistry methodRegistry, + ILogger logger) + : base(methodRegistry, logger) + { } + + protected override async Task StartTransportAsync() + { + _pipeName = $"FilesAppPipe_{Environment.UserName}_{Guid.NewGuid():N}"; + _transportStarted = true; + _acceptTask = Task.Run(AcceptConnectionsAsync, Cancellation.Token); + await IpcRendezvousFile.UpdateAsync(pipeName: _pipeName, epoch: CurrentEpoch); + } + + protected override async Task StopTransportAsync() + { + if (!_transportStarted) return; + try + { + if (_acceptTask != null) + await _acceptTask; // wait graceful exit + } + catch { } + finally + { + _transportStarted = false; + } + } + + private PipeSecurity CreatePipeSecurity() + { + var pipeSecurity = new PipeSecurity(); + var currentUser = WindowsIdentity.GetCurrent(); + if (currentUser?.User != null) + { + pipeSecurity.AddAccessRule(new PipeAccessRule( + currentUser.User, + PipeAccessRights.FullControl, + AccessControlType.Allow)); + } + return pipeSecurity; + } + + private async Task AcceptConnectionsAsync() + { + while (!Cancellation.IsCancellationRequested) + { + try + { + var pipeSecurity = CreatePipeSecurity(); + var server = NamedPipeServerStreamAcl.Create( + _pipeName!, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + 4096, 4096, + pipeSecurity); + + Logger.LogDebug("Waiting for named pipe connection..."); + await server.WaitForConnectionAsync(Cancellation.Token); + _ = Task.Run(() => HandleConnectionAsync(server), Cancellation.Token); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Logger.LogError(ex, "Error accepting named pipe connection"); + await Task.Delay(250, Cancellation.Token); + } + } + } + + private async Task HandleConnectionAsync(NamedPipeServerStream server) + { + ClientContext? client = null; + try + { + client = new ClientContext + { + Cancellation = CancellationTokenSource.CreateLinkedTokenSource(Cancellation.Token), + TransportHandle = server, + PipeWriter = new BinaryWriter(server, Encoding.UTF8, leaveOpen: true), + PipeWriteLock = new object() + }; + RegisterClient(client); + Logger.LogDebug("Pipe client {ClientId} connected", client.Id); + + // Dual loops + _ = Task.Run(() => RunSendLoopAsync(client), client.Cancellation.Token); // send loop + await RunReceiveLoopAsync(client, server); // receive loop (exits on disconnect) + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Pipe connection handler error"); + } + finally + { + if (client != null) + { + UnregisterClient(client); + Logger.LogDebug("Pipe client {ClientId} disconnected", client.Id); + } + try { server.Dispose(); } catch { } + } + } + + private async Task RunReceiveLoopAsync(ClientContext client, NamedPipeServerStream server) + { + try + { + while (server.IsConnected && !client.Cancellation!.IsCancellationRequested) + { + var lenBytes = new byte[4]; + int read = await server.ReadAsync(lenBytes, 0, 4, client.Cancellation.Token); + if (read == 0) break; // disconnect + if (read != 4) throw new IOException("Incomplete length prefix"); + + var length = BinaryPrimitives.ReadInt32LittleEndian(lenBytes); + // Check for negative, zero, excessive size, or potential integer overflow + if (length <= 0 || length > IpcConfig.NamedPipeMaxMessageBytes || length > int.MaxValue / 2) + break; // invalid / over limit / overflow protection + + var payload = new byte[length]; + int offset = 0; + while (offset < length) + { + var r = await server.ReadAsync(payload, offset, length - offset, client.Cancellation.Token); + if (r == 0) throw new IOException("Unexpected EOF"); + offset += r; + } + + var json = Encoding.UTF8.GetString(payload); + var msg = JsonRpcMessage.FromJson(json); + await ProcessIncomingMessageAsync(client, msg); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Logger.LogDebug(ex, "Receive loop error for pipe client {ClientId}", client.Id); + } + } + + protected override Task SendToClientAsync(ClientContext client, string payload) + { + // Frame: length prefix + UTF8 bytes + if (client.PipeWriter is null || client.PipeWriteLock is null) return Task.CompletedTask; + try + { + var bytes = Encoding.UTF8.GetBytes(payload); + var len = new byte[4]; + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, bytes.Length); + lock (client.PipeWriteLock) + { + client.PipeWriter.Write(len); + client.PipeWriter.Write(bytes); + client.PipeWriter.Flush(); + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Pipe send error to {ClientId}", client.Id); + } + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/NavigationStateFromShell.cs b/src/Files.App/Communication/NavigationStateFromShell.cs new file mode 100644 index 000000000000..00b3f01baac2 --- /dev/null +++ b/src/Files.App/Communication/NavigationStateFromShell.cs @@ -0,0 +1,71 @@ +using Files.App.Data.Contracts; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.App.Communication +{ + /// + /// Wraps IShellPage to provide INavigationStateProvider implementation. + /// + public sealed class NavigationStateFromShell : INavigationStateProvider, IDisposable + { + private readonly IShellPage _page; + public event EventHandler? StateChanged; + + public NavigationStateFromShell(IShellPage page) + { + _page = page ?? throw new ArgumentNullException(nameof(page)); + + // Subscribe to state change events + if (_page.ShellViewModel != null) + { + _page.ShellViewModel.WorkingDirectoryModified += OnWorkingDirectoryModified; + } + + // Note: NavigationToolbar.Navigated would be ideal but we need to check if it exists + _page.PropertyChanged += OnPagePropertyChanged; + } + + public string CurrentPath => _page.ShellViewModel?.WorkingDirectory ?? string.Empty; + + public bool CanGoBack => _page.CanNavigateBackward; + + public bool CanGoForward => _page.CanNavigateForward; + + public async Task NavigateToAsync(string path, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path cannot be empty", nameof(path)); + + if (ct.IsCancellationRequested) + return; + + _page.NavigateToPath(path); + await Task.CompletedTask; + } + + private void OnWorkingDirectoryModified(object? sender, WorkingDirectoryModifiedEventArgs e) + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void OnPagePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IShellPage.CanNavigateBackward) || + e.PropertyName == nameof(IShellPage.CanNavigateForward)) + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + } + + public void Dispose() + { + if (_page.ShellViewModel != null) + { + _page.ShellViewModel.WorkingDirectoryModified -= OnWorkingDirectoryModified; + } + _page.PropertyChanged -= OnPagePropertyChanged; + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/ProtectedTokenStore.cs b/src/Files.App/Communication/ProtectedTokenStore.cs new file mode 100644 index 000000000000..7fe189886c60 --- /dev/null +++ b/src/Files.App/Communication/ProtectedTokenStore.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading.Tasks; +using Windows.Security.Cryptography; +using Windows.Security.Cryptography.DataProtection; +using Windows.Storage; +using Microsoft.Extensions.Logging; + +namespace Files.App.Communication +{ + // DPAPI-backed token store. Stores encrypted token in LocalSettings and maintains an epoch for rotation. + internal static class ProtectedTokenStore + { + private const string KEY_TOKEN = "Files_RemoteControl_ProtectedToken"; + private const string KEY_ENABLED = "Files_RemoteControl_Enabled"; + private const string KEY_EPOCH = "Files_RemoteControl_TokenEpoch"; + private const string ENV_ENABLE = "FILES_IPC_ENABLE"; // set to 1/true to force-enable IPC services (used in tests & automation) + + private static ApplicationDataContainer Settings => ApplicationData.Current.LocalSettings; + + public static bool IsEnabled() + { + // Check if user explicitly enabled it + if (Settings.Values.TryGetValue(KEY_ENABLED, out var v) && v is bool b) + { + // User explicitly set the preference - no warning needed + return b; + } + + // Environment variable override (non persisted) for test/automation scenarios + var env = Environment.GetEnvironmentVariable(ENV_ENABLE); + if (!string.IsNullOrEmpty(env) && (string.Equals(env, "1", StringComparison.OrdinalIgnoreCase) || string.Equals(env, "true", StringComparison.OrdinalIgnoreCase))) + { + // Enabled via environment variable for testing - no warning needed + return true; + } + + return false; // IPC remains disabled until explicitly enabled by user + } + + public static void SetEnabled(bool enabled) => Settings.Values[KEY_ENABLED] = enabled; + + public static int GetEpoch() + { + if (Settings.Values.TryGetValue(KEY_EPOCH, out var v) && v is int e) + return e; + + SetEpoch(1); + return 1; + } + + public static async Task GetOrCreateTokenAsync() + { + if (Settings.Values.TryGetValue(KEY_TOKEN, out var val) && val is string b64 && !string.IsNullOrEmpty(b64)) + { + try + { + var protectedBuf = CryptographicBuffer.DecodeFromBase64String(b64); + var provider = new DataProtectionProvider(); + var unprotected = await provider.UnprotectAsync(protectedBuf); + return CryptographicBuffer.ConvertBinaryToString(BinaryStringEncoding.Utf8, unprotected); + } + catch + { + // fallback to regen + } + } + + var t = Guid.NewGuid().ToString("N"); + await SetTokenAsync(t); + SetEpoch(1); + return t; + } + + public static async Task RotateTokenAsync() + { + var t = Guid.NewGuid().ToString("N"); + await SetTokenAsync(t); + var epoch = GetEpoch() + 1; + SetEpoch(epoch); + return t; + } + + private static async Task SetTokenAsync(string token) + { + var provider = new DataProtectionProvider("LOCAL=user"); + var buffer = CryptographicBuffer.ConvertStringToBinary(token, BinaryStringEncoding.Utf8); + var protectedBuf = await provider.ProtectAsync(buffer); + var bytes = CryptographicBuffer.EncodeToBase64String(protectedBuf); + Settings.Values[KEY_TOKEN] = bytes; + } + + private static void SetEpoch(int epoch) => Settings.Values[KEY_EPOCH] = epoch; + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/RpcMethodRegistry.cs b/src/Files.App/Communication/RpcMethodRegistry.cs new file mode 100644 index 000000000000..fb3426486121 --- /dev/null +++ b/src/Files.App/Communication/RpcMethodRegistry.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Files.App.Communication +{ + public sealed class RpcMethod + { + public string Name { get; init; } = string.Empty; + + public int? MaxPayloadBytes { get; init; } // optional cap per method + + public bool RequiresAuth { get; init; } = true; + + public bool AllowNotifications { get; init; } = true; + + public Func? AuthorizationPolicy { get; init; } // additional checks + } + + public sealed class RpcMethodRegistry + { + private readonly ConcurrentDictionary _methods = new(); + + public RpcMethodRegistry() + { + Register(new RpcMethod { Name = "handshake", RequiresAuth = false, AllowNotifications = false }); + Register(new RpcMethod { Name = "getState", RequiresAuth = true, AllowNotifications = false }); + Register(new RpcMethod { Name = "listActions", RequiresAuth = true, AllowNotifications = false }); + Register(new RpcMethod { Name = "listShells", RequiresAuth = true, AllowNotifications = false }); + Register(new RpcMethod { Name = "getMetadata", RequiresAuth = true, AllowNotifications = false, MaxPayloadBytes = 2 * 1024 * 1024 }); + Register(new RpcMethod { Name = "navigate", RequiresAuth = true, AllowNotifications = false }); + Register(new RpcMethod { Name = "executeAction", RequiresAuth = true, AllowNotifications = false }); + } + + public void Register(RpcMethod method) => _methods[method.Name] = method; + + public bool TryGet(string name, out RpcMethod method) => _methods.TryGetValue(name, out method); + + public IEnumerable List() => _methods.Values; + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/ShellIpcBootstrapper.cs b/src/Files.App/Communication/ShellIpcBootstrapper.cs new file mode 100644 index 000000000000..477762bfca5a --- /dev/null +++ b/src/Files.App/Communication/ShellIpcBootstrapper.cs @@ -0,0 +1,125 @@ +using Files.App.Data.Commands; +using Files.App.Data.Contracts; +using Files.App.ViewModels; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using System; +using System.ComponentModel; + +namespace Files.App.Communication +{ + /// + /// Bootstraps IPC adapter for a shell instance and manages its lifecycle. + /// + public sealed class ShellIpcBootstrapper : IDisposable + { + public Guid ShellId { get; } = Guid.NewGuid(); + + private readonly IIpcShellRegistry _registry; + private readonly IShellPage _page; + private readonly NavigationStateFromShell _nav; + private readonly ShellIpcAdapter _adapter; + private readonly uint _appWindowId; + private readonly Guid _tabId; + private readonly ILogger _logger; + private bool _disposed; + + public ShellIpcBootstrapper( + IIpcShellRegistry registry, + IShellPage page, + uint appWindowId, + Guid tabId, + IAppCommunicationService commService, + ICommandManager commandManager, + RpcMethodRegistry methodRegistry, + DispatcherQueue dispatcherQueue, + ILogger logger, + ILogger adapterLogger, + ILogger actionAdapterLogger) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _page = page ?? throw new ArgumentNullException(nameof(page)); + _appWindowId = appWindowId; + _tabId = tabId; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + try + { + // Create the navigation state wrapper + _nav = new NavigationStateFromShell(page); + + // Create the action adapter that bridges to CommandManager + var actionAdapter = new IpcActionAdapter(commandManager, actionAdapterLogger); + + // Create the shell adapter + _adapter = new ShellIpcAdapter( + page.ShellViewModel, + page, + commService, + actionAdapter, + methodRegistry, + dispatcherQueue, + adapterLogger, + _nav); + + // Register with the registry + var descriptor = new IpcShellDescriptor(ShellId, _appWindowId, _tabId, _adapter, false); + _registry.Register(descriptor); + + // Hook up to track when this shell becomes active + _page.PropertyChanged += Page_PropertyChanged; + + // Set as active if currently the active pane + if (_page.IsCurrentPane) + _registry.SetActive(ShellId); + + _logger.LogInformation("Bootstrapped IPC for shell {ShellId} in window {WindowId}, tab {TabId}", + ShellId, _appWindowId, _tabId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to bootstrap IPC for shell"); + Dispose(); + throw; + } + } + + private void Page_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IShellPage.IsCurrentPane)) + { + if (_page.IsCurrentPane) + { + _registry.SetActive(ShellId); + _logger.LogDebug("Shell {ShellId} became active", ShellId); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + try + { + // Unhook property changed event + _page.PropertyChanged -= Page_PropertyChanged; + + // Unregister from registry + _registry.Unregister(ShellId); + + // Dispose the navigation wrapper + _nav?.Dispose(); + + _logger.LogInformation("Disposed IPC bootstrapper for shell {ShellId}", ShellId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during IPC bootstrapper disposal for shell {ShellId}", ShellId); + } + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/UIOperationQueue.cs b/src/Files.App/Communication/UIOperationQueue.cs new file mode 100644 index 000000000000..a52f49342664 --- /dev/null +++ b/src/Files.App/Communication/UIOperationQueue.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.UI.Dispatching; + +namespace Files.App.Communication +{ + // Ensures all UI-affecting operations are serialized on the dispatcher thread + public sealed class UIOperationQueue + { + private readonly DispatcherQueue _dispatcher; + + public UIOperationQueue(DispatcherQueue dispatcher) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + public Task EnqueueAsync(Func operation) + { + var tcs = new TaskCompletionSource(); + + _dispatcher.TryEnqueue(async () => + { + try + { + await operation().ConfigureAwait(false); + tcs.SetResult(null); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/WebSocketAppCommunicationService.cs b/src/Files.App/Communication/WebSocketAppCommunicationService.cs new file mode 100644 index 000000000000..17195796fff9 --- /dev/null +++ b/src/Files.App/Communication/WebSocketAppCommunicationService.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.App.Communication +{ + public sealed class WebSocketAppCommunicationService : AppCommunicationServiceBase + { + private readonly HttpListener _httpListener; + private bool _transportStarted; + private int? _port; // chosen port + + public WebSocketAppCommunicationService( + RpcMethodRegistry methodRegistry, + ILogger logger) + : base(methodRegistry, logger) + { + _httpListener = new HttpListener(); + } + + protected override async Task StartTransportAsync() + { + // Bind port & start listener + _port = BindAvailablePort(); + _httpListener.Start(); + _transportStarted = true; + _ = Task.Run(AcceptConnectionsAsync, Cancellation.Token); + await IpcRendezvousFile.UpdateAsync(webSocketPort: _port, epoch: CurrentEpoch); + } + + protected override Task StopTransportAsync() + { + if (_transportStarted) + { + try { _httpListener.Stop(); } catch { } + try { _httpListener.Close(); } catch { } + _transportStarted = false; + } + return Task.CompletedTask; + } + + private int BindAvailablePort() + { + int[] preferred = { 52345 }; + foreach (var p in preferred) + { + if (TryBindPort(p)) return p; + } + for (int p = 40000; p < 40100; p++) + { + if (TryBindPort(p)) return p; + } + throw new InvalidOperationException("No available port for WebSocket IPC"); + } + + private bool TryBindPort(int port) + { + try + { + _httpListener.Prefixes.Clear(); + _httpListener.Prefixes.Add($"http://127.0.0.1:{port}/"); + return true; + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Port {Port} unavailable", port); + return false; + } + } + + private async Task AcceptConnectionsAsync() + { + while (!Cancellation.IsCancellationRequested) + { + try + { + var context = await _httpListener.GetContextAsync(); + if (context.Request.IsWebSocketRequest) + _ = Task.Run(() => HandleWebSocketConnection(context), Cancellation.Token); + else + { + context.Response.StatusCode = 400; + context.Response.Close(); + } + } + catch (HttpListenerException) when (Cancellation.IsCancellationRequested) { break; } + catch (Exception ex) + { + Logger.LogError(ex, "Error accepting WebSocket connection"); + } + } + } + + private async Task HandleWebSocketConnection(HttpListenerContext httpContext) + { + WebSocketContext? webSocketContext = null; + ClientContext? client = null; + try + { + webSocketContext = await httpContext.AcceptWebSocketAsync(null); + var webSocket = webSocketContext.WebSocket; + client = new ClientContext + { + WebSocket = webSocket, + Cancellation = CancellationTokenSource.CreateLinkedTokenSource(Cancellation.Token) + }; + RegisterClient(client); + Logger.LogDebug("WebSocket client {ClientId} connected", client.Id); + + // Start send loop + _ = Task.Run(() => RunSendLoopAsync(client), client.Cancellation.Token); + await ClientReceiveLoopAsync(client); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error in WebSocket connection handler"); + } + finally + { + if (client != null) + { + UnregisterClient(client); + Logger.LogDebug("WebSocket client {ClientId} disconnected", client.Id); + } + } + } + + private async Task ClientReceiveLoopAsync(ClientContext client) + { + var buffer = new byte[4096]; + var builder = new StringBuilder(); + var received = 0; + try + { + while (client.WebSocket?.State == WebSocketState.Open && !client.Cancellation!.IsCancellationRequested) + { + var result = await client.WebSocket.ReceiveAsync(new ArraySegment(buffer), client.Cancellation.Token); + if (result.MessageType == WebSocketMessageType.Close) + break; + if (result.MessageType != WebSocketMessageType.Text) + continue; + + if (received + result.Count > IpcConfig.WebSocketMaxMessageBytes) + { + Logger.LogWarning("Client {ClientId} exceeded max message size, disconnecting", client.Id); + break; + } + builder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + received += result.Count; + if (result.EndOfMessage) + { + var text = builder.ToString(); + builder.Clear(); + received = 0; + var msg = JsonRpcMessage.FromJson(text); + await ProcessIncomingMessageAsync(client, msg); + } + } + } + catch (OperationCanceledException) { } + catch (WebSocketException ex) + { + Logger.LogDebug("WebSocket error {Client}: {Message}", client.Id, ex.Message); + } + catch (Exception ex) + { + Logger.LogError(ex, "Receive loop error {Client}", client.Id); + } + } + + protected override async Task SendToClientAsync(ClientContext client, string payload) + { + if (client.WebSocket is not { State: WebSocketState.Open }) + return; + try + { + var bytes = Encoding.UTF8.GetBytes(payload); + await client.WebSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, client.Cancellation!.Token); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Send error to client {Client}", client.Id); + } + } + } +} \ No newline at end of file diff --git a/src/Files.App/Communication/WindowResolver.cs b/src/Files.App/Communication/WindowResolver.cs new file mode 100644 index 000000000000..8b3077f9ec56 --- /dev/null +++ b/src/Files.App/Communication/WindowResolver.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; +using System; +using Files.App.Views; + +namespace Files.App.Communication +{ + /// + /// Resolves the active window ID for IPC routing. + /// + public sealed class WindowResolver : IWindowResolver + { + private readonly ILogger _logger; + + public WindowResolver(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public uint GetActiveWindowId() + { + try + { + // Get the window ID from the current MainWindow's AppWindow with overflow protection + // This works because Files is typically single-window (main window) + // For multi-window support, would need to track the focused window + uint windowId; + if (MainWindow.Instance?.AppWindow?.Id.Value is ulong rawId) + { + if (rawId <= uint.MaxValue) + { + windowId = (uint)rawId; + } + else + { + // Log a warning as this could lead to incorrect window identification + _logger.LogWarning("Window ID ({RawId}) exceeds uint.MaxValue and will be truncated to 0.", rawId); + windowId = 0u; + } + } + else + { + windowId = 0u; + } + + if (windowId == 0) + { + _logger.LogWarning("Could not determine active window ID, using default"); + return 1; // Default fallback + } + + _logger.LogDebug("Resolved active window ID: {WindowId}", windowId); + return windowId; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get active window ID"); + return 1; // Default fallback on error + } + } + } +} \ No newline at end of file diff --git a/src/Files.App/Constants.cs b/src/Files.App/Constants.cs index c3a531e502dd..510c9a385b43 100644 --- a/src/Files.App/Constants.cs +++ b/src/Files.App/Constants.cs @@ -144,6 +144,27 @@ public static class Drives } } + public static class IpcSettings + { + public const long WebSocketMaxMessageBytes = 16L * 1024L * 1024L; // 16 MB + + public const long NamedPipeMaxMessageBytes = 10L * 1024L * 1024L; // 10 MB + + public const long PerClientQueueCapBytes = 2L * 1024L * 1024L; // 2 MB + + public const int RateLimitPerSecond = 20; + + public const int RateLimitBurst = 60; + + public const int SelectionNotificationCap = 200; + + public const int GetMetadataMaxItems = 500; + + public const int GetMetadataTimeoutSec = 30; + + public const int StackTraceSanitizationMaxLength = 300; // Max characters for sanitized stack traces + } + public static class LocalSettings { public const string DateTimeFormat = "datetimeformat"; @@ -223,7 +244,7 @@ public static class Actions public static class DragAndDrop { - public const Int32 HoverToOpenTimespan = 1300; + public const int HoverToOpenTimespan = 1300; } public static class UserEnvironmentPaths @@ -282,5 +303,19 @@ public static class Distributions "FilesDev", // dev }; } + + public static class PathValidationConstants + { + public const string SHELL_PREFIX = "shell:"; + public const string HOME_PREFIX = "Home"; + public const string RELEASE_NOTES = "ReleaseNotes"; + public const string SETTINGS_PREFIX = "Settings"; + public const string TAG_PREFIX = "tag:"; + public const string SHELL_FOLDER_UNC_PREFIX = @"\\SHELL\"; // virtual shell namespace root used internally + public const string EXTENDED_PATH_PREFIX = @"\\?\"; // Win32 extended-length path prefix (for paths >260 chars) + public const string DEVICE_NAMESPACE_PREFIX = @"\\.\"; // Win32 device namespace prefix + public const string MTP_PREFIX = "mtp:"; // Media Transfer Protocol devices (Android phones, cameras) + public const string SHELL_FOLDER_PREFIX = "::"; // Shell folder CLSID prefix + } } } diff --git a/src/Files.App/Data/Contracts/INavigationStateProvider.cs b/src/Files.App/Data/Contracts/INavigationStateProvider.cs new file mode 100644 index 000000000000..6f471902cb78 --- /dev/null +++ b/src/Files.App/Data/Contracts/INavigationStateProvider.cs @@ -0,0 +1,34 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.App.Data.Contracts +{ + /// + /// Abstraction for reading and controlling navigation state of the shell. + /// + public interface INavigationStateProvider + { + /// Gets the current path shown in the shell. + string? CurrentPath { get; } + + /// True if navigating back is possible. + bool CanGoBack { get; } + + /// True if navigating forward is possible. + bool CanGoForward { get; } + + /// + /// Raised when CurrentPath, CanGoBack or CanGoForward changes. + /// + event EventHandler? StateChanged; + + /// + /// Navigates the shell to the given absolute path. + /// + Task NavigateToAsync(string path, CancellationToken ct = default); + } +} diff --git a/src/Files.App/Data/Enums/SettingsPageKind.cs b/src/Files.App/Data/Enums/SettingsPageKind.cs index 386c9409409e..45b9ed9800ca 100644 --- a/src/Files.App/Data/Enums/SettingsPageKind.cs +++ b/src/Files.App/Data/Enums/SettingsPageKind.cs @@ -14,5 +14,6 @@ public enum SettingsPageKind DevToolsPage, AdvancedPage, AboutPage, + IpcPage, } } diff --git a/src/Files.App/Dialogs/SettingsDialog.xaml b/src/Files.App/Dialogs/SettingsDialog.xaml index 451a492f67d2..94d92b05729c 100644 --- a/src/Files.App/Dialogs/SettingsDialog.xaml +++ b/src/Files.App/Dialogs/SettingsDialog.xaml @@ -1,4 +1,4 @@ - + + + + + + SettingsContentFrame.Navigate(typeof(TagsPage), null, new SuppressNavigationTransitionInfo()), SettingsPageKind.DevToolsPage => SettingsContentFrame.Navigate(typeof(DevToolsPage), null, new SuppressNavigationTransitionInfo()), SettingsPageKind.AdvancedPage => SettingsContentFrame.Navigate(typeof(AdvancedPage), null, new SuppressNavigationTransitionInfo()), + SettingsPageKind.IpcPage => SettingsContentFrame.Navigate(typeof(Files.App.Views.Settings.IpcPage), null, new SuppressNavigationTransitionInfo()), SettingsPageKind.AboutPage => SettingsContentFrame.Navigate(typeof(AboutPage), null, new SuppressNavigationTransitionInfo()), _ => SettingsContentFrame.Navigate(typeof(AppearancePage), null, new SuppressNavigationTransitionInfo()) }; diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index 872cd58d1cd9..288602eca29c 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -36,7 +36,7 @@ $(DefineConstants);DISABLE_XAML_GENERATED_MAIN - + @@ -137,5 +137,11 @@ + + + MSBuild:Compile + + + diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 06a7757d6cac..39b79961ecfb 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -17,6 +17,7 @@ using Windows.Storage; using Windows.System; using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using Files.App.Communication; // Added for IPC service registrations namespace Files.App.Helpers { @@ -102,6 +103,8 @@ public static async Task InitializeAppComponentsAsync() var addItemService = Ioc.Default.GetRequiredService(); var generalSettingsService = userSettingsService.GeneralSettingsService; var jumpListService = Ioc.Default.GetRequiredService(); + var ipcService = Ioc.Default.GetRequiredService(); + var ipcCoordinator = Ioc.Default.GetRequiredService(); // Start off a list of tasks we need to run before we can continue startup await Task.WhenAll( @@ -119,7 +122,9 @@ await Task.WhenAll( jumpListService.InitializeAsync(), addItemService.InitializeAsync(), ContextMenu.WarmUpQueryContextMenuAsync(), - CheckAppUpdate() + CheckAppUpdate(), + // Initialize IPC service if remote control is enabled + OptionalTaskAsync(InitializeIpcAsync(ipcService, ipcCoordinator), Files.App.Communication.ProtectedTokenStore.IsEnabled()) ); }); @@ -136,6 +141,15 @@ static Task OptionalTaskAsync(Task task, bool condition) generalSettingsService.PropertyChanged += GeneralSettingsService_PropertyChanged; } + private static async Task InitializeIpcAsync(IAppCommunicationService ipcService, IpcCoordinator ipcCoordinator) + { + App.Logger?.LogInformation("[IPC] Starting IPC service..."); + await ipcService.StartAsync(); + App.Logger?.LogInformation("[IPC] IPC service started, initializing coordinator..."); + ipcCoordinator.Initialize(); + App.Logger?.LogInformation("[IPC] IPC system fully initialized and ready for requests"); + } + /// /// Checks application updates and download if available. /// @@ -217,6 +231,7 @@ public static IHost ConfigureHost() .AddSingleton() .AddSingleton() // Services + .AddSingleton(Ioc.Default) .AddSingleton() .AddSingleton() .AddSingleton() @@ -249,6 +264,14 @@ public static IHost ConfigureHost() .AddSingleton() .AddSingleton() .AddSingleton() + // IPC system + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() // ViewModels .AddSingleton() .AddSingleton() @@ -320,36 +343,87 @@ public static void HandleAppUnhandledException(Exception? ex, bool showToastNoti if (ex is not null) { - ex.Data[Mechanism.HandledKey] = false; - ex.Data[Mechanism.MechanismKey] = "Application.UnhandledException"; + try + { + // Mark as unhandled for Sentry + ex.Data[Mechanism.HandledKey] = false; + ex.Data[Mechanism.MechanismKey] = "Application.UnhandledException"; + } + catch (Exception exData) + { + App.Logger?.LogTrace(exData, "Failed to set exception data for Sentry"); + } + // Capture with highest severity SentrySdk.CaptureException(ex, scope => { scope.User.Id = generalSettingsService?.UserId; scope.Level = SentryLevel.Fatal; }); + Exception primary = ex; + // Flatten aggregate exceptions so we log all inner exceptions + List all = new(); + if (ex is AggregateException aggr) + { + var flat = aggr.Flatten(); + primary = flat.InnerExceptions.FirstOrDefault() ?? aggr; + all.AddRange(flat.InnerExceptions); + } + else + { + all.Add(primary); + } + formattedException.AppendLine($">>>> HRESULT: {ex.HResult}"); - if (ex.Message is not null) + if (!string.IsNullOrWhiteSpace(primary.Message)) { formattedException.AppendLine("--- MESSAGE ---"); - formattedException.AppendLine(ex.Message); + // Sanitize primary exception message to prevent information disclosure + formattedException.AppendLine(SanitizeExceptionMessage(primary.Message)); } - if (ex.StackTrace is not null) + + if (!string.IsNullOrWhiteSpace(primary.StackTrace)) { formattedException.AppendLine("--- STACKTRACE ---"); - formattedException.AppendLine(ex.StackTrace); + // Sanitize primary stack trace to remove sensitive paths + formattedException.AppendLine(SanitizeStackTrace(primary.StackTrace)); } - if (ex.Source is not null) + + if (!string.IsNullOrWhiteSpace(primary.Source)) { formattedException.AppendLine("--- SOURCE ---"); - formattedException.AppendLine(ex.Source); + formattedException.AppendLine(primary.Source); } - if (ex.InnerException is not null) + + // Log all inner/aggregate exceptions (excluding the primary already logged above) + if (all.Count > 1 || primary.InnerException is not null) { - formattedException.AppendLine("--- INNER ---"); - formattedException.AppendLine(ex.InnerException.ToString()); + formattedException.AppendLine("--- INNER EXCEPTIONS ---"); + int idx = 0; + foreach (var inner in all) + { + if (ReferenceEquals(inner, primary)) + continue; + + // Sanitize inner exception messages to prevent information disclosure + var sanitizedMessage = SanitizeExceptionMessage(inner.Message); + formattedException.AppendLine($"[{idx++}] {inner.GetType().FullName}: {sanitizedMessage}"); + + if (!string.IsNullOrWhiteSpace(inner.StackTrace)) + { + // Sanitize stack traces to remove sensitive paths and information + var sanitizedStack = SanitizeStackTrace(inner.StackTrace); + formattedException.AppendLine(sanitizedStack); + } + } + if (primary.InnerException is not null && !all.Contains(primary.InnerException)) + { + // Sanitize the ToString() output which may contain sensitive data + var sanitizedInner = SanitizeExceptionMessage(primary.InnerException.ToString()); + formattedException.AppendLine($"[Inner] {sanitizedInner}"); + } } } else @@ -361,43 +435,134 @@ public static void HandleAppUnhandledException(Exception? ex, bool showToastNoti Debug.WriteLine(formattedException.ToString()); - // Please check "Output Window" for exception details (View -> Output Window) (CTRL + ALT + O) - Debugger.Break(); + // Only break if a debugger is attached to avoid prompting end users. + // Wrap in DEBUG directive to prevent breaking in release builds +#if DEBUG + if (Debugger.IsAttached) + Debugger.Break(); +#endif - // Save the current tab list in case it was overwriten by another instance + // Save the current tab list in case it was overwritten by another instance SaveSessionTabs(); App.Logger?.LogError(ex, ex?.Message ?? "An unhandled error occurred."); - if (!showToastNotification) - return; - - SafetyExtensions.IgnoreExceptions(() => + // Show toast if requested but do not short‑circuit restart logic. + if (showToastNotification) { - AppToastNotificationHelper.ShowUnhandledExceptionToast(); - }); + SafetyExtensions.IgnoreExceptions(() => + { + AppToastNotificationHelper.ShowUnhandledExceptionToast(); + }); + } - // Restart the app - var userSettingsService = Ioc.Default.GetRequiredService(); - var lastSessionTabList = userSettingsService.GeneralSettingsService.LastSessionTabList; + // Restart the app attempting to restore tabs (unless we detect a crash loop) + try + { + var userSettingsService = Ioc.Default.GetRequiredService(); + var lastSessionTabList = userSettingsService.GeneralSettingsService.LastSessionTabList; - if (userSettingsService.GeneralSettingsService.LastCrashedTabList?.SequenceEqual(lastSessionTabList) ?? false) + if (userSettingsService.GeneralSettingsService.LastCrashedTabList?.SequenceEqual(lastSessionTabList) ?? false) + { + // Avoid infinite restart loop + userSettingsService.GeneralSettingsService.LastSessionTabList = null; + } + else + { + userSettingsService.AppSettingsService.RestoreTabsOnStartup = true; + userSettingsService.GeneralSettingsService.LastCrashedTabList = lastSessionTabList; + + // Try to re-launch and start over (best effort, do not await indefinitely) + MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => + { + try + { + await Launcher.LaunchUriAsync(new Uri("files-dev:")); + } + catch (Exception ex) + { + App.Logger?.LogError(ex, "Failed to restart app via Launcher.LaunchUriAsync after crash."); + } + }) + .Wait(100); + } + } + catch (Exception restartEx) { - // Avoid infinite restart loop - userSettingsService.GeneralSettingsService.LastSessionTabList = null; + App.Logger?.LogError(restartEx, "Failed while attempting auto-restart after unhandled exception."); } - else + finally { - userSettingsService.AppSettingsService.RestoreTabsOnStartup = true; - userSettingsService.GeneralSettingsService.LastCrashedTabList = lastSessionTabList; + // Give Sentry a brief moment to flush events (best effort, non-blocking long) + try { SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult(); } catch { } + + // DESIGN DECISION: Abrupt Process Termination + // ============================================ + // We use Process.Kill() instead of Environment.Exit() or Application.Exit() because: + // 1. This is an unhandled exception handler - the app state may be corrupted + // 2. Graceful shutdown methods might hang or fail in corrupted state + // 3. We've already attempted restart and logged telemetry - immediate termination is safest + // 4. Any important data should have been saved during the restart attempt above + // + // Alternative approaches considered: + // - Environment.Exit(): May hang if finalizers are corrupted + // - Application.Exit(): May not work if UI thread is corrupted + // - Natural termination: May leave process hanging indefinitely + // + // Risk mitigation: The restart logic above saves session state before this point. + Process.GetCurrentProcess().Kill(); + } + } - // Try to re-launch and start over - MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => - { - await Launcher.LaunchUriAsync(new Uri("files-dev:")); - }) - .Wait(100); + /// + /// Sanitizes exception messages to remove sensitive information like file paths, tokens, and user data. + /// + private static string SanitizeExceptionMessage(string? message) + { + if (string.IsNullOrEmpty(message)) + return string.Empty; + + // Remove Windows absolute paths (C:\Users\...) + message = System.Text.RegularExpressions.Regex.Replace(message, @"[A-Z]:\\[^""<>|]*", "[path]", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + // Remove Unix-style paths (/home/user/...) + message = System.Text.RegularExpressions.Regex.Replace(message, @"\/[^""<>|]*\/[^""<>| ]+", "[path]"); + + // Remove UNC paths (\\server\share\...) + message = System.Text.RegularExpressions.Regex.Replace(message, @"\\\\[^\\""<>|]+\\[^""<>|]*", "[network-path]"); + + // Remove potential tokens/keys (base64-like strings) + message = System.Text.RegularExpressions.Regex.Replace(message, @"\b[A-Za-z0-9+/]{20,}={0,2}\b", "[token]"); + + // Remove GUIDs + message = System.Text.RegularExpressions.Regex.Replace(message, @"\b[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\b", "[guid]"); + + return message; + } + + /// + /// Sanitizes stack traces to remove sensitive file paths and system information. + /// + private static string SanitizeStackTrace(string? stackTrace) + { + if (string.IsNullOrEmpty(stackTrace)) + return string.Empty; + + // Remove file paths with line numbers (e.g., C:\path\file.cs:line 123) + stackTrace = System.Text.RegularExpressions.Regex.Replace(stackTrace, @"[A-Z]:\\[^:""<>|]+\.cs:line \d+", "[source]:line [n]", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + // Remove Unix paths with line numbers + stackTrace = System.Text.RegularExpressions.Regex.Replace(stackTrace, @"\/[^:""<>|]+\.cs:line \d+", "[source]:line [n]"); + + // Remove remaining file paths + stackTrace = System.Text.RegularExpressions.Regex.Replace(stackTrace, @"[A-Z]:\\[^""<>|\s]+", "[path]", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + // Truncate if too long to prevent log flooding + if (stackTrace.Length > 2000) + { + stackTrace = stackTrace.Substring(0, 2000) + "... [truncated]"; } - Process.GetCurrentProcess().Kill(); + + return stackTrace; } /// diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 266665c3a502..ec5e63bb68fb 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -4291,7 +4291,7 @@ Signatures - + Signature list @@ -4325,4 +4325,22 @@ Unable to open the log file + + Remote control + + + Enable remote control + + + Token + + + Rotate + + + Allow external apps to control Files for navigation and actions. Keep the token secret. Disable to stop accepting connections. + + + Token copied to clipboard + diff --git a/src/Files.App/ViewModels/Settings/IpcViewModel.cs b/src/Files.App/ViewModels/Settings/IpcViewModel.cs new file mode 100644 index 000000000000..0c1eb942845c --- /dev/null +++ b/src/Files.App/ViewModels/Settings/IpcViewModel.cs @@ -0,0 +1,98 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Files.App.Communication; +using Files.App.Helpers.Application; +using Files.App.Services.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using System.Threading.Tasks; +using Windows.ApplicationModel.DataTransfer; + +namespace Files.App.ViewModels.Settings +{ + public sealed partial class IpcViewModel : ObservableObject + { + private readonly ILogger _logger = Ioc.Default.GetRequiredService>(); + private readonly IAppCommunicationService _ipcService = Ioc.Default.GetRequiredService(); + + [ObservableProperty] + private bool _isEnabled; + + [ObservableProperty] + private string _token = string.Empty; + + public IpcViewModel() + { + // Initialize from store + IsEnabled = ProtectedTokenStore.IsEnabled(); + _ = LoadTokenAsync(); + } + + partial void OnIsEnabledChanged(bool value) + { + try + { + ProtectedTokenStore.SetEnabled(value); + + if (value) + _ = _ipcService.StartAsync(); + else + _ = _ipcService.StopAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to toggle IPC service"); + } + } + + public async Task LoadTokenAsync() + { + try + { + Token = await ProtectedTokenStore.GetOrCreateTokenAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load token"); + } + } + + [RelayCommand] + private async Task RotateTokenAsync() + { + try + { + Token = await ProtectedTokenStore.RotateTokenAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rotate token"); + } + } + + [RelayCommand] + private void CopyToken() + { + try + { + if (!string.IsNullOrWhiteSpace(Token)) + { + var data = new DataPackage(); + data.SetText(Token); + Clipboard.SetContent(data); + Clipboard.Flush(); + + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to copy token to clipboard"); + } + } + } +} diff --git a/src/Files.App/ViewModels/ShellIpcAdapter.cs b/src/Files.App/ViewModels/ShellIpcAdapter.cs new file mode 100644 index 000000000000..b9eb6573a7a0 --- /dev/null +++ b/src/Files.App/ViewModels/ShellIpcAdapter.cs @@ -0,0 +1,342 @@ +using Files.App.Communication; +using Files.App.Communication.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.UI.Dispatching; +using System.Threading; +using System.IO; +using Microsoft.Extensions.Logging; +using Files.App.Data.Contracts; +using Files.App.Data.Commands; + +namespace Files.App.ViewModels +{ + /// + /// Adapter that bridges IPC requests to shell-specific operations. + /// Provides safe, normalized access to file explorer functionality with: + /// - Strict path validation and normalization + /// - Selection notification throttling (capped at 200 items) + /// - UI thread marshaling for WinUI operations + /// - Structured error responses for IPC clients + /// + public sealed class ShellIpcAdapter + { + private readonly ShellViewModel _shell; + private readonly IShellPage _shellPage; + private readonly IAppCommunicationService _comm; + private readonly IpcActionAdapter _actionAdapter; + private readonly RpcMethodRegistry _methodRegistry; + private readonly UIOperationQueue _uiQueue; + private readonly ILogger _logger; + private readonly INavigationStateProvider _nav; + + private readonly TimeSpan _coalesceWindow = TimeSpan.FromMilliseconds(100); + private DateTime _lastWdmNotif = DateTime.MinValue; + + // Public methods for IpcCoordinator to call + public async Task GetStateAsync() + { + // Must run on UI thread to access Frame properties + var tcs = new TaskCompletionSource(); + + await _uiQueue.EnqueueAsync(async () => + { + try + { + var state = new + { + currentPath = _nav.CurrentPath ?? _shell.WorkingDirectory, + canNavigateBack = _nav.CanGoBack, + canNavigateForward = _nav.CanGoForward, + isLoading = _shell.FilesAndFolders.Count == 0, + itemCount = _shell.FilesAndFolders.Count + }; + tcs.SetResult(state); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + await Task.CompletedTask; + }).ConfigureAwait(false); + + return await tcs.Task.ConfigureAwait(false); + } + + public async Task ListActionsAsync() + { + var tcs = new TaskCompletionSource(); + + await _uiQueue.EnqueueAsync(async () => + { + try + { + var actions = _actionAdapter.GetAllowedActions().Select(actionId => new + { + id = actionId, + name = actionId, + description = $"Execute {actionId} action" + }).ToArray(); + tcs.SetResult(new { actions }); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + await Task.CompletedTask; + }).ConfigureAwait(false); + + return await tcs.Task.ConfigureAwait(false); + } + + public async Task NavigateAsync(string path) + { + if (!TryNormalizePath(path, out var normalizedPath)) + { + throw new JsonRpcException(JsonRpcException.InvalidParams, "Invalid path"); + } + + await _uiQueue.EnqueueAsync(async () => + { + await NavigateToPathNormalized(normalizedPath); + }).ConfigureAwait(false); + + return new { status = "ok" }; + } + + public async Task GetMetadataAsync(List paths) + { + // Validate path count to prevent resource exhaustion + if (paths == null || paths.Count == 0) + { + throw new JsonRpcException(JsonRpcException.InvalidParams, "No paths provided"); + } + + if (paths.Count > IpcConfig.GetMetadataMaxItems) + { + throw new JsonRpcException(JsonRpcException.InvalidParams, + $"Too many paths requested ({paths.Count}). Maximum is {IpcConfig.GetMetadataMaxItems}"); + } + + // Create timeout for metadata operations + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(IpcConfig.GetMetadataTimeoutSec)); + + try + { + // GetFileMetadata uses file system, doesn't need UI thread + return await Task.Run(() => + { + var metadata = GetFileMetadata(paths, cts.Token); + return new { items = metadata }; + }, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw new JsonRpcException(JsonRpcException.InternalError, + $"Metadata operation timed out after {IpcConfig.GetMetadataTimeoutSec} seconds"); + } + } + + public async Task ExecuteActionAsync(string actionId) + { + if (string.IsNullOrEmpty(actionId) || !_actionAdapter.CanExecute(actionId)) + { + throw new JsonRpcException(JsonRpcException.InvalidParams, "Action not found or cannot execute"); + } + + // Execute on UI thread since commands access UI elements + var tcs = new TaskCompletionSource(); + + await _uiQueue.EnqueueAsync(async () => + { + try + { + // Pass the shell page this adapter is attached to + var result = await _actionAdapter.ExecuteActionAsync(actionId, _shellPage); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }).ConfigureAwait(false); + + return await tcs.Task.ConfigureAwait(false); + } + + public ShellIpcAdapter( + ShellViewModel shell, + IShellPage shellPage, + IAppCommunicationService comm, + IpcActionAdapter actionAdapter, + RpcMethodRegistry methodRegistry, + DispatcherQueue dispatcher, + ILogger logger, + INavigationStateProvider nav) + { + _shell = shell ?? throw new ArgumentNullException(nameof(shell)); + _shellPage = shellPage ?? throw new ArgumentNullException(nameof(shellPage)); + _comm = comm ?? throw new ArgumentNullException(nameof(comm)); + _actionAdapter = actionAdapter ?? throw new ArgumentNullException(nameof(actionAdapter)); + _methodRegistry = methodRegistry ?? throw new ArgumentNullException(nameof(methodRegistry)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _nav = nav ?? throw new ArgumentNullException(nameof(nav)); + _uiQueue = new UIOperationQueue(dispatcher ?? throw new ArgumentNullException(nameof(dispatcher))); + + _shell.WorkingDirectoryModified += Shell_WorkingDirectoryModified; + _nav.StateChanged += Nav_StateChanged; + } + + private async void Nav_StateChanged(object? sender, EventArgs e) + { + try + { + var notif = new JsonRpcMessage + { + Method = "navigationStateChanged", + Params = JsonSerializer.SerializeToElement(new { canNavigateBack = _nav.CanGoBack, canNavigateForward = _nav.CanGoForward, path = _nav.CurrentPath }) + }; + await _comm.BroadcastAsync(notif).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error broadcasting navigation state change"); + } + } + + private async void Shell_WorkingDirectoryModified(object? sender, WorkingDirectoryModifiedEventArgs e) + { + var now = DateTime.UtcNow; + if (now - _lastWdmNotif < _coalesceWindow) return; + _lastWdmNotif = now; + + try + { + var notif = new JsonRpcMessage + { + Method = "workingDirectoryChanged", + Params = JsonSerializer.SerializeToElement(new { path = e.Path, name = e.Name, isLibrary = e.IsLibrary }) + }; + + await _comm.BroadcastAsync(notif).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error broadcasting working directory change"); + } + } + + public async void OnSelectionChanged(IEnumerable selectedPaths) + { + try + { + var summary = selectedPaths?.Select(p => new { + path = p, + name = Path.GetFileName(p), + isDir = Directory.Exists(p) + }) ?? Enumerable.Empty(); + + var list = summary.Take(IpcConfig.SelectionNotificationCap).ToArray(); + var notif = new JsonRpcMessage + { + Method = "selectionChanged", + Params = JsonSerializer.SerializeToElement(new { + items = list, + truncated = (summary.Count() > IpcConfig.SelectionNotificationCap) + }) + }; + + await _comm.BroadcastAsync(notif).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error broadcasting selection change"); + } + } + + private static bool TryNormalizePath(string raw, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(raw)) return false; + if (raw.IndexOf('\0') >= 0) return false; + + try + { + var p = Path.GetFullPath(raw); + // Reject device paths and odd prefixes + if (p.StartsWith(Constants.PathValidationConstants.EXTENDED_PATH_PREFIX) || + p.StartsWith(Constants.PathValidationConstants.DEVICE_NAMESPACE_PREFIX)) + return false; + + normalized = p; + return true; + } + catch + { + return false; + } + } + + private List GetFileMetadata(List paths, CancellationToken cancellationToken) + { + var results = new List(); + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = new ItemDto { Path = path, Name = Path.GetFileName(path) }; + + if (File.Exists(path)) + { + var fi = new FileInfo(path); + item.IsDirectory = false; + item.SizeBytes = fi.Length; + item.DateModified = fi.LastWriteTime.ToString("o"); + item.DateCreated = fi.CreationTime.ToString("o"); + item.Exists = true; + } + else if (Directory.Exists(path)) + { + var di = new DirectoryInfo(path); + item.IsDirectory = true; + item.SizeBytes = 0; + item.DateModified = di.LastWriteTime.ToString("o"); + item.DateCreated = di.CreationTime.ToString("o"); + item.Exists = true; + } + else + { + item.Exists = false; + } + + results.Add(item); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error getting metadata for path: {Path}", path); + results.Add(new ItemDto + { + Path = path, + Name = Path.GetFileName(path), + Exists = false + }); + } + } + + return results; + } + + + private async Task NavigateToPathNormalized(string path) + { + _logger.LogInformation("Navigating to path: {Path}", path); + await _nav.NavigateToAsync(path); + } + } +} \ No newline at end of file diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index e5bd26e0a617..20d352b44f7c 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -43,6 +43,16 @@ public sealed partial class ShellViewModel : ObservableObject, IDisposable private readonly DispatcherQueue dispatcherQueue; private readonly JsonElement defaultJson = JsonSerializer.SerializeToElement("{}"); private readonly string folderTypeTextLocalized = Strings.Folder.GetLocalizedResource(); + + // Path validation cache to minimize file system calls + // Entries expire after 2 seconds to handle file system changes + private readonly ConcurrentDictionary pathValidationCache = new(); + + // Cache configuration constants + private const int PathCacheMaxSize = 100; + private const int PathCacheCleanupBatchSize = 20; + private const int PathCacheCleanupThreshold = 150; // Cleanup when cache exceeds this size (50% over max) + private const double PathCacheExpirationSeconds = 2.0; private Task? aProcessQueueAction; private Task? gitProcessQueueAction; @@ -290,7 +300,7 @@ public async Task> GetFolderWithPathFrom public async Task> GetFileWithPathFromPathAsync(string value, CancellationToken cancellationToken = default) { await getFileOrFolderSemaphore.WaitAsync(cancellationToken); - try + try { return await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFileWithPathFromPathAsync(value, workingRoot, currentStorageFolder)); } @@ -490,7 +500,7 @@ public bool IsSortedBySyncStatus if (value) { folderSettings.DirectorySortOption = SortOption.SyncStatus; - OnPropertyChanged(nameof(IsSortedBySyncStatus)); + OnPropertyChanged(nameof(IsSortedBySyncStatus)); } } } @@ -1556,7 +1566,72 @@ await dispatcherQueue.EnqueueOrInvokeAsync(async () => return groupImage; } - public void RefreshItems(string? previousDir, Action postLoadCallback = null) + /// + /// Validates whether the specified path exists, using an optimized caching mechanism to minimize file system calls. + /// Virtual paths (e.g., shell folders and MTP devices) are always considered valid. + /// Physical paths are checked for existence and results are cached for 2 seconds to handle file system changes. + /// The cache is periodically cleaned to remove expired entries. + /// + /// The path to validate. Can be a virtual path (shell folder, MTP device) or a physical file system path. + /// True if the path is valid (exists or is a recognized virtual path); otherwise, false. + private bool IsPathValid(string path) + { + // Virtual paths are always valid (no file system check needed) + // Optimize: Quick length check first to avoid unnecessary StartsWith calls + if (path.Length > 0) + { + // Check for shell folder (starts with "::") + if (path.StartsWith(Constants.PathValidationConstants.SHELL_FOLDER_PREFIX, StringComparison.OrdinalIgnoreCase)) + return true; + + // Check for MTP device (starts with "mtp:") + if (path.StartsWith(Constants.PathValidationConstants.MTP_PREFIX, StringComparison.OrdinalIgnoreCase)) + return true; + } + + var now = DateTime.UtcNow; + + // Check cache first + if (pathValidationCache.TryGetValue(path, out var cached)) + { + // If cache entry is fresh, return cached result + if ((now - cached.checkedAt).TotalSeconds < PathCacheExpirationSeconds) + { + return cached.exists; + } + } + + // Perform actual file system check + bool exists = Directory.Exists(path); + + // Update cache with new result + pathValidationCache.AddOrUpdate(path, + (exists, now), + (_, _) => (exists, now)); + + // Clean up old cache entries deterministically when threshold exceeded + if (pathValidationCache.Count > PathCacheCleanupThreshold) + { + // CRITICAL: Take a snapshot to avoid "Collection was modified" exceptions + // ConcurrentDictionary enumeration is not thread-safe during concurrent modifications + var expiredKeys = pathValidationCache + .ToArray() // Snapshot prevents crashes from concurrent modifications + .Where(kvp => (now - kvp.Value.checkedAt).TotalSeconds > PathCacheExpirationSeconds * 2) + .Select(kvp => kvp.Key) + .Take(PathCacheCleanupBatchSize) + .ToList(); // Materialize before removal to avoid deferred execution issues + + // Safe to remove after snapshot and materialization + foreach (var key in expiredKeys) + { + pathValidationCache.TryRemove(key, out _); + } + } + + return exists; + } + + public void RefreshItems(string? previousDir = null, Action postLoadCallback = null) { RapidAddItemsToCollectionAsync(WorkingDirectory, previousDir, postLoadCallback); } @@ -1631,10 +1706,19 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib if (string.IsNullOrEmpty(path)) return; + // Quick validation to prevent hanging on invalid paths + // Use cached validation to minimize expensive Directory.Exists calls + if (!IsPathValid(path)) + { + Debug.WriteLine($"Skipping enumeration of non-existent path: {path}"); + IsLoadingItems = false; + return; + } + var stopwatch = new Stopwatch(); stopwatch.Start(); - var isRecycleBin = path.StartsWith(Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.Ordinal); + var isRecycleBin = path.StartsWith(Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase); var enumerated = await EnumerateItemsFromStandardFolderAsync(path, addFilesCTS.Token, library); // Hide progressbar after enumeration @@ -1709,8 +1793,9 @@ private async Task EnumerateItemsFromStandardFolderAsync(string path, Cance var isBoxFolder = CloudDrivesManager.Drives.FirstOrDefault(x => x.Text == "Box")?.Path?.TrimEnd('\\') is string boxFolder && path.StartsWith(boxFolder); bool isWslDistro = path.StartsWith(@"\\wsl$\", StringComparison.OrdinalIgnoreCase) || path.StartsWith(@"\\wsl.localhost\", StringComparison.OrdinalIgnoreCase) || path.Equals(@"\\wsl$", StringComparison.OrdinalIgnoreCase) || path.Equals(@"\\wsl.localhost", StringComparison.OrdinalIgnoreCase); - bool isMtp = path.StartsWith(@"\\?\", StringComparison.Ordinal); - bool isShellFolder = path.StartsWith(@"\\SHELL\", StringComparison.Ordinal); + // Check for special path prefixes + bool isMtp = path.StartsWith(Constants.PathValidationConstants.MTP_PREFIX, StringComparison.OrdinalIgnoreCase); + bool isShellFolder = path.StartsWith(Constants.PathValidationConstants.SHELL_FOLDER_PREFIX, StringComparison.Ordinal); bool isNetwork = path.StartsWith(@"\\", StringComparison.Ordinal) && !isMtp && !isShellFolder && @@ -1990,7 +2075,9 @@ private void GetDesktopIniFileData() public void CheckForBackgroundImage() { - if (WorkingDirectory == "Home" || WorkingDirectory == "ReleaseNotes" || WorkingDirectory == "Settings") + if (WorkingDirectory == Constants.PathValidationConstants.HOME_PREFIX || + WorkingDirectory == Constants.PathValidationConstants.RELEASE_NOTES || + WorkingDirectory == Constants.PathValidationConstants.SETTINGS_PREFIX) { FolderBackgroundImageSource = null; return; @@ -2513,8 +2600,6 @@ async Task HandleChangesOccurredAsync() { // Prevent disposed cancellation token } - - Debug.WriteLine("aProcessQueueAction done: {0}", rand); } public Task AddFileOrFolderFromShellFile(ShellFileItem item) diff --git a/src/Files.App/Views/Settings/IpcPage.xaml b/src/Files.App/Views/Settings/IpcPage.xaml new file mode 100644 index 000000000000..33ef89ea0c72 --- /dev/null +++ b/src/Files.App/Views/Settings/IpcPage.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +