commit 906fb231cc3c1e085550ecefc8c7616440134cd2 Author: upstream-mirror Date: Wed Sep 23 17:39:41 2026 +0500 snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted) diff --git a/.claude-context.md b/.claude-context.md new file mode 100644 index 0000000..97e20f6 --- /dev/null +++ b/.claude-context.md @@ -0,0 +1,83 @@ +# Claude Context: Detaching Tauri from Yaak + +## Goal + +Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`. + +## Project Structure + +``` +crates/ # Core crates - should NOT depend on Tauri +crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.) +crates-cli/ # CLI crate (yaak-cli) +``` + +## Completed Work + +### 1. Folder Restructure + +- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/` +- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling) +- Created `crates-cli/yaak-cli/` for the standalone CLI + +### 2. Decoupled Crates (no longer depend on Tauri) + +- **yaak-models**: Uses `init_standalone()` pattern for CLI database access +- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup +- **yaak-common**: Only contains Tauri-free utilities (serde, platform) +- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app +- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar + +### 3. CLI Implementation + +- Basic CLI at `crates-cli/yaak-cli/src/main.rs` +- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create +- Uses same database as Tauri app via `yaak_models::init_standalone()` + +## Remaining Work + +### Crates Still Depending on Tauri (in `crates/`) + +1. **yaak-git** (3 files) - Moderate complexity +2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication +3. **yaak-sync** (4 files) - Moderate complexity +4. **yaak-ws** (5 files) - Moderate complexity + +### Pattern for Decoupling + +1. Remove Tauri plugin `init()` function from the crate +2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs` +3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils +4. Initialize managers in yaak-app's `.setup()` block +5. Remove `tauri` from Cargo.toml dependencies +6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission +7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()` + +## Key Files + +- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers +- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands +- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits +- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state +- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage + +## Git Branch + +Working on `detach-tauri` branch. + +## Recent Commits + +``` +c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc +df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils +481e0273 Remove Tauri dependencies from yaak-http and yaak-common +10568ac3 Add HTTP request sending to yaak-cli +bcb7d600 Add yaak-cli stub with basic database access +e718a5f1 Refactor models_ext to use init_standalone from yaak-models +``` + +## Testing + +- Run `cargo check -p ` to verify a crate builds without Tauri +- Run `npm run client:dev` to test the Tauri app still works +- Run `cargo run -p yaak-cli -- --help` to test the CLI diff --git a/.claude/rules.md b/.claude/rules.md new file mode 100644 index 0000000..aa0fb39 --- /dev/null +++ b/.claude/rules.md @@ -0,0 +1,27 @@ +# Project Rules + +## General Development + +- **NEVER** commit or push without explicit confirmation + +## Build and Lint + +- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files +- Run `npm run bootstrap` after changing plugin runtime or MCP server code + +## Plugin System + +### Backend Constraints + +- Always use `UpdateSource::Plugin` when calling database methods from plugin events +- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these +- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings + +### MCP Server + +- MCP server has **no active window context** - cannot call `window.workspaceId()` +- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead + +## Rust Type Generation + +- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types diff --git a/.codex/skills/release-generate-release-notes/SKILL.md b/.codex/skills/release-generate-release-notes/SKILL.md new file mode 100644 index 0000000..f5df99c --- /dev/null +++ b/.codex/skills/release-generate-release-notes/SKILL.md @@ -0,0 +1,52 @@ +--- +name: release-generate-release-notes +description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command. +--- + +# Generate Release Notes + +Generate formatted markdown release notes for a Yaak tag. + +## Workflow + +1. Determine target tag. +2. Determine previous comparable tag: + - Beta tag: compare against previous beta (if the root version is the same) or stable tag. + - Stable tag: compare against previous stable tag. +3. Collect commits in range: + - `git log --oneline ..` +4. For linked PRs, fetch metadata: + - `gh pr view --json number,title,body,author,url` +5. Extract useful details: + - Feedback URLs (`feedback.yaak.app`) + - Contributor GitHub handles from `author.login` + - Plugin install links or other notable context +6. Format notes using Yaak style: + - Changelog badge at top + - Bulleted items with PR links where available + - Contributor handles for external PRs + - Feedback links where available + - Full changelog compare link at bottom + +## Formatting Rules + +- Wrap final notes in a markdown code fence. +- Keep a blank line before and after the code fence. +- Output the markdown code block last. +- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`. +- Do not append `by @gschier` for PRs authored by `@gschier`. +- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process. + +## Release Creation Prompt + +After producing notes, ask whether to create a draft GitHub release. + +If confirmed and release does not yet exist, run: + +`gh release create --draft --prerelease --title "Release " --notes ''` + +If a draft release for the tag already exists, update it instead: + +`gh release edit --title "Release " --notes-file ` + +Use title format `Release `, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`. diff --git a/.codex/skills/yaak-changelog/SKILL.md b/.codex/skills/yaak-changelog/SKILL.md new file mode 100644 index 0000000..48cd860 --- /dev/null +++ b/.codex/skills/yaak-changelog/SKILL.md @@ -0,0 +1,104 @@ +--- +name: yaak-changelog +description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style. +--- + +# Yaak Changelog + +Use this skill to create Yaak changelogs in the correct place: + +- Beta or draft prerelease changelogs live only on the GitHub release. +- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`. + +## Workflow + +1. Identify the target release. +- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases. +- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files. +- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub. +- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt. +- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors. + +2. Parse release bullets. +- Treat each release-note bullet as one changelog entry. +- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms. +- For stable website changelogs, skip bullets prefixed with `[beta-only]`. +- Preserve the entry wording closely. Remove wrapping quotes from titles. +- Map categories to `feature`, `fix`, `improvement`, or `breaking`. +- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`. + +3. For beta or draft prerelease changelogs, update the GitHub release. +- Keep the changelog in the GitHub release body. Do not create a website changelog directory. +- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases. +- Prefer concise bullets with PR links and feedback links when available. +- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`. +- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs. +- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`. +- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body. +- Stop after verifying the GitHub release body. The website checks below do not apply. + +4. For stable website changelogs, create or edit the release directory. +- Path format: `src/content/changelog/YYYYMMDD_VERSION/`. +- For a new release, use today's date for `YYYYMMDD`. +- For an existing release, keep the original directory date. +- Do not create changelog directories for beta releases. + +5. Write `_release.yaml`. +- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`. +- Keep minor items as quick entries without `content`. +- Use `content` only when an entry needs its own markdown section. + +```yaml +title: "What's New in 2026.1.0" +summary: "Brief overview of the most important additions and fixes" +draft: true +entries: + - title: "Request debugging" + category: feature + pr: "https://github.com/mountain-loop/yaak/pull/123" + feedback: "https://feedback.yaak.app/p/request-debugging" + content: "request-debugging.md" + - title: "Fix broken cookie clearing" + category: fix + pr: "https://github.com/mountain-loop/yaak/pull/124" +``` + +6. Expand major entries. +- Expand 3 to 6 major items when enough context exists. +- Create slugified markdown files and reference them with `content`. +- Read the related PR before writing expanded content. +- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections. + +7. Handle images. +- Reuse screenshots from PRs when they exist. +- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload. +- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it. +- If no real image is available, use a placeholder with real alt text and a caption. + +8. Write `_intro.md`. +- Add a short overview paragraph at the top of the release. +- Focus on the major themes across the release instead of repeating every bullet. + +9. Follow Yaak writing style. +- Be direct and factual. Avoid hype. +- State what changed and how to use it. +- Keep paragraphs short. +- Use backticks for code symbols, settings, and literal values. +- Use bold sparingly for the most important phrase in a section. + +## File Rules + +- Beta releases must not create or edit files in `src/content/changelog/`. +- Main files are `_release.yaml` and optional `_intro.md`. +- Expanded entry files are regular markdown files such as `request-debugging.md`. +- `entries[].content` must match an existing markdown filename in the same directory. +- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo. + +## Checks + +- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created. +- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link. +- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance. +- Ensure most entries include `pr` when the source release notes provide one. +- For stable releases, ensure every referenced `content` file exists. +- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`. diff --git a/.codex/skills/yaak-changelog/agents/openai.yaml b/.codex/skills/yaak-changelog/agents/openai.yaml new file mode 100644 index 0000000..d4d46b3 --- /dev/null +++ b/.codex/skills/yaak-changelog/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Yaak Changelog" + short_description: "Generate Yaak changelog releases" + default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes." + +policy: + allow_implicit_invocation: true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..50e4b2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +dist +**/dist +target +**/target +.claude +vendored +**/vendored +*.log +.git diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..67ab542 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true +crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true +**/bindings/* linguist-generated=true +crates/yaak-templates/pkg/* linguist-generated=true + +# Ensure consistent line endings for test files that check exact content +crates/yaak-http/tests/test.txt text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c1cde08 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: gschier diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9b77ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c9e2ff5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Bugs, Feedback, Feature Requests, and Questions + url: https://feedback.yaak.app + about: "Please report to Yaak's public feedback board. Issues will be created and linked here when applicable." diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0a86c6a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + ignore: + # Build tooling majors are opted into, not queued. These never reach a + # user, so the cost of being a release behind is low and the cost of a + # surprise is a broken build. Note this also suppresses security PRs for + # these, so Dependabot alerts are the backstop. + - dependency-name: typescript + update-types: ["version-update:semver-major"] + - dependency-name: vitest + update-types: ["version-update:semver-major"] + - dependency-name: "@types/*" + update-types: ["version-update:semver-major"] + - dependency-name: "vite-plugin-*" + update-types: ["version-update:semver-major"] + # 0.8 dropped stateExtensions and updateSchema from the package root and + # exposes no subpath to reach them, which GrpcEditor needs. Unpin once + # that editor moves to the jsonSchema helper. + - dependency-name: codemirror-json-schema + groups: + npm-production: + dependency-type: production + update-types: + - minor + - patch + npm-development: + dependency-type: development + update-types: + - minor + - patch + + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + ignore: + # Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it + # does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml. + - dependency-name: oxc_resolver + groups: + cargo: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..49d7b7e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +## Submission + + + +- [ ] This PR is a bug fix. +- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it. +- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). +- [ ] I tested this change locally. +- [ ] I added or updated tests, or tests are not reasonable for this change. +- [ ] I added screenshots or recordings, or this change does not affect the UI. + +Explicit permission feedback item (required if not a bug fix): + + + +## Related + + diff --git a/.github/scripts/check-contribution-policy.js b/.github/scripts/check-contribution-policy.js new file mode 100644 index 0000000..2c7dc57 --- /dev/null +++ b/.github/scripts/check-contribution-policy.js @@ -0,0 +1,881 @@ +const fs = require("node:fs"); + +const COMMENT_MARKER = ""; + +const MAINTAINER_LOGINS = new Set(["gschier"]); +const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]); +const REVIEWER_LOGIN = "gschier"; + +const LARGE_DIFF_CHANGED_FILES = 20; +const LARGE_DIFF_CHANGED_LINES = 800; +const SUMMARY_TITLE_MAX_LENGTH = 80; +const MIN_AUTOMATIC_PR_NUMBER = 494; + +const LABELS = { + inScope: { + name: "contribution: in scope", + color: "0E8A16", + description: "Community PR appears to be in scope for maintainer review.", + }, + outOfScope: { + name: "contribution: out of scope", + color: "B60205", + description: "Community PR does not match Yaak's contribution policy.", + }, + explicitPermission: { + name: "contribution: explicit permission", + color: "5319E7", + description: + "Community PR links feedback where @gschier explicitly allowed the work.", + }, + missingTemplate: { + name: "contribution: missing template", + color: "D93F0B", + description: + "Community PR is missing enough of the pull request template to review.", + }, + policyUnmet: { + name: "contribution: policy unmet", + color: "B60205", + description: + "Community PR does not currently satisfy the contribution policy.", + }, + needsScopeReview: { + name: "contribution: needs scope review", + color: "FBCA04", + description: + "Community PR may be broader than Yaak's bug-fix contribution policy.", + }, +}; + +const MANAGED_LABEL_NAMES = [ + ...new Set(Object.values(LABELS).map((label) => label.name)), +]; + +// Each checkbox lists its current label first, followed by legacy labels still +// accepted from PRs opened against older versions of the template. +const CHECKBOXES = { + bugFix: ["This PR is a bug fix."], + explicitPermission: [ + "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.", + ], + readContributing: [ + "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).", + ], + testedLocally: ["I tested this change locally."], + testsUpdated: [ + "I added or updated tests, or tests are not reasonable for this change.", + "I added or updated tests when reasonable.", + ], + screenshotsAdded: [ + "I added screenshots or recordings, or this change does not affect the UI.", + "I added screenshots or recordings for UI changes when reasonable.", + ], +}; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeBody(body) { + return (body || "").replace(/\r\n/g, "\n"); +} + +function stripComments(value) { + return value.replace(//g, "").trim(); +} + +function getSection(body, heading) { + const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim"); + const match = pattern.exec(body); + + if (match == null) { + return null; + } + + const rest = body.slice(match.index + match[0].length); + const nextHeadingIndex = rest.search(/^##\s+/m); + return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex); +} + +function hasMeaningfulText(value) { + return stripComments(value || "").length > 0; +} + +function normalizeCheckboxLabel(label) { + return label + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/`/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function checkboxState(body, labels) { + const expectedLabels = new Set(labels.map(normalizeCheckboxLabel)); + + for (const line of body.split("\n")) { + const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i); + + if (match == null) { + continue; + } + + if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) { + return match[1].toLowerCase() === "x"; + } + } + + return null; +} + +function findFeedbackUrl(body) { + return ( + body.match( + /https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i, + )?.[0] ?? null + ); +} + +function getLabelNames(pr) { + return new Set((pr.labels || []).map((label) => label.name)); +} + +function analyzePullRequest(pr) { + const body = normalizeBody(pr.body); + const labelNames = getLabelNames(pr); + const states = Object.fromEntries( + Object.entries(CHECKBOXES).map(([key, label]) => [ + key, + checkboxState(body, label), + ]), + ); + const sectionCount = ["Summary", "Submission", "Related"].filter( + (heading) => getSection(body, heading) != null, + ).length; + const checkboxCount = Object.values(states).filter( + (state) => state != null, + ).length; + const templateUsed = sectionCount >= 2 && checkboxCount >= 3; + const blockers = []; + const totalChangedLines = + Number(pr.additions || 0) + Number(pr.deletions || 0); + const changedFiles = Number(pr.changed_files || 0); + const largeDiff = + changedFiles > LARGE_DIFF_CHANGED_FILES || + totalChangedLines > LARGE_DIFF_CHANGED_LINES; + + if (labelNames.has(LABELS.outOfScope.name)) { + return { + blockers: [ + { + label: LABELS.outOfScope.name, + message: "Marked out of scope by maintainer label.", + }, + ], + changedFiles, + desiredLabels: [LABELS.outOfScope.name], + largeDiff, + status: "out_of_scope", + templateUsed, + totalChangedLines, + }; + } + + if (labelNames.has(LABELS.inScope.name)) { + return { + blockers: [], + changedFiles, + desiredLabels: [LABELS.inScope.name], + largeDiff, + status: "in_scope", + templateUsed, + totalChangedLines, + }; + } + + if (labelNames.has(LABELS.explicitPermission.name)) { + return { + blockers: [], + changedFiles, + desiredLabels: [LABELS.explicitPermission.name], + largeDiff, + status: "in_scope", + templateUsed, + totalChangedLines, + }; + } + + if (!templateUsed) { + blockers.push({ + label: LABELS.missingTemplate.name, + message: + "Update the PR description with the repository pull request template.", + }); + } else { + const summary = getSection(body, "Summary"); + const hasSummary = hasMeaningfulText(summary); + const feedbackUrl = findFeedbackUrl(body); + const bugFix = states.bugFix === true; + const explicitPermission = states.explicitPermission === true; + + if (!hasSummary) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: + "Add a short summary describing the bug fix or permitted change.", + }); + } + + if (bugFix && explicitPermission) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: + "Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.", + }); + } else if (!bugFix && !explicitPermission) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: + "Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.", + }); + } else if (explicitPermission && feedbackUrl == null) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: + "Link the feedback item where @gschier explicitly gave you permission to work on this.", + }); + } + + if (states.readContributing !== true) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: "Confirm that `CONTRIBUTING.md` was read and followed.", + }); + } + + if (states.testedLocally !== true) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: "Confirm that the change was tested locally.", + }); + } + + if (states.testsUpdated !== true) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: + "Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.", + }); + } + + if (states.screenshotsAdded !== true) { + blockers.push({ + label: LABELS.policyUnmet.name, + message: + "Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.", + }); + } + } + + const desiredLabels = new Set(); + + if (blockers.length === 0) { + desiredLabels.add( + largeDiff + ? LABELS.needsScopeReview.name + : states.explicitPermission + ? LABELS.explicitPermission.name + : LABELS.inScope.name, + ); + } else if ( + blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name) + ) { + desiredLabels.add(LABELS.missingTemplate.name); + } else { + desiredLabels.add(LABELS.policyUnmet.name); + } + + return { + blockers, + changedFiles, + desiredLabels: [...desiredLabels], + largeDiff, + status: blockers.length === 0 ? "in_scope" : "blocked", + templateUsed, + totalChangedLines, + }; +} + +function buildBlockingComment(analysis) { + const lines = [ + COMMENT_MARKER, + "Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.", + "", + "This PR cannot be accepted yet because the following contribution policy requirements were unmet:", + "", + ...analysis.blockers.map((blocker) => `- ${blocker.message}`), + ]; + + if (!analysis.templateUsed) { + lines.push( + "", + "You can copy this template into the PR description and keep any existing context that is still useful.", + "", + "
", + "PR description template", + "", + "```md", + getPullRequestTemplate(), + "```", + "", + "
", + ); + } + + if (analysis.largeDiff) { + lines.push( + "", + `This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`, + ); + } + + return lines.join("\n"); +} + +function getPullRequestTemplate() { + return fs.readFileSync(".github/pull_request_template.md", "utf8").trim(); +} + +function buildInScopeComment() { + return [ + COMMENT_MARKER, + "Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.", + "", + "This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.", + ].join("\n"); +} + +function buildOutOfScopeComment() { + return [ + COMMENT_MARKER, + "Thanks for the PR. This does not appear to match Yaak's current contribution policy.", + "", + "Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.", + "", + "If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.", + ].join("\n"); +} + +function buildPolicyComment(analysis) { + if (analysis.status === "out_of_scope") { + return buildOutOfScopeComment(); + } + + if (analysis.blockers.length > 0) { + return buildBlockingComment(analysis); + } + + return buildInScopeComment(); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function truncateTitle(title) { + if (title.length <= SUMMARY_TITLE_MAX_LENGTH) { + return title; + } + + return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`; +} + +function escapeTableText(value) { + return escapeHtml(value).replace(/\n/g, "
"); +} + +function summarizeResult({ pr, analysis, skipped, skipReason }) { + const comment = + analysis == null + ? "None" + : buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim(); + const summary = { + blocked: analysis?.blockers.length > 0, + comment, + details: "None", + labels: + analysis?.desiredLabels.length > 0 + ? analysis.desiredLabels.join(", ") + : "None", + number: pr.number, + prLink: `#${pr.number}`, + status: "In scope", + title: escapeHtml(truncateTitle(pr.title)), + }; + + if (skipped) { + return { + ...summary, + blocked: false, + comment: "None", + details: escapeHtml(skipReason), + labels: "None", + status: "Skipped", + }; + } + + if (summary.blocked) { + return { + ...summary, + comment: escapeTableText(summary.comment), + details: escapeHtml( + analysis.blockers.map((blocker) => blocker.message).join("; "), + ), + labels: escapeHtml(summary.labels), + status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked", + }; + } + + return { + ...summary, + comment: escapeTableText(summary.comment), + labels: escapeHtml(summary.labels), + }; +} + +async function isOfficialMaintainer({ github, owner, repo, pr }) { + if (MAINTAINER_LOGINS.has(pr.user.login)) { + return true; + } + + if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) { + return true; + } + + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: pr.user.login, + }); + + return MAINTAINER_PERMISSIONS.has(response.data.permission); + } catch (error) { + if (error.status === 404) { + return false; + } + + throw error; + } +} + +async function ensureManagedLabels({ github, owner, repo }) { + for (const label of Object.values(LABELS)) { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: label.name, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } +} + +async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) { + const desired = new Set(desiredLabels); + + await ensureManagedLabels({ github, owner, repo }); + + for (const labelName of MANAGED_LABEL_NAMES) { + if (desired.has(labelName)) { + continue; + } + + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: labelName, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + if (desired.size > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [...desired], + }); + } +} + +async function findPolicyComment({ github, owner, repo, issueNumber }) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + + return comments.find( + (comment) => + comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER), + ); +} + +async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) { + const existingComment = await findPolicyComment({ + github, + owner, + repo, + issueNumber, + }); + + if (existingComment == null) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + return; + } + + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body, + }); +} + +async function deletePolicyComment({ github, owner, repo, issueNumber }) { + const existingComment = await findPolicyComment({ + github, + owner, + repo, + issueNumber, + }); + + if (existingComment == null) { + return; + } + + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existingComment.id, + }); +} + +async function requestMaintainerReview({ github, owner, repo, pr }) { + if (pr.user.login === REVIEWER_LOGIN) { + return; + } + + try { + await github.rest.pulls.requestReviewers({ + owner, + repo, + pull_number: pr.number, + reviewers: [REVIEWER_LOGIN], + }); + } catch (error) { + if (error.status === 422) { + return; + } + + throw error; + } +} + +async function checkPullRequest({ + github, + core, + owner, + repo, + pullNumber, + dryRun, + minimumAutomaticPullNumber, +}) { + const response = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + const pr = response.data; + const issueNumber = pr.number; + + if (pr.user.type === "Bot") { + core.notice( + `Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`, + ); + return { + blocked: false, + number: pr.number, + summary: summarizeResult({ + pr, + skipped: true, + skipReason: `bot @${pr.user.login}`, + }), + skipped: true, + }; + } + + if ( + minimumAutomaticPullNumber != null && + pr.number < minimumAutomaticPullNumber + ) { + core.notice( + `Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`, + ); + return { + blocked: false, + number: pr.number, + summary: summarizeResult({ + pr, + skipped: true, + skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`, + }), + skipped: true, + }; + } + + if (pr.draft) { + core.notice(`Skipping contribution policy for draft PR #${pr.number}.`); + return { + blocked: false, + number: pr.number, + summary: summarizeResult({ + pr, + skipped: true, + skipReason: "draft", + }), + skipped: true, + }; + } + + if (await isOfficialMaintainer({ github, owner, repo, pr })) { + core.notice( + `Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`, + ); + if (!dryRun) { + await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] }); + await deletePolicyComment({ github, owner, repo, issueNumber }); + } + return { + blocked: false, + number: pr.number, + summary: summarizeResult({ + pr, + skipped: true, + skipReason: `maintainer @${pr.user.login}`, + }), + skipped: true, + }; + } + + const analysis = analyzePullRequest(pr); + + if (dryRun) { + const summary = summarizeResult({ pr, analysis }); + core.notice( + `[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`, + ); + return { + blocked: analysis.blockers.length > 0, + number: pr.number, + summary, + skipped: false, + }; + } + + await syncLabels({ + github, + owner, + repo, + issueNumber, + desiredLabels: analysis.desiredLabels, + }); + + if (analysis.blockers.length > 0) { + await upsertPolicyComment({ + github, + owner, + repo, + issueNumber, + body: buildPolicyComment(analysis), + }); + return { + blocked: true, + number: pr.number, + summary: summarizeResult({ pr, analysis }), + skipped: false, + }; + } + + await upsertPolicyComment({ + github, + owner, + repo, + issueNumber, + body: buildPolicyComment(analysis), + }); + await requestMaintainerReview({ github, owner, repo, pr }); + core.notice(`Contribution policy check passed for PR #${pr.number}.`); + return { + blocked: false, + number: pr.number, + summary: summarizeResult({ pr, analysis }), + skipped: false, + }; +} + +async function listOpenPullRequests({ github, owner, repo }) { + return github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + }); +} + +function getManualPullRequestNumbers({ context, core }) { + const value = String(context.payload.inputs?.pr || "all").trim(); + + if (value.toLowerCase() === "all") { + return null; + } + + const pullNumber = Number(value); + + if (!Number.isInteger(pullNumber) || pullNumber <= 0) { + core.setFailed('The "pr" input must be "all" or a positive PR number.'); + return []; + } + + return [pullNumber]; +} + +async function run({ github, context, core }) { + const { owner, repo } = context.repo; + const payloadPr = context.payload.pull_request; + const dryRunInput = context.payload.inputs?.dry_run; + const dryRun = + context.eventName === "workflow_dispatch" && + dryRunInput !== false && + dryRunInput !== "false"; + const minimumAutomaticPullNumber = + payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER; + let pullNumbers; + + if (payloadPr != null) { + pullNumbers = [payloadPr.number]; + } else { + pullNumbers = getManualPullRequestNumbers({ context, core }); + } + + if (pullNumbers?.length === 0) { + return; + } + + const pullRequests = + pullNumbers == null + ? await listOpenPullRequests({ github, owner, repo }) + : pullNumbers.map((number) => ({ number })); + const results = []; + + if (dryRun) { + core.notice( + `Running contribution policy in dry-run mode for ${ + pullNumbers == null + ? "all open PRs" + : pullNumbers.map((number) => `#${number}`).join(", ") + }.`, + ); + } + + for (const pr of pullRequests) { + results.push( + await checkPullRequest({ + github, + core, + owner, + repo, + pullNumber: pr.number, + dryRun, + minimumAutomaticPullNumber, + }), + ); + } + + await core.summary + .addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`) + .addTable([ + [ + { data: "PR", header: true }, + { data: "Title", header: true }, + { data: "Status", header: true }, + { data: "Labels", header: true }, + { data: "Details", header: true }, + { data: "Comment", header: true }, + ], + ...results.map((result) => [ + result.summary.prLink, + result.summary.title, + result.summary.status, + result.summary.labels, + result.summary.details, + result.summary.comment, + ]), + ]) + .write(); + + const blockedPullRequests = results.filter((result) => result.blocked); + + if (blockedPullRequests.length > 0) { + if (dryRun) { + core.warning( + `Dry run found contribution policy failures for PR(s): ${blockedPullRequests + .map((result) => `#${result.number}`) + .join(", ")}`, + ); + return; + } + + core.setFailed( + `Contribution policy failed for PR(s): ${blockedPullRequests + .map((result) => `#${result.number}`) + .join(", ")}`, + ); + } +} + +module.exports = { + analyzePullRequest, + run, +}; diff --git a/.github/scripts/preserve-release-artifacts.mjs b/.github/scripts/preserve-release-artifacts.mjs new file mode 100644 index 0000000..e64cfe9 --- /dev/null +++ b/.github/scripts/preserve-release-artifacts.mjs @@ -0,0 +1,79 @@ +// Retain only distributable bundle files, never the target tree or signing material. +import { createHash } from "node:crypto"; +import { + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { join, relative } from "node:path"; + +const env = process.env; +const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle"); +const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts"); +const extensions = + /\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/; +const files = []; + +function snapshot(directory) { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const source = join(directory, entry.name); + // Only descend through the bundler's immediate output folders. This excludes + // .app trees and the extracted CEF deb contents (which contain executables). + if (entry.isDirectory() && directory === bundle) { + snapshot(source); + } else if ( + entry.isFile() && + (extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name)) + ) { + // Match tauri-action's default macOS updater asset naming exactly. + const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64"; + const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`); + mkdirSync(destination, { recursive: true }); + const retained = join(destination, assetName); + // The second snapshot must not overwrite the original user installer/signature. + if (!existsSync(retained)) copyFileSync(source, retained); + files.push({ source: relative(".", source), assetName }); + } + } +} + +snapshot(bundle); +if (existsSync(destination)) { + const assets = readdirSync(destination) + .filter((name) => name !== "provenance.json") + .sort(); + const checksums = []; + for (const assetName of assets) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk); + checksums.push({ assetName, sha256: hash.digest("hex") }); + } + writeFileSync( + join(destination, "provenance.json"), + JSON.stringify( + { + repository: env.GITHUB_REPOSITORY, + tag: env.GITHUB_REF_NAME, + sha: env.GITHUB_SHA, + runId: env.GITHUB_RUN_ID, + runAttempt: env.GITHUB_RUN_ATTEMPT, + workflowRef: env.GITHUB_WORKFLOW_REF, + workflowSha: env.GITHUB_WORKFLOW_SHA, + tauriAction: "tauri-apps/tauri-action@v0", + platform: env.RELEASE_PLATFORM, + runtime: env.RELEASE_RUNTIME, + arch: env.RELEASE_ARCH, + target: env.RELEASE_TARGET, + args: env.RELEASE_ARGS, + assets: checksums, + }, + null, + 2, + ) + "\n", + ); +} +console.log(`Found ${files.length} distributable bundle files in ${bundle}`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd4f908 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +on: + pull_request: + push: + branches: + - main + +name: Lint and Test +permissions: + contents: read + +jobs: + test: + name: Lint/Test + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci + cache-on-failure: true + + - run: vp install + - run: npm run bootstrap + - run: npm run lint + - name: Run JS Tests + run: vp test + - name: Run Rust Tests + run: cargo test --all --features yaak-app-client/wry + - name: OpenAPI import round-trip + run: | + cargo build -p yaak-cli + node plugins/importer-openapi/tests/roundtrip.mjs diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml new file mode 100644 index 0000000..0567c9f --- /dev/null +++ b/.github/workflows/contribution-policy.yml @@ -0,0 +1,48 @@ +name: Contribution Policy + +on: + workflow_dispatch: + inputs: + pr: + description: PR number or all + required: true + default: all + type: string + dry_run: + description: Dry run + required: true + default: true + type: boolean + pull_request_target: + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + - labeled + - unlabeled + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + check: + if: github.repository == 'mountain-loop/yaak' + name: Check contribution policy + runs-on: ubuntu-latest + steps: + - name: Checkout policy script + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 1 + + - name: Check contribution policy + uses: actions/github-script@v7 + with: + script: | + const { run } = require("./.github/scripts/check-contribution-policy.js"); + await run({ github, context, core }); diff --git a/.github/workflows/flathub.yml b/.github/workflows/flathub.yml new file mode 100644 index 0000000..ce015ba --- /dev/null +++ b/.github/workflows/flathub.yml @@ -0,0 +1,56 @@ +name: Update Flathub + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish to Flathub + required: true + type: string + +permissions: + contents: read + +jobs: + update-flathub: + if: github.repository == 'mountain-loop/yaak' + name: Update Flathub manifest + runs-on: ubuntu-latest + steps: + - name: Checkout app repo + uses: actions/checkout@v4 + + - name: Checkout Flathub repo + uses: actions/checkout@v4 + with: + repository: flathub/app.yaak.Yaak + token: ${{ secrets.FLATHUB_TOKEN }} + path: flathub-repo + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install source generators + run: | + pip install flatpak-node-generator tomlkit aiohttp + git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools + + - name: Run update-manifest.sh + run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo + + - name: Commit and push to Flathub + working-directory: flathub-repo + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "Update to ${{ inputs.tag }}" + git push diff --git a/.github/workflows/release-api-npm.yml b/.github/workflows/release-api-npm.yml new file mode 100644 index 0000000..cc0f5c3 --- /dev/null +++ b/.github/workflows/release-api-npm.yml @@ -0,0 +1,60 @@ +name: Release API to NPM + +on: + push: + tags: [yaak-api-*] + workflow_dispatch: + inputs: + version: + description: API version to publish (for example 0.9.0 or v0.9.0) + required: true + type: string + +permissions: + contents: read + +jobs: + publish-npm: + if: github.repository == 'mountain-loop/yaak' + name: Publish @yaakapp/api + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: npm ci + + - name: Set @yaakapp/api version + shell: bash + env: + WORKFLOW_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="$WORKFLOW_VERSION" + else + VERSION="${GITHUB_REF_NAME#yaak-api-}" + fi + VERSION="${VERSION#v}" + echo "Preparing @yaakapp/api version: $VERSION" + cd packages/plugin-runtime-types + npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Build @yaakapp/api + working-directory: packages/plugin-runtime-types + run: npm run build + + - name: Publish @yaakapp/api + working-directory: packages/plugin-runtime-types + run: npm publish --provenance --access public diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml new file mode 100644 index 0000000..a9bb6c4 --- /dev/null +++ b/.github/workflows/release-app.yml @@ -0,0 +1,292 @@ +name: Release App Artifacts +on: + push: + tags: [v*] + +jobs: + build-artifacts: + if: github.repository == 'mountain-loop/yaak' + permissions: + contents: write + + name: Build + strategy: + fail-fast: false + matrix: + include: + - platform: "macos-latest" # for Arm-based Macs (M1 and above). + args: '--target aarch64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' + yaak_arch: "arm64" + os: "macos" + runtime: "wry" + targets: "aarch64-apple-darwin" + - platform: "macos-latest" # for Intel-based Macs. + args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' + yaak_arch: "x64" + os: "macos" + runtime: "wry" + targets: "x86_64-apple-darwin" + - platform: "ubuntu-22.04" + args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' + yaak_arch: "x64" + os: "ubuntu" + runtime: "wry" + targets: "" + - platform: "ubuntu-22.04-arm" + args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' + yaak_arch: "arm64" + os: "ubuntu" + runtime: "wry" + targets: "" + - platform: "ubuntu-22.04" + args: >- + --bundles deb + --config ./tauri.release.conf.json + --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}' + yaak_arch: "x64" + os: "ubuntu" + runtime: "cef" + targets: "" + - platform: "ubuntu-22.04-arm" + args: >- + --bundles deb + --config ./tauri.release.conf.json + --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}' + yaak_arch: "arm64" + os: "ubuntu" + runtime: "cef" + targets: "" + - platform: "windows-latest" + args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' + yaak_arch: "x64" + os: "windows" + runtime: "wry" + targets: "" + # Windows ARM64 + - platform: "windows-latest" + args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}''' + yaak_arch: "arm64" + os: "windows" + runtime: "wry" + targets: "aarch64-pc-windows-msvc" + runs-on: ${{ matrix.platform }} + timeout-minutes: 360 + steps: + - name: Checkout yaakapp/app + uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.targets }} + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci + cache-on-failure: true + + - name: Cache CEF (Linux only) + if: matrix.os == 'ubuntu' && matrix.runtime == 'cef' + uses: actions/cache@v4 + with: + path: ~/.cache/tauri-cef + key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }} + + - name: install dependencies (Linux only) + if: matrix.os == 'ubuntu' + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils + # crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim + # uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu + # 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls + # through to host glibc headers ("bits/libc-header-start.h" not found). + # clang-18 handles it (it is what ubuntu-24.04 uses). Install it from + # apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses + # this compiler, so the shipped binary keeps 22.04's glibc floor. + wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh + chmod +x /tmp/llvm.sh + sudo /tmp/llvm.sh 18 + echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV" + echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV" + + - name: Install Protoc for plugin-runtime + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install trusted-signing-cli (Windows only) + if: matrix.os == 'windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dir = "$env:USERPROFILE\trusted-signing" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" + $exe = Join-Path $dir "trusted-signing-cli.exe" + Invoke-WebRequest -Uri $url -OutFile $exe + echo $dir >> $env:GITHUB_PATH + & $exe --version + + - run: vp install + - run: npm run bootstrap + env: + YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} + - run: npm run lint + - name: Run JS Tests + run: vp test + - name: Run Rust Tests + run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry + + - name: Set version + run: npm run replace-version + env: + YAAK_VERSION: ${{ github.ref_name }} + + - name: Sign vendored binaries (macOS only) + if: matrix.os == 'macos' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # Create keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # Import certificate + echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # Sign vendored binaries with hardened runtime and their specific entitlements + codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true + codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true + + - name: Build and upload Tauri bundles + uses: tauri-apps/tauri-action@v0 + env: + YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} + + ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + + # Apple signing stuff + APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }} + + # Windows signing stuff + AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} + with: + # Also retries builds; upload retries alone cannot clean up starter assets. + retryAttempts: 3 + tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js" + tagName: "v__VERSION__" + releaseName: "Release __VERSION__" + releaseBody: "" + releaseDraft: true + prerelease: true + projectPath: ./crates-tauri/yaak-app-client + args: "${{ matrix.args }}" + + # Snapshot before the per-machine build overwrites the regular NSIS bundle/signature. + - name: Snapshot Tauri bundles + if: ${{ !cancelled() }} + run: node .github/scripts/preserve-release-artifacts.mjs + env: + RELEASE_TARGET: ${{ matrix.targets }} + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_RUNTIME: ${{ matrix.runtime }} + RELEASE_ARCH: ${{ matrix.yaak_arch }} + RELEASE_ARGS: ${{ matrix.args }} + + - name: Build and upload CEF tarball from deb (Linux only) + if: matrix.os == 'ubuntu' && matrix.runtime == 'cef' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1) + version="${GITHUB_REF_NAME#v}" + extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}" + tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz" + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + dpkg-deb -x "$deb" "$extract_dir" + tar -C "$extract_dir" -czf "$tarball" . + for attempt in 1 2 3 4; do + if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then + break + fi + if [ "$attempt" -eq 4 ]; then exit 1; fi + sleep 10 + done + + # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) + - name: Build and upload machine-wide installer (Windows only) + if: matrix.os == 'windows' + shell: pwsh + env: + YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + run: | + Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force + Push-Location crates-tauri/yaak-app-client + npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Pop-Location + $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1 + $setupSig = "$($setup.FullName).sig" + $dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe' + $destSig = "$dest.sig" + Copy-Item $setup.FullName $dest + Copy-Item $setupSig $destSig + foreach ($asset in @($dest, $destSig)) { + for ($attempt = 1; $attempt -le 4; $attempt++) { + gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber + if ($LASTEXITCODE -eq 0) { break } + if ($attempt -eq 4) { exit $LASTEXITCODE } + Start-Sleep -Seconds 10 + } + } + + - name: Snapshot additional release bundles + if: ${{ !cancelled() }} + run: node .github/scripts/preserve-release-artifacts.mjs + env: + RELEASE_TARGET: ${{ matrix.targets }} + RELEASE_PLATFORM: ${{ matrix.platform }} + RELEASE_RUNTIME: ${{ matrix.runtime }} + RELEASE_ARCH: ${{ matrix.yaak_arch }} + RELEASE_ARGS: ${{ matrix.args }} + + - name: Retain release bundles for recovery + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/yaak-release-artifacts/ + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/release-cli-npm.yml b/.github/workflows/release-cli-npm.yml new file mode 100644 index 0000000..8c45739 --- /dev/null +++ b/.github/workflows/release-cli-npm.yml @@ -0,0 +1,219 @@ +name: Release CLI to NPM + +on: + push: + tags: [v*] + workflow_dispatch: + inputs: + version: + description: CLI version to publish (for example 0.4.0 or v0.4.0) + required: true + type: string + +permissions: + contents: read + +jobs: + prepare-vendored-assets: + if: github.repository == 'mountain-loop/yaak' + name: Prepare vendored plugin assets + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Build plugin assets + env: + SKIP_WASM_BUILD: "1" + run: | + npm run build + npm run vendor:vendor-plugins + + - name: Upload vendored assets + uses: actions/upload-artifact@v4 + with: + name: vendored-assets + path: | + crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs + crates-tauri/yaak-app-client/vendored/plugins + if-no-files-found: error + + build-binaries: + name: Build ${{ matrix.pkg }} + needs: prepare-vendored-assets + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - pkg: cli-darwin-arm64 + runner: macos-latest + target: aarch64-apple-darwin + binary: yaak + - pkg: cli-darwin-x64 + runner: macos-latest + target: x86_64-apple-darwin + binary: yaak + - pkg: cli-linux-arm64 + runner: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + binary: yaak + - pkg: cli-linux-x64 + runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + binary: yaak + - pkg: cli-win32-arm64 + runner: windows-latest + target: aarch64-pc-windows-msvc + binary: yaak.exe + - pkg: cli-win32-x64 + runner: windows-latest + target: x86_64-pc-windows-msvc + binary: yaak.exe + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: release-cli-npm + cache-on-failure: true + + - name: Install Linux build dependencies + if: startsWith(matrix.runner, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libdbus-1-dev + + - name: Download vendored assets + uses: actions/download-artifact@v4 + with: + name: vendored-assets + path: crates-tauri/yaak-app-client/vendored + + - name: Set CLI build version + shell: bash + env: + WORKFLOW_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="$WORKFLOW_VERSION" + else + VERSION="${GITHUB_REF_NAME}" + fi + VERSION="${VERSION#v}" + echo "Building yaak version: $VERSION" + echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Build yaak + run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }} + + - name: Stage binary artifact + shell: bash + run: | + set -euo pipefail + mkdir -p "npm/dist/${{ matrix.pkg }}" + cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.pkg }} + path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }} + if-no-files-found: error + + publish-npm: + name: Publish @yaakapp/cli packages + needs: build-binaries + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + pattern: cli-* + path: npm/dist + merge-multiple: false + + - name: Prepare npm packages + shell: bash + env: + WORKFLOW_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="$WORKFLOW_VERSION" + else + VERSION="${GITHUB_REF_NAME}" + fi + VERSION="${VERSION#v}" + if [[ "$VERSION" == *-* ]]; then + PRERELEASE="${VERSION#*-}" + NPM_TAG="${PRERELEASE%%.*}" + else + NPM_TAG="latest" + fi + echo "Preparing CLI npm packages for version: $VERSION" + echo "Publishing with npm dist-tag: $NPM_TAG" + echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV" + YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js + + - name: Publish @yaakapp/cli-darwin-arm64 + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli-darwin-arm64 + + - name: Publish @yaakapp/cli-darwin-x64 + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli-darwin-x64 + + - name: Publish @yaakapp/cli-linux-arm64 + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli-linux-arm64 + + - name: Publish @yaakapp/cli-linux-x64 + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli-linux-x64 + + - name: Publish @yaakapp/cli-win32-arm64 + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli-win32-arm64 + + - name: Publish @yaakapp/cli-win32-x64 + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli-win32-x64 + + - name: Publish @yaakapp/cli + run: npm publish --provenance --access public --tag "$NPM_TAG" + working-directory: npm/cli diff --git a/.github/workflows/release-web-image.yml b/.github/workflows/release-web-image.yml new file mode 100644 index 0000000..eee30e0 --- /dev/null +++ b/.github/workflows/release-web-image.yml @@ -0,0 +1,123 @@ +name: Release web image + +# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it. +# One image per architecture on its own native runner (emulating a Rust release build is hours), +# joined into one multi-arch tag at the end. + +on: + push: + tags: [v*] + workflow_dispatch: + inputs: + version: + description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only. + required: false + type: string + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/mountain-loop/yaak-web + +jobs: + build: + if: github.repository == 'mountain-loop/yaak' + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-22.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-22.04-arm + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.web + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish manifest + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # `latest` follows a release tag, and a manual run that names a version — the way to + # publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it. + - name: Tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + flavor: latest=false + tags: | + type=semver,pattern={{version}} + type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }} + type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }} + type=ref,event=branch + type=sha,format=short + + - name: Create and push the manifest + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect + run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml new file mode 100644 index 0000000..a68a005 --- /dev/null +++ b/.github/workflows/sponsors.yml @@ -0,0 +1,45 @@ +name: Generate Sponsors README +on: + workflow_dispatch: + schedule: + - cron: 30 15 * * 0-6 +permissions: + contents: write +jobs: + deploy: + if: github.repository == 'mountain-loop/yaak' + runs-on: ubuntu-latest + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v2 + + - name: Generate Sponsors + uses: JamesIves/github-sponsors-readme-action@v1 + with: + token: ${{ secrets.SPONSORS_PAT }} + file: "README.md" + maximum: 1999 + template: 'User avatar: {{{ login }}}  ' + active-only: false + include-private: true + marker: "sponsors-base" + + - name: Generate Sponsors + uses: JamesIves/github-sponsors-readme-action@v1 + with: + token: ${{ secrets.SPONSORS_PAT }} + file: "README.md" + minimum: 2000 + template: 'User avatar: {{{ login }}}  ' + active-only: false + include-private: true + marker: "sponsors-premium" + + # ⚠️ Note: You can use any deployment step here to automatically push the README + # changes back to your branch. + - name: Commit Changes + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: main + force: false + folder: "." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1beb88f --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +!.vscode/launch.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.eslintcache +out + +*.sqlite +*.sqlite-* + +.cargo + +.tmp +tmp +.zed +codebook.toml +target + +# Per-worktree Tauri config (generated by post-checkout hook) +crates-tauri/yaak-app-client/tauri.worktree.conf.json +crates-tauri/yaak-app-proxy/tauri.worktree.conf.json + +# Tauri auto-generated permission files +**/permissions/autogenerated +**/permissions/schemas + +# Flatpak build artifacts +flatpak-repo/ +.flatpak-builder/ +flatpak/flatpak-builder-tools/ +flatpak/cargo-sources.json +flatpak/node-sources.json + +# Local Codex desktop env state +.codex/environments/environment.toml + +# Claude Code local settings +.claude/settings.local.json +.claude/worktrees/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..d845d9d --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.14.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..dc68961 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies +legacy-peer-deps=true diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..682f3b9 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 100, + "ignorePatterns": [ + "**/bindings/**", + "crates/yaak-templates/pkg/**", + "apps/yaak-client/routeTree.gen.ts" + ] +} diff --git a/.vite-hooks/post-checkout b/.vite-hooks/post-checkout new file mode 100755 index 0000000..8ca7227 --- /dev/null +++ b/.vite-hooks/post-checkout @@ -0,0 +1 @@ +node scripts/git-hooks/post-checkout.mjs "$@" diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100644 index 0000000..e0686b6 --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1,2 @@ +vp lint +vp staged diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..b2b5eb7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "bradlc.vscode-tailwindcss", + "VoidZero.vite-plus-extension-pack" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..daca8f7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Dev App", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "start"] + }, + { + "type": "node", + "request": "launch", + "name": "Build App", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "start"] + }, + { + "type": "node", + "request": "launch", + "name": "Bootstrap", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "bootstrap"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b806535 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f0ed185 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,2 @@ +- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging. +- Do not commit, push, or tag without explicit approval diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d8b23a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing to Yaak + +Yaak accepts community pull requests for: + +- Bug fixes + +Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. + +## Approval for Non-Bugfix Changes + +If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it. + +## Development Setup + +For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md). diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1e877ca --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,118 @@ +[workspace] +resolver = "2" +members = [ + "crates/yaak", + "crates/yaak-commands", + # Common/foundation crates + "crates/common/yaak-database", + "crates/common/yaak-rpc", + "crates/common/yaak-rpc-schema", + # Shared crates (no Tauri dependency) + "crates/yaak-core", + "crates/yaak-common", + "crates/yaak-crypto", + "crates/yaak-git", + "crates/yaak-grpc", + "crates/yaak-http", + "crates/yaak-lifecycle", + "crates/yaak-models", + "crates/yaak-plugins", + "crates/yaak-sse", + "crates/yaak-sync", + "crates/yaak-templates", + "crates/yaak-tls", + "crates/yaak-ws", + "crates/yaak-wasm", + "crates/yaak-api", + "crates/yaak-proxy", + # Proxy-specific crates + "crates-proxy/yaak-proxy-lib", + # Server crates (the browser tier's hosted send executor) + "crates-server/yaak-web", + "crates-server/yaak-playground", + # CLI crates + "crates-cli/yaak-cli", + # Tauri-specific crates + "crates-tauri/yaak-app-client", + "crates-tauri/yaak-app-proxy", + "crates-tauri/yaak-fonts", + "crates-tauri/yaak-license", + "crates-tauri/yaak-mac-window", + "crates-tauri/yaak-system-appearance", + "crates-tauri/yaak-tauri-utils", + "crates-tauri/yaak-window", +] + +[workspace.dependencies] +chrono = "0.4.42" +hex = "0.4.3" +keyring = "3.6.3" +log = "0.4.29" +reqwest = { version = "0.13.5", default-features = false, features = [ + # reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the + # rest of the old default set and stay on native-tls. + "charset", + "http2", + "system-proxy", + "native-tls", +] } +rustls = { version = "0.23.34", default-features = false } +rustls-platform-verifier = "0.6.2" +schemars = { version = "0.8.22", features = ["chrono"] } +serde = "1.0.228" +serde_json = "1.0.145" +sha2 = "0.10.9" +tauri = { version = "2.11.1", default-features = false, features = [ + "common-controls-v6", + "compression", + "dynamic-acl", +] } +tauri-plugin = "2.6.1" +tauri-plugin-dialog = { version = "2.7.1", default-features = false } +tauri-plugin-shell = "2.3.5" +thiserror = "2.0.17" +tokio = "1.48.0" +ts-rs = "11.1.0" + +# Internal crates - common/foundation +yaak-database = { path = "crates/common/yaak-database" } +yaak-rpc = { path = "crates/common/yaak-rpc" } +yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" } + +# Internal crates - shared +yaak-core = { path = "crates/yaak-core" } +yaak = { path = "crates/yaak" } +yaak-commands = { path = "crates/yaak-commands" } +yaak-common = { path = "crates/yaak-common" } +yaak-crypto = { path = "crates/yaak-crypto" } +yaak-git = { path = "crates/yaak-git" } +yaak-grpc = { path = "crates/yaak-grpc" } +yaak-http = { path = "crates/yaak-http" } +yaak-lifecycle = { path = "crates/yaak-lifecycle" } +yaak-models = { path = "crates/yaak-models" } +yaak-plugins = { path = "crates/yaak-plugins" } +yaak-sse = { path = "crates/yaak-sse" } +yaak-sync = { path = "crates/yaak-sync" } +yaak-templates = { path = "crates/yaak-templates" } +yaak-tls = { path = "crates/yaak-tls" } +yaak-ws = { path = "crates/yaak-ws" } +yaak-api = { path = "crates/yaak-api" } +yaak-proxy = { path = "crates/yaak-proxy" } + +# Internal crates - proxy +yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" } + +# Internal crates - Tauri-specific +yaak-fonts = { path = "crates-tauri/yaak-fonts" } +yaak-license = { path = "crates-tauri/yaak-license" } +yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } +yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" } +yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } +yaak-window = { path = "crates-tauri/yaak-window" } + +[profile.release] +strip = false + +[patch.crates-io] +tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" } +tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..df43cae --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,105 @@ +# Developer Setup + +Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so +uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered +by a Node.js sidecar that communicates to the app over gRPC. + +Because of the moving parts, there are a few setup steps required before development can +begin. + +## Prerequisites + +Make sure you have the following tools installed: + +- [Node.js](https://nodejs.org/en/download/package-manager) (v24+) +- [Rust](https://www.rust-lang.org/tools/install) +- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI) + +Check the installations with the following commands: + +```shell +node -v +npm -v +vp --version +rustc --version +``` + +Install the NPM dependencies: + +```shell +npm install +``` + +Run the `bootstrap` command to do some initial setup: + +```shell +npm run bootstrap +``` + +## Run the App + +After bootstrapping, start the app in development mode: + +```shell +npm start +``` + +## Run the App in a Browser + +The client can also run as a plain web page, with no Tauri and no local process +behind it. Set `YAAK_TARGET=web` and start the frontend on its own: + +```shell +YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client +``` + +That flag picks the browser host in `packages/platform/src/web/`, which answers +commands from an IndexedDB database the page owns instead of from the Rust +engine. Data persists across reloads and is shared between tabs on the same +origin. Sending HTTP is not available yet — the Send button reports that and +everything else about the request is still saved. `packages/platform/src/web/README.md` +lists which commands the browser host implements and which it declines. + +Desktop builds are unaffected: without the flag the platform package installs +the Tauri host exactly as before. + +## SQLite Migrations + +New migrations can be created from the `src-tauri/` directory: + +```shell +npm run migration +``` + +Rerun the app to apply the migrations. + +_Note: For safety, development builds use a separate database location from production builds._ + +## Lezer Grammar Generation + +```sh +# Example +lezer-generator components/core/Editor//.grammar > components/core/Editor//.ts +``` + +## Linting and Formatting + +This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt). + +- Lint the entire repo: + +```sh +npm run lint +``` + +- Format code: + +```sh +npm run format +``` + +Notes: + +- A pre-commit hook runs `vp lint` automatically on commit. +- Some workspace packages also run `tsc --noEmit` for type-checking. +- VS Code users should install the recommended extensions for format-on-save support. diff --git a/Dockerfile.playground b/Dockerfile.playground new file mode 100644 index 0000000..e631eb1 --- /dev/null +++ b/Dockerfile.playground @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +# The playground API behind yaak.run. See crates-server/yaak-playground/README.md. + +FROM rust:1-bookworm AS build +WORKDIR /app +COPY . . +RUN cargo build --release -p yaak-playground + +FROM debian:bookworm-slim +COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground +ENV HOST=0.0.0.0 +ENV PORT=8080 +EXPOSE 8080 +USER nobody +CMD ["yaak-playground"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..460b5a1 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1 +# Yaak in a browser, whole: the web client and the server that executes its sends, in one +# image serving both from one origin. +# +# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web +# +# See crates-server/yaak-web/README.md for the knobs. + +FROM node:22-slim AS web +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/* +COPY . . +# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in +# the build context — it is ignored, and in a worktree it is a pointer file anyway. +RUN git init -q && git add -A \ + && git -c user.email=build@yaak.app -c user.name=build commit -qm build +# Empty means the tab posts sends to its own origin, which is what this image serves. Set it +# only to build a bundle for a deployment whose server lives somewhere else. +ARG VITE_YAAK_WEB_URL="" +ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL +ENV YAAK_TARGET=web +# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly +# backend, which this image has no reason to carry. +ENV SKIP_WASM_BUILD=1 +RUN npm ci +RUN node_modules/.bin/vp -C apps/yaak-client build + +FROM rust:1-bookworm AS server +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/* +COPY . . +RUN cargo build --release -p yaak-web + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* +COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web +COPY --from=web /app/dist/apps/yaak-client /srv +# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a +# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed. +ENV HOST=0.0.0.0 +ENV PORT=8080 +EXPOSE 8080 +USER nobody +# Overriding the command (dropping --serve) leaves the stateless send executor: +# docker run ghcr.io/mountain-loop/yaak-web yaak-web +CMD ["yaak-web", "--serve", "/srv"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed6c1c3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Yaak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7778b21 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +

+ + + +

+ +

+ 💫 Yaak ➟ Desktop API Client 💫 +

+ +

+ A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React. +

+

+ Development is funded by community-purchased licenses. You can also become a sponsor to have your logo appear below. 💖 +

+
+ +

+ User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar: rauchg  User avatar:    +

+

+ User avatar: seanwash  User avatar: jerath  User avatar: itsa-sh  User avatar: dmmulroy  User avatar: timcole  User avatar: VLZH  User avatar: terasaka2k  User avatar: andriyor  User avatar: majudhu  User avatar: axelrindle  User avatar: jirizverina  User avatar: chip-well  User avatar: GRAYAH  User avatar: flashblaze  User avatar: Frostist  User avatar: PurplProto   +

+ +![Yaak API Client](https://yaak.app/static/screenshot.png) + +## Install + +Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation). + +## Features + +Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it. +Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in. + +### 🌐 Work with any API + +- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl. +- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events. +- Filter and inspect responses with JSONPath or XPath. + +### 🔐 Stay secure + +- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication. +- Secure sensitive values with encrypted secrets. +- Store secrets in your OS keychain. + +### ☁️ Organize & collaborate + +- Group requests into workspaces and nested folders. +- Use environment variables to switch between dev, staging, and prod. +- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox. + +### 🧩 Extend & customize + +- Insert dynamic values like UUIDs or timestamps with template tags. +- Pick from built-in themes or build your own. +- Create plugins to extend authentication, template tags, or the UI. + +## Contribution Policy + +> [!IMPORTANT] +> Community PRs are currently limited to bug fixes. +> If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it. +> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. + +## Useful Resources + +- [Feedback and Bug Reports](https://feedback.yaak.app) +- [Documentation](https://yaak.app/docs) +- [Yaak vs Postman](https://yaak.app/alternatives/postman) +- [Yaak vs Bruno](https://yaak.app/alternatives/bruno) +- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia) + +## License + +The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use. + +The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use. diff --git a/apps/yaak-client/.gitignore b/apps/yaak-client/.gitignore new file mode 100644 index 0000000..371c82b --- /dev/null +++ b/apps/yaak-client/.gitignore @@ -0,0 +1,2 @@ +vite.config.d.ts +vite.config.js diff --git a/apps/yaak-client/commands/commands.tsx b/apps/yaak-client/commands/commands.tsx new file mode 100644 index 0000000..40618a7 --- /dev/null +++ b/apps/yaak-client/commands/commands.tsx @@ -0,0 +1,170 @@ +import { createWorkspaceModel, type Folder, modelTypeLabel } from "@yaakapp-internal/models"; +import { applySync, calculateSync } from "@yaakapp-internal/sync"; +import { Button } from "../components/core/Button"; +import { + Banner, + InlineCode, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + TruncatedWideTableCell, +} from "@yaakapp-internal/ui"; +import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { showDialog } from "../lib/dialog"; +import { jotaiStore } from "../lib/jotai"; +import { pluralizeCount } from "../lib/pluralize"; +import { showPrompt } from "../lib/prompt"; +import { resolvedModelNameWithFolders } from "../lib/resolvedModelName"; + +export const createFolder = createFastMutation< + string | null, + void, + Partial> +>({ + mutationKey: ["create_folder"], + mutationFn: async (patch) => { + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + if (workspaceId == null) { + throw new Error("Cannot create folder when there's no active workspace"); + } + + if (!patch.name) { + const name = await showPrompt({ + id: "new-folder", + label: "Name", + defaultValue: "Folder", + title: "New Folder", + confirmText: "Create", + placeholder: "Name", + }); + if (name == null) return null; + + patch.name = name; + } + + patch.sortPriority = patch.sortPriority || -Date.now(); + const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch }); + return id; + }, +}); + +export const syncWorkspace = createFastMutation< + void, + void, + { workspaceId: string; syncDir: string; force?: boolean } +>({ + mutationKey: [], + mutationFn: async ({ workspaceId, syncDir, force }) => { + const ops = (await calculateSync(workspaceId, syncDir)) ?? []; + if (ops.length === 0) { + console.log("Nothing to sync", workspaceId, syncDir); + return; + } + console.log("Syncing workspace", workspaceId, syncDir, ops); + + const dbOps = ops.filter((o) => o.type.startsWith("db")); + + if (dbOps.length === 0) { + await applySync(workspaceId, syncDir, ops); + return; + } + + const isDeletingWorkspace = ops.some( + (o) => o.type === "dbDelete" && o.model.model === "workspace", + ); + + console.log("Directory changes detected", { dbOps, ops }); + + if (force) { + await applySync(workspaceId, syncDir, ops); + return; + } + + showDialog({ + id: "commit-sync", + title: "Changes Detected", + size: "md", + render: ({ hide }) => ( +
{ + e.preventDefault(); + await applySync(workspaceId, syncDir, ops); + hide(); + }} + > + {isDeletingWorkspace ? ( + + 🚨 Changes contain a workspace deletion! + + ) : ( + + )} +

+ {pluralizeCount("file", dbOps.length)} in the directory{" "} + {dbOps.length === 1 ? "has" : "have"} changed. Do you want to update your workspace? +

+ + + + Type + Name + Operation + + + + {dbOps.map((op, i) => { + let name: string; + let label: string; + let color: string; + let model: string; + + if (op.type === "dbCreate") { + label = "create"; + name = resolvedModelNameWithFolders(op.fs.model); + color = "text-success"; + model = modelTypeLabel(op.fs.model); + } else if (op.type === "dbUpdate") { + label = "update"; + name = resolvedModelNameWithFolders(op.fs.model); + color = "text-info"; + model = modelTypeLabel(op.fs.model); + } else if (op.type === "dbDelete") { + label = "delete"; + name = resolvedModelNameWithFolders(op.model); + color = "text-danger"; + model = modelTypeLabel(op.model); + } else { + return null; + } + + return ( + // oxlint-disable-next-line react/no-array-index-key + + {model} + {name} + + {label} + + + ); + })} + +
+
+ + +
+ + ), + }); + }, +}); diff --git a/apps/yaak-client/commands/createEnvironment.tsx b/apps/yaak-client/commands/createEnvironment.tsx new file mode 100644 index 0000000..80f68e1 --- /dev/null +++ b/apps/yaak-client/commands/createEnvironment.tsx @@ -0,0 +1,51 @@ +import type { Environment } from "@yaakapp-internal/models"; +import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog"; +import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { showDialog } from "../lib/dialog"; +import { jotaiStore } from "../lib/jotai"; +import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams"; + +export const createSubEnvironmentAndActivate = createFastMutation< + string | null, + unknown, + Environment | null +>({ + mutationKey: ["create_environment"], + mutationFn: async (baseEnvironment) => { + if (baseEnvironment == null) { + throw new Error("No base environment passed"); + } + + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + if (workspaceId == null) { + throw new Error("Cannot create environment when no active workspace"); + } + + return new Promise((resolve) => { + showDialog({ + id: "new-environment", + title: "New Environment", + description: "Create multiple environments with different sets of variables", + size: "sm", + onClose: () => resolve(null), + render: ({ hide }) => ( + { + resolve(id); + }} + /> + ), + }); + }); + }, + onSuccess: async (environmentId) => { + if (environmentId == null) { + return; // Was not created + } + + setWorkspaceSearchParams({ environment_id: environmentId }); + }, +}); diff --git a/apps/yaak-client/commands/deleteWebsocketConnections.ts b/apps/yaak-client/commands/deleteWebsocketConnections.ts new file mode 100644 index 0000000..e2ce134 --- /dev/null +++ b/apps/yaak-client/commands/deleteWebsocketConnections.ts @@ -0,0 +1,8 @@ +import type { WebsocketRequest } from "@yaakapp-internal/models"; +import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws"; +import { createFastMutation } from "../hooks/useFastMutation"; + +export const deleteWebsocketConnections = createFastMutation({ + mutationKey: ["delete_websocket_connections"], + mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id), +}); diff --git a/apps/yaak-client/commands/importCurl.ts b/apps/yaak-client/commands/importCurl.ts new file mode 100644 index 0000000..37973c3 --- /dev/null +++ b/apps/yaak-client/commands/importCurl.ts @@ -0,0 +1,52 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { patchModelById } from "@yaakapp-internal/models"; +import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey"; +import { createRequestAndNavigate } from "../lib/createRequestAndNavigate"; +import { jotaiStore } from "../lib/jotai"; +import { rpc } from "../lib/rpc"; +import { showToast } from "../lib/toast"; + +export function looksLikeCurl(text: string) { + return text.trim().startsWith("curl "); +} + +export const importCurl = createFastMutation< + void, + string, + { overwriteRequestId?: string; command: string } +>({ + mutationKey: ["import_curl"], + mutationFn: async ({ overwriteRequestId, command }) => { + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", { + command, + workspaceId, + }); + + let verb: string; + if (overwriteRequestId == null) { + verb = "Created"; + await createRequestAndNavigate(importedRequest); + } else { + verb = "Updated"; + await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({ + ...importedRequest, + id: r.id, + createdAt: r.createdAt, + workspaceId: r.workspaceId, + folderId: r.folderId, + name: r.name, + sortPriority: r.sortPriority, + })); + + setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100); + } + + showToast({ + color: "success", + message: `${verb} request from Curl`, + }); + }, +}); diff --git a/apps/yaak-client/commands/moveToWorkspace.tsx b/apps/yaak-client/commands/moveToWorkspace.tsx new file mode 100644 index 0000000..3428a58 --- /dev/null +++ b/apps/yaak-client/commands/moveToWorkspace.tsx @@ -0,0 +1,33 @@ +import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; + +import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog"; +import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { pluralizeCount } from "../lib/pluralize"; +import { showDialog } from "../lib/dialog"; +import { jotaiStore } from "../lib/jotai"; + +export const moveToWorkspace = createFastMutation({ + mutationKey: ["move_workspace"], + mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => { + const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom); + if (activeWorkspaceId == null) return; + if (requests.length === 0) return; + + const title = + requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`; + + showDialog({ + id: "change-workspace", + title, + size: "sm", + render: ({ hide }) => ( + + ), + }); + }, +}); diff --git a/apps/yaak-client/commands/openFolderSettings.tsx b/apps/yaak-client/commands/openFolderSettings.tsx new file mode 100644 index 0000000..e08ad57 --- /dev/null +++ b/apps/yaak-client/commands/openFolderSettings.tsx @@ -0,0 +1,17 @@ +import { getModel } from "@yaakapp-internal/models"; +import type { FolderSettingsTab } from "../components/FolderSettingsDialog"; +import { FolderSettingsDialog } from "../components/FolderSettingsDialog"; +import { showDialog } from "../lib/dialog"; + +export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) { + const folder = getModel("folder", folderId); + if (folder == null) return; + showDialog({ + id: "folder-settings", + title: null, + size: "lg", + className: "h-200", + noPadding: true, + render: () => , + }); +} diff --git a/apps/yaak-client/commands/openSettings.tsx b/apps/yaak-client/commands/openSettings.tsx new file mode 100644 index 0000000..7d7e103 --- /dev/null +++ b/apps/yaak-client/commands/openSettings.tsx @@ -0,0 +1,48 @@ +import { platform } from "@yaakapp-internal/platform"; +import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings"; +import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { showDialog } from "../lib/dialog"; +import { jotaiStore } from "../lib/jotai"; +import { router } from "../lib/router"; +import { rpc } from "../lib/rpc"; + +export const openSettings = createFastMutation({ + mutationKey: ["open_settings"], + mutationFn: async (tab) => { + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + if (workspaceId == null) return; + + // Settings is its own window where the host has windows to give. Where it + // doesn't — a browser tab — it's a dialog like any other, so opening it + // doesn't take you away from the request you were working on. + if (!platform.capabilities.multiWindow) { + // Imported here so Settings stays out of the startup bundle, the way the + // route that renders it on desktop already keeps it + const { default: Settings } = await import("../components/Settings/Settings"); + showDialog({ + id: "settings", + size: "md", + className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden", + noPadding: true, + noScroll: true, + // Keyed so opening a specific tab while the dialog is already up moves to it + render: ({ hide }) => , + }); + return; + } + + const location = router.buildLocation({ + to: "/workspaces/$workspaceId/settings", + params: { workspaceId }, + search: { tab: (tab ?? undefined) as SettingsTab | undefined }, + }); + + await rpc("cmd_new_child_window", { + url: location.href, + label: "settings", + title: "Yaak Settings", + innerSize: [750, 600], + }); + }, +}); diff --git a/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx new file mode 100644 index 0000000..9c5b6d2 --- /dev/null +++ b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx @@ -0,0 +1,27 @@ +import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { showSimpleAlert } from "../lib/alert"; +import { router } from "../lib/router"; + +export const openWorkspaceFromSyncDir = createFastMutation({ + mutationKey: [], + mutationFn: async (dir) => { + const ops = await calculateSyncFsOnly(dir); + + const workspace = ops + .map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null)) + .filter((m) => m)[0]; + + if (workspace == null) { + showSimpleAlert("Failed to Open", "No workspace found in directory"); + return; + } + + await applySync(workspace.id, dir, ops); + + await router.navigate({ + to: "/workspaces/$workspaceId", + params: { workspaceId: workspace.id }, + }); + }, +}); diff --git a/apps/yaak-client/commands/openWorkspaceSettings.tsx b/apps/yaak-client/commands/openWorkspaceSettings.tsx new file mode 100644 index 0000000..1af368f --- /dev/null +++ b/apps/yaak-client/commands/openWorkspaceSettings.tsx @@ -0,0 +1,10 @@ +import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog"; +import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog"; +import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { jotaiStore } from "../lib/jotai"; + +export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) { + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + if (workspaceId == null) return; + WorkspaceSettingsDialog.show(workspaceId, tab); +} diff --git a/apps/yaak-client/commands/switchWorkspace.tsx b/apps/yaak-client/commands/switchWorkspace.tsx new file mode 100644 index 0000000..9891d57 --- /dev/null +++ b/apps/yaak-client/commands/switchWorkspace.tsx @@ -0,0 +1,46 @@ +import { platform } from "@yaakapp-internal/platform"; +import { createFastMutation } from "../hooks/useFastMutation"; +import { getRecentCookieJars } from "../hooks/useRecentCookieJars"; +import { getRecentEnvironments } from "../hooks/useRecentEnvironments"; +import { getRecentRequests } from "../hooks/useRecentRequests"; +import { router } from "../lib/router"; +import { rpc } from "../lib/rpc"; + +export const switchWorkspace = createFastMutation< + void, + unknown, + { + workspaceId: string; + inNewWindow: boolean; + } +>({ + mutationKey: ["open_workspace"], + mutationFn: async ({ workspaceId, inNewWindow }) => { + const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined; + const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined; + const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined; + const search = { + environment_id: environmentId, + cookie_jar_id: cookieJarId, + request_id: requestId, + }; + + // A host without windows opens the workspace here instead. Refusing would + // strand the user on the workspace they were trying to leave. + if (inNewWindow && platform.capabilities.multiWindow) { + const location = router.buildLocation({ + to: "/workspaces/$workspaceId", + params: { workspaceId }, + search, + }); + await rpc("cmd_new_main_window", { url: location.href }); + return; + } + + await router.navigate({ + to: "/workspaces/$workspaceId", + params: { workspaceId }, + search, + }); + }, +}); diff --git a/apps/yaak-client/components/BinaryFileEditor.tsx b/apps/yaak-client/components/BinaryFileEditor.tsx new file mode 100644 index 0000000..ccab5fa --- /dev/null +++ b/apps/yaak-client/components/BinaryFileEditor.tsx @@ -0,0 +1,63 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui"; +import mime from "mime"; +import { useKeyValue } from "../hooks/useKeyValue"; +import { Button } from "./core/Button"; +import { SelectFile } from "./SelectFile"; + +type Props = { + requestId: string; + contentType: string | null; + body: HttpRequest["body"]; + onChange: (body: HttpRequest["body"]) => void; + onChangeContentType: (contentType: string | null) => void; +}; + +export function BinaryFileEditor({ + contentType, + body, + onChange, + onChangeContentType, + requestId, +}: Props) { + const ignoreContentType = useKeyValue({ + namespace: "global", + key: ["ignore_content_type", requestId], + fallback: false, + }); + + const handleChange = async ({ filePath }: { filePath: string | null }) => { + await ignoreContentType.set(false); + onChange({ filePath: filePath ?? undefined }); + }; + + const filePath = typeof body.filePath === "string" ? body.filePath : null; + const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream"; + + return ( + + + {filePath != null && mimeType !== contentType && !ignoreContentType.value && ( + +
+
Set Content-Type header
+ {mimeType} for current request? +
+ + + + +
+ )} +
+ ); +} diff --git a/apps/yaak-client/components/CargoFeature.tsx b/apps/yaak-client/components/CargoFeature.tsx new file mode 100644 index 0000000..7a5d1fe --- /dev/null +++ b/apps/yaak-client/components/CargoFeature.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { appInfo } from "../lib/appInfo"; + +interface Props { + children: ReactNode; + feature: "updater" | "license"; +} + +const featureMap: Record = { + updater: appInfo.featureUpdater, + license: appInfo.featureLicense, +}; + +export function CargoFeature({ children, feature }: Props) { + if (featureMap[feature]) { + return <>{children}; + } + return null; +} diff --git a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx new file mode 100644 index 0000000..1602110 --- /dev/null +++ b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx @@ -0,0 +1,163 @@ +import { gitClone } from "@yaakapp-internal/git"; +import { Banner, VStack } from "@yaakapp-internal/ui"; +import { useState } from "react"; +import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; +import { appInfo } from "../lib/appInfo"; +import { CommercialUseBanner } from "./CommercialUseBanner"; +import { showErrorToast } from "../lib/toast"; +import { Button } from "./core/Button"; +import { Checkbox } from "./core/Checkbox"; +import { IconButton } from "./core/IconButton"; +import { PlainInput } from "./core/PlainInput"; +import { promptCredentials } from "./git/credentials"; +import { platform } from "@yaakapp-internal/platform"; + +interface Props { + hide: () => void; +} + +// Detect path separator from an existing path (defaults to /) +function getPathSeparator(path: string): string { + return path.includes("\\") ? "\\" : "/"; +} + +export function CloneGitRepositoryDialog({ hide }: Props) { + const [url, setUrl] = useState(""); + const [baseDirectory, setBaseDirectory] = useState(appInfo.defaultProjectDir); + const [directoryOverride, setDirectoryOverride] = useState(null); + const [hasSubdirectory, setHasSubdirectory] = useState(false); + const [subdirectory, setSubdirectory] = useState(""); + const [isCloning, setIsCloning] = useState(false); + const [error, setError] = useState(null); + + const repoName = extractRepoName(url); + const sep = getPathSeparator(baseDirectory); + const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory; + const directory = directoryOverride ?? computedDirectory; + const workspaceDirectory = + hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory; + + const handleSelectDirectory = async () => { + const dir = await platform.dialog.open({ + title: "Select Directory", + directory: true, + multiple: false, + }); + if (dir != null) { + setBaseDirectory(dir); + setDirectoryOverride(null); + } + }; + + const handleClone = async (e: React.FormEvent) => { + e.preventDefault(); + if (!url || !directory) return; + + setIsCloning(true); + setError(null); + + try { + const result = await gitClone(url, directory, promptCredentials); + + if (result.type === "needs_credentials") { + setError( + result.error ?? "Authentication failed. Please check your credentials and try again.", + ); + return; + } + + // Open the workspace from the cloned directory (or subdirectory) + await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory); + + hide(); + } catch (err) { + setError(String(err)); + showErrorToast({ + id: "git-clone-error", + title: "Clone Failed", + message: String(err), + }); + } finally { + setIsCloning(false); + } + }; + + return ( + + {error && ( + + {error} + + )} + + + + + + + } + /> + + + + {hasSubdirectory && ( + + )} + + + + ); +} + +function extractRepoName(url: string): string { + // Handle various Git URL formats: + // https://github.com/user/repo.git + // git@github.com:user/repo.git + // https://github.com/user/repo + const match = url.match(/\/([^/]+?)(\.git)?$/); + if (match?.[1]) { + return match[1]; + } + // Fallback for SSH-style URLs + const sshMatch = url.match(/:([^/]+?)(\.git)?$/); + if (sshMatch?.[1]) { + return sshMatch[1]; + } + return ""; +} diff --git a/apps/yaak-client/components/ColorIndicator.tsx b/apps/yaak-client/components/ColorIndicator.tsx new file mode 100644 index 0000000..c5ed3a1 --- /dev/null +++ b/apps/yaak-client/components/ColorIndicator.tsx @@ -0,0 +1,28 @@ +import classNames from "classnames"; +import type { CSSProperties } from "react"; + +interface Props { + color: string | null; + onClick?: () => void; + className?: string; +} + +export function ColorIndicator({ color, onClick, className }: Props) { + const style: CSSProperties = { backgroundColor: color ?? undefined }; + const finalClassName = classNames( + className, + "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0", + ); + + if (onClick) { + return ( + + ); +} + +function CommandPaletteAction({ action }: { action: HotkeyAction }) { + return ; +} diff --git a/apps/yaak-client/components/CommercialUseBanner.tsx b/apps/yaak-client/components/CommercialUseBanner.tsx new file mode 100644 index 0000000..a69283d --- /dev/null +++ b/apps/yaak-client/components/CommercialUseBanner.tsx @@ -0,0 +1,129 @@ +import { checkLicense } from "@yaakapp-internal/license"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useKeyValue } from "../hooks/useKeyValue"; +import { appInfo } from "../lib/appInfo"; +import { pricingUrl } from "../lib/pricingUrl"; +import { DismissibleBanner } from "./core/DismissibleBanner"; +import { platform } from "@yaakapp-internal/platform"; + +const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; +const COMMERCIAL_USE_BANNER_MESSAGE = + "Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license."; + +export function CommercialUseBanner({ + source, + title, +}: { + source: string; + title: string; +}) { + const [visible, setVisible] = useState(false); + const snoozeStartedRef = useRef(false); + const { + isLoading: isSnoozeLoading, + set: setSnoozedAt, + value: snoozedAt, + } = useKeyValue({ + namespace: "global", + key: "commercial-use-banner-snoozed-at", + fallback: null, + }); + + useEffect(() => { + let canceled = false; + + shouldShowCommercialUsePrompt() + .then((shouldShow) => { + if (!canceled) setVisible(shouldShow); + }) + .catch(console.error); + + return () => { + canceled = true; + }; + }, [source]); + + const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS); + const handleShow = useCallback(() => { + if (snoozeStartedRef.current || snoozed) { + return; + } + + snoozeStartedRef.current = true; + setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error); + }, [setSnoozedAt, snoozed, source]); + + if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) { + return null; + } + + return ( +
+ + setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })) + } + onShow={handleShow} + actions={[ + { + label: "Purchase License", + color: "info", + variant: "solid", + onClick: () => { + openCommercialUsePricing(source).catch(console.error); + }, + }, + ]} + > +
+

{title}

+

{COMMERCIAL_USE_BANNER_MESSAGE}

+
+
+
+ ); +} + +async function shouldShowCommercialUsePrompt(): Promise { + // Open-source builds omit the Rust license plugin, so never show commercial-use prompts there. + if (appInfo.featureLicense !== true) { + return false; + } + + try { + const license = await checkLicense(); + return license.status === "personal_use"; + } catch (err) { + console.log("Failed to check license before commercial-use prompt", err); + return false; + } +} + +async function openCommercialUsePricing(source: string): Promise { + await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error); +} + +function isSnoozed(value: string | null, ms: number): boolean { + if (value == null) return false; + + try { + const snooze = JSON.parse(value) as { at?: unknown }; + const at = typeof snooze.at === "string" ? snooze.at : null; + return isWithinMs(at, ms); + } catch { + // Older builds stored only the timestamp, so keep respecting that as a global snooze. + return isWithinMs(value, ms); + } +} + +function isWithinMs(date: string | null, ms: number): boolean { + if (date == null) return false; + + const time = new Date(date).getTime(); + if (Number.isNaN(time)) return false; + + return Date.now() - time < ms; +} diff --git a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx new file mode 100644 index 0000000..f8acf53 --- /dev/null +++ b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx @@ -0,0 +1,74 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { patchModel } from "@yaakapp-internal/models"; +import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import type { ReactNode } from "react"; +import { useToggle } from "../hooks/useToggle"; +import { showConfirm } from "../lib/confirm"; +import { Button } from "./core/Button"; +import { Link } from "./core/Link"; +import { SizeTag } from "./core/SizeTag"; + +interface Props { + children: ReactNode; + request: HttpRequest; +} + +const LARGE_TEXT_BYTES = 2 * 1000 * 1000; + +export function ConfirmLargeRequestBody({ children, request }: Props) { + const [showLargeResponse, toggleShowLargeResponse] = useToggle(); + + if (request.body?.text == null) { + return children; + } + + const contentLength = request.body.text.length ?? 0; + const tooLargeBytes = LARGE_TEXT_BYTES; + const isLarge = contentLength > tooLargeBytes; + if (!showLargeResponse && isLarge) { + return ( + +

+ Rendering content over{" "} + + + {" "} + may impact performance. +

+

+ See{" "} + + Working With Large Values + {" "} + for tips. +

+ + + + +
+ ); + } + + return <>{children}; +} diff --git a/apps/yaak-client/components/ConfirmLargeResponse.tsx b/apps/yaak-client/components/ConfirmLargeResponse.tsx new file mode 100644 index 0000000..90bb2c8 --- /dev/null +++ b/apps/yaak-client/components/ConfirmLargeResponse.tsx @@ -0,0 +1,61 @@ +import type { HttpResponse } from "@yaakapp-internal/models"; +import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { type ReactNode, useMemo } from "react"; +import { useSaveResponse } from "../hooks/useSaveResponse"; +import { useToggle } from "../hooks/useToggle"; +import { isProbablyTextContentType } from "../lib/contentType"; +import { getContentTypeFromHeaders } from "../lib/model_util"; +import { getResponseBodyText } from "../lib/responseBody"; +import { CopyButton } from "./CopyButton"; +import { Button } from "./core/Button"; +import { SizeTag } from "./core/SizeTag"; + +interface Props { + children: ReactNode; + response: HttpResponse; +} + +const LARGE_BYTES = 2 * 1000 * 1000; + +export function ConfirmLargeResponse({ children, response }: Props) { + const { mutate: saveResponse } = useSaveResponse(response); + const [showLargeResponse, toggleShowLargeResponse] = useToggle(); + const isProbablyText = useMemo(() => { + const contentType = getContentTypeFromHeaders(response.headers); + return isProbablyTextContentType(contentType); + }, [response.headers]); + + const contentLength = response.contentLength ?? 0; + const isLarge = contentLength > LARGE_BYTES; + if (!showLargeResponse && isLarge) { + return ( + +

+ Showing responses over{" "} + + + {" "} + may impact performance +

+ + + + {isProbablyText && ( + getResponseBodyText({ response, filter: null })} + /> + )} + +
+ ); + } + + return <>{children}; +} diff --git a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx new file mode 100644 index 0000000..92525f7 --- /dev/null +++ b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx @@ -0,0 +1,56 @@ +import type { HttpResponse } from "@yaakapp-internal/models"; +import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { type ReactNode, useMemo } from "react"; +import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody"; +import { useToggle } from "../hooks/useToggle"; +import { isProbablyTextContentType } from "../lib/contentType"; +import { getContentTypeFromHeaders } from "../lib/model_util"; +import { CopyButton } from "./CopyButton"; +import { Button } from "./core/Button"; +import { SizeTag } from "./core/SizeTag"; + +interface Props { + children: ReactNode; + response: HttpResponse; +} + +const LARGE_BYTES = 2 * 1000 * 1000; + +export function ConfirmLargeResponseRequest({ children, response }: Props) { + const [showLargeResponse, toggleShowLargeResponse] = useToggle(); + const isProbablyText = useMemo(() => { + const contentType = getContentTypeFromHeaders(response.headers); + return isProbablyTextContentType(contentType); + }, [response.headers]); + + const contentLength = response.requestContentLength ?? 0; + const isLarge = contentLength > LARGE_BYTES; + if (!showLargeResponse && isLarge) { + return ( + +

+ Showing content over{" "} + + + {" "} + may impact performance +

+ + + {isProbablyText && ( + getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")} + /> + )} + +
+ ); + } + + return <>{children}; +} diff --git a/apps/yaak-client/components/ContextMenus.tsx b/apps/yaak-client/components/ContextMenus.tsx new file mode 100644 index 0000000..dd8bad8 --- /dev/null +++ b/apps/yaak-client/components/ContextMenus.tsx @@ -0,0 +1,26 @@ +import { useAtomValue } from "jotai"; +import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu"; +import { ContextMenu } from "./core/Dropdown"; +import { ErrorBoundary } from "./ErrorBoundary"; + +/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */ +export function ContextMenus() { + const menus = useAtomValue(contextMenusAtom); + return ( + <> + {menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => ( + + hideContextMenu(id)} + /> + + ))} + + ); +} diff --git a/apps/yaak-client/components/CookieDialog.tsx b/apps/yaak-client/components/CookieDialog.tsx new file mode 100644 index 0000000..31c9091 --- /dev/null +++ b/apps/yaak-client/components/CookieDialog.tsx @@ -0,0 +1,731 @@ +import type { Cookie } from "@yaakapp-internal/models"; +import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models"; +import { formatDate } from "date-fns/format"; +import { useAtomValue } from "jotai"; +import { + type ComponentProps, + type CSSProperties, + type FormEvent, + type ReactNode, + type RefObject, + useMemo, + useRef, + useState, +} from "react"; +import { showDialog } from "../lib/dialog"; +import { jotaiStore } from "../lib/jotai"; +import { cookieDomain } from "../lib/model_util"; +import { + Icon, + SplitLayout, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + TruncatedWideTableCell, +} from "@yaakapp-internal/ui"; +import { IconButton } from "./core/IconButton"; +import { Checkbox } from "./core/Checkbox"; +import classNames from "classnames"; +import { EventDetailHeader } from "./core/EventViewer"; +import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { EmptyStateText } from "./EmptyStateText"; +import { PlainInput } from "./core/PlainInput"; +import { Select } from "./core/Select"; +import { showAlert } from "../lib/alert"; + +interface Props { + cookieJarId: string | null; +} + +export const CookieDialog = ({ cookieJarId }: Props) => { + const cookieJars = useAtomValue(cookieJarsAtom); + const cookieJar = cookieJars?.find((c) => c.id === cookieJarId); + const [filter, setFilter] = useState(""); + const [filterUpdateKey, setFilterUpdateKey] = useState(0); + const [selectedCookieKey, setSelectedCookieKey] = useState(null); + const [editingCookieKey, setEditingCookieKey] = useState(null); + const [draftCookie, setDraftCookie] = useState(null); + const [draftExpiresInput, setDraftExpiresInput] = useState(""); + const editorFormRef = useRef(null); + const filteredCookies = useMemo(() => { + return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? []; + }, [cookieJar?.cookies, filter]); + const selectedCookie = useMemo( + () => + selectedCookieKey == null + ? null + : (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null), + [filteredCookies, selectedCookieKey], + ); + const detailCookie = draftCookie ?? selectedCookie; + const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY; + const isEditingCookie = draftCookie != null; + + const handleAddCookie = () => { + setSelectedCookieKey(null); + setEditingCookieKey(NEW_COOKIE_KEY); + setDraftCookie(newCookieDraft()); + setDraftExpiresInput(""); + }; + + const handleEditCookie = () => { + if (selectedCookie == null) { + return; + } + + setEditingCookieKey(cookieKey(selectedCookie)); + setDraftCookie(selectedCookie); + setDraftExpiresInput(cookieExpiresInputValue(selectedCookie)); + }; + + const handleCancelEdit = () => { + if (isCreatingCookie) { + setSelectedCookieKey(null); + } + setEditingCookieKey(null); + setDraftCookie(null); + setDraftExpiresInput(""); + }; + + const handleCloseDetails = () => { + if (isEditingCookie) { + handleCancelEdit(); + return; + } + + setSelectedCookieKey(null); + }; + + const handleSaveCookie = (event: FormEvent) => { + event.preventDefault(); + + if (cookieJar == null || draftCookie == null) { + return; + } + + let nextCookie = normalizeCookie(draftCookie); + if (nextCookie.expires !== "SessionEnd") { + const expires = cookieExpiresFromInput(draftExpiresInput); + if (expires == null) { + showAlert({ + id: "invalid-cookie-expires", + title: "Invalid Cookie", + body: "Cookie expiration must be a valid date.", + }); + return; + } + + nextCookie = { ...nextCookie, expires }; + } + + const nextCookieKey = cookieKey(nextCookie); + const nextCookies = cookieJar.cookies.filter((cookie) => { + const key = cookieKey(cookie); + if (editingCookieKey != null && key === editingCookieKey) { + return false; + } + return key !== nextCookieKey; + }); + + void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] }); + setSelectedCookieKey(nextCookieKey); + setEditingCookieKey(null); + setDraftCookie(null); + setDraftExpiresInput(""); + }; + + if (cookieJar == null) { + return
No cookie jar selected
; + } + + return ( +
+
+ 0 && ( + { + setFilter(""); + setFilterUpdateKey((key) => key + 1); + }} + /> + ) + } + /> + +
+ {cookieJar.cookies.length === 0 && detailCookie == null ? ( + + Cookies will appear when a response includes a Set-Cookie header. + + ) : filteredCookies.length === 0 && detailCookie == null ? ( + No cookies match the current filter. + ) : ( + + filteredCookies.length === 0 ? ( +
+ No cookies match the current filter. +
+ ) : ( + + + + Name + Value + Domain + Path + Expires + Size + HTTP Only + Secure + Same Site + + { + setSelectedCookieKey(null); + setEditingCookieKey(null); + setDraftCookie(null); + setDraftExpiresInput(""); + void patchModel(cookieJar, { cookies: [] }); + }} + /> + + + + + {filteredCookies.map((c: Cookie) => { + const key = cookieKey(c); + const isSelected = key === selectedCookieKey; + + return ( + { + setSelectedCookieKey(key); + setEditingCookieKey(null); + setDraftCookie(null); + setDraftExpiresInput(""); + }} + > + + {c.name} + + + {c.value} + + {cookieDomain(c)} + {c.path} + {cookieExpires(c)} + {cookieSize(c)} + + + + + + + {c.sameSite} + + { + event.stopPropagation(); + if (isSelected) { + setSelectedCookieKey(null); + } + if (editingCookieKey === key) { + setEditingCookieKey(null); + setDraftCookie(null); + setDraftExpiresInput(""); + } + void patchModel(cookieJar, { + cookies: cookieJar.cookies.filter( + (c2: Cookie) => cookieKey(c2) !== key, + ), + }); + }} + /> + + + ); + })} + +
+ ) + } + secondSlot={ + detailCookie == null + ? null + : ({ style }) => ( + + editorFormRef.current?.requestSubmit(), + }, + { + key: "cancel", + label: "Cancel", + onClick: handleCancelEdit, + }, + ] + : [ + { + key: "edit", + label: "Edit", + onClick: handleEditCookie, + }, + ] + } + onClose={handleCloseDetails} + /> + {isEditingCookie ? ( + + ) : ( + + )} + + ) + } + /> + )} +
+ ); +}; + +function CookieDetailsPane({ + children, + formRef, + isEditing, + onSubmit, + style, +}: { + children: ReactNode; + formRef: RefObject; + isEditing: boolean; + onSubmit: (event: FormEvent) => void; + style: CSSProperties; +}) { + const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2"; + + if (isEditing) { + return ( +
+ {children} +
+ ); + } + + return ( +
+ {children} +
+ ); +} + +CookieDialog.show = (cookieJarId: string | null) => { + const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId); + if (cookieJar == null) { + showAlert({ + id: "invalid-jar", + body: `Failed to find cookie jar for ID: ${cookieJarId}`, + title: "Invalid Cookie Jar", + }); + return; + } + + showDialog({ + id: "cookies", + title: `${cookieJar.name} Cookies`, + size: "full", + render: () => , + }); +}; + +function CookieDetails({ cookie }: { cookie: Cookie }) { + return ( +
+ + {cookie.name} + +
{cookie.value}
+
+ {cookieDomain(cookie)} + {cookie.path} + {cookieExpires(cookie)} + {cookieSize(cookie)} + {cookie.httpOnly ? "Yes" : "No"} + {cookie.secure ? "Yes" : "No"} + {cookie.sameSite && ( + {cookie.sameSite} + )} +
+
+ ); +} + +function CookieEditor({ + cookie, + expiresInputValue, + onChange, + onExpiresInputChange, +}: { + cookie: Cookie; + expiresInputValue: string; + onChange: (cookie: Cookie) => void; + onExpiresInputChange: (value: string) => void; +}) { + const sessionCookie = cookie.expires === "SessionEnd"; + + return ( +
+ + + onChange({ ...cookie, name })} + /> + + + onChange({ ...cookie, value })} + /> + + + onChange(cookieWithDomain(cookie, domain))} + /> + + + onChange({ ...cookie, path })} + /> + + +
+ { + if (checked) { + onChange({ ...cookie, expires: "SessionEnd" }); + return; + } + + const expiresInput = + cookieExpiresFromInput(expiresInputValue) == null + ? defaultCookieExpiresInputValue() + : expiresInputValue; + + onExpiresInputChange(expiresInput); + onChange({ + ...cookie, + expires: cookieExpiresFromInput(expiresInput)!, + }); + }} + /> + { + onExpiresInputChange(value); + + const expires = cookieExpiresFromInput(value); + if (expires != null) { + onChange({ ...cookie, expires }); + } + }} + /> +
+
+ {cookieSize(cookie)} + + onChange({ ...cookie, httpOnly })} + /> + + + onChange({ ...cookie, secure })} + /> + + + onChange(event.target.value)} + pattern={pattern} + placeholder={placeholder} + required={required} + type="text" + value={value} + /> + ); +} + +function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) { + return ( +