diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..e41e397 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "litepost-preview", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["exec", "vite", "preview", "--port", "4173", "--strictPort"], + "port": 4173 + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a62187 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + frontend: + name: Frontend Quality Gates + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # pnpm must be installed before setup-node so its cache probe can find it + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test:run + + - name: Build frontend + run: pnpm build + + rust: + name: Rust Quality Gate + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo check + working-directory: src-tauri + run: cargo check diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ddd6de3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: Deploy Docs + +on: + push: + branches: + - main + paths: + - "docs/**" + - ".github/workflows/docs.yml" + - "package.json" + - "pnpm-lock.yaml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build Docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # pnpm must be installed before setup-node so its cache probe can find it + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build with VitePress + run: pnpm docs:build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index f2924a2..c2f9930 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,13 @@ coverage .temp .cache +# VitePress +docs/.vitepress/cache +docs/.vitepress/dist + +# Claude Code local settings +.claude/settings.local.json + # TypeScript *.tsbuildinfo diff --git a/REAL_APP_TEST_CHECKLIST.md b/REAL_APP_TEST_CHECKLIST.md new file mode 100644 index 0000000..9bf32dd --- /dev/null +++ b/REAL_APP_TEST_CHECKLIST.md @@ -0,0 +1,124 @@ +# LitePost Real-App Test Checklist (Phase 1 -> Phase 2 Gate) + +Use this checklist against the **release build** (`pnpm tauri build`) to validate core power-user flows before starting Phase 2. + +## 1. Release Smoke + +- [ ] Install and launch from `nsis` or `msi` bundle. +- [ ] Create/open/close tabs (`Ctrl+N`, `Ctrl+W` if enabled). +- [ ] Send a basic request (`GET https://httpbin.org/get`) and confirm `200`. +- [ ] Restart app and confirm state persists (tabs, active environment, saved collections). + +## 2. cURL Import Robustness + +### 2.1 Basic + Headers + Query + +Paste: + +```bash +curl -X GET "https://httpbin.org/anything?from=curl&n=1" -H "X-Test: litepost" -H "Accept: application/json" +``` + +- [ ] Method, URL, query, and headers are populated correctly. +- [ ] Sending the imported request succeeds. + +### 2.2 JSON Body + Escaping + +Paste: + +```bash +curl -X POST "https://httpbin.org/anything" -H "Content-Type: application/json" -d "{\"name\":\"LitePost\",\"msg\":\"hello \\\"world\\\"\"}" +``` + +- [ ] Body is valid JSON in editor. +- [ ] Sent request echoes JSON in response. + +### 2.3 Multipart + File + +Paste (replace with a real file path): + +```bash +curl -X POST "https://httpbin.org/post" -F "meta=demo" -F "file=@C:/tmp/demo.txt" +``` + +- [ ] `multipart/form-data` mode activates. +- [ ] Text field and file field are mapped correctly. +- [ ] Request succeeds and response includes `form` + `files`. + +## 3. Multipart Editor UX + +- [ ] Add/remove text and file rows. +- [ ] Pick file with dialog, then send. +- [ ] Save request to collection, reopen it, verify rows persisted. +- [ ] Switch tabs and return; rows remain intact. + +## 4. Pre-request Scripts + +Use script: + +```javascript +pm.environment.set("nonce", Math.random().toString(16).slice(2)); +pm.request.setHeader("X-Nonce", pm.environment.get("nonce")); +pm.request.setQueryParam("nonce", pm.environment.get("nonce")); +``` + +- [ ] Request sends with dynamic header/query values. +- [ ] `{{nonce}}` becomes available in environment. + +Failure behavior: + +```javascript +throw new Error("intentional test error"); +``` + +- [ ] Request does not send. +- [ ] Error clearly identifies the failing script name. + +## 5. Extraction Rules (Single Request) + +Send: + +```http +GET https://httpbin.org/uuid +``` + +Rule: + +- Source: `body` +- Path: `uuid` +- Variable: `last_uuid` + +- [ ] Preview resolves before extraction. +- [ ] "Extract All" stores `last_uuid` in active environment. + +Also verify: + +- [ ] Source `status` with variable `last_status` stores `200`. +- [ ] Source `header` with path `content-type` stores expected value. + +## 6. Collection Runner + Chaining + +Create collection with two requests: + +1) `GET https://httpbin.org/uuid` with extraction rule `uuid -> run_uuid` +2) `GET https://httpbin.org/anything?id={{run_uuid}}` + +- [ ] Select active environment. +- [ ] Run collection. +- [ ] Request 2 resolves `{{run_uuid}}` from request 1 extraction. +- [ ] Runner summary (pass/fail, durations) looks correct. + +## 7. Regression Checks After Bundle Split + +- [ ] Body editor still loads and formats JSON. +- [ ] GraphQL editor still mounts and accepts query/variables. +- [ ] Response/code snippet syntax highlighting still renders. +- [ ] No blank/unstyled editor panes after cold start. + +## 8. Release Artifacts + +Current expected outputs: + +- `src-tauri/target/release/litepost.exe` +- `src-tauri/target/release/bundle/msi/litepost_0.2.0_x64_en-US.msi` +- `src-tauri/target/release/bundle/nsis/litepost_0.2.0_x64-setup.exe` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..b22f093 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,104 @@ +# LitePost Roadmap (Post-Phase 1) + +This roadmap reflects current implementation status and the remaining planned phases. + +## Current Status + +- Phase 0 complete: multipart persistence hardening, collection runner multipart support, cURL parser robustness + tests. +- Phase 1 complete: pre-request script runtime, persistent extraction rules, request chaining support in single sends and collection runner. +- Phase 2 complete: global + per-request timeout, connect timeout, SSL verification toggle, proxy configuration. +- Phase 3 complete: schema introspection, field/argument autocomplete, operation picker, GraphQL error rendering, syntax highlighting. + +## Phase 2 - Network Controls (Complete) + +### Goals + +- Add global and per-request timeout controls. +- Add SSL verification toggle (for local/self-signed development). +- Add proxy configuration support. + +### Deliverables + +- Settings UI + request-level overrides. +- Persisted settings model updates. +- Frontend request options wiring. +- Rust/Tauri transport wiring (`timeout`, `connect timeout`, SSL verify, proxy). +- Error messages for bad proxy/certificate config. + +### Acceptance Criteria + +- Users can set global timeout and override on specific requests. +- Users can disable SSL verification per request or globally. +- Users can route traffic through configured proxy and see requests succeed. +- Collection runner honors the same settings. + +## Phase 3 - GraphQL Power Mode (Complete) + +### Goals + +- Move from basic GraphQL editing to high-productivity workflow. + +### Deliverables + +- Schema introspection fetch and cache. +- Query/mutation autocomplete from schema. +- Operation picker/validation improvements. +- Better GraphQL error rendering. + +### Acceptance Criteria + +- Users can introspect a GraphQL endpoint and get field autocomplete. +- Invalid query/variables issues are surfaced before send where possible. + +## Phase 4 - WebSocket + Runner V2 + +### Goals + +- Add first-class WebSocket support and enhance collection execution. + +### Deliverables + +- WebSocket panel: connect/send/log/status/close. +- Optional JSON formatting helpers in WS panel. +- Runner enhancements: sequential/parallel modes, configurable concurrency. +- Stop-on-fail and summary improvements. + +### Acceptance Criteria + +- Users can use `ws://` / `wss://` endpoints in-app. +- Runner can execute collections in configured mode with clear pass/fail reporting. + +## Phase 5 - Power UX + Local-First Differentiators + +### Goals + +- Improve discoverability and speed for power users. + +### Deliverables + +- Command palette and expanded keyboard shortcuts. +- Response diff/compare workflow. +- OpenAPI import UX cleanup (modal-first, remove prompt-based flow). +- Better local backup/export/import ergonomics. + +### Acceptance Criteria + +- Common actions are available via shortcut/palette. +- Users can compare two responses with meaningful diffs. +- OpenAPI imports are guided and reliable. + +## Phase 6 - Optional Stretch + +### Candidate Features + +- Local mock server. +- Local scheduled monitors/checks. +- Cookie jar inspector/editor enhancements. +- Additional auth helpers (SigV4/HMAC presets). + +## Testing and Quality Gates (All Future Phases) + +- Add targeted unit tests for each new runtime utility. +- Add integration-style tests for request preparation and chaining behavior. +- Ensure `pnpm build` and targeted Vitest suites pass before phase completion. + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..3a6d07f --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,80 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'LitePost', + description: 'A lightweight, cross-platform API testing application', + // Deployed to GitHub Pages (org domain): lykos.ai/LitePost/ + base: '/LitePost/', + head: [['link', { rel: 'icon', href: '/LitePost/logo.png' }]], + + themeConfig: { + logo: '/logo.png', + + nav: [ + { text: 'Guide', link: '/getting-started' }, + { + text: 'Features', + items: [ + { text: 'Making Requests', link: '/making-requests' }, + { text: 'Authentication', link: '/authentication' }, + { text: 'Responses', link: '/responses' }, + { text: 'Collections', link: '/collections' }, + { text: 'Environments', link: '/environments' }, + { text: 'Testing', link: '/testing' }, + ], + }, + { text: 'Contributing', link: '/contributing' }, + ], + + sidebar: [ + { + text: 'Introduction', + items: [ + { text: 'Getting Started', link: '/getting-started' }, + { text: 'Making Requests', link: '/making-requests' }, + ], + }, + { + text: 'Core Features', + items: [ + { text: 'Authentication', link: '/authentication' }, + { text: 'Responses', link: '/responses' }, + { text: 'Collections', link: '/collections' }, + { text: 'Environments', link: '/environments' }, + { text: 'Testing', link: '/testing' }, + ], + }, + { + text: 'Advanced', + items: [ + { text: 'Pre-Request Scripts', link: '/pre-request-scripts' }, + { text: 'Response Extraction', link: '/response-extraction' }, + { text: 'SSE Streaming', link: '/streaming' }, + { text: 'GraphQL', link: '/graphql' }, + { text: 'cURL Import', link: '/curl-import' }, + { text: 'Code Snippets', link: '/code-snippets' }, + ], + }, + { + text: 'Reference', + items: [ + { text: 'Settings', link: '/settings' }, + { text: 'Contributing', link: '/contributing' }, + ], + }, + ], + + socialLinks: [ + { icon: 'github', link: 'https://github.com/LykosAI/LitePost' }, + ], + + search: { + provider: 'local', + }, + + footer: { + message: 'Released under the AGPL-3.0 License.', + copyright: 'Copyright 2025-present LykosAI', + }, + }, +}) diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..e350262 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,187 @@ +# Authentication + +LitePost supports several authentication schemes through the **Auth** chip on the request panel (the chip shows a ✓ whenever auth is configured). Select an auth type from the dropdown to configure credentials. The appropriate headers or parameters are applied automatically when the request is sent. + +## No Auth + +The default setting. No authentication headers or parameters are added to the request. Use this for public endpoints or when you are managing auth headers manually in the Headers section. + +## Basic Auth + +HTTP Basic Authentication encodes a username and password as a Base64 string and sends it in the `Authorization` header. + +**Fields:** + +| Field | Description | +|----------|--------------------------| +| Username | Your account username | +| Password | Your account password | + +When you fill in both fields, LitePost generates the header automatically: + +``` +Authorization: Basic amFuZTpzM2NyZXQ= +``` + +The value is `base64("username:password")`. LitePost encodes this on every send, so changes to the credentials take effect immediately. + +::: warning +Basic Auth transmits credentials in a reversible encoding, not encryption. Always use HTTPS when sending Basic Auth requests. +::: + +## Bearer Token + +Bearer token authentication is the most common scheme for APIs secured with OAuth 2.0, JWTs, or similar token-based systems. + +**Fields:** + +| Field | Description | +|-------|-------------------------------------| +| Token | The bearer token string | + +Paste your token into the field and LitePost adds the header: + +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +If you obtain tokens through the OAuth 2.0 flow (described below), the token field is populated automatically. + +## API Key + +API Key authentication sends a custom key-value pair as either a header or a query parameter. This is common for services like Google Maps, OpenWeatherMap, and Stripe. + +**Fields:** + +| Field | Description | +|-----------|----------------------------------------------------| +| Key Name | The name of the header or query parameter | +| Value | The API key string | +| Add To | Choose **Header** or **Query Parameter** placement | + +### Header placement + +``` +X-API-Key: sk-abc123def456 +``` + +### Query parameter placement + +``` +https://api.example.com/data?api_key=sk-abc123def456 +``` + +The key name and placement are fully configurable, so this works with APIs that expect the key under any name (`apiKey`, `x-api-key`, `access_token`, etc.). + +## OAuth 2.0 + +LitePost supports three OAuth 2.0 grant types. Each grant type is suited to a different scenario -- choose the one that matches your API's requirements. + +### Common Fields + +These fields appear for all OAuth 2.0 grant types: + +| Field | Description | +|---------------|--------------------------------------------------------------| +| Token URL | The authorization server's token endpoint | +| Client ID | Your application's client identifier | +| Client Secret | Your application's client secret (may be optional for PKCE) | +| Scopes | Space-separated list of requested permissions | + +### Auto-Fill from OIDC Discovery + +Instead of hunting down endpoint URLs, paste your provider's issuer into the +**Discovery URL** field and click **Auto-fill**. LitePost accepts any of: + +- A bare issuer or base URL -- `https://accounts.google.com` or just `auth.example.com` +- An issuer with a tenant path -- `https://login.example.com/tenant/v2.0` +- The full `/.well-known/openid-configuration` URL + +LitePost fetches the discovery document (through its own HTTP backend, so CORS is +not a concern), fills in the **Authorization URL** and **Token URL**, and -- if the +Scopes field is empty -- seeds it with the standard scopes the provider actually +supports (`openid profile email`). Values you have already typed in Scopes are +never overwritten, and the field supports `{{variables}}` like everything else. + +### Authorization Code + +Use this grant type when the API requires user login through a browser. This is the standard flow for apps acting on behalf of a user. + +**How it works:** + +1. Click **Get New Access Token** in LitePost. +2. A browser window opens to the authorization server's login page. +3. The user logs in and grants consent. +4. The authorization server redirects back to LitePost with an authorization code. +5. LitePost exchanges the code for an access token at the Token URL. + +**Additional fields:** + +| Field | Description | +|-------------------|----------------------------------------------------------| +| Authorization URL | The authorization server's authorize endpoint | +| Callback URL | The redirect URI registered with your OAuth application. Leave blank to use LitePost's local callback server (`http://localhost:17823/callback`; if that port is busy, LitePost automatically falls back to a free one). | + +#### PKCE Support + +LitePost supports Proof Key for Code Exchange (PKCE), which is recommended for public clients that cannot securely store a client secret. When PKCE is enabled: + +- LitePost generates a random `code_verifier` and derives a `code_challenge` using SHA-256. +- The `code_challenge` is sent with the authorization request. +- The `code_verifier` is sent with the token exchange request. + +This prevents authorization code interception attacks without requiring a client secret. + +### Client Credentials + +Use this grant type for server-to-server communication where no user is involved. The application authenticates directly using its own client ID and secret. + +**How it works:** + +1. LitePost sends the client ID and secret to the Token URL. +2. The authorization server returns an access token. + +``` +POST /oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id=my-app&client_secret=secret123&scope=read write +``` + +No browser interaction is required. This is the simplest OAuth flow. + +### Password Grant + +Use this grant type when you have the user's username and password directly. This grant type is typically reserved for first-party applications or testing environments. + +**Additional fields:** + +| Field | Description | +|----------|------------------------| +| Username | The user's username | +| Password | The user's password | + +**How it works:** + +1. LitePost sends the username, password, client ID, and secret to the Token URL. +2. The authorization server validates the credentials and returns an access token. + +``` +POST /oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=password&username=jane&password=s3cret&client_id=my-app&client_secret=secret123 +``` + +::: warning +The Password Grant sends user credentials directly to the token endpoint. Only use this with trusted authorization servers over HTTPS. Many providers have deprecated this grant type in favor of Authorization Code with PKCE. +::: + +### Token Management + +Once a token is obtained through any OAuth flow, LitePost handles it as follows: + +- **Automatic attachment** -- the access token is added to requests as a `Bearer` token in the `Authorization` header. +- **Expiration tracking** -- LitePost records the token's `expires_in` value and displays when the token will expire. Expired tokens are flagged so you know when to refresh. +- **Token refresh** -- if the authorization server issued a refresh token, LitePost can exchange it for a new access token without repeating the full authorization flow. Click **Refresh Token** when the current token has expired or is about to expire. +- **Manual override** -- you can paste a token directly into the Access Token field if you obtained it outside of LitePost. diff --git a/docs/code-snippets.md b/docs/code-snippets.md new file mode 100644 index 0000000..12d2733 --- /dev/null +++ b/docs/code-snippets.md @@ -0,0 +1,168 @@ +# Code Snippets + +LitePost can generate ready-to-use code from any request in six languages. This is useful for sharing requests with teammates, embedding API calls in applications, or quickly prototyping integrations outside of LitePost. + +## Accessing Code Snippets + +Open the **Code** section in the request panel (under the `⋯` menu). The generated code reflects the current state of the request -- method, URL, headers, body, auth, and cookies. As you modify the request, the snippet updates automatically. + +## Supported Languages + +Select a language from the dropdown at the top of the snippet viewer. + +### cURL + +A shell command using `curl`. Includes method, headers, body, cookies, and auth headers. + +```bash +# WARNING: curl commands may expose secrets in process listings + +curl + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer tok_abc123" \ + -d '{"name": "Jane Doe"}' \ + "https://api.example.com/users" +``` + +### Python + +Uses the `httpx` library with a reusable client, timeout, redirect following, and HTTP/2 support. + +```python +# Install: pip install httpx +import httpx + +url = "https://api.example.com/users" + +headers = { + "Content-Type": "application/json", + "Authorization": "Bearer tok_abc123", +} + +with httpx.Client( + timeout=30.0, + follow_redirects=True, + http2=True, +) as client: + response = client.post( + url, + headers=headers, + ) + + print(f"Status Code: {response.status_code}") +``` + +### JavaScript + +Provides two variants in a single snippet -- one using the Fetch API and one using Axios. + +```javascript +// Using fetch +const options = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({"name": "Jane Doe"}), +}; + +fetch( + "https://api.example.com/users", + options +) + .then(response => response.text()) + .then(data => { + console.log("Response:", JSON.parse(data)) + }) + .catch(error => console.error(error)); +``` + +### C# + +Uses `HttpClient` with `IHttpClientFactory` through dependency injection, following Microsoft's recommended patterns for production use. + +```csharp +using System.Net.Http; +using System.Text; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddHttpClient(); +var provider = services.BuildServiceProvider(); + +var clientFactory = provider.GetRequiredService(); +var client = clientFactory.CreateClient(); + +var response = await client + .PostAsync(url, content); +``` + +### Go + +Uses `net/http` with context-based timeouts, a configured transport, and proper resource cleanup. + +```go +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "time" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + "POST", + url, + body, + ) + // ... +} +``` + +### Ruby + +Uses the `faraday` gem with retry middleware, logging, and error handling. + +```ruby +# Install with: gem install faraday +require "faraday" + +conn = Faraday.new( + url: "https://api.example.com/users", + request: { timeout: 30 } +) do |f| + f.request :retry, max: 2, interval: 0.05 + f.response :logger + f.adapter Faraday.default_adapter +end + +response = conn + .post do |req| + req.body = request_body + end +``` + +## What Is Included + +The generated code includes all active parts of the request: + +- **Method** -- the HTTP verb +- **URL** -- with API-key query parameters appended when auth is set to query mode +- **Headers** -- all enabled custom headers plus auth headers (Basic, Bearer, API Key) +- **Body** -- the request body in the appropriate format for the language +- **Cookies** -- attached as headers or language-specific cookie objects +- **Content-Type** -- set automatically based on the body type + +Disabled headers and parameters are excluded from the generated code. + +## Copy to Clipboard + +Click the **copy button** next to the language selector to copy the full snippet to your clipboard. The snippet is ready to paste into a terminal, script, or source file. diff --git a/docs/collections.md b/docs/collections.md new file mode 100644 index 0000000..9a04b48 --- /dev/null +++ b/docs/collections.md @@ -0,0 +1,101 @@ +# Collections + +Collections are saved groups of API requests that let you organize, reuse, and share your work. Instead of rebuilding requests from scratch each session, you save them into named collections and load them whenever needed. + +## Creating a Collection + +1. Click the **collections icon** in the title bar to open the Collections panel. +2. Click **Create New Collection**. +3. Enter a name (e.g., "User Service" or "Payment API") and confirm. + +The new collection appears in the panel, ready for requests. + +## Saving Requests + +To save the current request into a collection: + +1. Click the **Save** button in the request panel (or use the save dialog). +2. Select the target collection from the list. +3. Optionally edit the request name, then confirm. + +The request is saved with its full configuration: URL, method, headers, body, authentication, and query parameters. + +## Loading Requests + +Click any saved request in the Collections panel to open it in a new tab. Each loaded request is independent -- edits to the tab do not modify the saved collection entry until you explicitly save again. + +## Searching Collections + +Use the search field at the top of the Collections panel to filter collections and requests by name. The filter applies across all collections, surfacing matching requests regardless of which collection they belong to. + +## Renaming and Deleting + +- **Rename a collection**: Click the collection menu (three-dot icon) and select **Rename**. Enter the new name and confirm. +- **Delete a collection**: Click the collection menu and select **Delete**. This removes the collection and all its saved requests permanently. +- **Delete a single request**: Open the request context menu within a collection and select **Delete**. + +## Import and Export + +### Export as JSON + +Export a collection to a JSON file for backup or sharing: + +1. Open the collection menu and select **Export**. +2. Choose a save location. The file is written in LitePost's native JSON format. + +### Import from Postman v2.1 + +LitePost fully supports importing Postman Collection v2.1 files: + +1. Click **Import** in the Collections panel. +2. Select a `.json` file exported from Postman (v2.1 format). +3. LitePost parses the file and creates a new collection with all requests, folders, headers, bodies, and auth configurations preserved. + +### Import from OpenAPI 3.x + +Import an OpenAPI 3.x specification to auto-generate a collection of requests: + +1. Click **Import** and select **OpenAPI**. +2. Provide the spec via **file upload** or by entering a **URL**. +3. LitePost parses the spec and creates requests for each endpoint, including: + - HTTP method and path + - Path parameters extracted and inserted as `{{paramName}}` variables + - Request body schemas converted to sample JSON payloads + - Documented headers and query parameters + +This is useful for quickly scaffolding a collection from an existing API definition. + +## Collection Runner + +The Collection Runner executes every request in a collection sequentially, applying environment variables, pre-request scripts, and test assertions along the way. + +### Starting a Run + +1. Open a collection and click **Run Collection**. +2. Select the active environment (if any) for variable substitution. +3. Click **Start** to begin execution. + +### Execution Flow + +For each request in the collection, the runner: + +1. **Substitutes environment variables** -- replaces `{{variableName}}` placeholders in the URL, headers, body, and auth fields with values from the active environment. +2. **Executes pre-request scripts** -- runs any configured pre-request script before sending the request. Scripts can modify variables or set up state. +3. **Sends the request** and records the response. +4. **Runs test assertions** -- evaluates test scripts against the response. Each assertion is marked as pass or fail. + +### Progress Tracking + +During execution, the runner displays progress as **current / total** (e.g., "3 / 12"). You can see which request is currently executing. + +### Results Summary + +After the run completes, the results view shows: + +- **Per-request status**: HTTP status code for each request. +- **Timing**: Response time for each request in milliseconds. +- **Test results**: Pass/fail count for each request's test assertions, with details on any failures. + +### Cancellation + +Click **Cancel** at any time during a run to stop execution. Requests already completed retain their results; the remaining requests are skipped. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..7eda8d0 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,127 @@ +# Contributing + +This guide covers how to set up a local development environment, run tests, and submit changes to LitePost. + +## Prerequisites + +### All Platforms + +- [Node.js](https://nodejs.org/) v18 or later +- [pnpm](https://pnpm.io/) v8 or later +- [Rust](https://www.rust-lang.org/tools/install) (latest stable toolchain) + +### Windows + +- Microsoft Visual Studio C++ Build Tools (install via the [Visual Studio Installer](https://visualstudio.microsoft.com/visual-cpp-build-tools/) -- select the "Desktop development with C++" workload) + +### macOS + +- Xcode Command Line Tools (install with `xcode-select --install`) + +### Linux + +Install the required system libraries: + +```bash +sudo apt update +sudo apt install -y \ + build-essential \ + libwebkit2gtk-4.0-dev \ + curl \ + wget \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev +``` + +## Development Setup + +1. Fork and clone the repository: + + ```bash + git clone https://github.com//LitePost.git + cd LitePost + ``` + +2. Install frontend dependencies: + + ```bash + pnpm install + ``` + +3. Start the development server: + + ```bash + pnpm tauri dev + ``` + + This launches both the Vite dev server (with hot-reload for the React frontend) and the Tauri application window. Rust backend changes trigger an automatic rebuild of the native binary. + +## Building for Production + +```bash +pnpm tauri build +``` + +Platform-specific installers and bundles are written to `src-tauri/target/release/bundle/`. + +## Project Structure + +``` +LitePost/ + src/ React frontend + components/ UI components (request panel, response panel, auth, etc.) + hooks/ Custom React hooks (useRequest, useStreamingResponse, useTabs) + store/ Zustand stores (environments, settings, theme) + utils/ Utility functions (cURL parser, streaming, persistence) + types/ Shared TypeScript type definitions + test/ Test files (Vitest + React Testing Library) + src-tauri/ Rust backend + src/ + lib.rs Tauri command definitions + http_client.rs HTTP request execution + models.rs Shared Rust types + network_utils.rs Network helper functions + docs/ Documentation (VitePress) +``` + +## Running Tests + +Tests use [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/). Test files live in `src/test/`. + +| Command | Description | +|------------------------|------------------------------------| +| `pnpm test` | Run all tests | +| `pnpm test:watch` | Run tests in watch mode | +| `pnpm test:coverage` | Generate a coverage report | +| `pnpm test:run` | Run all tests once (CI-friendly) | +| `pnpm test:ui` | Open the Vitest UI | + +## Contributing Workflow + +1. **Fork** the repository on GitHub. +2. **Create a feature branch** from `main`: + ```bash + git checkout -b feature/my-change + ``` +3. **Make your changes.** Follow existing code style and patterns. Keep commits focused. +4. **Run tests** to make sure nothing is broken: + ```bash + pnpm test:run + ``` +5. **Open a pull request** against `main` on [github.com/LykosAI/LitePost](https://github.com/LykosAI/LitePost). Include a clear description of what your change does and why. + +## Documentation + +The docs site is built with [VitePress](https://vitepress.dev/). To preview documentation changes locally: + +```bash +pnpm docs:dev +``` + +This starts a local dev server with hot-reload at `http://localhost:5173`. + +## License + +LitePost is licensed under the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). By contributing, you agree that your contributions will be licensed under the same terms. diff --git a/docs/curl-import.md b/docs/curl-import.md new file mode 100644 index 0000000..c5a00aa --- /dev/null +++ b/docs/curl-import.md @@ -0,0 +1,86 @@ +# cURL Import + +LitePost can parse any cURL command and convert it into a fully populated request tab. This is useful when you copy a request from browser DevTools, a README, or a teammate's message and want to replay or modify it in LitePost. + +## Opening the Import Dialog + +Open the cURL import modal in one of two ways: + +- Click the **cURL import button** in the title bar (terminal icon). +- Use the keyboard shortcut **Ctrl+I** (Windows/Linux) or **Cmd+I** (macOS). + +## Importing a cURL Command + +Paste or type a cURL command into the text area. LitePost parses the command as you type and shows a **live preview** below the input. + +``` +curl -X POST https://api.example.com/users \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer tok_abc123" \ + -d '{"name": "Jane Doe", "email": "jane@example.com"}' +``` + +The preview displays: + +- **Method** -- the resolved HTTP method (e.g. `POST`) +- **URL** -- the target URL +- **Headers count** -- how many headers were parsed +- **Auth type** -- detected auth (basic, bearer, or none) +- **Body type** -- the inferred content type +- **Cookies count** -- how many cookies were parsed +- **Params count** -- query parameters extracted from the URL + +If the command is malformed or missing a URL, an error message appears instead of the preview. Fix the command and the preview updates automatically. + +Click **Import Request** to create a new tab with all the parsed fields populated. Click **Cancel** to close the dialog without importing. + +## Supported cURL Features + +The parser handles the following flags and formats: + +| Flag | Long form | Description | +|------|-----------|-------------| +| `-X` | `--request` | HTTP method (`GET`, `POST`, `PUT`, etc.) | +| `-H` | `--header` | Request header (`"Key: Value"`) | +| `-d` | `--data`, `--data-raw`, `--data-binary`, `--data-ascii`, `--data-urlencode` | Request body | +| `-u` | `--user` | Basic auth credentials (`user:password`) | +| `-A` | `--user-agent` | User-Agent header | +| `-b` | `--cookie` | Cookies (`"name=value; name2=value2"`) | +| `-F` | `--form`, `--form-string` | Multipart form data (`"field=value"` or `"file=@path"`) | +| `-e` | `--referer` | Referer header | +| `-G` | `--get` | Force GET method (appends `-d` data as query params) | +| `-I` | `--head` | Force HEAD method | +| | `--compressed` | Adds `Accept-Encoding: gzip, deflate, br` | +| | `--url` | Explicit URL (alternative to positional argument) | + +Flags that do not affect the HTTP request itself (`-L`, `-k`, `-s`, `-v`, `-i`, `-o`) are silently ignored. + +## Parsing Behavior + +The parser handles several real-world cURL patterns: + +- **Quoted strings** -- both single and double quotes are supported for header values and body content. +- **Line continuations** -- backslash-newline sequences (`\` at end of line) are joined into a single command. +- **Attached flags** -- compact forms like `-XPOST` or `-HAccept:application/json` are expanded correctly. +- **Long flag equals syntax** -- `--header="Key: Value"` is equivalent to `--header "Key: Value"`. +- **Auth detection** -- `Authorization: Basic ...` and `Authorization: Bearer ...` headers are detected and moved to the auth configuration instead of being listed as plain headers. +- **Content type inference** -- if no `Content-Type` header is present, the parser infers `application/json` for JSON bodies, `application/x-www-form-urlencoded` for key-value pairs, and `text/plain` otherwise. + +## Example + +A cURL command copied from browser DevTools: + +```bash +curl 'https://api.example.com/search?q=litepost' \ + -H 'Accept: application/json' \ + -H 'Cookie: session=abc123; theme=dark' \ + --compressed +``` + +After import, the new tab contains: + +- **Method:** `GET` +- **URL:** `https://api.example.com/search?q=litepost` +- **Headers:** `Accept: application/json`, `Accept-Encoding: gzip, deflate, br` +- **Cookies:** `session=abc123`, `theme=dark` +- **Params:** `q=litepost` diff --git a/docs/environments.md b/docs/environments.md new file mode 100644 index 0000000..6e2e70b --- /dev/null +++ b/docs/environments.md @@ -0,0 +1,91 @@ +# Environments + +Environments are named sets of key-value variables that let you switch between configurations without editing individual requests. A typical setup includes environments like "Development", "Staging", and "Production", each defining the same variable names with different values. + +## Creating and Managing Environments + +1. Click the **environment icon** in the title bar to open the Environments panel. +2. Click **Create New Environment** and enter a name (e.g., "Development"). +3. To edit an existing environment, select it from the list and modify its variables. +4. To delete an environment, open its context menu and select **Delete**. + +## Adding Variables + +Each environment contains a list of key-value pairs. To add a variable: + +1. Open the environment editor for the target environment. +2. Enter a **key** (e.g., `baseUrl`) and a **value** (e.g., `https://dev.api.example.com`). +3. Add as many variables as needed. Common examples: + +| Key | Value (Development) | Value (Production) | +|-------------|--------------------------------------|-----------------------------------| +| `baseUrl` | `https://dev.api.example.com` | `https://api.example.com` | +| `authToken` | `dev-token-abc123` | `prod-token-xyz789` | +| `apiVersion`| `v2` | `v2` | + +## Switching Environments + +Select the active environment from the **dropdown** in the title bar. Only one environment is active at a time. All variable substitutions use the active environment's values. Selecting "No Environment" disables substitution. + +## Variable Substitution + +Wrap any variable name in double curly braces to reference it: `{{variableName}}`. LitePost replaces these placeholders with the corresponding value from the active environment at the time the request is sent. + +### Where Substitution Works + +Variables are resolved in: + +- **URLs** -- `{{baseUrl}}/api/users/{{userId}}` +- **Headers** -- `Authorization: Bearer {{authToken}}` +- **Request bodies** -- `{"tenant": "{{tenantId}}", "role": "admin"}` +- **Query parameters** -- `page={{pageNumber}}&limit={{pageSize}}` +- **Authentication credentials** -- API keys, bearer tokens, basic auth username/password fields + +### Example + +Given an environment with: + +| Key | Value | +|-----------|--------------------------------| +| `baseUrl` | `https://dev.api.example.com` | +| `userId` | `42` | + +A request to `{{baseUrl}}/api/users/{{userId}}` resolves to: + +``` +GET https://dev.api.example.com/api/users/42 +``` + +## How Variables Get Populated + +Variables can be set through three mechanisms. + +### Manual Entry + +Open the environment editor and type key-value pairs directly. This is the most straightforward approach for static configuration like base URLs, API keys, and default parameters. + +### Pre-Request Scripts + +Pre-request scripts run before each request is sent and can programmatically set environment variables using the `lp.environment.set()` function: + +```javascript +// Generate a timestamp before each request +lp.environment.set("timestamp", Date.now().toString()); + +// Compute a derived value +lp.environment.set("requestId", crypto.randomUUID()); +``` + +This is useful for values that need to be computed dynamically, such as timestamps, nonces, or derived tokens. + +### Response Extraction Rules + +Response extraction rules automatically save values from a response into environment variables. After a request completes, the extraction engine pulls data from the response (e.g., a JSON field, a header value) and writes it to the specified variable. + +For example, after a login request returns `{"access_token": "eyJ..."}`, an extraction rule can save the token to `{{authToken}}` so subsequent requests use it automatically. + +This enables chaining requests: authenticate once, extract the token, and use it in every following request without manual copy-paste. + +## Persistence + +Environments are automatically saved to disk and persist across application sessions. There is no manual save step -- changes are written as you make them. Environment data is stored locally in a JSON file managed by LitePost's file system plugin. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..90cabdd --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,138 @@ +# Getting Started + +This guide walks you through installing LitePost and sending your first API request. + +## Download & Install + +LitePost is available for Windows, macOS, and Linux. Head to the +[GitHub Releases page](https://github.com/LykosAI/LitePost/releases) to grab the +latest installer for your platform. + +| Platform | Status | Installer | +|----------|--------|-----------| +| Windows | Stable | `.msi` or `.exe` setup | +| macOS | Beta | `.dmg` disk image | +| Linux | Beta | `.AppImage` or `.deb` package | + +Download the installer, run it, and follow the on-screen prompts. LitePost has no +sign-up requirement and no account needed -- it is ready to use the moment you open it. + +:::tip +On macOS you may need to right-click the app and choose **Open** the first time, since +the build is not yet notarized by Apple. +::: + +## Your First Request + +Once LitePost is open, you will see a blank request tab ready to go. Follow these steps +to send a simple GET request. + +1. **Choose a method.** The method dropdown on the left of the URL bar defaults to + **GET**, which is what you want for a basic read request. + +2. **Enter a URL.** Click the URL input field and type a test endpoint. A good one to + start with is: + + ``` + https://jsonplaceholder.typicode.com/posts/1 + ``` + +3. **Send the request.** Click the **Send** button, or simply press **Enter**. LitePost + will fire the request and display a loading indicator while it waits for the server. + +4. **Read the response.** The response panel below the request panel shows the returned + data. For the URL above you will see a JSON object with fields like `userId`, `id`, + `title`, and `body`. The status badge (e.g., **200 OK**), response time, and + response size are displayed at the top of the panel. + +:::info +LitePost supports environment variables in URLs and headers. Wrap a variable name in +double curly braces -- for example `{{base_url}}/posts/1` -- and it will be replaced +at send time with the value from your active environment. +::: + +## Exploring the Interface + +LitePost organizes everything into a few key areas. + +### Tab Bar + +The tab bar runs across the top of the main content area. Each tab represents an +independent request with its own method, URL, headers, body, and response. You can +open as many tabs as you need and rename them by double-clicking the tab title. + +### Request Panel + +The top of the main area is the request panel: the URL bar plus a row of **section +chips**. Each chip summarizes its section at a glance (`Headers 3`, `Auth ✓`, +`Body 768B`) and clicking one opens that editor; clicking again -- or sending the +request -- collapses it so the response gets the full window. Drag the handle +below an open editor to resize it. + +The everyday sections have their own chips: + +- **Params** -- query parameters parsed from the URL, editable as key-value pairs. +- **Headers** -- add or edit request headers. +- **Auth** -- configure authentication (None, Basic, Bearer Token, API Key, or OAuth 2.0). +- **Body** -- write a request body in JSON, plain text, XML, or other formats. Multipart form-data with file uploads is also supported. +- **Tests** -- write test scripts and assertions that execute after a response arrives. + +The rest live in the **`⋯` menu**: + +- **Cookies** -- attach cookies to the request. +- **Pre-request** -- run JavaScript scripts before the request is sent. +- **Code** -- view auto-generated code snippets for the current request in cURL, Python, JavaScript, C#, Go, or Ruby. +- **GraphQL** -- toggle GraphQL mode to write queries and variables with a dedicated editor. +- **Settings** -- per-request network overrides (timeout, SSL verification, proxy). +- **WebSocket** -- connect to a WebSocket endpoint and exchange messages. + +### Response Panel + +The rest of the window belongs to the response. Before your first request it offers +a small gallery of sample requests you can send with one click. After a response +arrives you get: + +- **Response** -- the formatted response body with syntax highlighting and collapsible JSON, plus a filter bar for narrowing JSON bodies with plain text or a path like `$.items[*].name`. +- **Preview** -- an HTML preview when the response is an HTML document. +- **Raw** -- the unformatted response body. +- **Headers** -- response headers in a readable table. +- **Redirects** -- the full redirect chain, when applicable. +- **Cookies** -- cookies set by the server. +- **Timing** -- a breakdown of DNS, TCP, TLS, first byte, and download times. +- **Extract** -- define rules to extract values from responses into environment variables. + +### History Sidebar + +The left sidebar lists every request you have sent, grouped by date, with +back-to-back repeats collapsed into a single row (marked `×N`). Click an entry to +reopen it in a new tab, search it from the box at the top, or collapse the whole +sidebar to a slim rail with the button in its header. + +### Command Palette + +Press **Ctrl+K** (or **Cmd+K** on macOS) anywhere to open the command palette. It +fuzzy-searches your history and saved collection requests, switches environments, +and runs actions like importing a cURL command or opening any panel -- all without +touching the mouse. + +### Title Bar + +The title bar at the very top of the window gives you quick access to: + +- **Search (`Ctrl K` pill)** -- opens the command palette. +- **Environment selector** -- switch the active environment. +- **cURL Import** -- paste a cURL command to instantly populate a new request tab. +- **Collection Runner** -- batch-run a saved collection. +- **Collections** -- open and manage saved request collections. +- **Environments** -- create and edit environment variable sets. +- **Settings** -- configure theme, JSON viewer behavior, and other preferences. + +## Next Steps + +Now that you know your way around, explore some of LitePost's deeper features: + +- [Collections](/collections) -- save, organize, and batch-run groups of requests. +- [Environments](/environments) -- manage variables across development, staging, and production. +- [Authentication](/authentication) -- set up OAuth 2.0, API keys, and more. +- [Testing](/testing) -- write assertions and scripts to automate response validation. +- [Streaming](/streaming) -- work with SSE and chunked transfer responses in real time. diff --git a/docs/graphql.md b/docs/graphql.md new file mode 100644 index 0000000..3ebe73d --- /dev/null +++ b/docs/graphql.md @@ -0,0 +1,92 @@ +# GraphQL + +LitePost includes a dedicated GraphQL mode that provides a structured editing experience for GraphQL queries and mutations. Instead of manually constructing a JSON body, you get purpose-built editors for the query, variables, and operation name. + +## Enabling GraphQL Mode + +In the request body editor, select **GraphQL** from the body type dropdown. The body editor is replaced by the GraphQL editor panel with three sections. + +The request method is typically `POST` and the URL points to your GraphQL endpoint: + +``` +POST https://api.example.com/graphql +``` + +## Editor Sections + +### Query Editor + +The main editor where you write your GraphQL query or mutation. It uses Monaco with GraphQL syntax highlighting, bracket matching, folding, and word wrap. + +```graphql +query GetUser($id: ID!) { + user(id: $id) { + name + email + posts { + title + createdAt + } + } +} +``` + +A **Format** button in the toolbar reformats the document for readability. + +### Variables Editor + +Switch to the **Variables** tab to open a JSON editor for query variables. Variables are sent as a parsed JSON object alongside the query. + +```json +{ + "id": "usr_42" +} +``` + +If the variables field is empty or contains invalid JSON, the request is sent without a `variables` key. LitePost does not block the request -- this lets you iterate quickly even with incomplete input. + +### Operation Name + +A text input to the right of the tab bar lets you specify which operation to execute. This is required when your document contains multiple named operations: + +```graphql +query GetUser($id: ID!) { + user(id: $id) { name } +} + +query ListUsers { + users { name } +} +``` + +Setting the operation name to `ListUsers` tells the server to execute that specific query. If your document has only one operation, you can leave this field blank. + +## Request Format + +When you click **Send**, LitePost constructs a JSON body from the three editor fields and sends it as a `POST` request with `Content-Type: application/json`: + +```json +{ + "query": "query GetUser($id: ID!) { user(id: $id) { name email } }", + "variables": { + "id": "usr_42" + }, + "operationName": "GetUser" +} +``` + +The `variables` key is omitted if the variables editor is empty. The `operationName` key is omitted if the operation name input is blank. + +## Auth and Environment Variables + +GraphQL mode works with all authentication types -- Basic, Bearer, API Key, and OAuth 2.0. Configure auth in the **Auth** section as you would for any other request. + +Environment variables (e.g. `{{base_url}}`, `{{auth_token}}`) are resolved in the URL, headers, and variable values before the request is sent. + +## Planned Improvements + +The following enhancements are on the roadmap: + +- **Schema introspection** -- fetch the schema from the endpoint and use it to power editor features +- **Autocomplete** -- field and type suggestions based on the introspected schema +- **Better error rendering** -- inline display of GraphQL-specific errors returned in the `errors` array diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8632fdb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,29 @@ +--- +layout: home + +hero: + name: LitePost + text: Lightweight API Testing + tagline: A fast, native, cross-platform alternative to Postman -- built with Tauri, Rust, and React. + actions: + - theme: brand + text: Get Started + link: /getting-started + - theme: alt + text: View on GitHub + link: https://github.com/LykosAI/LitePost + +features: + - title: Fast & Native + details: Built on Tauri 2.0 and Rust, LitePost launches instantly and uses a fraction of the memory of Electron-based tools. Your system resources stay free for the work that matters. + - title: Full HTTP Support + details: All standard methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS), multiple body types including JSON, form-data, and GraphQL, plus authentication via Basic, Bearer, API Key, and OAuth 2.0. + - title: Collections & Environments + details: Organize requests into collections, define environment variables, and switch contexts in a click. Import existing workflows from cURL commands or OpenAPI specs. + - title: Testing & Assertions + details: Write test scripts and assertions that run against responses automatically. Validate status codes, JSON paths, headers, and response times without leaving the app. + - title: Streaming + details: First-class support for Server-Sent Events (SSE) and chunked transfer streams. Watch events arrive in real time with per-chunk timestamps and one-click cancellation. + - title: Code Generation + details: Generate ready-to-use code snippets for your request in six languages -- cURL, Python, JavaScript, C#, Go, and Ruby -- and copy them to your clipboard instantly. +--- diff --git a/docs/making-requests.md b/docs/making-requests.md new file mode 100644 index 0000000..ae4b13a --- /dev/null +++ b/docs/making-requests.md @@ -0,0 +1,163 @@ +# Making Requests + +LitePost supports all standard HTTP methods and gives you full control over every part of an outgoing request -- URL, parameters, headers, body, and cookies. + +## HTTP Methods + +Select a method from the dropdown to the left of the URL bar. The following methods are available: + +| Method | Typical Use | +|-----------|------------------------------------| +| `GET` | Retrieve a resource | +| `POST` | Create a resource or submit data | +| `PUT` | Replace a resource entirely | +| `PATCH` | Partially update a resource | +| `DELETE` | Remove a resource | +| `HEAD` | Retrieve headers only (no body) | +| `OPTIONS` | Query supported methods/CORS info | + +The selected method is displayed in the URL bar and persists with the tab. + +## URL Bar and Sending + +Type or paste a full URL into the URL bar: + +``` +https://api.example.com/v1/users +``` + +Press the **Send** button (or hit **Enter** in the URL bar) to execute the request. LitePost will resolve environment variables in the URL before sending -- see the Environments documentation for details. When the URL contains `{{variables}}`, a badge appears at the end of the URL bar; hover it to see each variable's resolved value, with a ⚠ warning for anything the active environment cannot resolve. + +If the URL contains query parameters inline (e.g. `?page=1&limit=20`), they will be parsed and shown in the Query Parameters editor automatically. + +## Query Parameters + +Below the URL bar, the **Params** chip opens an editor for building query parameters as key-value pairs without hand-editing the URL string. (Each request section -- Params, Headers, Auth, Body, Tests, and the rest under the `⋯` menu -- works this way: click the chip to open its editor, click again or send the request to collapse it, and drag the handle underneath to resize.) + +Each parameter row has: + +- **Key** -- the parameter name +- **Value** -- the parameter value +- **Toggle** -- a checkbox to enable or disable the parameter without deleting it + +Disabled parameters stay in the list but are excluded from the outgoing URL. This is useful for experimenting with optional parameters. + +``` +Key: page Value: 1 [enabled] +Key: limit Value: 20 [enabled] +Key: debug Value: true [disabled] +``` + +The URL bar updates in real time as you add, remove, or toggle parameters. + +## Request Headers + +The **Headers** chip opens an editor for attaching custom HTTP headers as key-value pairs. Like query parameters, each header has a toggle to enable or disable it individually, and values containing `{{variables}}` show an inline badge with their resolved values. + +``` +Key: Content-Type Value: application/json [enabled] +Key: X-Request-ID Value: abc-123 [enabled] +Key: X-Debug-Mode Value: verbose [disabled] +``` + +LitePost automatically includes standard headers such as `User-Agent` and `Accept`. Any headers you add override the defaults when there is a name collision. + +## Request Body + +When using methods that accept a body (`POST`, `PUT`, `PATCH`), select a body type from the dropdown above the body editor. The available types are described below. + +### JSON + +The default body type for most API work. LitePost provides a Monaco-based editor with: + +- Syntax highlighting +- Bracket matching and auto-closing +- Inline validation for malformed JSON + +```json +{ + "name": "Jane Doe", + "email": "jane@example.com", + "roles": ["admin", "editor"] +} +``` + +The `Content-Type` header is set to `application/json` automatically. + +### Form URL-Encoded + +A key-value editor for `application/x-www-form-urlencoded` bodies -- the encoding used by standard HTML forms. + +``` +Key: username Value: janedoe +Key: password Value: s3cret +``` + +Values are URL-encoded on send. Each row can be toggled on or off. + +### Multipart/Form-Data + +A key-value editor that also supports file uploads. Each row can be either a text field or a file field: + +- **Text fields** -- enter a key and a string value. +- **File fields** -- click the file selector to attach a file from disk. + +``` +Key: description Value: "Profile photo" (text) +Key: avatar Value: avatar.png (file) +``` + +The `Content-Type` header is set to `multipart/form-data` with an auto-generated boundary. + +### XML + +A plain-text editor for XML payloads. The `Content-Type` header is set to `application/xml`. + +```xml + + Jane Doe + jane@example.com + +``` + +### Plain Text + +A plain-text editor with no format-specific features. The `Content-Type` header is set to `text/plain`. + +### HTML + +A plain-text editor for HTML content. The `Content-Type` header is set to `text/html`. + +```html +
+

Your order has been confirmed.

+
+``` + +## Cookies + +The **Cookies** section lets you attach cookies to outgoing requests as key-value pairs. Cookies received in `Set-Cookie` response headers are automatically parsed and displayed, so you can inspect, modify, and resend them on subsequent requests. + +::: tip +If the API you are testing relies on session cookies, send a login request first. LitePost will capture the `Set-Cookie` values and make them available for follow-up requests. +::: + +## Tabs + +LitePost supports multiple concurrent request tabs. Each tab preserves the full request state: + +- HTTP method and URL +- Query parameters +- Headers +- Body type and content +- Auth configuration +- Cookies + +You can: + +- **Open a new tab** using the `+` button in the tab bar. +- **Rename a tab** by double-clicking its title and typing a new name. +- **Close a tab** with the close button on the tab. +- **Switch between tabs** by clicking them -- the request and response panels update immediately. + +Tabs are session-scoped: they reset when you close LitePost. To keep a request permanently, save it to a [collection](/collections) -- and everything you send is always recorded in the history sidebar, so recent work is one click away after a restart. diff --git a/docs/pre-request-scripts.md b/docs/pre-request-scripts.md new file mode 100644 index 0000000..96afdec --- /dev/null +++ b/docs/pre-request-scripts.md @@ -0,0 +1,201 @@ +# Pre-Request Scripts + +Pre-request scripts are JavaScript code that runs **before** each request is sent. They let you dynamically modify requests, set environment variables, compute values, and prepare authentication — all without manual intervention. + +You write pre-request scripts in the **Pre-request** section of the request panel (under the `⋯` menu). The script executes every time you send the request, including during Collection Runner execution. + +## The `lp` API + +LitePost provides the `lp` object as the script runtime API. It gives you access to environment variables, variable substitution, and request modification. + +### Environment Access + +Read and write environment variables from your scripts. Changes take effect immediately and persist in the active environment. + +#### `lp.environment.get(key)` + +Returns the value of an environment variable, or `undefined` if it does not exist. + +```javascript +const baseUrl = lp.environment.get("baseUrl"); +// "https://api.example.com" +``` + +#### `lp.environment.set(key, value)` + +Creates or updates an environment variable. The value is stored as a string. + +```javascript +lp.environment.set("timestamp", Date.now().toString()); +``` + +#### `lp.environment.has(key)` + +Returns `true` if the variable exists in the current environment, `false` otherwise. + +```javascript +if (!lp.environment.has("authToken")) { + lp.environment.set("authToken", "default-dev-token"); +} +``` + +#### `lp.environment.unset(key)` + +Removes a variable from the current environment. + +```javascript +lp.environment.unset("tempValue"); +``` + +### Variable Substitution + +#### `lp.variables.replaceIn(text)` + +Substitutes `{{variable}}` placeholders in a string with their current environment values. This is the same substitution that LitePost applies to URLs and headers, but available for use in your script logic. + +```javascript +const url = lp.variables.replaceIn("{{baseUrl}}/api/{{version}}/users"); +// "https://api.example.com/api/v2/users" +``` + +### Request Modification + +Read and modify properties of the outgoing request before it is sent. + +#### Method and URL + +`lp.request.method` and `lp.request.url` are readable and writable properties. + +```javascript +// Read the current method +const method = lp.request.method; // "GET" + +// Change the method +lp.request.method = "POST"; + +// Read and modify the URL +const currentUrl = lp.request.url; +lp.request.url = currentUrl + "?debug=true"; +``` + +#### Body + +`lp.request.body` gets or sets the request body. For JSON APIs, parse and stringify as needed. + +```javascript +// Set a JSON body +lp.request.body = JSON.stringify({ + username: lp.environment.get("testUser"), + timestamp: Date.now() +}); +``` + +#### Headers + +Manage request headers with three methods: + +```javascript +// Read a header value +const contentType = lp.request.header("Content-Type"); + +// Set or overwrite a header +lp.request.setHeader("Content-Type", "application/json"); +lp.request.setHeader("X-Custom-Header", "my-value"); + +// Remove a header entirely +lp.request.removeHeader("X-Deprecated-Header"); +``` + +#### Query Parameters + +Add or overwrite query parameters on the request URL: + +```javascript +// Add a query parameter (appends even if key exists) +lp.request.addQueryParam("tag", "beta"); + +// Set a query parameter (replaces if key exists, adds if not) +lp.request.setQueryParam("page", "1"); +lp.request.setQueryParam("limit", "50"); +``` + +## Use Cases + +### Dynamic Timestamps + +Add a timestamp header to every request for logging or cache-busting: + +```javascript +lp.request.setHeader("X-Timestamp", Date.now().toString()); +lp.request.setHeader("X-Request-Time", new Date().toISOString()); +``` + +### Token Chaining + +Read a previously extracted token from the environment and apply it as an authorization header. This is commonly paired with [response extraction rules](/response-extraction) that save tokens after a login request. + +```javascript +const token = lp.environment.get("authToken"); + +if (token) { + lp.request.setHeader("Authorization", "Bearer " + token); +} else { + // Fallback for development + lp.request.setHeader("Authorization", "Bearer dev-placeholder"); +} +``` + +### Request Signing + +Compute an HMAC signature or hash for APIs that require signed requests: + +```javascript +const secret = lp.environment.get("apiSecret"); +const timestamp = Date.now().toString(); +const body = lp.request.body || ""; + +// Build the string to sign +const stringToSign = lp.request.method + "\n" + timestamp + "\n" + body; + +// Set signing headers +lp.request.setHeader("X-Timestamp", timestamp); +lp.request.setHeader("X-Signature", stringToSign); +lp.request.setHeader("X-Api-Key", lp.environment.get("apiKey")); +``` + +### Custom Auth Preparation + +Some APIs require rotating or computed credentials. Use a pre-request script to prepare them: + +```javascript +// Rotate between API keys for rate limit distribution +const keys = ["key-alpha", "key-bravo", "key-charlie"]; +const index = Date.now() % keys.length; +lp.request.setHeader("X-Api-Key", keys[index]); + +// Set a nonce for replay protection +const nonce = Math.random().toString(36).substring(2, 15); +lp.request.setHeader("X-Nonce", nonce); +lp.environment.set("lastNonce", nonce); +``` + +### Conditional Request Modification + +Modify the request based on environment configuration: + +```javascript +const env = lp.environment.get("environment"); + +if (env === "staging") { + lp.request.url = lp.request.url.replace("api.example.com", "staging.example.com"); + lp.request.setHeader("X-Debug", "true"); +} + +if (lp.environment.has("mockMode")) { + lp.request.setQueryParam("mock", "true"); +} +``` + +:::tip +Pre-request scripts run in the Collection Runner too. Define your auth and setup logic in pre-request scripts so that every request in the collection is properly configured without manual steps between requests. +::: diff --git a/docs/public/logo.png b/docs/public/logo.png new file mode 100644 index 0000000..c9c9a5f Binary files /dev/null and b/docs/public/logo.png differ diff --git a/docs/response-extraction.md b/docs/response-extraction.md new file mode 100644 index 0000000..1a2f7ee --- /dev/null +++ b/docs/response-extraction.md @@ -0,0 +1,147 @@ +# Response Extraction + +Extraction rules let you automatically pull values out of HTTP responses and save them as environment variables. Once configured, rules run after every send -- no manual copy-pasting needed. This is the foundation for **request chaining**, where one request's output feeds into the next request's input. + +## Configuring Extraction Rules + +You configure extraction rules in the **Extract** tab of the Response panel. Each rule has three parts: + +1. **Source**: Where to extract from (body, header, status, or cookie). +2. **Path or name**: The specific value to extract (a JSON path, header name, or cookie name). +3. **Variable name**: The environment variable to save the extracted value into. + +Rules are saved per request and persist across sessions. Every time you send the request, the rules execute against the new response and update the environment variables. + +## Sources + +### Body (JSON Path) + +Extract a value from a JSON response body using dot notation. Array indices use bracket notation. + +| JSON Path | Response Body | Extracted Value | +|--------------------|--------------------------------------------------------|-----------------| +| `data.token` | `{"data": {"token": "abc123"}}` | `abc123` | +| `data.user.name` | `{"data": {"user": {"name": "Alice"}}}` | `Alice` | +| `items[0].id` | `{"items": [{"id": 42}, {"id": 43}]}` | `42` | +| `meta.pagination.next` | `{"meta": {"pagination": {"next": "/page/2"}}}` | `/page/2` | + +Paths are evaluated against the parsed JSON. If the path does not resolve to a value (e.g., the key is missing or the response is not valid JSON), the extraction is skipped and the environment variable is not updated. + +### Header + +Extract the value of a response header by name. + +| Header Name | Extracted Value Example | +|-----------------|------------------------------------------| +| `Content-Type` | `application/json; charset=utf-8` | +| `X-Request-Id` | `req-7f3a2b1c` | +| `X-RateLimit-Remaining` | `42` | +| `Location` | `https://api.example.com/resources/123` | + +Header name matching is case-insensitive. The full header value string is stored in the environment variable. + +### Status + +Extract the HTTP status code as a numeric string. + +| Response Status | Extracted Value | +|-----------------|-----------------| +| 200 OK | `200` | +| 201 Created | `201` | +| 404 Not Found | `404` | + +This is useful for conditional logic in [pre-request scripts](/pre-request-scripts) that check the outcome of a previous request. + +### Cookie + +Extract a cookie value by name from the response's `Set-Cookie` headers. + +| Cookie Name | Extracted Value Example | +|----------------|-------------------------------| +| `session_id` | `s%3Aabc123.xyz` | +| `csrf_token` | `d4f7a2b1c3e8` | + +Only the cookie value is extracted, not the attributes (Path, Expires, HttpOnly, etc.). + +## Rule Configuration Example + +Here is how three extraction rules might be configured for a login endpoint: + +| Source | Path / Name | Variable Name | +|--------|-------------------|----------------| +| Body | `data.token` | `authToken` | +| Body | `data.user.id` | `userId` | +| Header | `X-Request-Id` | `lastRequestId`| + +After sending `POST /login`, LitePost extracts the token and user ID from the JSON body and the request ID from the headers, saving each to the specified environment variable. Any subsequent request can reference these as `{{authToken}}`, `{{userId}}`, and `{{lastRequestId}}`. + +## Request Chaining Workflow + +Extraction rules are most powerful when used to chain requests together. Here is a concrete three-step example. + +### Step 1: Authenticate + +**POST** `{{baseUrl}}/auth/login` + +Request body: + +```json +{ + "email": "alice@example.com", + "password": "{{testPassword}}" +} +``` + +Extraction rules on this request: + +| Source | Path | Variable Name | +|--------|---------------|---------------| +| Body | `data.token` | `authToken` | +| Body | `data.user.id`| `userId` | + +After this request completes, the environment now contains `authToken` and `userId`. + +### Step 2: Fetch User Profile + +**GET** `{{baseUrl}}/users/{{userId}}/profile` + +Headers: + +``` +Authorization: Bearer {{authToken}} +``` + +The URL substitutes `{{userId}}` (extracted in Step 1), and the Authorization header substitutes `{{authToken}}` (also from Step 1). + +Extraction rules on this request: + +| Source | Path | Variable Name | +|--------|-----------------------|------------------| +| Body | `data.profile.orgId` | `organizationId` | + +### Step 3: List Organization Resources + +**GET** `{{baseUrl}}/orgs/{{organizationId}}/resources` + +Headers: + +``` +Authorization: Bearer {{authToken}} +``` + +This request uses `{{organizationId}}` from Step 2 and `{{authToken}}` from Step 1, completing a three-step chain where each request depends on data from the previous one. + +## Extraction in the Collection Runner + +When you execute a collection through the Collection Runner, extraction rules on each request run in sequence. This means you can build entire workflows: + +1. The runner sends the first request and executes its extraction rules. +2. The extracted values are written to the environment immediately. +3. The next request in the collection picks up those values via `{{variable}}` substitution. +4. This continues through every request in the collection. + +Because the environment is shared and updated in real time, a collection can implement complex multi-step API workflows -- authentication, data creation, verification, and cleanup -- all in a single automated run. + +:::tip +Pair extraction rules with [test assertions](/testing) to both validate and capture response data in one step. For example, assert that `data.token` exists, then extract it into an environment variable. +::: diff --git a/docs/responses.md b/docs/responses.md new file mode 100644 index 0000000..c4fe12f --- /dev/null +++ b/docs/responses.md @@ -0,0 +1,190 @@ +# Responses + +After a request is sent, LitePost displays the full response in the response panel. This includes the status code, body, headers, timing breakdown, size metrics, and redirect chain. + +## Status Code + +The HTTP status code and status text are displayed at the top of the response panel. The status is color-coded for quick identification: + +| Range | Color | Meaning | Example | +|-------|--------|---------------|----------------------| +| 2xx | Green | Success | `200 OK` | +| 3xx | Yellow | Redirection | `301 Moved Permanently` | +| 4xx | Red | Client Error | `404 Not Found` | +| 5xx | Red | Server Error | `500 Internal Server Error` | + +## Response Body + +LitePost selects a viewer based on the `Content-Type` header of the response. You can also switch viewers manually. + +### JSON + +JSON responses are displayed in a collapsible tree view with syntax highlighting. Each object and array can be expanded or collapsed individually. + +You can configure the default expand depth to control how many levels are expanded when the response first loads: + +- **Depth 1** -- only top-level keys are visible. +- **Depth 2** -- top-level keys and their immediate children are visible. +- **All** -- the entire tree is expanded. + +Example of a collapsed JSON response: + +```json +{ + "user": { ... }, + "meta": { ... } +} +``` + +Expanding the `user` key reveals its contents: + +```json +{ + "user": { + "id": 42, + "name": "Jane Doe", + "email": "jane@example.com" + }, + "meta": { ... } +} +``` + +### XML + +XML responses are formatted with proper indentation and displayed with syntax highlighting. + +```xml + + ok + + 42 + Jane Doe + + +``` + +### HTML Preview + +HTML responses are rendered in an inline preview pane, so you can see the page as a browser would display it. The raw HTML source is also available by switching to the source view. + +### Images + +Image responses (`image/png`, `image/jpeg`, `image/gif`, and other standard image types) are displayed as an inline preview. The image dimensions and file size are shown alongside the preview. + +### Plain Text + +Text responses that do not match a structured format are displayed with syntax highlighting in a read-only editor. + +### Binary + +Binary responses that cannot be displayed as text or images are shown as a Base64-encoded string. You can copy the Base64 value for use elsewhere. + +### Large Response Handling + +Very large responses are handled with automatic fallbacks to keep the interface responsive: + +- **JSON responses up to 2 MB** render in the collapsible tree view. To keep huge documents fast, each node shows at most 100 children at a time with a **"show more"** row that reveals the rest in chunks. +- **JSON responses over 2 MB** are displayed as raw plain text. +- **Non-JSON responses over 500 KB** are displayed as plain text without syntax highlighting. + +## Filtering the Response Body + +JSON responses get a filter bar above the body. Type either kind of query: + +- **A path** (starts with `$` or `.`) extracts part of the document: + + | Query | Result | + |------------------------|--------------------------------------------| + | `$.items[0].name` | One value | + | `$.items[*].id` | That field from every array element | + | `$["content type"]` | Keys containing spaces or dots | + + While you type, the filter falls back to the **longest valid prefix** instead of + snapping back to the whole document -- and a trailing partial key matches by + prefix, so `$.headers.Acc` shows `Accept`, `Accept-Encoding`, and friends. An + amber badge shows which path actually matched. + +- **Plain text** deep-filters the tree to branches whose keys or values contain + the query (matching a key keeps its whole subtree). + +The badge on the right reports `filtered`, a partial-match path, or `no matches`. +The filter resets when a new response arrives, and the **Copy** button always +copies the full, unfiltered body. + +## Response Headers + +The **Headers** tab in the response panel shows all headers returned by the server in a read-only formatted view. Each header is displayed as a key-value pair: + +``` +Content-Type: application/json; charset=utf-8 +X-Request-Id: req-abc123 +Cache-Control: no-cache +X-RateLimit-Remaining: 98 +``` + +Headers are listed in the order they were received from the server. + +## Timing Breakdown + +The **Timing** tab provides a detailed breakdown of where time was spent during the request lifecycle. Each phase is displayed as a labeled bar in a waterfall chart: + +| Phase | What It Measures | +|--------------------|-------------------------------------------------------------------| +| DNS Lookup | Time to resolve the hostname to an IP address | +| TCP Connection | Time to establish the TCP connection | +| TLS Handshake | Time to negotiate the TLS/SSL session (HTTPS only) | +| Request Processing | Time from sending the request to the server beginning its response | +| Time to First Byte | Total time until the first byte of the response is received | +| Download | Time to download the complete response body | +| **Total** | End-to-end duration from send to complete | + +The timing data is useful for diagnosing performance issues. For example: + +- A slow **DNS Lookup** suggests DNS resolver problems or a missing local cache entry. +- A slow **TLS Handshake** may indicate certificate chain verification overhead. +- A slow **Request Processing** time points to server-side latency. +- A slow **Download** time relative to body size suggests bandwidth constraints. + +## Size Metrics + +Below the status code, LitePost displays the size of the response broken down into: + +| Metric | Description | +|--------------|------------------------------------------------| +| Headers Size | Total size of all response headers | +| Body Size | Size of the response body | +| Total | Combined size of headers and body | + +Sizes are displayed in human-readable units: **B** (bytes) for small responses, **KB** for responses in the kilobyte range, and **MB** for larger payloads. + +## Redirect Chain + +When a request results in one or more redirects (3xx responses), LitePost records the entire redirect chain and displays it in the **Redirects** section. + +Each hop in the chain shows: + +- **Status code and status text** -- e.g., `301 Moved Permanently` +- **URL** -- the target URL for that redirect +- **Response headers** -- the headers returned at that hop +- **Timing** -- how long that individual hop took + +Example redirect chain: + +``` +1. GET http://example.com/old-page + -> 301 Moved Permanently + -> Location: https://example.com/old-page + +2. GET https://example.com/old-page + -> 308 Permanent Redirect + -> Location: https://example.com/new-page + +3. GET https://example.com/new-page + -> 200 OK (final response) +``` + +The final response (the one with a non-3xx status code) is what appears in the main response panel. The redirect chain provides visibility into the intermediate hops so you can debug redirect loops, inspect HSTS upgrades, or verify that your API's redirect behavior is correct. + +::: warning +If a redirect chain exceeds the maximum number of allowed redirects, LitePost stops following and reports the error. Check the redirect chain to identify loops or unexpectedly long chains. +::: diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..872a795 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,82 @@ +# Settings + +LitePost settings let you customize the application theme, configure JSON response display behavior, and manage updates. + +## Accessing Settings + +Click the **gear icon** in the title bar (top-right area, next to the window controls) to open the settings panel. It slides in from the right side of the window. + +## Theme + +LitePost ships with six built-in themes -- five dark and one light. Selecting a theme applies it immediately, no restart required. + +| Theme | Description | +|----------------|----------------------------------------------------------| +| **Night Desk** | Warm graphite with an amber accent (default) | +| **Green C** | The Night Desk graphite ground with an emerald accent | +| **Schematic** | Light engineering-paper theme with cobalt ink | +| **Sapphire** | Deep navy with a blue accent | +| **Amethyst** | Aubergine with a violet accent | +| **Obsidian** | Pure black with white accents (OLED-friendly) | + +Each theme controls the accent color, surfaces, and highlights across the entire application -- including the Monaco body editor and response syntax highlighting, which follow the active theme. The active theme is indicated by a highlighted ring around its color swatch in the settings panel. + +## JSON Viewer + +These settings control how JSON responses are rendered in the collapsible tree viewer. Adjusting them is useful when working with large or deeply nested API responses. + +### Max Auto-Expand Depth + +How many levels deep the JSON tree automatically expands when a response is first displayed. + +- **Default:** 2 +- **Range:** 0 -- 10 +- Set to 0 to collapse everything by default. Set higher to see more of the response structure without manual expanding. + +### Max Auto-Expand Array Size + +Arrays with more elements than this threshold are collapsed by default, showing only a summary line (e.g., `Array(150)`). + +- **Default:** 50 +- **Range:** 0 -- 200 (step: 10) + +### Max Auto-Expand Object Size + +Objects with more properties than this threshold are collapsed by default. + +- **Default:** 20 +- **Range:** 0 -- 100 (step: 5) + +::: tip +If you frequently work with large payloads, lowering these thresholds can significantly improve rendering performance and make it easier to navigate responses. +::: + +## Updates + +LitePost can check for updates both automatically and manually. + +### Automatic Checks + +The application checks for updates on startup and then once every 24 hours while running. No action is needed to enable this -- it happens by default. + +### Manual Check + +In the settings panel, click **Check Now** in the Updates section to trigger an immediate check. If a new version is found, a toast notification appears with: + +- The new version number +- Release notes (when available) +- An **Install Now** button that downloads and installs the update with a progress indicator, then relaunches the app + +If you are already on the latest version, a confirmation toast is shown instead. + +## Keyboard Shortcuts + +| Shortcut | Action | +|---------------------------------|-----------------------------------------------| +| `Ctrl+K` / `Cmd+K` | Open the command palette | +| `Ctrl+I` / `Cmd+I` | Open cURL import modal | +| `Enter` (in the URL bar) | Send the request | + +## Data Storage + +All application data -- collections, environments, settings, and theme preferences -- is stored locally on your machine as JSON files managed by the [Tauri filesystem plugin](https://v2.tauri.app/plugin/file-system/). Nothing is sent to any external server. diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..62833ec --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,59 @@ +# SSE Streaming + +LitePost has built-in support for Server-Sent Events (SSE) and chunked transfer encoding. When a response is detected as a stream, LitePost automatically switches from the standard response view to a live streaming display. + +## How It Works + +After you send a request, LitePost inspects the response headers. If the `Content-Type` is `text/event-stream` or the transfer encoding is chunked, the response panel switches to streaming mode. Data appears in real time as the server pushes it -- there is no need to wait for the connection to close before seeing output. + +Each incoming chunk is appended to the display immediately. JSON responses are parsed and rendered with the collapsible JSON viewer as they arrive. + +## Stream Display + +The streaming view shows the accumulated content in a scrollable pane. As new chunks arrive, the view auto-scrolls to the bottom so you always see the latest data. A pulsing green indicator in the corner confirms that the stream is still active and receiving data. + +The status bar above the stream content shows: + +- **Status** -- the HTTP status code and reason phrase (e.g. `200 OK`) +- **Streaming badge** -- displays `Streaming (N chunks)...` while active, or `Complete` when the stream ends +- **Time** -- elapsed wall-clock time since the request was sent + +``` +Status: 200 OK Streaming (42 chunks)... Time: 1830ms +``` + +## Controls + +Two controls appear in the top-right corner of the stream pane: + +### Pause / Play + +Click the pause button to freeze auto-scrolling. The stream continues to receive data in the background -- pausing only stops the display from scrolling so you can inspect earlier content. Click play to resume auto-scrolling to the latest content. + +### Cancel + +Click the cancel button to terminate the stream. LitePost sends a cancellation signal to the backend, which closes the underlying connection. The stream view marks the response as complete and shows `Request cancelled by user`. + +A copy button is also available to copy the full accumulated content to the clipboard at any point during or after the stream. + +## Headers Inspection + +While streaming, you can switch to the **Headers** tab to inspect response headers without interrupting the stream. This is useful for checking `Content-Type`, caching directives, or custom headers the server sends before the body. + +## Error Handling + +If the server responds with a 4xx or 5xx status code, the status text is highlighted in red even while the stream is active. This makes it easy to spot error responses that still use chunked transfer encoding. + +If a network error occurs during streaming, the error message is displayed in the stream pane and the stream is marked as complete. + +## Scoped Streams + +Each stream is scoped to the request tab that initiated it. Stream events use a unique request ID (e.g. `sse-chunk-`) so that data from one stream never leaks into another tab. You can run multiple concurrent streams in different tabs without interference. + +::: tip +If you want to compare the output of two SSE endpoints side by side, open each in its own tab and send both requests. Each tab maintains its own independent stream state. +::: + +## Cancellation Under the Hood + +On the backend, cancellation uses a `tokio::sync::watch` channel. When you click cancel, the frontend invokes the `cancel_stream` command with the request ID. The Rust backend flips the watch flag, and the streaming loop -- running inside a `tokio::select!` -- detects the signal and closes the connection cleanly. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..9e30af8 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,151 @@ +# Testing + +The **Test** panel in LitePost lets you define assertions that automatically run after each HTTP request. You configure assertions through UI dropdowns — no hand-coding required for basic checks. For advanced scenarios, LitePost also supports JavaScript-based test scripts with a Postman-compatible `pm` API. + +## Assertion Types + +Each assertion targets a specific part of the response. You select the type, a comparison operator, and an expected value from the Test panel's dropdown interface. + +### Status + +Validate the HTTP status code returned by the response. + +| Operator | Example | Passes when | +|-------------|--------------------------|----------------------------| +| equals | Status equals `200` | Status code is exactly 200 | +| greaterThan | Status greaterThan `199` | Status code is 200 or more | +| lessThan | Status lessThan `400` | Status code is under 400 | + +This is the most common assertion. A quick `Status equals 200` check confirms the request succeeded before you inspect the body. + +### JSON + +Validate a value at a specific JSON path in the response body. Paths use dot notation to traverse nested objects and bracket notation for arrays. + +| JSON Path | Operator | Expected | Passes when | +|----------------------|----------|------------|------------------------------------------| +| `data.user.name` | equals | `"Alice"` | The nested name field is exactly "Alice" | +| `data.items` | exists | — | The `items` key is present in `data` | +| `data.count` | greaterThan | `0` | The count value is at least 1 | +| `message` | contains | `"success"`| The message string includes "success" | + +Paths are evaluated against the parsed JSON response. If the path does not resolve (e.g., the key is missing), the assertion fails unless the operator is `exists` with a negated expectation. + +### Header + +Validate the value of a response header. + +| Header Name | Operator | Expected | Passes when | +|----------------|----------|-------------------|------------------------------------------------| +| Content-Type | contains | `"json"` | Content-Type header includes "json" | +| Cache-Control | equals | `"no-cache"` | Cache-Control is exactly "no-cache" | +| X-Request-Id | exists | — | The X-Request-Id header is present | + +Header name matching is case-insensitive, consistent with the HTTP specification. + +### Response Time + +Validate how long the request took to complete. Timing values are in milliseconds. + +| Operator | Expected | Passes when | +|-------------|----------|-----------------------------------| +| lessThan | `500` | Total response time is under 500ms | +| greaterThan | `100` | Response took more than 100ms | +| equals | `200` | Response time is exactly 200ms | + +Response time assertions are useful for performance monitoring. The `lessThan` operator is the most practical choice here — exact equality on timing is rarely meaningful. + +## Operators + +All assertion types share the same set of comparison operators: + +| Operator | Description | +|---------------|----------------------------------------------------------| +| `equals` | Exact match (strict equality for numbers, string match for text) | +| `contains` | The actual value includes the expected value as a substring | +| `exists` | The target (header, JSON path, etc.) is present in the response | +| `greaterThan` | Numeric comparison, actual > expected | +| `lessThan` | Numeric comparison, actual < expected | + +## Test Scripts + +For more complex validation logic, LitePost supports JavaScript-based test scripts with a Postman-compatible `pm` API. You write these in the script editor within the Test panel. + +### The `pm` API + +#### Defining Tests + +Use `pm.test()` to define named test cases. Each test receives a callback function where you write your assertions. + +```javascript +pm.test("Status code is 200", function () { + pm.expect(pm.response.code).to.equal(200); +}); +``` + +#### Assertions with `pm.expect` + +`pm.expect(value)` provides chai-like assertion chaining: + +```javascript +pm.test("Response contains user data", function () { + const body = pm.response.json(); + pm.expect(body.data).to.have.property("name"); + pm.expect(body.data.name).to.equal("Alice"); +}); +``` + +#### Accessing the Response + +`pm.response` exposes properties of the received response: + +```javascript +pm.test("Comprehensive response check", function () { + // Status code + pm.expect(pm.response.code).to.equal(200); + + // Parse the JSON body + const data = pm.response.json(); + pm.expect(data.items.length).to.be.greaterThan(0); +}); +``` + +### Combining Dropdowns and Scripts + +You can use both dropdown assertions and test scripts on the same request. Dropdown assertions run first, followed by any scripts. Both sets of results appear together in the test results display. + +```javascript +// Script-based test alongside dropdown assertions +pm.test("User array is not empty", function () { + const users = pm.response.json().data.users; + pm.expect(users.length).to.be.greaterThan(0); +}); + +pm.test("First user has an email", function () { + const firstUser = pm.response.json().data.users[0]; + pm.expect(firstUser).to.have.property("email"); +}); +``` + +## Test Results Display + +After a request completes, the Test panel shows results for every assertion and test script: + +- **Pass**: The assertion succeeded. Displayed with a green indicator and the assertion description. +- **Fail**: The assertion did not hold. Displayed with a red indicator, the assertion description, and an error message explaining what went wrong (e.g., "Expected 200 but received 404"). + +Each result is listed individually, so you can see at a glance which checks passed and which need attention. + +## Tests in the Collection Runner + +When you execute a collection through the **Collection Runner**, every request's assertions run automatically after that request completes. This turns your collection into a repeatable test suite. + +- Assertions defined on each request (both dropdown and script-based) execute in order as the runner processes the collection. +- The runner summary shows aggregate results: total assertions, pass count, and fail count. +- Failed assertions are highlighted with the request name and failure details, so you can identify which endpoint in the collection broke and why. + +:::tip +Define assertions on each request in a collection before running the Collection Runner. This gives you a full pass/fail report across your entire API workflow in a single run. +::: + +This workflow is especially useful for regression testing — save a collection of critical endpoints with their assertions, then re-run the collection after backend changes to verify nothing is broken. diff --git a/index.html b/index.html index ff93803..e56a854 100644 --- a/index.html +++ b/index.html @@ -4,11 +4,46 @@ - Tauri + React + Typescript + LitePost — API Testing + + -
+
+
+
Loading LitePost...
+
+
diff --git a/package.json b/package.json index ef079b2..7efaf73 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,27 @@ { "name": "litepost", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", + "browserslist:update": "npx update-browserslist-db@latest", "preview": "vite preview", "tauri": "tauri", "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", - "test:watch": "vitest --watch" + "test:watch": "vitest --watch", + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs" }, "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-context-menu": "^2.2.5", "@radix-ui/react-dialog": "^1.1.5", @@ -65,12 +72,14 @@ "@vitest/coverage-v8": "^3.0.4", "autoprefixer": "^10.4.20", "jsdom": "^26.0.0", + "monaco-editor": "^0.55.1", "postcss": "^8.5.1", "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "typescript": "~5.6.2", "vite": "^6.0.3", + "vitepress": "^1.6.4", "vitest": "^3.0.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c74e4..80c50b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-alert-dialog': specifier: ^1.1.5 version: 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -153,6 +162,9 @@ importers: jsdom: specifier: ^26.0.0 version: 26.0.0 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 postcss: specifier: ^8.5.1 version: 8.5.1 @@ -171,6 +183,9 @@ importers: vite: specifier: ^6.0.3 version: 6.0.11(@types/node@22.10.7)(jiti@1.21.7)(yaml@2.7.0) + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.10.7)(@types/react@18.3.18)(postcss@8.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.6.3) vitest: specifier: ^3.0.4 version: 3.0.4(@types/node@22.10.7)(jiti@1.21.7)(jsdom@26.0.0)(yaml@2.7.0) @@ -180,6 +195,82 @@ packages: '@adobe/css-tools@4.4.1': resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==} + '@algolia/abtesting@1.15.1': + resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.49.1': + resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.49.1': + resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.49.1': + resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.49.1': + resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.49.1': + resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.49.1': + resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.49.1': + resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.49.1': + resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.49.1': + resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.49.1': + resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.49.1': + resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.49.1': + resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.49.1': + resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==} + engines: {node: '>= 14.0.0'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -229,10 +320,18 @@ packages: resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.25.9': resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} @@ -246,6 +345,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.25.9': resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} engines: {node: '>=6.9.0'} @@ -274,6 +378,10 @@ packages: resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -306,102 +414,227 @@ packages: resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} engines: {node: '>=18'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -414,6 +647,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} @@ -426,30 +665,60 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} @@ -471,6 +740,18 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@fontsource-variable/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} + + '@iconify-json/simple-icons@1.2.71': + resolution: {integrity: sha512-rNoDFbq1fAYiEexBvrw613/xiUOPEu5MKVV/X8lI64AgdTzLQUUemr9f9fplxUMPoxCBP2rWzlhOEeTHk/Sf0Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -494,9 +775,22 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1089,6 +1383,30 @@ packages: resolution: {integrity: sha512-0dtu/5ApsOZ24qgaZwtif8jVwqol7a4m1x5AxPuM1k5wxhqU7t/qEfBGtaSki1R8VlbTQfCj5PAlO45NKCa7Gg==} hasBin: true + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@tauri-apps/api@2.2.0': resolution: {integrity: sha512-R8epOeZl1eJEl603aUMIGb4RXlhPjpgxbGVEaqY+0G5JG9vzV/clNlzTeqc+NLYXVqXcn8mb4c5b9pJIUDEyAg==} @@ -1228,6 +1546,21 @@ packages: '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@22.10.7': resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} @@ -1245,18 +1578,37 @@ packages: '@types/react@18.3.18': resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/coverage-v8@3.0.4': resolution: {integrity: sha512-f0twgRCHgbs24Dp8cLWagzcObXMcuKtAwgxjJV/nnysPAJJk1JiKu/W0gIehZLmkljhJXU/E0/dmuQzsA/4jhA==} peerDependencies: @@ -1295,10 +1647,102 @@ packages: '@vitest/utils@3.0.4': resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==} + '@vue/compiler-core@3.5.29': + resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} + + '@vue/compiler-dom@3.5.29': + resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + + '@vue/compiler-sfc@3.5.29': + resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + + '@vue/compiler-ssr@3.5.29': + resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.29': + resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==} + + '@vue/runtime-core@3.5.29': + resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==} + + '@vue/runtime-dom@3.5.29': + resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==} + + '@vue/server-renderer@3.5.29': + resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==} + peerDependencies: + vue: 3.5.29 + + '@vue/shared@3.5.29': + resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + agent-base@7.1.3: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + algoliasearch@5.49.1: + resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==} + engines: {node: '>= 14.0.0'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1364,6 +1808,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} @@ -1393,6 +1840,9 @@ packages: caniuse-lite@1.0.30001695: resolution: {integrity: sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.1.2: resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} engines: {node: '>=12'} @@ -1409,9 +1859,15 @@ packages: resolution: {integrity: sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + character-entities-legacy@1.1.4: resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@1.2.4: resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} @@ -1459,6 +1915,9 @@ packages: comma-separated-tokens@1.0.8: resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -1474,6 +1933,10 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1493,6 +1956,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -1531,6 +1997,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1543,12 +2012,18 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} electron-to-chromium@1.5.84: resolution: {integrity: sha512-I+DQ8xgafao9Ha6y0qjHHvpZ9OfyA1qKlkHkjywxzniORU2awxyz7f/iVJcULmrF2yrM3nHQf+iDjJtbbexd/g==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1559,9 +2034,18 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -1571,6 +2055,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1600,6 +2087,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} @@ -1673,6 +2163,12 @@ packages: hast-util-parse-selector@2.2.5: resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@6.0.0: resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==} @@ -1682,6 +2178,9 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1689,6 +2188,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1766,6 +2268,10 @@ packages: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1863,6 +2369,9 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -1870,6 +2379,17 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1877,6 +2397,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1909,12 +2444,26 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1962,6 +2511,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + ora@6.3.1: resolution: {integrity: sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1997,6 +2549,9 @@ packages: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2053,6 +2608,13 @@ packages: resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2072,6 +2634,9 @@ packages: property-information@5.6.0: resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2174,6 +2739,15 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -2187,6 +2761,9 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.31.0: resolution: {integrity: sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2211,6 +2788,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2231,6 +2811,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2257,9 +2840,19 @@ packages: space-separated-tokens@1.1.5: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} @@ -2278,6 +2871,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2299,6 +2895,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2310,6 +2910,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -2377,6 +2980,9 @@ packages: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -2394,6 +3000,21 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -2431,11 +3052,48 @@ packages: resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} hasBin: true + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.0.4: resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.0.11: resolution: {integrity: sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2476,6 +3134,18 @@ packages: yaml: optional: true + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@3.0.4: resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2504,6 +3174,14 @@ packages: jsdom: optional: true + vue@3.5.29: + resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -2601,9 +3279,124 @@ packages: use-sync-external-store: optional: true -snapshots: + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@adobe/css-tools@4.4.1': {} + + '@algolia/abtesting@1.15.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + '@algolia/client-search': 5.49.1 + algoliasearch: 5.49.1 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)': + dependencies: + '@algolia/client-search': 5.49.1 + algoliasearch: 5.49.1 + + '@algolia/client-abtesting@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-analytics@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-common@5.49.1': {} + + '@algolia/client-insights@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-personalization@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-query-suggestions@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-search@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/ingestion@1.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/monitoring@1.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/recommend@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/requester-browser-xhr@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 - '@adobe/css-tools@4.4.1': {} + '@algolia/requester-fetch@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@algolia/requester-node-http@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 '@alloc/quick-lru@5.2.0': {} @@ -2684,8 +3477,12 @@ snapshots: '@babel/helper-string-parser@7.25.9': {} + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.25.9': {} '@babel/helpers@7.26.0': @@ -2697,6 +3494,10 @@ snapshots: dependencies: '@babel/types': 7.26.5 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -2734,6 +3535,11 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} '@csstools/color-helpers@5.0.1': {} @@ -2756,78 +3562,174 @@ snapshots: '@csstools/css-tokenizer@3.0.3': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + preact: 10.28.4 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + '@docsearch/css': 3.8.2 + algoliasearch: 5.49.1 + optionalDependencies: + '@types/react': 18.3.18 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.24.2': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.24.2': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.24.2': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.24.2': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.24.2': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.24.2': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.24.2': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.24.2': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.24.2': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.24.2': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.24.2': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.24.2': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.24.2': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.24.2': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.24.2': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.24.2': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.24.2': optional: true '@esbuild/netbsd-arm64@0.24.2': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.24.2': optional: true '@esbuild/openbsd-arm64@0.24.2': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.24.2': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.24.2': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.24.2': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.24.2': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.24.2': optional: true @@ -2848,6 +3750,16 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@fontsource-variable/inter@5.2.8': {} + + '@fontsource-variable/jetbrains-mono@5.2.8': {} + + '@iconify-json/simple-icons@1.2.71': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2871,11 +3783,24 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3459,6 +4384,46 @@ snapshots: prompts: 2.4.2 zod: 3.24.1 + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@tauri-apps/api@2.2.0': {} '@tauri-apps/cli-darwin-arm64@2.2.5': @@ -3594,6 +4559,23 @@ snapshots: dependencies: '@types/unist': 2.0.11 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 2.0.11 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + '@types/node@22.10.7': dependencies: undici-types: 6.20.0 @@ -3613,10 +4595,19 @@ snapshots: '@types/prop-types': 15.7.14 csstype: 3.1.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@22.10.7)(jiti@1.21.7)(yaml@2.7.0))': dependencies: '@babel/core': 7.26.0 @@ -3628,6 +4619,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.10.7))(vue@3.5.29(typescript@5.6.3))': + dependencies: + vite: 5.4.21(@types/node@22.10.7) + vue: 3.5.29(typescript@5.6.3) + '@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/node@22.10.7)(jiti@1.21.7)(jsdom@26.0.0)(yaml@2.7.0))': dependencies: '@ampproject/remapping': 2.3.0 @@ -3686,8 +4682,124 @@ snapshots: loupe: 3.1.2 tinyrainbow: 2.0.0 + '@vue/compiler-core@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.29 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.29': + dependencies: + '@vue/compiler-core': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/compiler-sfc@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.29 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.29': + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.29': + dependencies: + '@vue/shared': 3.5.29 + + '@vue/runtime-core@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/runtime-dom@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/runtime-core': 3.5.29 + '@vue/shared': 3.5.29 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.6.3))': + dependencies: + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29(typescript@5.6.3) + + '@vue/shared@3.5.29': {} + + '@vueuse/core@12.8.2(typescript@5.6.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.6.3) + vue: 3.5.29(typescript@5.6.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@5.6.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.6.3) + '@vueuse/shared': 12.8.2(typescript@5.6.3) + vue: 3.5.29(typescript@5.6.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@5.6.3)': + dependencies: + vue: 3.5.29(typescript@5.6.3) + transitivePeerDependencies: + - typescript + agent-base@7.1.3: {} + algoliasearch@5.49.1: + dependencies: + '@algolia/abtesting': 1.15.1 + '@algolia/client-abtesting': 5.49.1 + '@algolia/client-analytics': 5.49.1 + '@algolia/client-common': 5.49.1 + '@algolia/client-insights': 5.49.1 + '@algolia/client-personalization': 5.49.1 + '@algolia/client-query-suggestions': 5.49.1 + '@algolia/client-search': 5.49.1 + '@algolia/ingestion': 1.49.1 + '@algolia/monitoring': 1.49.1 + '@algolia/recommend': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -3739,6 +4851,8 @@ snapshots: binary-extensions@2.3.0: {} + birpc@2.9.0: {} + bl@5.1.0: dependencies: buffer: 6.0.3 @@ -3771,6 +4885,8 @@ snapshots: caniuse-lite@1.0.30001695: {} + ccount@2.0.1: {} + chai@5.1.2: dependencies: assertion-error: 2.0.1 @@ -3791,8 +4907,12 @@ snapshots: chalk@5.2.0: {} + character-entities-html4@2.1.0: {} + character-entities-legacy@1.1.4: {} + character-entities-legacy@3.0.0: {} + character-entities@1.2.4: {} character-reference-invalid@1.1.4: {} @@ -3837,6 +4957,8 @@ snapshots: comma-separated-tokens@1.0.8: {} + comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} commander@4.1.1: {} @@ -3845,6 +4967,10 @@ snapshots: cookie@1.0.2: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3862,6 +4988,8 @@ snapshots: csstype@3.1.3: {} + csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} data-urls@5.0.0: @@ -3887,6 +5015,10 @@ snapshots: detect-node-es@1.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -3895,18 +5027,52 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.84: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} entities@4.5.0: {} + entities@7.0.1: {} + es-module-lexer@1.6.0: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -3937,6 +5103,8 @@ snapshots: escalade@3.2.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.6 @@ -3980,6 +5148,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.6 @@ -4045,6 +5217,24 @@ snapshots: hast-util-parse-selector@2.2.5: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hastscript@6.0.0: dependencies: '@types/hast': 2.3.10 @@ -4057,12 +5247,16 @@ snapshots: highlightjs-vue@1.0.0: {} + hookable@5.5.3: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -4126,6 +5320,8 @@ snapshots: is-unicode-supported@1.3.0: {} + is-what@5.5.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -4237,6 +5433,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: dependencies: '@babel/parser': 7.26.5 @@ -4247,10 +5447,43 @@ snapshots: dependencies: semver: 7.6.3 + mark.js@8.11.1: {} + + marked@14.0.0: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + merge-stream@2.0.0: {} merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -4274,6 +5507,15 @@ snapshots: minipass@7.1.2: {} + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} mz@2.7.0: @@ -4282,6 +5524,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + nanoid@3.3.8: {} node-domexception@1.0.0: {} @@ -4316,6 +5560,12 @@ snapshots: dependencies: mimic-fn: 4.0.0 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + ora@6.3.1: dependencies: chalk: 5.2.0 @@ -4358,6 +5608,8 @@ snapshots: pathval@2.0.0: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -4403,6 +5655,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.28.4: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -4422,6 +5682,8 @@ snapshots: dependencies: xtend: 4.0.2 + property-information@7.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -4525,6 +5787,16 @@ snapshots: regenerator-runtime@0.14.1: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + resolve@1.22.10: dependencies: is-core-module: 2.16.1 @@ -4538,6 +5810,8 @@ snapshots: reusify@1.0.4: {} + rfdc@1.4.1: {} + rollup@4.31.0: dependencies: '@types/estree': 1.0.6 @@ -4581,6 +5855,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + search-insights@2.17.3: {} + semver@6.3.1: {} semver@7.6.3: {} @@ -4593,6 +5869,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -4610,8 +5897,14 @@ snapshots: space-separated-tokens@1.1.5: {} + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + stackback@0.0.2: {} + state-local@1.0.7: {} + std-env@3.8.0: {} stdin-discarder@0.1.0: @@ -4634,6 +5927,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4658,6 +5956,10 @@ snapshots: pirates: 4.0.6 ts-interface-checker: 0.1.13 + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4666,6 +5968,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.4.0: {} + tailwind-merge@2.6.0: {} tailwind-scrollbar@3.1.0(tailwindcss@3.4.17): @@ -4745,6 +6049,8 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: {} @@ -4755,6 +6061,29 @@ snapshots: undici-types@6.20.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} update-browserslist-db@1.1.2(browserslist@4.24.4): @@ -4782,6 +6111,16 @@ snapshots: uuid@11.0.5: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-node@3.0.4(@types/node@22.10.7)(jiti@1.21.7)(yaml@2.7.0): dependencies: cac: 6.7.14 @@ -4803,6 +6142,15 @@ snapshots: - tsx - yaml + vite@5.4.21(@types/node@22.10.7): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.1 + rollup: 4.31.0 + optionalDependencies: + '@types/node': 22.10.7 + fsevents: 2.3.3 + vite@6.0.11(@types/node@22.10.7)(jiti@1.21.7)(yaml@2.7.0): dependencies: esbuild: 0.24.2 @@ -4814,6 +6162,55 @@ snapshots: jiti: 1.21.7 yaml: 2.7.0 + vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@22.10.7)(@types/react@18.3.18)(postcss@8.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.6.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.49.1)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.71 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@22.10.7))(vue@3.5.29(typescript@5.6.3)) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.29 + '@vueuse/core': 12.8.2(typescript@5.6.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.6.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@22.10.7) + vue: 3.5.29(typescript@5.6.3) + optionalDependencies: + postcss: 8.5.1 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + vitest@3.0.4(@types/node@22.10.7)(jiti@1.21.7)(jsdom@26.0.0)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.4 @@ -4853,6 +6250,16 @@ snapshots: - tsx - yaml + vue@3.5.29(typescript@5.6.3): + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@5.6.3)) + '@vue/shared': 3.5.29 + optionalDependencies: + typescript: 5.6.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -4915,3 +6322,5 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 react: 18.3.1 + + zwitch@2.0.4: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f41d62b..f7d11a2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -718,6 +718,36 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "curl" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2d5c8f48d9c0c23250e52b55e82a6ab4fdba6650c931f5a0a57a43abda812b" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.59.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.85+curl-8.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0efa6142b5ecc05f6d3eaa39e6af4888b9d3939273fb592c92b7088a8cf3fdb" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.59.0", +] + [[package]] name = "darling" version = "0.20.10" @@ -753,6 +783,12 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "data-url" version = "0.3.1" @@ -2161,6 +2197,18 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-sys" +version = "1.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2175,18 +2223,25 @@ checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "litepost" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64 0.21.7", + "curl", + "futures-util", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-updater", + "tokio", + "tokio-tungstenite", + "url", ] [[package]] @@ -3348,10 +3403,12 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "winreg 0.50.0", ] @@ -3770,6 +3827,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -4616,6 +4684,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.13" @@ -4784,6 +4866,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.2.0", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 200545b..4da4fa1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "litepost" -version = "0.2.0" +version = "0.3.0" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -24,9 +24,15 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-http = "2" tauri-plugin-fs = "2" -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", features = ["json", "stream"] } +curl = "0.4" base64 = "0.21" +futures-util = "0.3" +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } +url = "2" +tokio = { version = "1", features = ["sync", "net", "time", "macros", "io-util"] } +sha2 = "0.10" +rand = "0.8" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" - diff --git a/src-tauri/src/http_client.rs b/src-tauri/src/http_client.rs new file mode 100644 index 0000000..d769d81 --- /dev/null +++ b/src-tauri/src/http_client.rs @@ -0,0 +1,995 @@ +use base64::engine::general_purpose; +use base64::Engine as _; +use curl::easy::{Easy, List}; +use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; +use tauri::Url; + +use crate::models::{ + ClientWrapper, RedirectInfo, RequestOptions, ResponseData, ResponseSize, ResponseTiming, +}; +use crate::network_utils::now_millis; + +fn is_binary_content_type(content_type: Option<&String>) -> bool { + let Some(content_type) = content_type else { + return false; + }; + + let ct = content_type.to_ascii_lowercase(); + (ct.starts_with("image/") && !ct.starts_with("image/svg")) + || ct.starts_with("application/octet-stream") + || ct.starts_with("application/pdf") + || ct.starts_with("application/zip") + || ct.starts_with("application/gzip") + || ct.starts_with("application/x-tar") + || ct.starts_with("application/x-gzip") + || ct.starts_with("application/x-bzip2") + || ct.starts_with("application/x-7z-compressed") + || ct.starts_with("application/vnd.ms-") + || ct.starts_with("application/vnd.openxmlformats-") + || ct.starts_with("application/wasm") + || ct.starts_with("font/") + || ct.starts_with("audio/") + || ct.starts_with("video/") +} + +#[derive(Clone)] +struct StoredCookie { + name: String, + value: String, + domain: String, + path: String, + secure: bool, +} + +fn build_request_header_lines(options: &RequestOptions) -> Vec { + let mut headers = Vec::new(); + let mut has_content_type = false; + + for (key, value) in &options.headers { + if key.eq_ignore_ascii_case("cookie") { + continue; + } + if key.eq_ignore_ascii_case("content-type") { + has_content_type = true; + } + headers.push(format!("{}: {}", key, value)); + } + + if options.body.is_some() && !has_content_type { + if let Some(content_type) = options.content_type.as_deref() { + headers.push(format!("Content-Type: {}", content_type)); + } + } + + headers +} + +fn set_or_replace_content_type(headers: &mut Vec, content_type: &str) { + headers.retain(|header| { + header + .split_once(':') + .map(|(name, _)| !name.trim().eq_ignore_ascii_case("content-type")) + .unwrap_or(true) + }); + headers.push(format!("Content-Type: {}", content_type)); +} + +fn parse_status_line(line: &str) -> Option<(u16, String)> { + if !line.starts_with("HTTP/") { + return None; + } + + let mut parts = line.splitn(3, ' '); + let _http_version = parts.next()?; + let status = parts.next()?.parse::().ok()?; + let reason = parts.next().unwrap_or_default().trim(); + let status_text = if reason.is_empty() { + status.to_string() + } else { + format!("{} {}", status, reason) + }; + + Some((status, status_text)) +} + +fn parse_cookie_name_value(set_cookie: &str) -> Option<(String, String)> { + let first_segment = set_cookie.split(';').next()?.trim(); + let (name, value) = first_segment.split_once('=')?; + if name.trim().is_empty() { + return None; + } + Some((name.trim().to_string(), value.trim().to_string())) +} + +fn normalize_cookie_domain(domain: &str) -> String { + domain.trim().trim_start_matches('.').to_ascii_lowercase() +} + +fn domain_matches(host: &str, domain: &str) -> bool { + host.eq_ignore_ascii_case(domain) + || host + .to_ascii_lowercase() + .ends_with(&format!(".{}", domain.to_ascii_lowercase())) +} + +fn path_matches(request_path: &str, cookie_path: &str) -> bool { + if cookie_path == "/" { + return true; + } + request_path.starts_with(cookie_path) +} + +fn upsert_cookie(cookie_store: &mut Vec, cookie: StoredCookie) { + if let Some(existing) = cookie_store.iter_mut().find(|existing| { + existing.name == cookie.name + && existing.domain == cookie.domain + && existing.path == cookie.path + }) { + existing.value = cookie.value; + existing.secure = cookie.secure; + } else { + cookie_store.push(cookie); + } +} + +fn apply_set_cookie_headers( + cookie_store: &mut Vec, + set_cookies: &[String], + current_url: &str, +) -> Result<(), String> { + let current_host = Url::parse(current_url) + .map_err(|e| e.to_string())? + .host_str() + .ok_or_else(|| "Current URL host missing".to_string())? + .to_ascii_lowercase(); + let default_path = "/"; + + for cookie in set_cookies { + let mut segments = cookie.split(';'); + let Some(first_segment) = segments.next() else { + continue; + }; + let Some((name, value)) = parse_cookie_name_value(first_segment) else { + continue; + }; + + let mut domain = current_host.clone(); + let mut path = default_path.to_string(); + let mut secure = false; + for attribute in segments { + let trimmed = attribute.trim(); + if trimmed.eq_ignore_ascii_case("secure") { + secure = true; + continue; + } + + if let Some((attr_name, attr_value)) = trimmed.split_once('=') { + if attr_name.trim().eq_ignore_ascii_case("domain") { + let parsed = normalize_cookie_domain(attr_value); + if !parsed.is_empty() { + domain = parsed; + } + } else if attr_name.trim().eq_ignore_ascii_case("path") { + let parsed = attr_value.trim(); + if !parsed.is_empty() { + path = parsed.to_string(); + } + } + } + } + + upsert_cookie( + cookie_store, + StoredCookie { + name, + value, + domain, + path, + secure, + }, + ); + } + + Ok(()) +} + +fn build_cookie_header_value( + cookie_store: &[StoredCookie], + current_url: &str, +) -> Result, String> { + let current_host = Url::parse(current_url) + .map_err(|e| e.to_string())? + .host_str() + .ok_or_else(|| "Current URL host missing".to_string())? + .to_ascii_lowercase(); + let parsed_url = Url::parse(current_url).map_err(|e| e.to_string())?; + let current_path = parsed_url.path(); + let is_https = parsed_url.scheme().eq_ignore_ascii_case("https"); + + let mut pairs = Vec::new(); + for cookie in cookie_store { + if domain_matches(¤t_host, &cookie.domain) + && path_matches(current_path, &cookie.path) + && (!cookie.secure || is_https) + { + pairs.push(format!("{}={}", cookie.name, cookie.value)); + } + } + + if pairs.is_empty() { + Ok(None) + } else { + Ok(Some(pairs.join("; "))) + } +} + +fn duration_to_ms(duration: Result) -> Option { + duration.ok().map(|d| d.as_secs_f64() * 1000.0) +} + +fn phase_delta_ms(end: Option, start: Option) -> Option { + match (end, start) { + (Some(end_ms), Some(start_ms)) if end_ms >= 0.0 && start_ms >= 0.0 => { + Some((end_ms - start_ms).max(0.0)) + } + _ => None, + } +} + +fn sum_optional(values: I) -> Option +where + I: IntoIterator>, +{ + let mut total = 0.0; + let mut has_value = false; + for value in values { + if let Some(v) = value { + total += v; + has_value = true; + } + } + has_value.then_some(total) +} + +struct CurlHopResponse { + status: u16, + status_text: String, + headers: HashMap, + body: Vec, + cookies: Vec, + location: Option, + timing: ResponseTiming, + header_size: usize, +} + +struct NetworkSettings { + timeout_secs: u64, + connect_timeout_secs: u64, + ssl_verification: bool, + proxy: Option, +} + +impl Default for NetworkSettings { + fn default() -> Self { + Self { + timeout_secs: 30, + connect_timeout_secs: 10, + ssl_verification: true, + proxy: None, + } + } +} + +fn perform_curl_request( + method: String, + url: String, + headers: Vec, + // Arc so the redirect loop can re-send the body without copying it per hop + body: Option>>, + cookie_header: Option, + network: &NetworkSettings, +) -> Result { + let request_start = now_millis(); + let mut easy = Easy::new(); + + easy.url(&url).map_err(|e| e.to_string())?; + easy.follow_location(false).map_err(|e| e.to_string())?; + easy.connect_timeout(Duration::from_secs(network.connect_timeout_secs)) + .map_err(|e| e.to_string())?; + if network.timeout_secs > 0 { + easy.timeout(Duration::from_secs(network.timeout_secs)) + .map_err(|e| e.to_string())?; + } + easy.accept_encoding("").map_err(|e| e.to_string())?; + + // SSL verification + easy.ssl_verify_peer(network.ssl_verification) + .map_err(|e| e.to_string())?; + easy.ssl_verify_host(network.ssl_verification) + .map_err(|e| e.to_string())?; + + // Proxy + if let Some(ref proxy_url) = network.proxy { + if !proxy_url.is_empty() { + easy.proxy(proxy_url).map_err(|e| e.to_string())?; + } + } + + let method_upper = method.to_ascii_uppercase(); + match method_upper.as_str() { + "GET" => easy.get(true).map_err(|e| e.to_string())?, + "POST" => easy.post(true).map_err(|e| e.to_string())?, + "HEAD" => { + easy.nobody(true).map_err(|e| e.to_string())?; + easy.custom_request("HEAD").map_err(|e| e.to_string())?; + } + _ => easy + .custom_request(&method_upper) + .map_err(|e| e.to_string())?, + } + + if let Some(ref request_body) = body { + if method_upper != "GET" && method_upper != "HEAD" { + easy.post_fields_copy(request_body) + .map_err(|e| e.to_string())?; + } + } + + if !headers.is_empty() || cookie_header.is_some() { + let mut header_list = List::new(); + for header in headers { + header_list.append(&header).map_err(|e| e.to_string())?; + } + if let Some(cookie) = cookie_header { + header_list + .append(&format!("Cookie: {}", cookie)) + .map_err(|e| e.to_string())?; + } + easy.http_headers(header_list).map_err(|e| e.to_string())?; + } + + let mut response_headers = HashMap::new(); + let mut response_body = Vec::new(); + let mut response_cookies = Vec::new(); + let mut location = None; + let mut status_text = String::new(); + let mut header_size = 0usize; + + { + let mut transfer = easy.transfer(); + + transfer + .header_function(|header| { + let Ok(raw_line) = std::str::from_utf8(header) else { + return true; + }; + + let line = raw_line.trim_end_matches(|c| c == '\r' || c == '\n'); + if line.is_empty() { + return true; + } + + if let Some((_status, parsed_status_text)) = parse_status_line(line) { + response_headers.clear(); + response_cookies.clear(); + location = None; + header_size = line.len() + 2; + status_text = parsed_status_text; + return true; + } + + header_size += line.len() + 2; + + if let Some((name, value)) = line.split_once(':') { + let key = name.trim().to_ascii_lowercase(); + let value = value.trim().to_string(); + + if key == "set-cookie" { + response_cookies.push(value.clone()); + } + if key == "location" { + location = Some(value.clone()); + } + + response_headers.insert(key, value); + } + + true + }) + .map_err(|e| e.to_string())?; + + transfer + .write_function(|data| { + response_body.extend_from_slice(data); + Ok(data.len()) + }) + .map_err(|e| e.to_string())?; + + transfer.perform().map_err(|e| e.to_string())?; + } + + let request_end = now_millis(); + let status = easy.response_code().map_err(|e| e.to_string())? as u16; + let status_text = if status_text.is_empty() { + status.to_string() + } else { + status_text + }; + + let dns_ms = duration_to_ms(easy.namelookup_time()); + let connect_ms = duration_to_ms(easy.connect_time()); + let app_connect_ms = duration_to_ms(easy.appconnect_time()); + let pre_transfer_ms = duration_to_ms(easy.pretransfer_time()); + let start_transfer_ms = duration_to_ms(easy.starttransfer_time()); + let total_ms = + duration_to_ms(easy.total_time()).unwrap_or((request_end - request_start).max(0.0)); + + let tcp_ms = phase_delta_ms(connect_ms, dns_ms); + let tls_ms = if app_connect_ms.unwrap_or(0.0) > 0.0 { + phase_delta_ms(app_connect_ms, connect_ms) + } else { + None + }; + let request_ms = phase_delta_ms(start_transfer_ms, pre_transfer_ms); + let download_ms = start_transfer_ms.map(|start_ms| (total_ms - start_ms).max(0.0)); + + Ok(CurlHopResponse { + status, + status_text, + headers: response_headers, + body: response_body, + cookies: response_cookies, + location, + timing: ResponseTiming { + start: request_start, + end: request_start + total_ms, + duration: total_ms, + dns: dns_ms, + tcp: tcp_ms, + tls: tls_ms, + request: request_ms, + first_byte: start_transfer_ms, + download: download_ms, + total: total_ms, + }, + header_size, + }) +} + +/// Guess MIME type from file extension +fn guess_mime(file_name: &str) -> &'static str { + file_name + .rsplit('.') + .next() + .map(|ext| match ext.to_lowercase().as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + "json" => "application/json", + "xml" => "application/xml", + "txt" => "text/plain", + "csv" => "text/csv", + "zip" => "application/zip", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + _ => "application/octet-stream", + }) + .unwrap_or("application/octet-stream") +} + +/// Build a multipart/form-data body manually (RFC 2046). +/// Returns (body_bytes, content_type_header_with_boundary). +fn build_multipart_body( + fields: &[crate::models::FormDataField], +) -> Result<(Vec, String), String> { + use rand::Rng; + let boundary: String = { + let mut rng = rand::thread_rng(); + format!("----LitePostBoundary{:016x}", rng.gen::()) + }; + + let mut body: Vec = Vec::new(); + + for field in fields { + if !field.enabled || field.key.is_empty() { + continue; + } + + // Part delimiter + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + + if field.field_type == "file" { + let file_name = field + .file_name + .as_deref() + .or_else(|| { + field.file_path.as_deref().and_then(|path| { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + }) + }) + .unwrap_or("upload"); + let mime = guess_mime(file_name); + + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n", + field.key, file_name + ) + .as_bytes(), + ); + body.extend_from_slice(format!("Content-Type: {}\r\n", mime).as_bytes()); + body.extend_from_slice(b"\r\n"); + + if let Some(ref file_data) = field.file_data { + let decoded = general_purpose::STANDARD + .decode(file_data) + .map_err(|e| format!("Failed to decode file data: {}", e))?; + body.extend_from_slice(&decoded); + } else if let Some(ref file_path) = field.file_path { + let cleaned_path = file_path.trim().trim_matches('"'); + let file_bytes = std::fs::read(cleaned_path) + .map_err(|e| format!("Failed to read file '{}': {}", cleaned_path, e))?; + body.extend_from_slice(&file_bytes); + } + } else { + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{}\"\r\n", field.key).as_bytes(), + ); + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(field.value.as_bytes()); + } + + body.extend_from_slice(b"\r\n"); + } + + // Closing delimiter + body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + let content_type = format!("multipart/form-data; boundary={}", boundary); + Ok((body, content_type)) +} + +#[tauri::command] +pub async fn send_request( + options: RequestOptions, + _client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let start_time = now_millis(); + let request_headers = build_request_header_lines(&options); + let original_body = options.body.clone().map(|body| body.into_bytes()); + let is_multipart = options + .content_type + .as_deref() + .map(|ct| ct.starts_with("multipart/form-data")) + .unwrap_or(false); + let form_data = options.form_data.clone(); + + // Pre-compute multipart body if needed + let (multipart_body, multipart_content_type) = if is_multipart { + if let Some(ref fields) = form_data { + let (body, ct) = build_multipart_body(fields)?; + (Some(body), Some(ct)) + } else { + (None, None) + } + } else { + (None, None) + }; + + let network = NetworkSettings { + timeout_secs: options.timeout.unwrap_or(30), + connect_timeout_secs: options.connect_timeout.unwrap_or(10), + ssl_verification: options.ssl_verification, + proxy: options.proxy.clone(), + }; + + let mut request_headers = request_headers; + if let Some(content_type) = multipart_content_type.as_deref() { + set_or_replace_content_type(&mut request_headers, content_type); + } + + // Arc: redirect hops share one buffer instead of cloning it each time + let request_body = multipart_body.or(original_body).map(std::sync::Arc::new); + let method = options.method.to_ascii_uppercase(); + let mut current_url = options.url; + let initial_host = Url::parse(¤t_url) + .map_err(|e| e.to_string())? + .host_str() + .ok_or_else(|| "Initial URL host missing".to_string())? + .to_ascii_lowercase(); + let mut cookie_store: Vec = options + .cookies + .iter() + .map(|cookie| StoredCookie { + name: cookie.name.clone(), + value: cookie.value.clone(), + domain: cookie + .domain + .as_deref() + .map(normalize_cookie_domain) + .filter(|domain| !domain.is_empty()) + .unwrap_or_else(|| initial_host.clone()), + path: cookie.path.clone().unwrap_or_else(|| "/".to_string()), + secure: cookie.secure.unwrap_or(false), + }) + .collect(); + let mut redirect_chain = Vec::new(); + let mut all_cookies = Vec::new(); + let mut hop_timings = Vec::new(); + let mut final_response = None; + + for redirect_index in 0..10 { + let headers_for_request = request_headers.clone(); + let body_for_request = request_body.clone(); + let method_for_request = method.clone(); + let url_for_request = current_url.clone(); + let cookie_header = build_cookie_header_value(&cookie_store, ¤t_url)?; + + let network_for_request = NetworkSettings { + timeout_secs: network.timeout_secs, + connect_timeout_secs: network.connect_timeout_secs, + ssl_verification: network.ssl_verification, + proxy: network.proxy.clone(), + }; + let hop = tokio::task::spawn_blocking(move || { + perform_curl_request( + method_for_request, + url_for_request, + headers_for_request, + body_for_request, + cookie_header, + &network_for_request, + ) + }) + .await + .map_err(|e| format!("Request task failed: {}", e))??; + + apply_set_cookie_headers(&mut cookie_store, &hop.cookies, ¤t_url)?; + all_cookies.extend(hop.cookies.clone()); + hop_timings.push(hop.timing.clone()); + + let is_redirect = (300..400).contains(&hop.status) && hop.location.is_some(); + if is_redirect { + let hop_body_size = hop.body.len(); + redirect_chain.push(RedirectInfo { + url: current_url.clone(), + status: hop.status, + status_text: hop.status_text.clone(), + headers: hop.headers.clone(), + cookies: hop.cookies.clone(), + timing: Some(hop.timing.clone()), + size: Some(ResponseSize { + headers: hop.header_size, + body: hop_body_size, + total: hop.header_size + hop_body_size, + }), + }); + + if redirect_index == 9 { + return Err( + "Maximum redirect limit (10) exceeded. The server might be in a redirect loop." + .to_string(), + ); + } + + let location = hop.location.unwrap_or_default(); + current_url = Url::parse(¤t_url) + .map_err(|e| e.to_string())? + .join(&location) + .map_err(|e| e.to_string())? + .to_string(); + continue; + } + + final_response = Some(hop); + break; + } + + let final_response = final_response.ok_or_else(|| "No response received".to_string())?; + let body_size = final_response.body.len(); + let is_binary = is_binary_content_type(final_response.headers.get("content-type")); + + let (body, is_base64) = if is_binary { + (general_purpose::STANDARD.encode(&final_response.body), true) + } else { + ( + String::from_utf8_lossy(&final_response.body).to_string(), + false, + ) + }; + + let end_time = now_millis(); + let total_ms = (end_time - start_time).max(0.0); + + Ok(ResponseData { + status: final_response.status, + status_text: final_response.status_text, + headers: final_response.headers, + body, + is_base64, + redirect_chain, + cookies: all_cookies, + timing: Some(ResponseTiming { + start: start_time, + end: end_time, + duration: total_ms, + dns: sum_optional(hop_timings.iter().map(|timing| timing.dns)), + tcp: sum_optional(hop_timings.iter().map(|timing| timing.tcp)), + tls: sum_optional(hop_timings.iter().map(|timing| timing.tls)), + request: sum_optional(hop_timings.iter().map(|timing| timing.request)), + first_byte: sum_optional(hop_timings.iter().map(|timing| timing.first_byte)), + download: sum_optional(hop_timings.iter().map(|timing| timing.download)), + total: total_ms, + }), + size: Some(ResponseSize { + headers: final_response.header_size, + body: body_size, + total: final_response.header_size + body_size, + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc::{self, Receiver}; + use std::thread; + + fn spawn_one_shot_server(response: impl Into) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("server should bind"); + let address = listener + .local_addr() + .expect("server should have local addr"); + let response_payload = response.into(); + + thread::spawn(move || { + if let Ok((mut socket, _)) = listener.accept() { + let mut buffer = [0u8; 4096]; + let _ = socket.read(&mut buffer); + let _ = socket.write_all(response_payload.as_bytes()); + let _ = socket.flush(); + } + }); + + format!("http://{}", address) + } + + fn spawn_capture_server(response: impl Into) -> (String, Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").expect("server should bind"); + let address = listener + .local_addr() + .expect("server should have local addr"); + let response_payload = response.into(); + let (tx, rx) = mpsc::channel::(); + + thread::spawn(move || { + if let Ok((mut socket, _)) = listener.accept() { + let _ = socket.set_read_timeout(Some(std::time::Duration::from_secs(2))); + let mut request_bytes = Vec::new(); + let mut buffer = [0u8; 1024]; + + loop { + match socket.read(&mut buffer) { + Ok(0) => break, + Ok(size) => { + request_bytes.extend_from_slice(&buffer[..size]); + if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::TimedOut + || error.kind() == std::io::ErrorKind::WouldBlock => + { + break; + } + Err(_) => break, + } + } + + let request_text = String::from_utf8_lossy(&request_bytes).to_string(); + let _ = tx.send(request_text); + + let _ = socket.write_all(response_payload.as_bytes()); + let _ = socket.flush(); + } + }); + + (format!("http://{}", address), rx) + } + + #[test] + fn parse_status_line_keeps_code_and_reason() { + let parsed = + parse_status_line("HTTP/1.1 301 Moved Permanently").expect("status line should parse"); + assert_eq!(parsed.0, 301); + assert_eq!(parsed.1, "301 Moved Permanently"); + } + + #[test] + fn cookie_domain_and_path_filtering_works() { + let cookies = vec![ + StoredCookie { + name: "sid".to_string(), + value: "123".to_string(), + domain: "example.com".to_string(), + path: "/api".to_string(), + secure: false, + }, + StoredCookie { + name: "secure_token".to_string(), + value: "abc".to_string(), + domain: "example.com".to_string(), + path: "/".to_string(), + secure: true, + }, + ]; + + let http_header = build_cookie_header_value(&cookies, "http://api.example.com/api/users") + .expect("cookie header build should succeed") + .expect("at least one cookie should match"); + assert!(http_header.contains("sid=123")); + assert!(!http_header.contains("secure_token=abc")); + + let https_header = build_cookie_header_value(&cookies, "https://api.example.com/api/users") + .expect("cookie header build should succeed") + .expect("cookies should match on https"); + assert!(https_header.contains("sid=123")); + assert!(https_header.contains("secure_token=abc")); + + let no_match = build_cookie_header_value(&cookies, "https://api.example.com/other") + .expect("cookie header build should succeed"); + assert_eq!(no_match, Some("secure_token=abc".to_string())); + } + + #[test] + fn set_cookie_parsing_applies_domain_path_secure() { + let mut store = Vec::new(); + let set_cookies = vec![ + "session=xyz; Domain=.example.com; Path=/v1; Secure".to_string(), + "theme=dark; Path=/".to_string(), + ]; + + apply_set_cookie_headers(&mut store, &set_cookies, "https://api.example.com/v1") + .expect("set-cookie parse should succeed"); + + let session = store + .iter() + .find(|cookie| cookie.name == "session") + .expect("session cookie should exist"); + assert_eq!(session.domain, "example.com"); + assert_eq!(session.path, "/v1"); + assert!(session.secure); + + let theme = store + .iter() + .find(|cookie| cookie.name == "theme") + .expect("theme cookie should exist"); + assert_eq!(theme.domain, "api.example.com"); + assert_eq!(theme.path, "/"); + assert!(!theme.secure); + } + + #[test] + fn perform_curl_request_smoke_test() { + let url = spawn_one_shot_server( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nSet-Cookie: sid=1; Path=/\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + ); + + let response = perform_curl_request("GET".to_string(), url, Vec::new(), None, None, &NetworkSettings::default()) + .expect("curl request should succeed"); + + assert_eq!(response.status, 200); + assert_eq!(response.status_text, "200 OK"); + assert_eq!(response.body, b"hello"); + assert_eq!( + response.headers.get("content-type"), + Some(&"text/plain".to_string()) + ); + assert!(response + .cookies + .iter() + .any(|cookie| cookie.starts_with("sid=1"))); + assert!(response.timing.total >= 0.0); + assert!(response.timing.first_byte.is_some()); + } + + #[test] + fn manual_redirect_chain_forwards_cookie_to_final_hop() { + let (final_url, final_request_rx) = spawn_capture_server( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nConnection: close\r\n\r\nfinal", + ); + let redirect_url = spawn_one_shot_server(format!( + "HTTP/1.1 302 Found\r\nLocation: {}\r\nSet-Cookie: hop=1; Path=/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + final_url + )); + + let mut current_url = redirect_url; + let mut cookie_store = Vec::::new(); + let mut redirect_count = 0usize; + let mut final_hop = None; + + for _ in 0..3 { + let cookie_header = build_cookie_header_value(&cookie_store, ¤t_url) + .expect("cookie header should be buildable"); + let hop = perform_curl_request( + "GET".to_string(), + current_url.clone(), + Vec::new(), + None, + cookie_header, + &NetworkSettings::default(), + ) + .expect("curl request should succeed"); + apply_set_cookie_headers(&mut cookie_store, &hop.cookies, ¤t_url) + .expect("set-cookie parse should succeed"); + + if (300..400).contains(&hop.status) && hop.location.is_some() { + redirect_count += 1; + current_url = Url::parse(¤t_url) + .expect("current url should parse") + .join(hop.location.as_deref().unwrap_or_default()) + .expect("redirect location should resolve") + .to_string(); + continue; + } + + final_hop = Some(hop); + break; + } + + let final_hop = final_hop.expect("final hop should be present"); + assert_eq!(redirect_count, 1); + assert_eq!(final_hop.status, 200); + assert_eq!(final_hop.status_text, "200 OK"); + assert_eq!(final_hop.body, b"final"); + + let final_request = final_request_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("final request should be captured"); + assert!(final_request.to_ascii_lowercase().contains("cookie: hop=1")); + } + + #[test] + fn multipart_reads_file_bytes_from_file_path() { + let temp_file = std::env::temp_dir().join(format!( + "litepost-upload-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time should be valid") + .as_nanos() + )); + + std::fs::write(&temp_file, b"hello from file path") + .expect("temporary file should be writable"); + + let fields = vec![crate::models::FormDataField { + key: "file".to_string(), + value: String::new(), + field_type: "file".to_string(), + file_name: Some("sample.txt".to_string()), + file_data: None, + file_path: Some(temp_file.to_string_lossy().to_string()), + enabled: true, + }]; + + let (body, content_type) = + build_multipart_body(&fields).expect("multipart body should be built"); + let body_text = String::from_utf8_lossy(&body); + + assert!(content_type.starts_with("multipart/form-data; boundary=")); + assert!(body_text.contains("name=\"file\"; filename=\"sample.txt\"")); + assert!(body_text.contains("hello from file path")); + + let _ = std::fs::remove_file(temp_file); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fe9719..770f4e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,362 +1,44 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -use base64; -use base64::engine::general_purpose; -use base64::Engine as _; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::str::FromStr; -use std::sync::Arc; -use tauri::http::method::Method; -use tauri::Url; -use tauri_plugin_http::reqwest::{ - self, - header::{HeaderMap, HeaderName, HeaderValue}, - Client, -}; - -#[derive(Debug, Serialize, Deserialize)] -struct RequestOptions { - method: String, - url: String, - headers: HashMap, - body: Option, - content_type: Option, - cookies: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -struct Cookie { - name: String, - value: String, - domain: Option, - path: Option, - expires: Option, - secure: Option, - http_only: Option, -} - -#[derive(Debug, Serialize)] -struct ResponseTiming { - start: u128, - end: u128, - duration: u128, - dns: Option, - tcp: Option, - tls: Option, - request: Option, - first_byte: Option, - download: Option, - total: u128, -} - -#[derive(Debug, Serialize)] -struct ResponseSize { - headers: usize, - body: usize, - total: usize, -} - -#[derive(Debug, Serialize)] -struct RedirectInfo { - url: String, - status: u16, - status_text: String, - headers: HashMap, - cookies: Vec, - timing: Option, - size: Option, -} - -#[derive(Debug, Serialize)] -struct ResponseData { - status: u16, - status_text: String, - headers: HashMap, - body: String, - is_base64: bool, - redirect_chain: Vec, - cookies: Vec, - timing: Option, - size: Option, -} - -// Add new struct for application state -struct AppState { - client: Client, -} - -#[derive(Clone)] -struct ClientWrapper { - client: Client, - cookie_jar: Arc, -} - -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} - -#[tauri::command] -async fn send_request( - options: RequestOptions, - client_wrapper: tauri::State<'_, ClientWrapper>, -) -> Result { - let client = &client_wrapper.client; - let cookie_jar = &client_wrapper.cookie_jar; - let start_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - // Add UI cookies to the cookie store - let url = Url::parse(&options.url).map_err(|e| e.to_string())?; - for cookie in &options.cookies { - let cookie_str = format!("{}={}", cookie.name, cookie.value); - cookie_jar.add_cookie_str(&cookie_str, &url); - } - - let mut headers = HeaderMap::new(); - for (key, value) in options.headers { - // Skip Cookie header as we're handling cookies separately - if key.to_lowercase() != "cookie" { - headers.insert( - HeaderName::from_str(&key).map_err(|e| e.to_string())?, - HeaderValue::from_str(&value).map_err(|e| e.to_string())?, - ); - } - } - - // Add content type header if body is present - if options.body.is_some() && !headers.contains_key(HeaderName::from_static("content-type")) { - if let Some(content_type) = options.content_type { - headers.insert( - HeaderName::from_static("content-type"), - HeaderValue::from_str(&content_type).map_err(|e| e.to_string())?, - ); - } - } - - let mut request = client - .request( - Method::from_str(&options.method).map_err(|e| e.to_string())?, - &options.url, - ) - .headers(headers); - - if let Some(body) = options.body { - request = request.body(body); - } +mod http_client; +mod models; +mod network_utils; +mod oauth; +mod streaming; +mod websocket; - let mut current_url = options.url; - let mut redirect_chain = Vec::new(); - let mut response = None; - - for i in 0..10 { - // Max 10 redirects - let dns_start = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - let resp = request - .try_clone() - .ok_or_else(|| "Failed to clone request".to_string())? - .send() - .await - .map_err(|e| e.to_string())?; - - let first_byte_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - let status = resp.status(); - let headers: HashMap = resp - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - // Calculate headers size - let headers_size = headers - .iter() - .map(|(k, v)| k.len() + v.len() + 4) // +4 for ": " and "\r\n" - .sum(); - - if status.is_redirection() { - if let Some(location) = resp.headers().get("location") { - let location = location.to_str().map_err(|e| e.to_string())?; - let next_url = Url::parse(¤t_url) - .map_err(|e| e.to_string())? - .join(location) - .map_err(|e| e.to_string())? - .to_string(); - - let redirect_cookies: Vec = resp - .headers() - .get_all("set-cookie") - .iter() - .filter_map(|h| h.to_str().ok()) - .map(String::from) - .collect(); - - let end_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - redirect_chain.push(RedirectInfo { - url: current_url.clone(), - status: status.as_u16(), - status_text: status.to_string(), - headers: headers.clone(), - cookies: redirect_cookies, - timing: Some(ResponseTiming { - start: dns_start, - end: end_time, - duration: end_time - dns_start, - dns: Some(first_byte_time - dns_start), - tcp: None, - tls: None, - request: None, - first_byte: Some(first_byte_time - dns_start), - download: Some(end_time - first_byte_time), - total: end_time - dns_start, - }), - size: Some(ResponseSize { - headers: headers_size, - body: 0, // Redirects don't typically have bodies - total: headers_size, - }), - }); - - if i == 9 { - return Err("Maximum redirect limit (10) exceeded. The server might be in a redirect loop.".to_string()); - } - - current_url = next_url; - request = client.request( - Method::from_str(&options.method).map_err(|e| e.to_string())?, - ¤t_url, - ); - continue; - } - } - - response = Some((resp, dns_start, first_byte_time)); - break; - } - - let (final_response, dns_start, first_byte_time) = - response.ok_or_else(|| "No response received".to_string())?; - let status = final_response.status(); - let headers: HashMap = final_response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - // Calculate headers size - let headers_size = headers - .iter() - .map(|(k, v)| k.len() + v.len() + 4) // +4 for ": " and "\r\n" - .sum(); - - let mut all_cookies = Vec::new(); - - // Collect cookies from redirect chain - for redirect in &redirect_chain { - all_cookies.extend(redirect.cookies.clone()); - } - - // Add cookies from final response - all_cookies.extend( - final_response - .headers() - .get_all("set-cookie") - .iter() - .filter_map(|h| h.to_str().ok()) - .map(String::from), - ); - - // Check content type to determine if response is binary - let content_type = headers.get("content-type").map(|s| s.to_lowercase()); - let is_binary = content_type.as_ref().map_or(false, |ct| { - (ct.starts_with("image/") && !ct.starts_with("image/svg")) || // SVG is text-based XML - ct.starts_with("application/octet-stream") || - ct.starts_with("audio/") || - ct.starts_with("video/") - }); - - let (body, body_size, is_base64) = if is_binary { - // For binary data, get bytes and base64 encode - let bytes = final_response.bytes().await.map_err(|e| e.to_string())?; - let size = bytes.len(); - ( - base64::engine::general_purpose::STANDARD.encode(bytes), - size, - true, - ) - } else { - // For text data (including SVG), get as string - let text = final_response.text().await.map_err(|e| e.to_string())?; - let size = text.len(); - (text, size, false) - }; - - let end_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); +use std::collections::HashMap; +use std::sync::Mutex; - Ok(ResponseData { - status: status.as_u16(), - status_text: status.to_string(), - headers, - body, - is_base64, - redirect_chain, - cookies: all_cookies, - timing: Some(ResponseTiming { - start: start_time, - end: end_time, - duration: end_time - start_time, - dns: Some(first_byte_time - dns_start), - tcp: None, - tls: None, - request: None, - first_byte: Some(first_byte_time - dns_start), - download: Some(end_time - first_byte_time), - total: end_time - start_time, - }), - size: Some(ResponseSize { - headers: headers_size, - body: body_size, - total: headers_size + body_size, - }), - }) -} +use models::{ActiveStreams, ClientWrapper}; +use websocket::ActiveWebSockets; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let cookie_jar = Arc::new(reqwest::cookie::Jar::default()); - let client = Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .cookie_provider(Arc::clone(&cookie_jar)) - .build() - .unwrap(); + let active_streams = ActiveStreams { + streams: Mutex::new(HashMap::new()), + }; - let client_wrapper = ClientWrapper { client, cookie_jar }; + let active_websockets = ActiveWebSockets { + connections: Mutex::new(HashMap::new()), + }; tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) - .manage(client_wrapper) + .manage(ClientWrapper::new()) + .manage(active_streams) + .manage(active_websockets) .invoke_handler(tauri::generate_handler![ - greet, - send_request, + http_client::send_request, + streaming::stream_sse, + streaming::cancel_stream, + oauth::oauth2_token_exchange, + oauth::oauth2_auth_code_flow, + oauth::oauth2_refresh, + websocket::ws_connect, + websocket::ws_send, + websocket::ws_disconnect, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index eb11574..467c1e6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,33 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(target_os = "windows")] +fn configure_webview2_args() { + let disable_gpu = std::env::args().any(|arg| arg == "--disable-webview-gpu") + || std::env::var("LITEPOST_DISABLE_WEBVIEW_GPU") + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if !disable_gpu { + return; + } + + const ENV_KEY: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"; + const DISABLE_GPU_ARG: &str = "--disable-gpu"; + + let next_args = match std::env::var(ENV_KEY) { + Ok(existing) if existing.split_whitespace().any(|arg| arg == DISABLE_GPU_ARG) => existing, + Ok(existing) if !existing.trim().is_empty() => format!("{existing} {DISABLE_GPU_ARG}"), + _ => DISABLE_GPU_ARG.to_string(), + }; + + std::env::set_var(ENV_KEY, next_args); +} + +#[cfg(not(target_os = "windows"))] +fn configure_webview2_args() {} + fn main() { + configure_webview2_args(); litepost_lib::run() } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..7db8db7 --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,146 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; +use tauri_plugin_http::reqwest::{self, Client}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RequestOptions { + pub method: String, + pub url: String, + pub headers: HashMap, + pub body: Option, + pub content_type: Option, + pub cookies: Vec, + #[serde(default)] + pub form_data: Option>, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub connect_timeout: Option, + #[serde(default = "default_true")] + pub ssl_verification: bool, + #[serde(default)] + pub proxy: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FormDataField { + pub key: String, + pub value: String, + #[serde(rename = "type")] + pub field_type: String, // "text" or "file" + #[serde(rename = "fileName")] + pub file_name: Option, + #[serde(rename = "fileData")] + pub file_data: Option, // base64 encoded + #[serde(rename = "filePath")] + pub file_path: Option, + pub enabled: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Cookie { + pub name: String, + pub value: String, + pub domain: Option, + pub path: Option, + pub expires: Option, + pub secure: Option, + pub http_only: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResponseTiming { + pub start: f64, + pub end: f64, + pub duration: f64, + pub dns: Option, + pub tcp: Option, + pub tls: Option, + pub request: Option, + pub first_byte: Option, + pub download: Option, + pub total: f64, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResponseSize { + pub headers: usize, + pub body: usize, + pub total: usize, +} + +#[derive(Debug, Serialize, Clone)] +pub struct RedirectInfo { + pub url: String, + pub status: u16, + pub status_text: String, + pub headers: HashMap, + pub cookies: Vec, + pub timing: Option, + pub size: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResponseData { + pub status: u16, + pub status_text: String, + pub headers: HashMap, + pub body: String, + pub is_base64: bool, + pub redirect_chain: Vec, + pub cookies: Vec, + pub timing: Option, + pub size: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StreamChunk { + pub id: Option, + pub event: Option, + pub data: String, + pub is_done: bool, +} + +pub struct ClientWrapper { + client: Mutex>, +} + +impl ClientWrapper { + pub fn new() -> Self { + Self { + client: Mutex::new(None), + } + } + + pub fn get_or_init_client(&self) -> Result { + let mut guard = self + .client + .lock() + .map_err(|error| format!("Client lock error: {}", error))?; + + if guard.is_none() { + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|error| format!("Failed to build HTTP client: {}", error))?; + + *guard = Some(client); + } + + Ok(guard + .as_ref() + .expect("HTTP client should be initialized") + .clone()) + } +} + +pub struct ActiveStreams { + pub streams: Mutex>>, +} diff --git a/src-tauri/src/network_utils.rs b/src-tauri/src/network_utils.rs new file mode 100644 index 0000000..ea35ee9 --- /dev/null +++ b/src-tauri/src/network_utils.rs @@ -0,0 +1,38 @@ +use std::str::FromStr; +use tauri_plugin_http::reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + +use crate::models::RequestOptions; + +pub fn now_millis() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + * 1000.0 +} + +pub fn build_request_headers(options: &RequestOptions) -> Result { + let mut headers = HeaderMap::new(); + + for (key, value) in &options.headers { + if key.eq_ignore_ascii_case("cookie") { + continue; + } + + headers.insert( + HeaderName::from_str(key).map_err(|e| e.to_string())?, + HeaderValue::from_str(value).map_err(|e| e.to_string())?, + ); + } + + if options.body.is_some() && !headers.contains_key(HeaderName::from_static("content-type")) { + if let Some(content_type) = options.content_type.as_deref() { + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_str(content_type).map_err(|e| e.to_string())?, + ); + } + } + + Ok(headers) +} diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs new file mode 100644 index 0000000..e079343 --- /dev/null +++ b/src-tauri/src/oauth.rs @@ -0,0 +1,795 @@ +use base64::engine::general_purpose; +use base64::Engine as _; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use tauri::Url; +use tauri_plugin_http::reqwest; +use tauri_plugin_opener::OpenerExt; + +use crate::models::ClientWrapper; + +const OAUTH_TOKEN_ACCEPT_HEADER: &str = + "application/json, application/x-www-form-urlencoded, text/plain"; +const TOKEN_CONTAINER_KEYS: &[&str] = &["data", "token", "result", "response"]; + +#[derive(Debug, Deserialize)] +pub struct OAuth2TokenExchangeOptions { + token_url: String, + grant_type: String, + client_id: String, + client_secret: Option, + scope: Option, + username: Option, + password: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct OAuth2TokenResponse { + access_token: String, + token_type: Option, + expires_in: Option, + refresh_token: Option, + scope: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OAuth2AuthCodeOptions { + auth_url: String, + token_url: String, + client_id: String, + client_secret: Option, + scope: Option, + use_pkce: Option, + redirect_uri: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OAuth2RefreshOptions { + token_url: String, + client_id: String, + client_secret: Option, + refresh_token: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenBodyFormat { + Json, + Form, +} + +fn required_field(value: String, field_name: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{} is required", field_name)); + } + Ok(trimmed.to_string()) +} + +fn normalize_optional_input(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn insert_optional_param(params: &mut HashMap, key: &str, value: Option) { + if let Some(value) = normalize_optional_input(value) { + params.insert(key.to_string(), value); + } +} + +fn oauth_body_preview(body: &str, max_chars: usize) -> String { + let trimmed = body.trim(); + let mut preview = trimmed.chars().take(max_chars).collect::(); + if trimmed.chars().count() > max_chars { + preview.push_str("..."); + } + preview +} + +fn lookup_value<'a>(map: &'a Map, keys: &[&str]) -> Option<&'a Value> { + for key in keys { + if let Some(value) = map.get(*key) { + return Some(value); + } + } + + for container_key in TOKEN_CONTAINER_KEYS { + if let Some(Value::Object(inner)) = map.get(*container_key) { + for key in keys { + if let Some(value) = inner.get(*key) { + return Some(value); + } + } + } + } + + None +} + +fn value_as_non_empty_string(value: &Value) -> Option { + match value { + Value::String(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +fn parse_required_string_field( + map: &Map, + aliases: &[&str], + field_name: &str, +) -> Result { + let value = lookup_value(map, aliases).ok_or_else(|| format!("missing {}", field_name))?; + value_as_non_empty_string(value) + .ok_or_else(|| format!("{} must be a non-empty string", field_name)) +} + +fn parse_optional_string_field( + map: &Map, + aliases: &[&str], + field_name: &str, +) -> Result, String> { + match lookup_value(map, aliases) { + None | Some(Value::Null) => Ok(None), + Some(value) => { + let parsed = value_as_non_empty_string(value) + .ok_or_else(|| format!("{} must be a string", field_name))?; + Ok(Some(parsed)) + } + } +} + +fn parse_optional_expires_in(map: &Map) -> Result, String> { + match lookup_value(map, &["expires_in", "expiresIn"]) { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(value)) => { + if let Some(as_u64) = value.as_u64() { + return Ok(Some(as_u64)); + } + if let Some(as_f64) = value.as_f64() { + if as_f64.is_finite() && as_f64 >= 0.0 && as_f64.fract() == 0.0 { + return Ok(Some(as_f64 as u64)); + } + } + Err("expires_in must be a whole non-negative number".to_string()) + } + Some(Value::String(value)) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed = trimmed + .parse::() + .map_err(|_| "expires_in must be a whole non-negative number".to_string())?; + Ok(Some(parsed)) + } + Some(_) => Err("expires_in must be a number or string".to_string()), + } +} + +fn extract_oauth_error(map: &Map) -> Option { + let error = lookup_value(map, &["error", "error_code"]).and_then(value_as_non_empty_string)?; + let description = lookup_value( + map, + &[ + "error_description", + "errorDescription", + "error_message", + "message", + ], + ) + .and_then(value_as_non_empty_string); + let error_uri = + lookup_value(map, &["error_uri", "errorUri"]).and_then(value_as_non_empty_string); + + let mut message = error; + if let Some(description) = description { + message.push_str(": "); + message.push_str(&description); + } + if let Some(error_uri) = error_uri { + message.push_str(" ("); + message.push_str(&error_uri); + message.push(')'); + } + + Some(message) +} + +fn map_to_token_response(map: &Map) -> Result { + let access_token_aliases = &["access_token", "accessToken"]; + let has_access_token = lookup_value(map, access_token_aliases).is_some(); + + if !has_access_token { + if let Some(provider_error) = extract_oauth_error(map) { + return Err(format!("OAuth provider error: {}", provider_error)); + } + } + + Ok(OAuth2TokenResponse { + access_token: parse_required_string_field(map, access_token_aliases, "access_token")?, + token_type: parse_optional_string_field(map, &["token_type", "tokenType"], "token_type")?, + expires_in: parse_optional_expires_in(map)?, + refresh_token: parse_optional_string_field( + map, + &["refresh_token", "refreshToken"], + "refresh_token", + )?, + scope: parse_optional_string_field(map, &["scope"], "scope")?, + }) +} + +fn parse_json_map(body: &str) -> Result, String> { + let value: Value = + serde_json::from_str(body).map_err(|e| format!("JSON parse error: {}", e))?; + + match value { + Value::Object(map) => Ok(map), + _ => Err("JSON token response must be an object".to_string()), + } +} + +fn parse_form_map(body: &str) -> Result, String> { + let form = body.trim_start_matches('?'); + if form.is_empty() { + return Err("form-encoded response body is empty".to_string()); + } + + let fake_url = format!("http://localhost/?{}", form); + let url = Url::parse(&fake_url).map_err(|e| format!("form-encoded parse error: {}", e))?; + + let mut values = Map::new(); + for (key, value) in url.query_pairs() { + values.insert(key.into_owned(), Value::String(value.into_owned())); + } + + if values.is_empty() { + return Err("no form fields found".to_string()); + } + + Ok(values) +} + +fn looks_like_json(body: &str) -> bool { + body.starts_with('{') || body.starts_with('[') +} + +fn looks_like_form(body: &str) -> bool { + body.contains('=') && !looks_like_json(body) +} + +fn detect_parse_order(trimmed_body: &str, content_type: &str) -> Vec { + let mut order = Vec::new(); + + if content_type.contains("json") { + order.push(TokenBodyFormat::Json); + order.push(TokenBodyFormat::Form); + } else if content_type.contains("x-www-form-urlencoded") + || content_type.contains("form-urlencoded") + { + order.push(TokenBodyFormat::Form); + order.push(TokenBodyFormat::Json); + } else if looks_like_json(trimmed_body) { + order.push(TokenBodyFormat::Json); + order.push(TokenBodyFormat::Form); + } else if looks_like_form(trimmed_body) { + order.push(TokenBodyFormat::Form); + order.push(TokenBodyFormat::Json); + } else { + order.push(TokenBodyFormat::Json); + order.push(TokenBodyFormat::Form); + } + + order +} + +fn parse_oauth_token_body(body: &str, content_type: &str) -> Result { + let trimmed = body.trim(); + if trimmed.is_empty() { + return Err("token response body is empty".to_string()); + } + + let mut errors = Vec::new(); + + for format in detect_parse_order(trimmed, content_type) { + let result = match format { + TokenBodyFormat::Json => { + parse_json_map(trimmed).and_then(|map| map_to_token_response(&map)) + } + TokenBodyFormat::Form => { + parse_form_map(trimmed).and_then(|map| map_to_token_response(&map)) + } + }; + + match result { + Ok(token) => return Ok(token), + Err(error) => errors.push(error), + } + } + + Err(errors.join("; ")) +} + +fn parse_oauth_error_body(body: &str, content_type: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() { + return None; + } + + for format in detect_parse_order(trimmed, content_type) { + let parsed = match format { + TokenBodyFormat::Json => parse_json_map(trimmed), + TokenBodyFormat::Form => parse_form_map(trimmed), + }; + + if let Ok(map) = parsed { + if let Some(error) = extract_oauth_error(&map) { + return Some(error); + } + } + } + + None +} + +async fn parse_oauth_token_response(res: reqwest::Response) -> Result { + let content_type = res + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + let body = res + .text() + .await + .map_err(|e| format!("Failed to read token response body: {}", e))?; + + parse_oauth_token_body(&body, &content_type).map_err(|error| { + let content_type_display = if content_type.is_empty() { + "" + } else { + &content_type + }; + format!( + "Failed to parse token response: {} (content-type: {}, body preview: {})", + error, + content_type_display, + oauth_body_preview(&body, 300) + ) + }) +} + +async fn oauth_http_error(prefix: &str, res: reqwest::Response) -> String { + let status = res.status(); + let content_type = res + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + let body = res.text().await.unwrap_or_default(); + if let Some(error) = parse_oauth_error_body(&body, &content_type) { + return format!("{} ({}): {}", prefix, status, error); + } + + let preview = oauth_body_preview(&body, 300); + if preview.is_empty() { + format!("{} ({})", prefix, status) + } else { + format!("{} ({}): {}", prefix, status, preview) + } +} + +#[tauri::command] +pub async fn oauth2_token_exchange( + options: OAuth2TokenExchangeOptions, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let token_url = required_field(options.token_url, "token_url")?; + let grant_type = required_field(options.grant_type, "grant_type")?.to_ascii_lowercase(); + let client_id = required_field(options.client_id, "client_id")?; + + if grant_type != "client_credentials" && grant_type != "password" { + return Err(format!( + "Unsupported grant_type: {}. Supported values: client_credentials, password", + grant_type + )); + } + + let mut params = HashMap::new(); + params.insert("grant_type".to_string(), grant_type.clone()); + params.insert("client_id".to_string(), client_id); + + insert_optional_param(&mut params, "client_secret", options.client_secret); + insert_optional_param(&mut params, "scope", options.scope); + + if grant_type == "password" { + let username = normalize_optional_input(options.username) + .ok_or_else(|| "username is required for password grant".to_string())?; + let password = normalize_optional_input(options.password) + .ok_or_else(|| "password is required for password grant".to_string())?; + + params.insert("username".to_string(), username); + params.insert("password".to_string(), password); + } + + let client = client_wrapper.get_or_init_client()?; + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Token request failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Token request failed", res).await); + } + + parse_oauth_token_response(res).await +} + +#[tauri::command] +pub async fn oauth2_auth_code_flow( + options: OAuth2AuthCodeOptions, + app: tauri::AppHandle, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let auth_url = required_field(options.auth_url, "auth_url")?; + let token_url = required_field(options.token_url, "token_url")?; + let client_id = required_field(options.client_id, "client_id")?; + + let custom_redirect_uri = normalize_optional_input(options.redirect_uri); + let (listener, redirect_uri) = if let Some(custom_uri) = custom_redirect_uri { + let url = Url::parse(&custom_uri).map_err(|e| format!("Invalid redirect URI: {}", e))?; + + if url.scheme() != "http" && url.scheme() != "https" { + return Err("Redirect URI must use http or https".to_string()); + } + + let host = url + .host_str() + .ok_or_else(|| "Redirect URI must include a host".to_string())?; + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return Err( + "Redirect URI host must be localhost, 127.0.0.1, or ::1 for local callback" + .to_string(), + ); + } + + let port = url + .port() + .ok_or_else(|| "Redirect URI must include an explicit port".to_string())?; + + let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)) + .await + .map_err(|e| format!("Failed to bind callback server on port {}: {}", port, e))?; + + (listener, custom_uri) + } else { + // Prefer the documented default port (users may have registered it as a + // redirect URI), but fall back to an ephemeral port if it's taken — + // e.g. a second LitePost instance. RFC 8252 §7.3 requires providers to + // accept any port on a loopback redirect. + let listener = match tokio::net::TcpListener::bind("127.0.0.1:17823").await { + Ok(listener) => listener, + Err(_) => tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind callback server: {}", e))?, + }; + let port = listener + .local_addr() + .map_err(|e| format!("Failed to read callback server port: {}", e))? + .port(); + (listener, format!("http://localhost:{}/callback", port)) + }; + + let pkce = if options.use_pkce.unwrap_or(false) { + Some(generate_pkce()) + } else { + None + }; + + let state = generate_state(); + + let mut auth_uri = Url::parse(&auth_url).map_err(|e| format!("Invalid auth URL: {}", e))?; + auth_uri + .query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("state", &state); + + if let Some(scope) = normalize_optional_input(options.scope) { + auth_uri.query_pairs_mut().append_pair("scope", &scope); + } + + if let Some((_, code_challenge)) = &pkce { + auth_uri + .query_pairs_mut() + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + } + + app.opener() + .open_url(auth_uri.as_str(), None::<&str>) + .map_err(|e| format!("Failed to open browser: {}", e))?; + + let (code, received_state) = tokio::time::timeout( + std::time::Duration::from_secs(120), + wait_for_callback(listener), + ) + .await + .map_err(|_| "Authorization timed out after 2 minutes".to_string())? + .map_err(|e| format!("Callback error: {}", e))?; + + if received_state != state { + return Err("State mismatch - possible CSRF attack".to_string()); + } + + let mut params = HashMap::new(); + params.insert("grant_type".to_string(), "authorization_code".to_string()); + params.insert("code".to_string(), code); + params.insert("redirect_uri".to_string(), redirect_uri); + params.insert("client_id".to_string(), client_id); + + insert_optional_param(&mut params, "client_secret", options.client_secret); + + if let Some((code_verifier, _)) = pkce { + params.insert("code_verifier".to_string(), code_verifier); + } + + let client = client_wrapper.get_or_init_client()?; + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Token exchange failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Token exchange failed", res).await); + } + + parse_oauth_token_response(res).await +} + +async fn wait_for_callback(listener: tokio::net::TcpListener) -> Result<(String, String), String> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (mut stream, _) = listener + .accept() + .await + .map_err(|e| format!("Accept error: {}", e))?; + + let mut buf = Vec::with_capacity(4096); + loop { + let mut chunk = [0u8; 1024]; + let bytes_read = stream + .read(&mut chunk) + .await + .map_err(|e| format!("Read error: {}", e))?; + + if bytes_read == 0 { + break; + } + + buf.extend_from_slice(&chunk[..bytes_read]); + + if buf.len() >= 16 * 1024 || buf.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + if buf.is_empty() { + return Err("Empty callback request".to_string()); + } + + let request = String::from_utf8_lossy(&buf).to_string(); + let first_line = request + .lines() + .next() + .ok_or_else(|| "Empty request".to_string())?; + let path = first_line + .split_whitespace() + .nth(1) + .ok_or_else(|| "No path in request".to_string())?; + + let url = Url::parse(&format!("http://localhost{}", path)) + .map_err(|e| format!("Failed to parse callback URL: {}", e))?; + + let mut code = None; + let mut state = String::new(); + let mut error = None; + let mut error_description = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.to_string()), + "state" => state = value.to_string(), + "error" => error = Some(value.to_string()), + "error_description" => error_description = Some(value.to_string()), + _ => {} + } + } + + let html = if error.is_some() { + "

Authorization Failed

You can close this window.

" + } else { + "

Authorization Successful

You can close this window and return to LitePost.

" + }; + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ); + let _ = stream.write_all(response.as_bytes()).await; + + if let Some(error) = error { + if let Some(description) = error_description { + return Err(format!("Authorization denied: {}: {}", error, description)); + } + return Err(format!("Authorization denied: {}", error)); + } + + let code = code.ok_or_else(|| "No authorization code received".to_string())?; + Ok((code, state)) +} + +fn generate_state() -> String { + let mut bytes = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// Generate PKCE code_verifier and code_challenge (S256). +/// Returns (code_verifier, code_challenge). +fn generate_pkce() -> (String, String) { + use sha2::{Digest, Sha256}; + + let mut bytes = [0u8; 64]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + + let code_verifier = general_purpose::URL_SAFE_NO_PAD.encode(bytes); + + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge = general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize()); + + (code_verifier, code_challenge) +} + +#[tauri::command] +pub async fn oauth2_refresh( + options: OAuth2RefreshOptions, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let token_url = required_field(options.token_url, "token_url")?; + let client_id = required_field(options.client_id, "client_id")?; + let refresh_token = required_field(options.refresh_token, "refresh_token")?; + + let mut params = HashMap::new(); + params.insert("grant_type".to_string(), "refresh_token".to_string()); + params.insert("refresh_token".to_string(), refresh_token); + params.insert("client_id".to_string(), client_id); + + insert_optional_param(&mut params, "client_secret", options.client_secret); + + let client = client_wrapper.get_or_init_client()?; + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Refresh token request failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Refresh failed", res).await); + } + + parse_oauth_token_response(res).await +} + +#[cfg(test)] +mod oauth_parser_tests { + use super::*; + + #[test] + fn parses_json_token_response() { + let body = r#"{"access_token":"abc123","token_type":"bearer","expires_in":3600}"#; + let token = parse_oauth_token_body(body, "application/json").unwrap(); + + assert_eq!(token.access_token, "abc123"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + assert_eq!(token.expires_in, Some(3600)); + } + + #[test] + fn parses_form_encoded_token_response() { + let body = "access_token=xyz789&token_type=bearer&scope=repo%20user&expires_in=7200"; + let token = parse_oauth_token_body(body, "application/x-www-form-urlencoded").unwrap(); + + assert_eq!(token.access_token, "xyz789"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + assert_eq!(token.scope.as_deref(), Some("repo user")); + assert_eq!(token.expires_in, Some(7200)); + } + + #[test] + fn parses_json_with_string_expires_in() { + let body = r#"{"access_token":"token","expires_in":"1800"}"#; + let token = parse_oauth_token_body(body, "application/json").unwrap(); + + assert_eq!(token.access_token, "token"); + assert_eq!(token.expires_in, Some(1800)); + } + + #[test] + fn falls_back_to_json_when_content_type_is_text_plain() { + let body = r#"{"access_token":"from_text_plain","token_type":"bearer"}"#; + let token = parse_oauth_token_body(body, "text/plain").unwrap(); + + assert_eq!(token.access_token, "from_text_plain"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + } + + #[test] + fn falls_back_to_form_when_content_type_is_text_plain() { + let body = "access_token=from_text_plain_form&token_type=bearer"; + let token = parse_oauth_token_body(body, "text/plain").unwrap(); + + assert_eq!(token.access_token, "from_text_plain_form"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + } + + #[test] + fn parses_wrapped_camel_case_token_payload() { + let body = r#"{"data":{"accessToken":"wrapped","tokenType":"Bearer","expiresIn":"60"}}"#; + let token = parse_oauth_token_body(body, "application/json").unwrap(); + + assert_eq!(token.access_token, "wrapped"); + assert_eq!(token.token_type.as_deref(), Some("Bearer")); + assert_eq!(token.expires_in, Some(60)); + } + + #[test] + fn surfaces_json_provider_errors() { + let body = r#"{"error":"invalid_client","error_description":"Bad client secret"}"#; + let error = parse_oauth_token_body(body, "application/json").unwrap_err(); + + assert!(error.contains("invalid_client")); + assert!(error.contains("Bad client secret")); + } + + #[test] + fn surfaces_form_provider_errors() { + let body = "error=invalid_grant&error_description=Code+expired"; + let error = parse_oauth_token_body(body, "application/x-www-form-urlencoded").unwrap_err(); + + assert!(error.contains("invalid_grant")); + assert!(error.contains("Code expired")); + } +} diff --git a/src-tauri/src/streaming.rs b/src-tauri/src/streaming.rs new file mode 100644 index 0000000..1ce6657 --- /dev/null +++ b/src-tauri/src/streaming.rs @@ -0,0 +1,248 @@ +use futures_util::StreamExt; +use std::collections::HashMap; +use std::str::FromStr; +use tauri::http::method::Method; +use tauri::Emitter; + +use crate::models::{ActiveStreams, ClientWrapper, RequestOptions, StreamChunk}; +use crate::network_utils::{build_request_headers, now_millis}; + +/// Move all complete UTF-8 from `bytes` into `out`, leaving any trailing +/// partial multi-byte sequence in `bytes` to be completed by the next network +/// chunk. Genuinely invalid sequences are replaced with U+FFFD and skipped so +/// a bad byte can never stall the stream. +fn drain_valid_utf8(bytes: &mut Vec, out: &mut String) { + loop { + match std::str::from_utf8(bytes) { + Ok(valid) => { + out.push_str(valid); + bytes.clear(); + return; + } + Err(error) => { + let valid_up_to = error.valid_up_to(); + // Safety: from_utf8 just validated this prefix + out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[..valid_up_to]) }); + + match error.error_len() { + // Invalid sequence: emit a replacement char, skip it, keep going + Some(invalid_len) => { + out.push('\u{FFFD}'); + bytes.drain(..valid_up_to + invalid_len); + } + // Truncated sequence: keep the tail for the next chunk + None => { + bytes.drain(..valid_up_to); + return; + } + } + } + } + } +} + +#[tauri::command] +pub async fn stream_sse( + options: RequestOptions, + request_id: String, + window: tauri::Window, + client_wrapper: tauri::State<'_, ClientWrapper>, + active_streams: tauri::State<'_, ActiveStreams>, +) -> Result<(), String> { + let client = client_wrapper.get_or_init_client()?; + let start_time = now_millis(); + + let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); + { + let mut streams = active_streams + .streams + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + streams.insert(request_id.clone(), cancel_tx); + } + + let headers = build_request_headers(&options)?; + + let mut request = client + .request( + Method::from_str(&options.method).map_err(|e| e.to_string())?, + &options.url, + ) + .headers(headers) + .timeout(std::time::Duration::from_secs(300)); + + if let Some(body) = &options.body { + request = request.body(body.clone()); + } + + let header_event = format!("sse-headers-{}", request_id); + let chunk_event = format!("sse-chunk-{}", request_id); + let done_event = format!("sse-done-{}", request_id); + + let res = match request.send().await { + Ok(res) => res, + Err(e) => { + if let Ok(mut streams) = active_streams.streams.lock() { + streams.remove(&request_id); + } + let _ = window.emit( + &done_event, + serde_json::json!({ + "error": e.to_string(), + "cancelled": false, + "duration": now_millis() - start_time, + }), + ); + return Err(e.to_string()); + } + }; + + let status = res.status(); + let resp_headers: HashMap = res + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string())) + .collect(); + + window + .emit( + &header_event, + serde_json::json!({ + "status": status.as_u16(), + "statusText": status.to_string(), + "headers": resp_headers, + }), + ) + .map_err(|e| e.to_string())?; + + let mut stream = res.bytes_stream(); + let mut byte_buffer: Vec = Vec::new(); + let mut buffer = String::new(); + let mut current_event: Option = None; + let mut current_id: Option = None; + let mut current_data: Vec = Vec::new(); + let mut cancelled = false; + + loop { + tokio::select! { + _ = cancel_rx.changed() => { + if *cancel_rx.borrow() { + cancelled = true; + break; + } + } + chunk = stream.next() => { + match chunk { + Some(Ok(bytes)) => { + byte_buffer.extend_from_slice(&bytes); + drain_valid_utf8(&mut byte_buffer, &mut buffer); + + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + if line.is_empty() { + if !current_data.is_empty() { + let data = current_data.join("\n"); + let _ = window.emit(&chunk_event, StreamChunk { + id: current_id.take(), + event: current_event.take(), + data, + is_done: false, + }); + current_data.clear(); + } + } else if let Some(data) = line.strip_prefix("data:") { + current_data.push(data.trim_start().to_string()); + } else if let Some(event) = line.strip_prefix("event:") { + current_event = Some(event.trim_start().to_string()); + } else if let Some(id) = line.strip_prefix("id:") { + current_id = Some(id.trim_start().to_string()); + } else if line.starts_with(':') { + // SSE comment line + } else { + let _ = window.emit(&chunk_event, StreamChunk { + id: None, + event: None, + data: line + "\n", + is_done: false, + }); + } + } + } + Some(Err(e)) => { + let _ = window.emit(&done_event, serde_json::json!({ + "error": e.to_string(), + "cancelled": false, + "duration": now_millis() - start_time, + })); + if let Ok(mut streams) = active_streams.streams.lock() { + streams.remove(&request_id); + } + return Err(e.to_string()); + } + None => { + // Stream ended: flush any bytes still waiting on a + // UTF-8 continuation (now genuinely incomplete) + if !byte_buffer.is_empty() { + buffer.push_str(&String::from_utf8_lossy(&byte_buffer)); + byte_buffer.clear(); + } + + if !current_data.is_empty() { + let data = current_data.join("\n"); + let _ = window.emit(&chunk_event, StreamChunk { + id: current_id.take(), + event: current_event.take(), + data, + is_done: false, + }); + } + + let remaining = buffer.trim().to_string(); + if !remaining.is_empty() { + let _ = window.emit(&chunk_event, StreamChunk { + id: None, + event: None, + data: remaining, + is_done: false, + }); + } + break; + } + } + } + } + } + + if let Ok(mut streams) = active_streams.streams.lock() { + streams.remove(&request_id); + } + + let _ = window.emit( + &done_event, + serde_json::json!({ + "cancelled": cancelled, + "duration": now_millis() - start_time, + }), + ); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_stream( + request_id: String, + active_streams: tauri::State<'_, ActiveStreams>, +) -> Result<(), String> { + let streams = active_streams + .streams + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + + if let Some(tx) = streams.get(&request_id) { + let _ = tx.send(true); + } + + Ok(()) +} diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs new file mode 100644 index 0000000..807bee2 --- /dev/null +++ b/src-tauri/src/websocket.rs @@ -0,0 +1,259 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; + +use crate::network_utils::now_millis; + +// ── Shared state ──────────────────────────────────────── + +pub struct ActiveWebSockets { + pub connections: Mutex>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WsCommand { + None, + Send(String), + Close, +} + +// ── Models ────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct WsConnectOptions { + pub url: String, + pub headers: HashMap, + pub protocols: Option>, +} + +#[derive(Debug, Serialize, Clone)] +pub struct WsMessagePayload { + pub data: String, + pub is_binary: bool, + pub timestamp: f64, + pub direction: String, // "incoming" or "outgoing" +} + +// ── Commands ──────────────────────────────────────────── + +#[tauri::command] +pub async fn ws_connect( + options: WsConnectOptions, + connection_id: String, + window: tauri::Window, + active_ws: tauri::State<'_, ActiveWebSockets>, +) -> Result<(), String> { + let connected_event = format!("ws-connected-{}", connection_id); + let message_event = format!("ws-message-{}", connection_id); + let error_event = format!("ws-error-{}", connection_id); + let closed_event = format!("ws-closed-{}", connection_id); + + // Parse the URL + let url = url::Url::parse(&options.url).map_err(|e| format!("Invalid URL: {}", e))?; + + // Build the request with custom headers + let mut request = url + .into_client_request() + .map_err(|e| format!("Failed to build request: {}", e))?; + + for (key, value) in &options.headers { + if let (Ok(name), Ok(val)) = ( + key.parse::(), + value.parse::(), + ) { + request.headers_mut().insert(name, val); + } + } + + // Add subprotocols if specified + if let Some(protocols) = &options.protocols { + if !protocols.is_empty() { + let proto_str = protocols.join(", "); + if let Ok(val) = proto_str.parse::() { + request.headers_mut().insert("Sec-WebSocket-Protocol", val); + } + } + } + + // Create command channel for sending messages and closing + let (cmd_tx, mut cmd_rx) = tokio::sync::watch::channel(WsCommand::None); + { + let mut connections = active_ws + .connections + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + connections.insert(connection_id.clone(), cmd_tx); + } + + // Connect + let ws_stream = match tokio_tungstenite::connect_async(request).await { + Ok((stream, _response)) => stream, + Err(e) => { + if let Ok(mut connections) = active_ws.connections.lock() { + connections.remove(&connection_id); + } + let _ = window.emit( + &error_event, + serde_json::json!({ "error": e.to_string() }), + ); + return Err(format!("WebSocket connection failed: {}", e)); + } + }; + + let _ = window.emit( + &connected_event, + serde_json::json!({ "timestamp": now_millis() }), + ); + + let (mut write, mut read) = ws_stream.split(); + let mut close_emitted = false; + + // Main event loop + loop { + tokio::select! { + _ = cmd_rx.changed() => { + let cmd = cmd_rx.borrow().clone(); + match cmd { + WsCommand::Send(data) => { + let msg = Message::Text(data.clone()); + if let Err(e) = write.send(msg).await { + let _ = window.emit( + &error_event, + serde_json::json!({ "error": format!("Send failed: {}", e) }), + ); + } else { + let _ = window.emit(&message_event, WsMessagePayload { + data, + is_binary: false, + timestamp: now_millis(), + direction: "outgoing".to_string(), + }); + } + } + WsCommand::Close => { + let _ = write.send(Message::Close(None)).await; + break; + } + WsCommand::None => {} + } + } + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + let _ = window.emit(&message_event, WsMessagePayload { + data: text, + is_binary: false, + timestamp: now_millis(), + direction: "incoming".to_string(), + }); + } + Some(Ok(Message::Binary(data))) => { + let text = format!("[Binary: {} bytes]", data.len()); + let _ = window.emit(&message_event, WsMessagePayload { + data: text, + is_binary: true, + timestamp: now_millis(), + direction: "incoming".to_string(), + }); + } + Some(Ok(Message::Ping(data))) => { + let _ = write.send(Message::Pong(data)).await; + } + Some(Ok(Message::Pong(_))) => { + // Ignore pongs + } + Some(Ok(Message::Close(frame))) => { + let reason = frame + .map(|f| format!("{}: {}", f.code, f.reason)) + .unwrap_or_else(|| "Connection closed".to_string()); + let _ = window.emit( + &closed_event, + serde_json::json!({ + "reason": reason, + "clean": true, + "timestamp": now_millis(), + }), + ); + close_emitted = true; + break; + } + Some(Ok(Message::Frame(_))) => { + // Raw frame, ignore + } + Some(Err(e)) => { + let _ = window.emit( + &error_event, + serde_json::json!({ "error": e.to_string() }), + ); + break; + } + None => { + // Stream ended + break; + } + } + } + } + } + + // Cleanup + if let Ok(mut connections) = active_ws.connections.lock() { + connections.remove(&connection_id); + } + + // The server-close branch already emitted with the real close reason + if !close_emitted { + let _ = window.emit( + &closed_event, + serde_json::json!({ + "reason": "Connection closed", + "clean": true, + "timestamp": now_millis(), + }), + ); + } + + Ok(()) +} + +#[tauri::command] +pub async fn ws_send( + connection_id: String, + message: String, + active_ws: tauri::State<'_, ActiveWebSockets>, +) -> Result<(), String> { + let connections = active_ws + .connections + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + + if let Some(tx) = connections.get(&connection_id) { + tx.send(WsCommand::Send(message)) + .map_err(|e| format!("Send error: {}", e))?; + Ok(()) + } else { + Err("Connection not found".to_string()) + } +} + +#[tauri::command] +pub async fn ws_disconnect( + connection_id: String, + active_ws: tauri::State<'_, ActiveWebSockets>, +) -> Result<(), String> { + let connections = active_ws + .connections + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + + if let Some(tx) = connections.get(&connection_id) { + let _ = tx.send(WsCommand::Close); + } + + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bb183dc..1aa93cd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "litepost", - "version": "0.2.0", + "version": "0.3.0", "identifier": "com.litepost.app", "build": { "beforeDevCommand": "pnpm dev", @@ -13,10 +13,14 @@ "windows": [ { "title": "litepost", - "width": 800, - "height": 600, + "width": 1280, + "height": 840, + "minWidth": 900, + "minHeight": 620, + "center": true, "decorations": false, - "transparent": true + "transparent": false, + "backgroundColor": "#131114" } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 4497d5b..eac3efb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,68 @@ -import { useEffect } from "react" -import { RequestPanel } from "./components/RequestPanel" -import { ResponsePanel } from "./components/ResponsePanel" +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react" import { TitleBar } from "./components/Titlebar" -import { HistoryPanel } from "./components/HistoryPanel" import { TabBar } from "./components/TabBar" import { useTabs } from "./hooks/useTabs" import { useUrlParams } from "./hooks/useUrlParams" import { useRequest } from "./hooks/useRequest" import { useHistory } from "./hooks/useHistory" import { useThemeClass } from "./hooks/useThemeClass" -import { HistoryItem, Tab } from "./types" +import { HistoryItem, SavedRequest, Tab } from "./types" import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels" import { Toaster } from "sonner" -import { getRequestNameFromUrl } from "./utils/url" -import { UpdateChecker } from './components/UpdateChecker' +import { buildQueryString, getRequestNameFromUrl, replaceUrlQuery } from "./utils/url" +import { CommandPalette } from "./components/CommandPalette" +import { History } from "lucide-react" +import { useUiStore } from "./store/ui" +// Prefetch lazy panel chunks immediately so they load in parallel with App rendering +const historyPanelPromise = import("./components/HistoryPanel") +const requestPanelPromise = import("./components/RequestPanel") +const responsePanelPromise = import("./components/ResponsePanel") + +const UpdateChecker = lazy(async () => { + const module = await import("./components/UpdateChecker") + return { default: module.UpdateChecker } +}) + +const HistoryPanel = lazy(async () => { + const module = await historyPanelPromise + return { default: module.HistoryPanel } +}) + +const RequestPanel = lazy(async () => { + const module = await requestPanelPromise + return { default: module.RequestPanel } +}) + +const ResponsePanel = lazy(async () => { + const module = await responsePanelPromise + return { default: module.ResponsePanel } +}) + +function PanelFallback({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + +const HISTORY_COLLAPSED_KEY = "litepost:historyCollapsed" function App() { + const [enableUpdateChecker, setEnableUpdateChecker] = useState(false) + const { paletteOpen, setPaletteOpen, togglePalette } = useUiStore() + const [historyCollapsed, setHistoryCollapsed] = useState(() => { + try { + return localStorage.getItem(HISTORY_COLLAPSED_KEY) === "1" + } catch { + return false + } + }) const { history, addHistoryItem, removeHistoryItem, clearHistory } = useHistory() const themeClass = useThemeClass() - const { - tabs, - activeTab, + const { + tabs, + activeTab, currentTab, setActiveTab, addTab, @@ -42,49 +84,96 @@ function App() { } }, currentTab?.params) + useEffect(() => { + const timer = setTimeout(() => { + setEnableUpdateChecker(true) + }, 5_000) + + return () => { + clearTimeout(timer) + } + }, []) + + // Ctrl/Cmd+K opens the command palette from anywhere + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault() + togglePalette() + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [togglePalette]) + + // Mirror the theme class onto so portaled content (tooltips, menus, + // dialogs) inherits theme tokens instead of falling back to :root defaults. + useEffect(() => { + const root = document.documentElement + const classes = themeClass.split(" ").filter(Boolean) + root.classList.remove("dark", "schematic", "theme-amber", "theme-green", "theme-black", "theme-purple") + root.classList.add(...classes) + }, [themeClass]) + + const toggleHistoryCollapsed = useCallback(() => { + setHistoryCollapsed((prev) => { + const next = !prev + try { + localStorage.setItem(HISTORY_COLLAPSED_KEY, next ? "1" : "0") + } catch { + // localStorage unavailable — collapse state just won't persist + } + return next + }) + }, []) + // Update raw URL when params change without affecting user input useEffect(() => { if (!currentTab) return try { - const urlParts = currentTab.rawUrl.split('?') - const baseUrl = urlParts[0] const enabledParams = currentTab.params.filter(p => p.enabled && p.key) - - if (enabledParams.length === 0) { - // Only update URL if we have no params and there's a query string - if (urlParts.length > 1) { - updateTab(currentTab.id, { rawUrl: baseUrl }) - } - return - } - - const searchParams = new URLSearchParams() - enabledParams.forEach(param => { - searchParams.append(param.key, param.value) - }) - const queryString = searchParams.toString() - if (queryString) { - updateTab(currentTab.id, { rawUrl: `${baseUrl}?${queryString}` }) + const queryString = buildQueryString(enabledParams) + const nextRawUrl = replaceUrlQuery(currentTab.rawUrl, queryString) + if (nextRawUrl !== currentTab.rawUrl) { + updateTab(currentTab.id, { rawUrl: nextRawUrl }) } } catch (error) { console.error('Error updating URL with params:', error) } }, [currentTab?.params]) - const handleSend = async (tabId: string) => { + const handleSend = async (tabId: string, overrides: { body?: string; url?: string } = {}) => { const tab = tabs.find(t => t.id === tabId) - if (!tab) return + const requestUrl = overrides.url ?? tab?.rawUrl + if (!tab || !requestUrl?.trim()) return - updateTab(tabId, { loading: true, response: null }) - const response = await sendRequest(tab) - if (response) { - updateTab(tabId, { loading: false, response }) + const requestTab = { + ...tab, + ...(overrides.body === undefined ? {} : { body: overrides.body }), + ...(overrides.url === undefined ? {} : { + rawUrl: overrides.url, + url: overrides.url, + name: getRequestNameFromUrl(overrides.url), + }), } + updateTab(tabId, { + loading: true, + response: null, + ...(overrides.body === undefined ? {} : { body: overrides.body }), + ...(overrides.url === undefined ? {} : { + rawUrl: overrides.url, + url: overrides.url, + name: getRequestNameFromUrl(overrides.url), + }), + }) + const response = await sendRequest(requestTab) + updateTab(tabId, { loading: false, response: response || null }) } - const handleHistorySelect = (item: HistoryItem) => { + // Stable identity: HistoryPanel is memoized and re-renders whenever this changes. + const handleHistorySelect = useCallback((item: HistoryItem) => { const newTab = createNewTab({ name: getRequestNameFromUrl(item.url), method: item.method, @@ -94,38 +183,110 @@ function App() { headers: item.headers, body: item.body, contentType: item.contentType, - auth: item.auth + auth: item.auth, + formDataEntries: item.formDataEntries, + preRequestScripts: item.preRequestScripts, }) setTabs((prev: Tab[]) => [...prev, newTab]) setActiveTab(newTab.id) - } + }, [createNewTab, setTabs, setActiveTab]) + + // Ref mirror so stable callbacks (passed to memoized panels) can read the + // current tab without being recreated on every tab change. + const currentTabRef = useRef(currentTab) + currentTabRef.current = currentTab + + const handleSampleSelect = useCallback((sample: Partial) => { + const target = currentTabRef.current + const isPristine = target && !target.rawUrl.trim() && !target.body.trim() && !target.response + if (target && isPristine) { + updateTab(target.id, sample) + return + } + const newTab = createNewTab(sample) + setTabs((prev: Tab[]) => [...prev, newTab]) + setActiveTab(newTab.id) + }, [updateTab, createNewTab, setTabs, setActiveTab]) + + const handleSavedSelect = useCallback((request: SavedRequest) => { + const newTab = createNewTab({ + name: request.name || getRequestNameFromUrl(request.url), + method: request.method, + url: request.url, + rawUrl: request.rawUrl, + params: request.params, + headers: request.headers, + body: request.body, + contentType: request.contentType, + auth: request.auth, + cookies: request.cookies, + testScripts: request.testScripts, + preRequestScripts: request.preRequestScripts, + testAssertions: request.testAssertions, + extractionRules: request.extractionRules, + graphqlQuery: request.graphqlQuery, + graphqlVariables: request.graphqlVariables, + graphqlOperationName: request.graphqlOperationName, + isGraphQL: request.isGraphQL, + formDataEntries: request.formDataEntries, + networkConfig: request.networkConfig, + }) + setTabs((prev: Tab[]) => [...prev, newTab]) + setActiveTab(newTab.id) + }, [createNewTab, setTabs, setActiveTab]) return (
- +
- { setTabs((prev: Tab[]) => [...prev, request]) setActiveTab(request.id) }} /> -
- - -
- -
-
- - -
+
+ {historyCollapsed && ( +
+ +
+ )} + + {!historyCollapsed && ( + <> + +
+ }> + + +
+
+ + + )} + +
{currentTab && ( - - - updateTab(currentTab.id, { method })} - onUrlChange={(rawUrl) => { - updateTab(currentTab.id, { - rawUrl, - url: rawUrl, - name: getRequestNameFromUrl(rawUrl) - }) - }} - onParamsChange={(params) => updateTab(currentTab.id, { params })} - onHeadersChange={(headers) => updateTab(currentTab.id, { headers })} - onBodyChange={(body) => updateTab(currentTab.id, { body })} - onContentTypeChange={(contentType) => updateTab(currentTab.id, { contentType })} - onAuthChange={(auth) => updateTab(currentTab.id, { auth })} - onCookiesChange={(cookies) => updateTab(currentTab.id, { cookies })} - onTestScriptsChange={(testScripts) => updateTab(currentTab.id, { testScripts })} - onTestAssertionsChange={(testAssertions) => updateTab(currentTab.id, { testAssertions })} - onTestResultsChange={(testResults) => updateTab(currentTab.id, { testResults })} - onSend={() => handleSend(currentTab.id)} - /> - - - - - - +
+
+ }> + updateTab(currentTab.id, { method })} + onUrlChange={(rawUrl) => { + updateTab(currentTab.id, { + rawUrl, + url: rawUrl, + name: getRequestNameFromUrl(rawUrl) + }) + }} + onParamsChange={(params) => updateTab(currentTab.id, { params })} + onHeadersChange={(headers) => updateTab(currentTab.id, { headers })} + onBodyChange={(body) => updateTab(currentTab.id, { body })} + onContentTypeChange={(contentType) => updateTab(currentTab.id, { contentType })} + onAuthChange={(auth) => updateTab(currentTab.id, { auth })} + onCookiesChange={(cookies) => updateTab(currentTab.id, { cookies })} + onTestScriptsChange={(testScripts) => updateTab(currentTab.id, { testScripts })} + onPreRequestScriptsChange={(preRequestScripts) => updateTab(currentTab.id, { preRequestScripts })} + onTestAssertionsChange={(testAssertions) => updateTab(currentTab.id, { testAssertions })} + onTestResultsChange={(testResults) => updateTab(currentTab.id, { testResults })} + onStreamingStateChange={(streaming, cancelStream) => + updateTab(currentTab.id, { + streaming, + cancelStream: cancelStream || undefined, + }) + } + onSend={(overrides) => handleSend(currentTab.id, overrides)} + isGraphQL={currentTab.isGraphQL} + graphqlQuery={currentTab.graphqlQuery} + graphqlVariables={currentTab.graphqlVariables} + graphqlOperationName={currentTab.graphqlOperationName} + onGraphQLChange={(updates) => updateTab(currentTab.id, updates)} + formDataEntries={currentTab.formDataEntries} + onFormDataEntriesChange={(entries) => updateTab(currentTab.id, { formDataEntries: entries })} + networkConfig={currentTab.networkConfig} + onNetworkConfigChange={(networkConfig) => updateTab(currentTab.id, { networkConfig })} + /> + +
+
+ }> + updateTab(currentTab.id, { extractionRules })} + onLoadSample={handleSampleSelect} + /> + +
+
)}
- + + {enableUpdateChecker && ( + + + + )}
) diff --git a/src/assets/icon_128.png b/src/assets/icon_128.png new file mode 100644 index 0000000..cfce314 Binary files /dev/null and b/src/assets/icon_128.png differ diff --git a/src/components/AuthConfigurator.tsx b/src/components/AuthConfigurator.tsx index 79e18c9..93f043b 100644 --- a/src/components/AuthConfigurator.tsx +++ b/src/components/AuthConfigurator.tsx @@ -2,6 +2,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Input } from "@/components/ui/input" import { AuthConfig, AuthType } from "@/types" import { useThemeClass } from "@/hooks/useThemeClass" +import { OAuthConfigurator } from "./OAuthConfigurator" +import { VariablePeek } from "./VariablePeek" +import { ShieldOff, KeyRound, Lock, Key, ShieldCheck } from "lucide-react" interface AuthConfiguratorProps { auth: AuthConfig @@ -9,88 +12,150 @@ interface AuthConfiguratorProps { } const AUTH_TYPES = [ - { value: 'none', label: 'No Auth' }, - { value: 'basic', label: 'Basic Auth' }, - { value: 'bearer', label: 'Bearer Token' }, - { value: 'api-key', label: 'API Key' }, + { value: 'none', label: 'No Auth', icon: ShieldOff }, + { value: 'basic', label: 'Basic Auth', icon: Lock }, + { value: 'bearer', label: 'Bearer Token', icon: KeyRound }, + { value: 'api-key', label: 'API Key', icon: Key }, + { value: 'oauth2', label: 'OAuth 2.0', icon: ShieldCheck }, ] export function AuthConfigurator({ auth, onAuthChange }: AuthConfiguratorProps) { const themeClass = useThemeClass() + // Every auth field that supports {{var}} substitution, for the peek badge + const authText = [ + auth.username, auth.password, auth.token, auth.key, auth.value, + auth.oauth2?.clientId, auth.oauth2?.clientSecret, auth.oauth2?.scope, + auth.oauth2?.authUrl, auth.oauth2?.tokenUrl, auth.oauth2?.discoveryUrl, + auth.oauth2?.username, auth.oauth2?.password, + ].filter(Boolean).join(' ') return (
- onAuthChange({ ...auth, type: value })}> + - - {AUTH_TYPES.map((type) => ( - - {type.label} - - ))} + + {AUTH_TYPES.map((type) => { + const Icon = type.icon + return ( + + + + {type.label} + + + ) + })} - + + +
+ + {auth.type === 'none' && ( +
+ +
+

No authentication

+

This request will be sent without any auth credentials.

+
+
+ )} {auth.type === 'basic' && ( -
- onAuthChange({ ...auth, username: e.target.value })} - /> - onAuthChange({ ...auth, password: e.target.value })} - /> +
+
+ + onAuthChange({ ...auth, username: e.target.value })} + className="bg-background/50" + /> +
+
+ + onAuthChange({ ...auth, password: e.target.value })} + className="bg-background/50" + /> +
)} {auth.type === 'bearer' && ( - onAuthChange({ ...auth, token: e.target.value })} - /> +
+ + onAuthChange({ ...auth, token: e.target.value })} + className="font-mono text-[13px] bg-background/50" + /> +

+ The token will be sent as Authorization: Bearer <token> +

+
)} {auth.type === 'api-key' && ( -
- onAuthChange({ ...auth, key: e.target.value })} - /> - onAuthChange({ ...auth, value: e.target.value })} - /> - +
+
+
+ + onAuthChange({ ...auth, key: e.target.value })} + className="bg-background/50" + /> +
+
+ + onAuthChange({ ...auth, value: e.target.value })} + className="bg-background/50" + /> +
+
+
+ + +
)} + + {auth.type === 'oauth2' && ( + onAuthChange({ ...auth, oauth2 })} + /> + )}
) } \ No newline at end of file diff --git a/src/components/CodeSnippetViewer.tsx b/src/components/CodeSnippetViewer.tsx index dc8300e..0453f48 100644 --- a/src/components/CodeSnippetViewer.tsx +++ b/src/components/CodeSnippetViewer.tsx @@ -1,6 +1,4 @@ import { useState, useMemo } from "react" -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' -import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' import { CopyButton } from "./CopyButton" import { CODE_SNIPPETS } from "@/utils/codeSnippets" import { AuthConfig, Header, Cookie } from "@/types" @@ -13,6 +11,7 @@ import { } from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" import { useThemeClass } from "@/hooks/useThemeClass" +import { LazySyntaxHighlighter } from "./LazySyntaxHighlighter" interface CodeSnippetViewerProps { method: string @@ -39,7 +38,7 @@ export function CodeSnippetViewer({ const codeSnippet = useMemo(() => { const generator = CODE_SNIPPETS.find(s => s.value === selectedLanguage)?.generator if (!generator) return '' - + return generator({ method, url, @@ -53,18 +52,18 @@ export function CodeSnippetViewer({ return ( -
+
- -
- + code': { - ...oneDark['pre > code'], - background: 'transparent', - }, - 'token': { - background: 'transparent', - } - }} - customStyle={{ - background: 'transparent', - fontSize: 'inherit', - whiteSpace: 'pre-wrap', - wordBreak: 'break-all', - overflowWrap: 'break-word', - }} + variant="code-snippet" wrapLongLines > {codeSnippet} - +
) -} \ No newline at end of file +} diff --git a/src/components/CollapsibleJSON.tsx b/src/components/CollapsibleJSON.tsx index f7db470..fbb21a5 100644 --- a/src/components/CollapsibleJSON.tsx +++ b/src/components/CollapsibleJSON.tsx @@ -1,7 +1,11 @@ -import { useState, memo } from "react" +import { useEffect, useMemo, useState, memo } from "react" import { Button } from "./ui/button" import { ChevronRight, ChevronDown } from "lucide-react" +// Cap children rendered per node so expanding huge arrays stays fast; +// a "Show more" row reveals the rest in chunks. +const CHILDREN_PAGE_SIZE = 100 + interface CollapsibleJSONProps { data: any level?: number @@ -33,13 +37,26 @@ export const CollapsibleJSON = memo(function CollapsibleJSON({ return true } - const [expanded, setExpanded] = useState(isExpanded && shouldAutoExpand()) + const autoExpanded = useMemo( + () => isExpanded && shouldAutoExpand(), + [data, isExpanded, level, maxAutoExpandArraySize, maxAutoExpandDepth, maxAutoExpandObjectSize] + ) + + const [expanded, setExpanded] = useState(autoExpanded) + const [visibleCount, setVisibleCount] = useState(CHILDREN_PAGE_SIZE) + useEffect(() => { + setExpanded(autoExpanded) + setVisibleCount(CHILDREN_PAGE_SIZE) + }, [autoExpanded, data]) + const isObject = typeof data === 'object' && data !== null const isArray = Array.isArray(data) if (!isObject) { return ( - + {JSON.stringify(data)} ) @@ -72,17 +89,17 @@ export const CollapsibleJSON = memo(function CollapsibleJSON({
{expanded && (
- {entries.map(([key, value]) => ( + {entries.slice(0, visibleCount).map(([key, value]) => (
- + {!isArray && `"${key}"`}{isArray && key} :
-
))} + {entries.length > visibleCount && ( + + )}
)}
@@ -98,4 +128,4 @@ export const CollapsibleJSON = memo(function CollapsibleJSON({
) -}) \ No newline at end of file +}) diff --git a/src/components/CollectionRunner.tsx b/src/components/CollectionRunner.tsx new file mode 100644 index 0000000..7b09690 --- /dev/null +++ b/src/components/CollectionRunner.tsx @@ -0,0 +1,537 @@ +import { useState, useCallback, useRef } from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Card } from "@/components/ui/card" +import { SavedRequest, Response, TestResult } from "@/types" +import { useCollectionStore } from "@/store/collections" +import { useEnvironmentStore } from "@/store/environments" +import { useSettingsStore } from "@/store/settings" +import { runTests } from "@/utils/testRunner" +import { invoke } from "@tauri-apps/api/core" +import { runPreRequestScripts } from "@/utils/preRequestRunner" +import { applyExtractionRules } from "@/utils/responseExtraction" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Play, + Square, + CheckCircle2, + XCircle, + Clock, + SkipForward, + Loader2, + Zap, + BarChart3, +} from "lucide-react" +import { useThemeClass } from "@/hooks/useThemeClass" + +interface RequestResult { + requestId: string + requestName: string + method: string + url: string + status: number | null + statusText: string + duration: number + error?: string + testResult?: TestResult + response?: Response +} + +interface CollectionRunnerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps) { + const { collections } = useCollectionStore() + const { getVariable, setVariable, activeEnvironmentId } = useEnvironmentStore() + const { network: globalNetwork } = useSettingsStore() + const themeClass = useThemeClass() + + const [selectedCollectionId, setSelectedCollectionId] = useState("") + const [results, setResults] = useState([]) + const [isRunning, setIsRunning] = useState(false) + const [currentIndex, setCurrentIndex] = useState(0) + const [totalRequests, setTotalRequests] = useState(0) + const cancelRef = useRef(false) + + const selectedCollection = collections.find((c) => c.id === selectedCollectionId) + + const substituteVariables = useCallback( + (text: string): string => { + return text.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + const value = getVariable(key.trim()) + return value !== undefined ? value : match + }) + }, + [getVariable] + ) + + const runRequest = useCallback( + async (request: SavedRequest): Promise => { + const startTime = performance.now() + + try { + // Build headers + const headerRecord: Record = {} + request.headers.forEach((header) => { + if (header.enabled && header.key) { + headerRecord[substituteVariables(header.key)] = substituteVariables(header.value) + } + }) + + // Apply auth + let url = substituteVariables(request.rawUrl || request.url) + if (request.auth.type === 'basic') { + const username = substituteVariables(request.auth.username || '') + const password = substituteVariables(request.auth.password || '') + const credentials = btoa(`${username}:${password}`) + headerRecord['Authorization'] = `Basic ${credentials}` + } else if (request.auth.type === 'bearer' && request.auth.token) { + headerRecord['Authorization'] = `Bearer ${substituteVariables(request.auth.token)}` + } else if (request.auth.type === 'api-key' && request.auth.key && request.auth.value) { + const key = substituteVariables(request.auth.key) + const value = substituteVariables(request.auth.value) + if (request.auth.addTo === 'header') { + headerRecord[key] = value + } else { + const separator = url.includes('?') ? '&' : '?' + url += `${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}` + } + } else if (request.auth.type === 'oauth2' && request.auth.oauth2?.accessToken) { + const tokenType = request.auth.oauth2.tokenType || 'Bearer' + headerRecord['Authorization'] = `${tokenType} ${substituteVariables(request.auth.oauth2.accessToken)}` + } + + // Cookie header + const cookieHeader = request.cookies + .map( + (c) => + `${encodeURIComponent(substituteVariables(c.name))}=${encodeURIComponent( + substituteVariables(c.value) + )}` + ) + .join("; ") + if (cookieHeader) headerRecord["Cookie"] = cookieHeader + + const body = + request.body && request.method !== "GET" && request.method !== "HEAD" + ? substituteVariables(request.body) + : undefined + + let method = request.method + let runtimeUrl = url + let runtimeBody = body + const runtimeHeaders = { ...headerRecord } + + if (request.preRequestScripts && request.preRequestScripts.length > 0) { + const runtime = await runPreRequestScripts({ + scripts: request.preRequestScripts, + request: { + method, + url: runtimeUrl, + headers: runtimeHeaders, + body: runtimeBody, + }, + getVariable, + setVariable, + substituteVariables, + }) + + method = runtime.method + runtimeUrl = runtime.url + runtimeBody = runtime.body + Object.keys(runtimeHeaders).forEach((key) => { + delete runtimeHeaders[key] + }) + Object.assign(runtimeHeaders, runtime.headers) + } + + const nc = request.networkConfig + const options: Record = { + method, + url: runtimeUrl, + headers: runtimeHeaders, + body: runtimeBody, + content_type: + runtimeBody && method !== "GET" && method !== "HEAD" + ? request.contentType + : undefined, + cookies: request.cookies.map((c) => ({ + ...c, + name: substituteVariables(c.name), + value: substituteVariables(c.value), + })), + timeout: (nc?.timeout ?? globalNetwork.timeout) || undefined, + connect_timeout: (nc?.connectTimeout ?? globalNetwork.connectTimeout) || undefined, + ssl_verification: nc?.sslVerification ?? globalNetwork.sslVerification, + proxy: (nc?.proxy ?? globalNetwork.proxy) || undefined, + } + + if (request.contentType === "multipart/form-data" && request.formDataEntries) { + options.form_data = request.formDataEntries.map((entry) => ({ + ...entry, + key: substituteVariables(entry.key), + value: entry.type === "text" ? substituteVariables(entry.value) : entry.value, + fileName: entry.fileName ? substituteVariables(entry.fileName) : entry.fileName, + })) + options.content_type = "multipart/form-data" + } + + const responseData = await invoke<{ + status: number + status_text: string + headers: Record + body: string + redirect_chain: unknown[] + cookies: string[] + is_base64: boolean + timing?: { start: number; end: number; duration: number; total: number } + size?: { headers: number; body: number; total: number } + }>("send_request", { options }) + + const duration = performance.now() - startTime + + const response: Response = { + status: responseData.status, + statusText: responseData.status_text, + headers: responseData.headers, + body: responseData.body, + redirectChain: [], + cookies: responseData.cookies, + is_base64: responseData.is_base64, + timing: responseData.timing + ? { ...responseData.timing, total: responseData.timing.total } + : undefined, + size: responseData.size, + } + + if (activeEnvironmentId && request.extractionRules && request.extractionRules.length > 0) { + applyExtractionRules(response, request.extractionRules, setVariable) + } + + // Run tests if they exist + let testResult: TestResult | undefined + if (request.testScripts.length > 0 || request.testAssertions.length > 0) { + testResult = await runTests( + request.testScripts.filter((s) => s.enabled), + request.testAssertions.filter((a) => a.enabled), + response + ) + } + + return { + requestId: request.id, + requestName: request.name, + method, + url: request.url, + status: response.status, + statusText: response.statusText, + duration: Math.round(duration), + testResult, + response, + } + } catch (error) { + return { + requestId: request.id, + requestName: request.name, + method: request.method, + url: request.url, + status: null, + statusText: "Error", + duration: Math.round(performance.now() - startTime), + error: typeof error === "string" ? error : error instanceof Error ? error.message : "Unknown error", + } + } + }, + [activeEnvironmentId, getVariable, setVariable, substituteVariables] + ) + + const runCollection = useCallback(async () => { + if (!selectedCollection) return + + cancelRef.current = false + setIsRunning(true) + setResults([]) + setCurrentIndex(0) + setTotalRequests(selectedCollection.requests.length) + + for (let i = 0; i < selectedCollection.requests.length; i++) { + if (cancelRef.current) break + + setCurrentIndex(i + 1) + const result = await runRequest(selectedCollection.requests[i]) + setResults((prev) => [...prev, result]) + } + + setIsRunning(false) + }, [selectedCollection, runRequest]) + + const cancelRun = useCallback(() => { + cancelRef.current = true + }, []) + + // Stats + const passedRequests = results.filter((r) => !r.error && r.status !== null && r.status < 400) + const failedRequests = results.filter((r) => r.error || (r.status !== null && r.status >= 400)) + const totalDuration = results.reduce((sum, r) => sum + r.duration, 0) + const passedTests = results.reduce( + (sum, r) => sum + (r.testResult?.assertions.filter((a) => a.success).length || 0) + + (r.testResult?.scriptResults.filter((s) => s.success).length || 0), + 0 + ) + const failedTests = results.reduce( + (sum, r) => sum + (r.testResult?.assertions.filter((a) => !a.success).length || 0) + + (r.testResult?.scriptResults.filter((s) => !s.success).length || 0), + 0 + ) + + const progress = totalRequests > 0 ? (currentIndex / totalRequests) * 100 : 0 + + // Method colors + const methodColors: Record = { + GET: "text-sky-400", + POST: "text-emerald-400", + PUT: "text-amber-400", + DELETE: "text-rose-400", + PATCH: "text-orange-400", + HEAD: "text-violet-400", + OPTIONS: "text-cyan-400", + } + + return ( + + + + + + Collection Runner + + + Run all requests in a collection sequentially and view results. + + + +
+ {/* Controls */} +
+ + + {isRunning ? ( + + ) : ( + + )} +
+ + {/* Progress */} + {(isRunning || results.length > 0) && ( +
+
+ + {isRunning ? ( + <> + + Running {currentIndex} of {totalRequests}… + + ) : ( + `Completed ${results.length} of ${totalRequests} requests` + )} + + {Math.round(progress)}% +
+ +
+ )} + + {/* Summary stats */} + {results.length > 0 && !isRunning && ( +
+ +
+ +
+
{results.length}
+
Requests
+
+
+
+ +
+ +
+
{passedRequests.length}
+
Passed
+
+
+
+ +
+ +
+
{failedRequests.length}
+
Failed
+
+
+
+ +
+ +
+
{totalDuration}ms
+
Total Time
+
+
+
+
+ )} + + {/* Test summary if any tests ran */} + {(passedTests > 0 || failedTests > 0) && !isRunning && ( +
+ Tests: + {passedTests} passed + {failedTests > 0 && {failedTests} failed} +
+ )} + + {/* Results list */} + {results.length > 0 && ( + +
+ {results.map((result, index) => ( +
+ {/* Status icon */} + {result.error || (result.status && result.status >= 400) ? ( + + ) : ( + + )} + + {/* Method */} + + {result.method} + + + {/* Name + URL */} +
+
{result.requestName}
+
{result.url}
+
+ + {/* Status badge */} + {result.status && ( + = 200 && result.status < 300 + ? "border-emerald-500/30 text-emerald-400 text-[10px]" + : result.status >= 400 + ? "border-rose-500/30 text-rose-400 text-[10px]" + : "border-amber-500/30 text-amber-400 text-[10px]" + } + > + {result.status} + + )} + + {/* Error */} + {result.error && ( + + Error + + )} + + {/* Test results */} + {result.testResult && ( + + {result.testResult.success ? "Tests ✓" : "Tests ✗"} + + )} + + {/* Duration */} + + {result.duration}ms + +
+ ))} +
+
+ )} + + {/* Empty state - no collection selected */} + {!selectedCollection && collections.length > 0 && ( +
+ +

Select a collection to run

+
+ )} + + {/* Empty state - no collections */} + {collections.length === 0 && ( +
+ +

No collections found

+

+ Create a collection and add some requests first. +

+
+ )} +
+
+
+ ) +} diff --git a/src/components/CollectionsPanel.tsx b/src/components/CollectionsPanel.tsx index 9564b60..47281df 100644 --- a/src/components/CollectionsPanel.tsx +++ b/src/components/CollectionsPanel.tsx @@ -3,14 +3,11 @@ import { SheetContent, SheetHeader, SheetTitle, - SheetTrigger, SheetDescription, } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" import { ScrollArea } from "@/components/ui/scroll-area" -import { Folder, FolderPlus, MoreVertical, ChevronRight, ChevronDown, Save, Trash2, RotateCw, Download, Upload } from "lucide-react" +import { FolderPlus, Download, Upload } from "lucide-react" import { useCollectionStore } from "@/store/collections" import { Tab } from "@/types" import { getRequestNameFromUrl } from "@/utils/url" @@ -22,18 +19,11 @@ import { } from "@/components/ui/dropdown-menu" import { useState, forwardRef, useRef } from "react" import { toast } from "sonner" -import { cn } from "@/lib/utils" import { useThemeClass } from "@/hooks/useThemeClass" - -const methodColors: Record = { - GET: "bg-blue-500/10 text-blue-500", - POST: "bg-green-500/10 text-green-500", - PUT: "bg-yellow-500/10 text-yellow-500", - PATCH: "bg-orange-500/10 text-orange-500", - DELETE: "bg-red-500/10 text-red-500", - HEAD: "bg-purple-500/10 text-purple-500", - OPTIONS: "bg-cyan-500/10 text-cyan-500" -} +import { importFromOpenapi } from '@/utils/collection-converter' +import { CollectionCard } from "./collections/CollectionCard" +import { savedRequestToTab } from "./collections/collectionUtils" +import { useResizablePanel } from "@/hooks/useResizablePanel" interface CollectionsPanelProps { open: boolean @@ -42,8 +32,8 @@ interface CollectionsPanelProps { onRequestSelect: (request: Tab) => void } -export const CollectionsPanel = forwardRef( - ({ open, onOpenChange, currentRequest, onRequestSelect }, ref) => { +export const CollectionsPanel = forwardRef( + ({ open, onOpenChange, currentRequest, onRequestSelect }, _ref) => { const { collections, addCollection, @@ -60,6 +50,11 @@ export const CollectionsPanel = forwardRef>(new Set()) const fileInputRef = useRef(null) const themeClass = useThemeClass() + const { width, isDragging, setIsDragging } = useResizablePanel(600, 450) + const shouldLogImportErrors = + typeof import.meta !== "undefined" && + Boolean(import.meta.env?.DEV) && + import.meta.env?.MODE !== "test" const toggleCollection = (id: string) => { setExpandedCollections(prev => { @@ -87,6 +82,24 @@ export const CollectionsPanel = forwardRef { + onRequestSelect(request) + onOpenChange(false) + } + + const handleSelectSavedRequest = (request: Parameters[0]) => { + handleSelectRequest(savedRequestToTab(request)) + } + + const handleRestoreAllRequests = (collectionId: string) => { + const targetCollection = collections.find((collection) => collection.id === collectionId) + if (!targetCollection) return + targetCollection.requests.forEach((request) => { + onRequestSelect(savedRequestToTab(request)) + }) + onOpenChange(false) + } + const handleExport = () => { const blob = new Blob([exportCollections()], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -122,7 +135,9 @@ export const CollectionsPanel = forwardRef { + const openapiUrl = window.prompt("Enter the URL for the OpenAPI JSON file:"); + if (!openapiUrl) return; + try { + const response = await fetch(openapiUrl); + if (!response.ok) { + throw new Error("Failed to fetch the OpenAPI document."); + } + const apiDoc = await response.json(); + const baseUrl = window.prompt("Enter the base URL for the API:"); + if (!baseUrl) return; + const importedCollections = importFromOpenapi(apiDoc, baseUrl); + importCollections(importedCollections); + toast.success("OpenAPI collections imported successfully"); + } catch (error) { + if (shouldLogImportErrors) { + console.error("Error importing OpenAPI:", error); + } + toast.error(error instanceof Error ? error.message : "Invalid OpenAPI format"); + } + }; + return ( - - - - svg]:text-foreground [&_.close-button]:hover:bg-muted/60`} + svg]:text-foreground [&_.close-button]:hover:bg-muted/60 ${isDragging ? "transition-none !duration-0" : ""}`} + style={{ width: width ? `${width}px` : undefined }} side="right" > + {/* Resize Handle */} +
{ e.preventDefault(); setIsDragging(true); }} + > +
+
Collections @@ -185,235 +221,85 @@ export const CollectionsPanel = forwardRef
-
-
-

Collections

-

- Organize and save your API requests -

-
-
- - - - - - - fileInputRef.current?.click()}> - LitePost Format - - - Postman Format - - - - - - - - - - LitePost Format - - - Postman Format - - - - -
+
+ + + + + + + fileInputRef.current?.click()}> + LitePost Format + + + Postman Format + + + OpenAPI Format + + + + + + + + + + LitePost Format + + + Postman Format + + + +
-
{collections.map((collection) => ( -
-
-
toggleCollection(collection.id)} - > - - updateCollection(collection.id, { name: e.target.value })} - onClick={(e) => e.stopPropagation()} - className="h-8 bg-background text-foreground" - aria-label={`Collection Name ${collection.name}`} - /> -
-
- {currentRequest && ( - - )} - - -
-
- - {collection.description && ( -