commit 1073b0c21435e98c8b6c2cb06529a4dd7eecddc1 Author: huanld Date: Fri May 29 08:31:04 2026 +0700 Initial OpenScreen import diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0efeecb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce0e08b --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +APP_NAME=Openscreen +BUNDLE_ID=com.siddharthvaddem.openscreen + +APPLE_ID= +TEAM_ID= +SIGN_IDENTITY="Developer ID Application: Samir Patil ()" +CSC_NAME="Samir Patil ()" + +NOTARY_PROFILE=OpenScreen-notary +APPLE_APP_SPECIFIC_PASSWORD= diff --git a/.env.signing.example b/.env.signing.example new file mode 100644 index 0000000..321fcca --- /dev/null +++ b/.env.signing.example @@ -0,0 +1,8 @@ +# Copy to .env.signing.local for a local signing machine. Do not commit real values. +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TRUSTED_SIGNING_ENDPOINT=https://.codesigning.azure.net/ +AZURE_TRUSTED_SIGNING_ACCOUNT_NAME= +AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME= +AZURE_TRUSTED_SIGNING_PUBLISHER_NAME= diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cfee36d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @siddharthvaddem diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1c91769 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,149 @@ +name: Bug Report +description: Create a report to help us improve +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: checkboxes + attributes: + label: Search existing issues + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true + + - type: textarea + id: bug-description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + placeholder: e.g., When I click submit, nothing happens... + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + placeholder: e.g., The form should submit and show a success message + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. + placeholder: Drag and drop images here or paste them + validations: + required: false + + - type: dropdown + id: os-type + attributes: + label: OS + description: Operating system + options: + - Windows + - macOS + - Linux + - iOS + - Android + - Other + validations: + required: false + + - type: input + id: os-version + attributes: + label: OS Version + description: Please specify your OS version + placeholder: e.g., Windows 11, macOS Sonoma, Ubuntu 22.04 + validations: + required: false + + - type: input + id: os-other + attributes: + label: Other OS + description: If you selected "Other" for OS, please specify your operating system + placeholder: e.g., FreeBSD, Solaris + validations: + required: false + + - type: dropdown + id: browser + attributes: + label: Browser + description: What browser are you using? + options: + - Chrome + - Firefox + - Safari + - Edge + - Other + validations: + required: false + + - type: input + id: browser-version + attributes: + label: Browser Version + description: Please specify your browser version + placeholder: e.g., 120.0, 121.0.1 + validations: + required: false + + - type: input + id: browser-other + attributes: + label: Other Browser + description: If you selected "Other" for Browser, please specify your browser + placeholder: e.g., Brave, Vivaldi, Opera + validations: + required: false + + - type: dropdown + id: device-type + attributes: + label: Device Type + description: Device category + options: + - Desktop + - Laptop + - Tablet + - Mobile + - Other + validations: + required: false + + - type: input + id: device-other + attributes: + label: Other Device + description: If you selected "Other" for Device Type, please specify your device + placeholder: e.g., Smart TV, IoT device + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context about the problem here. + placeholder: Links, references, or any additional information + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..752af95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,48 @@ +name: Feature Request +description: Suggest an idea for this project +title: "[Feature]: " +labels: ["enhancement", "feature-request"] +body: + - type: checkboxes + attributes: + label: Search existing issues + description: Please search to see if an issue already exists for this feature request. + options: + - label: I have searched the existing issues + required: true + + - type: textarea + id: problem-description + attributes: + label: Is your feature request related to a problem? + description: A clear and concise description of what the problem is. + placeholder: e.g., I'm always frustrated when I have to... + validations: + required: true + + - type: textarea + id: solution-description + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: Describe the feature or change you're proposing + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + placeholder: Have you considered any workarounds or alternative approaches? + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + placeholder: Links, mockups, or any additional information + validations: + required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..79f39d4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,43 @@ +# Pull Request Template + +## Description + + +## Motivation + + +## Type of Change +- [ ] New Feature +- [ ] Bug Fix +- [ ] Refactor / Code Cleanup +- [ ] Documentation Update +- [ ] Other (please specify) + +## Related Issue(s) + + +## Screenshots / Video + + +**Screenshot** (if applicable): + +```markdown +![Screenshot Description](path/to/screenshot.png) +``` + +**Video** (if applicable): + +```html + +``` + +## Testing + + +## Checklist +- [ ] I have performed a self-review of my code. +- [ ] I have added any necessary screenshots or videos. +- [ ] I have linked related issue(s) and updated the changelog if applicable. + +--- +*Thank you for contributing!* diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1f85736 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,253 @@ + +name: Build Electron App + +on: + workflow_dispatch: + inputs: + arch: + description: 'Architecture to build' + required: true + default: 'both' + type: choice + options: + - arm64 + - x64 + - both + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Build Windows app + run: npm run build:win + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Windows build + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: release/**/*.exe + retention-days: 30 + + build-macos: + runs-on: macos-latest + strategy: + matrix: + arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }} + + steps: + # ─── Checkout ───────────────────────────────────────────── + - name: Checkout code + uses: actions/checkout@v4 + + # ─── Setup Node.js ──────────────────────────────────────── + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # ─── Setup Python (needed by some native deps) ──────────── + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # ─── Install Dependencies ───────────────────────────────── + - name: Install dependencies + run: npm ci + + # ─── Import Code Signing Certificate ────────────────────── + # This is the KEY step that makes CI signing work. + # We create a temporary keychain, import the .p12 cert into it, + # and set it as the default so codesign can find it. + - name: Import code signing certificate + env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + run: | + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create and configure keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Decode and import certificate + echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -k "$KEYCHAIN_PATH" \ + -P "$MAC_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + + # Allow codesign to access the keychain without UI prompt + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add to keychain search path (makes it the default) + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + + # Verify the identity is available + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + # Clean up the .p12 file + rm -f $RUNNER_TEMP/certificate.p12 + + # ─── Build Vite + Electron ──────────────────────────────── + - name: Build Vite + Electron + run: npx tsc && npx vite build + + # ─── Package with electron-builder ──────────────────────── + # electron-builder handles deep codesigning the .app bundle + # "notarize: false" in electron-builder.json5 prevents it from + # trying its own notarization flow + - name: Package .app bundle + run: npx electron-builder --mac --${{ matrix.arch }} --dir + env: + CSC_NAME: "Samir Patil (N26FZ4GW28)" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ─── Read version from package.json ─────────────────────── + - name: Get version + id: version + run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT + + # ─── Locate the .app bundle ─────────────────────────────── + - name: Find .app bundle + id: find_app + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "=== Release directory contents ===" + ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found" + echo "=== Searching for .app bundle ===" + APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1) + if [ -z "$APP_BUNDLE" ]; then + echo "::error::No .app bundle found in release/${VERSION}/" + exit 1 + fi + echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT + echo "Found: $APP_BUNDLE" + + # ─── Verify .app signature ──────────────────────────────── + - name: Verify .app code signature + run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" + + # ─── Create DMG ─────────────────────────────────────────── + - name: Create DMG + id: dmg + run: | + VERSION="${{ steps.version.outputs.version }}" + ARCH="${{ matrix.arch }}" + DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg" + RELEASE_DIR="release/${VERSION}" + DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" + STAGING="${RELEASE_DIR}/dmg-staging" + + mkdir -p "$STAGING" + cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + + hdiutil create \ + -srcfolder "$STAGING" \ + -volname "Openscreen" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -format UDBZ \ + "$DMG_OUTPUT" + + rm -rf "$STAGING" + + echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT + echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT + + # ─── Sign DMG ───────────────────────────────────────────── + - name: Sign DMG + run: | + codesign --force \ + --sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \ + --timestamp \ + "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Notarize DMG ──────────────────────────────────────── + # On CI we can't use keychain profiles for notarytool, so we + # pass credentials directly via env vars / flags + - name: Notarize DMG + run: | + xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ + --wait + timeout-minutes: 15 + + # ─── Staple ─────────────────────────────────────────────── + - name: Staple notarization ticket + run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Validate ───────────────────────────────────────────── + - name: Validate stapled DMG + run: | + xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" + spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" + + # ─── Upload Artifact ────────────────────────────────────── + - name: Upload notarized DMG + uses: actions/upload-artifact@v4 + with: + name: openscreen-mac-${{ matrix.arch }} + path: ${{ steps.dmg.outputs.dmg_path }} + retention-days: 30 + + # ─── Cleanup Keychain ───────────────────────────────────── + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true + + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + # bsdtar (from libarchive-tools) is required by fpm to build pacman + # packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it. + - name: Install pacman build dependencies + run: sudo apt-get update && sudo apt-get install -y libarchive-tools + + - name: Build Linux app + run: npm run build:linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Linux build + uses: actions/upload-artifact@v4 + with: + name: linux-installer + path: | + release/**/*.AppImage + release/**/*.zsync + release/**/*.deb + release/**/*.pacman + retention-days: 30 diff --git a/.github/workflows/bump-nix-package.yml b/.github/workflows/bump-nix-package.yml new file mode 100644 index 0000000..5ff3c73 --- /dev/null +++ b/.github/workflows/bump-nix-package.yml @@ -0,0 +1,118 @@ +name: Bump Nix package on release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to bump (e.g. v1.5.0)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + BRANCH="chore/bump-nix-${VERSION}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Compute npmDepsHash + id: hash + run: | + set -euo pipefail + HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) + if [[ -z "$HASH" ]]; then + echo "::error::prefetch-npm-deps returned an empty hash" + exit 1 + fi + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "Computed npmDepsHash: $HASH" + + - name: Update nix/package.nix + env: + VERSION: ${{ steps.meta.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + run: | + set -euo pipefail + # Update version line: ` version = "";` + sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix + # Update npmDepsHash line: ` npmDepsHash = "";` + sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix + + echo "=== diff ===" + git --no-pager diff nix/package.nix || true + + - name: Create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + HASH: ${{ steps.hash.outputs.hash }} + BRANCH: ${{ steps.meta.outputs.branch }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + + if git diff --quiet -- nix/package.nix; then + echo "nix/package.nix already at v${VERSION} with this hash — nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Replace any prior bump branch to keep the workflow idempotent. + git push origin --delete "$BRANCH" 2>/dev/null || true + git checkout -b "$BRANCH" + git add nix/package.nix + git commit -m "chore: bump nix package to v${VERSION}" + git push -u origin "$BRANCH" + + gh pr create \ + --title "chore: bump nix package to v${VERSION}" \ + --base main \ + --head "$BRANCH" \ + --body "$(cat < Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR. + EOF + )" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3c9e8ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx tsc --noEmit + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test + - run: npm run test:browser:install + - run: npm run test:browser + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx vite build diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml new file mode 100644 index 0000000..6da25d0 --- /dev/null +++ b/.github/workflows/discord.yaml @@ -0,0 +1,519 @@ +name: PR to Discord Forum + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed] + pull_request_review: + types: [submitted] + issue_comment: + types: [created] + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + notify: + if: github.event_name != 'schedule' && github.actor != 'github-actions[bot]' + concurrency: + group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - name: Sync PR activity to Discord forum thread + id: sync + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }} + DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} + DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }} + DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }} + with: + script: | + const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); + const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); + + const THREAD_MARKER_REGEX = //i; + const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").trim(); + const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim(); + const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim(); + const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim(); + + const TAGS = { + open: "1493976692967080096", + draft: "1493976782028935279", + ready: "1493976833626996756", + changes: "1493976909875515564", + approved: "1493976951038152764", + merged: "1493977049709281320", + closed: "1493977108102516786", + }; + + const labelTagMap = { + bug: "1493977562773458975", + enhancement: "1493977619216207993", + documentation: "1493978565153394830", + }; + + function cleanDescription(text, maxLen = 3500) { + if (!text) return "No description provided."; + const normalized = text + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, maxLen - 1)}…`; + } + + function trimThreadName(name) { + return name.length > 95 ? name.slice(0, 95) : name; + } + + function extractThreadId(body) { + if (!body) return null; + const match = body.match(THREAD_MARKER_REGEX); + return match ? match[1] : null; + } + + function upsertThreadMarker(body, threadId) { + const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim(); + return `${cleaned}\n\n`.trim(); + } + + async function discordPost(payload, options = {}) { + const endpoint = new URL(webhookUrl); + endpoint.searchParams.set("wait", "true"); + if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId)); + + const response = await fetch(endpoint.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: WEBHOOK_USERNAME, + avatar_url: WEBHOOK_AVATAR, + allowed_mentions: { parse: [] }, + ...payload, + }) + }); + + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`Discord API error ${response.status}: ${text}`); + } + + if (!text) return {}; + if (contentType.includes("application/json")) return JSON.parse(text); + + // Some proxy/CDN edge responses may return HTML with 2xx; avoid crashing on JSON parse. + core.warning(`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`); + return {}; + } + + async function patchDiscordThread(threadId, patchBody) { + if (!botToken || !threadId) return; + const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, { + method: "PATCH", + headers: { + "Authorization": `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(patchBody), + }); + if (!response.ok) { + const text = await response.text(); + core.warning(`Discord thread patch failed (${response.status}): ${text}`); + } + } + + function desiredStatusTag(prState) { + if (prState.merged && TAGS.merged) return TAGS.merged; + if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed; + if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes; + if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved; + if (prState.draft && TAGS.draft) return TAGS.draft; + if (!prState.draft && TAGS.ready) return TAGS.ready; + return TAGS.open || null; + } + + function tagIdsFromLabels(labels) { + const out = []; + for (const label of labels) { + const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label]; + if (mapped) out.push(String(mapped)); + } + return out; + } + + async function getPullRequest() { + if (context.eventName === "pull_request_target" || context.eventName === "pull_request_review") { + return context.payload.pull_request || null; + } + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + if (!issue?.pull_request) return null; + const { data } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number, + }); + return data; + } + return null; + } + + async function getReviewState(owner, repo, pullNumber) { + const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 }); + let hasChanges = false; + let hasApproved = false; + for (const r of data) { + const s = (r.state || "").toUpperCase(); + if (s === "CHANGES_REQUESTED") hasChanges = true; + if (s === "APPROVED") hasApproved = true; + } + if (hasChanges) return "CHANGES_REQUESTED"; + if (hasApproved) return "APPROVED"; + return "NONE"; + } + + async function sendFailureAlert(message) { + if (!alertWebhookUrl) return; + try { + await fetch(alertWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "OpenScreen", + avatar_url: WEBHOOK_AVATAR, + content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + allowed_mentions: { parse: [] } + }) + }); + } catch { + core.warning("Failed to send failure alert webhook."); + } + } + + try { + const pr = await getPullRequest(); + if (!pr) { + core.info("No PR context found. Skipping."); + return; + } + + if (!webhookUrl) { + const strictEvents = new Set(["pull_request_target", "workflow_dispatch"]); + const msg = + `Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` + + "Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets."; + if (strictEvents.has(context.eventName)) { + core.setFailed(msg); + } else { + core.warning(msg); + } + return; + } + + const action = context.payload.action || ""; + const owner = context.repo.owner; + const repo = context.repo.repo; + const number = pr.number; + const title = pr.title; + const author = pr.user?.login || "unknown"; + const url = pr.html_url; + const authorUrl = pr.user?.html_url || ""; + const authorAvatar = pr.user?.avatar_url || ""; + const base = pr.base?.ref || ""; + const head = pr.head?.ref || ""; + const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`; + const labels = (pr.labels || []).map((l) => l.name); + const body = (pr.body || "").trim(); + const reviewState = await getReviewState(owner, repo, number); + + let threadId = extractThreadId(body); + const shouldCreateThread = + context.eventName === "pull_request_target" && + ["opened", "reopened", "ready_for_review"].includes(action) && + !threadId; + + if (shouldCreateThread) { + const fields = [ + { name: "PR", value: `[#${number}](${url})`, inline: true }, + { name: "Author", value: `[${author}](${authorUrl || url})`, inline: true }, + { name: "Status", value: pr.draft ? "Draft" : "Open", inline: true }, + { name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true }, + { name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true }, + { name: "Files Changed", value: String(pr.changed_files), inline: true } + ]; + + if (labels.length) { + fields.push({ + name: "Labels", + value: labels.map((l) => `\`${l}\``).join(" "), + inline: false, + }); + } + + const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + + const createPayload = { + content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened", + thread_name: trimThreadName(`PR #${number} - ${title}`), + applied_tags: appliedTags, + embeds: [ + { + title: `PR #${number}: ${title}`, + url, + description: cleanDescription(body), + color: pr.draft ? 15105570 : 1998671, + author: { + name: author, + url: authorUrl || undefined, + icon_url: authorAvatar || undefined, + }, + fields, + footer: { text: repoFullName }, + timestamp: new Date().toISOString(), + }, + ], + }; + + const result = await discordPost(createPayload); + const createdThreadId = result.channel_id || null; + if (createdThreadId) { + const updatedBody = upsertThreadMarker(body, createdThreadId); + await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody }); + core.info(`Created Discord thread ${createdThreadId} and stored mapping.`); + } else { + core.warning("Discord thread created but channel_id missing in response."); + } + return; + } + + if (!threadId) { + core.info("No mapped Discord thread ID found; skipping update event."); + return; + } + + if (context.eventName === "pull_request_target" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) { + const statusTag = desiredStatusTag({ + draft: action === "converted_to_draft" ? true : pr.draft, + reviewState, + merged: false, + closed: false, + }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + await patchDiscordThread(threadId, { + name: trimThreadName(`PR #${number} - ${title}`), + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }); + } + + let updateMessage = null; + let updateEmbed = null; + + if (context.eventName === "pull_request_target") { + if (action === "synchronize") { + const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 }); + const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details"; + updateMessage = `🧩 New commits pushed to PR #${number}`; + updateEmbed = { + title: `Commit Update • PR #${number}`, + url: `${url}/files`, + description: `${list}`, + color: 1998671, + footer: { text: repoFullName }, + timestamp: new Date().toISOString(), + }; + } else if (action === "edited") { + updateMessage = `✏️ PR #${number} details were edited`; + updateEmbed = { + title: `PR Updated • #${number}`, + url, + description: cleanDescription(body, 1200), + color: 1998671, + timestamp: new Date().toISOString(), + }; + } else if (action === "closed") { + const isMerged = !!pr.merged; + const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + await patchDiscordThread(threadId, { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + ...(isMerged ? { archived: true, locked: true } : {}), + }); + + updateMessage = isMerged + ? `✅ PR #${number} was merged` + : `🛑 PR #${number} was closed without merge`; + updateEmbed = { + title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`, + url, + description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.", + color: isMerged ? 5763719 : 15158332, + timestamp: new Date().toISOString(), + }; + } else if (action === "ready_for_review") { + updateMessage = `🚀 PR #${number} moved from draft to ready for review`; + if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; + } else if (action === "converted_to_draft") { + updateMessage = `📝 PR #${number} converted to draft`; + } + } else if (context.eventName === "pull_request_review") { + const review = context.payload.review; + if (review) { + const state = (review.state || "commented").toUpperCase(); + const reviewer = review.user?.login || "reviewer"; + updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`; + if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`; + updateEmbed = { + title: `Review ${state} • PR #${number}`, + url: review.html_url || url, + description: cleanDescription(review.body || "No review note.", 1000), + color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671, + timestamp: new Date().toISOString(), + }; + + if (state === "CHANGES_REQUESTED" || state === "APPROVED") { + const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false }); + const mappedLabelTags = tagIdsFromLabels(labels); + const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))]; + await patchDiscordThread(threadId, { + ...(appliedTags.length ? { applied_tags: appliedTags } : {}), + }); + } + } + } else if (context.eventName === "issue_comment") { + const comment = context.payload.comment; + if (comment) { + const commenter = comment.user?.login || "user"; + updateMessage = `💬 New comment by **${commenter}** on PR #${number}`; + updateEmbed = { + title: `New PR Comment • #${number}`, + url: comment.html_url || url, + description: cleanDescription(comment.body || "No comment body.", 1000), + color: 1998671, + timestamp: new Date().toISOString(), + }; + } + } + + if (!updateMessage && !updateEmbed) { + core.info("No Discord update message for this event/action. Skipping."); + return; + } + + const payload = { content: updateMessage || "" }; + if (updateEmbed) payload.embeds = [updateEmbed]; + await discordPost(payload, { threadId }); + core.info(`Posted update to Discord thread ${threadId}.`); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + core.setFailed(msg); + + const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL; + if (alertWebhook) { + try { + await fetch(alertWebhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "OpenScreen", + avatar_url: WEBHOOK_AVATAR, + content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + allowed_mentions: { parse: [] } + }) + }); + } catch { + core.warning("Failed to send alert webhook."); + } + } + } + + weekly-contributor-leaderboard: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Post weekly contributor leaderboard + uses: actions/github-script@v7 + env: + DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }} + DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }} + DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }} + with: + script: | + const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim(); + const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim(); + const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim(); + if (!spotlightWebhook) { + core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post."); + return; + } + + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const owner = context.repo.owner; + const repo = context.repo.repo; + + const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`; + const search = await github.rest.search.issuesAndPullRequests({ + q, + per_page: 100, + }); + + const counter = new Map(); + for (const item of search.data.items) { + const login = item.user?.login; + if (!login) continue; + counter.set(login, (counter.get(login) || 0) + 1); + } + + const ranked = [...counter.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + const totalMerged = search.data.items.length; + const lines = ranked.length + ? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n") + : "No merged PRs this week."; + + const payload = { + username: webhookUsername, + ...(webhookAvatar ? { avatar_url: webhookAvatar } : {}), + embeds: [ + { + title: "🌟 Weekly Contributor Leaderboard", + description: lines, + color: 1998671, + fields: [ + { name: "Merged PRs (7d)", value: String(totalMerged), inline: true }, + { name: "Repository", value: `${owner}/${repo}`, inline: true }, + { name: "Period", value: "Last 7 days", inline: true } + ], + timestamp: new Date().toISOString() + } + ], + allowed_mentions: { parse: [] } + }; + + const res = await fetch(`${spotlightWebhook}?wait=true`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const txt = await res.text(); + core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`); + } diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml new file mode 100644 index 0000000..62b4b7a --- /dev/null +++ b/.github/workflows/publish-winget.yml @@ -0,0 +1,26 @@ +name: Publish release to WinGet + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish to winget (e.g. v1.4.0)" + required: true + type: string + +jobs: + publish: + runs-on: windows-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + steps: + - uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: SiddharthVaddem.OpenScreen + # Match the Windows installer asset attached to each release. + # Today: "Openscreen.Setup.latest.exe". Adjust this regex if you + # ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe"). + installers-regex: 'Setup\..*\.exe$' + release-tag: ${{ inputs.tag || github.event.release.tag_name }} + token: ${{ secrets.WINGET_ACC_TOKEN }} diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml new file mode 100644 index 0000000..3d65cb0 --- /dev/null +++ b/.github/workflows/update-homebrew-cask.yml @@ -0,0 +1,168 @@ +name: Update Homebrew Cask + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish to the tap (e.g. v1.4.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + update-cask: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + env: + TAP_OWNER: siddharthvaddem + TAP_REPO: homebrew-openscreen + CASK_NAME: openscreen + steps: + - name: Resolve tag and version + id: meta + env: + GH_EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${GH_EVENT_TAG:-$INPUT_TAG}" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved from release event or workflow input" + exit 1 + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Find macOS DMG assets + id: assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name') + + # arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg + # whose name does NOT contain "x64" or non-mac platform markers. + ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true) + if [[ -z "$ARM_NAME" ]]; then + ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true) + fi + + # x64 DMG + X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \ + | grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true) + + if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then + echo "::error::Could not locate both arm64 and x64 DMGs in release assets" + echo "Available assets:" + echo "$NAMES" + exit 1 + fi + + echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT" + echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT" + echo "Found arm64 asset: $ARM_NAME" + echo "Found x64 asset: $X64_NAME" + + - name: Download DMGs and compute sha256 + id: shas + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + ARM_NAME: ${{ steps.assets.outputs.arm_name }} + X64_NAME: ${{ steps.assets.outputs.x64_name }} + run: | + set -euo pipefail + BASE="https://github.com/${REPO}/releases/download/${TAG}" + curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}" + curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}" + ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}') + X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}') + echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT" + echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT" + + - name: Checkout tap + uses: actions/checkout@v4 + with: + repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Write cask file + env: + REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + ARM_NAME: ${{ steps.assets.outputs.arm_name }} + X64_NAME: ${{ steps.assets.outputs.x64_name }} + ARM_SHA: ${{ steps.shas.outputs.arm_sha }} + X64_SHA: ${{ steps.shas.outputs.x64_sha }} + run: | + set -euo pipefail + mkdir -p tap/Casks + BASE="https://github.com/${REPO}/releases/download/${TAG}" + + # #{version} is Ruby interpolation written literally to the cask + # file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA}, + # etc. are bash variables expanded by the heredoc. The literal + # #{version} fixes Homebrew's "URL is unversioned" audit warning by + # making the version string statically detectable. + cat > "tap/Casks/${CASK_NAME}.rb" <= :big_sur" + + app "Openscreen.app" + + zap trash: [ + "~/Library/Application Support/Openscreen", + "~/Library/Caches/com.siddharthvaddem.openscreen", + "~/Library/Logs/Openscreen", + "~/Library/Preferences/com.siddharthvaddem.openscreen.plist", + "~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState", + ] + end + EOF + + - name: Commit and push to tap + working-directory: tap + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "Casks/${CASK_NAME}.rb" + if git diff --cached --quiet; then + echo "Cask already up to date for ${VERSION} — nothing to commit." + exit 0 + fi + git commit -m "Bump ${CASK_NAME} to ${VERSION}" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8827b47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-electron +dist-ssr +*.local +.env +.env.signing.local + +# Native helper build outputs +/electron/native/wgc-capture/build/ +/electron/native/screencapturekit/build/ +/electron/native/screencapturekit/.build/ +/electron/native/screencapturekit/.swiftpm/ +/electron/native/bin/ +/tools/ocr/build/ +/tools/ocr/dist/ +/tools/ocr/models/**/.gitattributes +/tools/ocr/models/**/README.md + +# Native macOS generated files +DerivedData/ +*.xcuserstate +xcuserdata/ + +# Editor directories and files +.vscode/* +.zed/ +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +release/** +*.kiro/ +.claude/ +__pycache__/ +*.py[cod] +# npx electron-builder --mac --win + +# Playwright +test-results +playwright-report/ + + +# Vitest browser mode screenshots +__screenshots__/ + +# shell files +/shell.sh +# Nix +result +result-* +.direnv/ + +#kilocode +.kilo/ + +#others + +**/*.import + +# Local agent/tooling state +/.agent/ +/.serena/ +/.venv-ocr-build/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..32a2d7b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.22.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f2bbbe2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contribution Guidelines + +Thank you for considering contributing to this project! By contributing, you help make this project better for everyone. Please take a moment to review these guidelines to ensure a smooth contribution process. + +## How to Contribute + +1. **Fork the Repository** + - Click the "Fork" button at the top right of this repository to create your own copy. + +2. **Clone Your Fork** + - Clone your forked repository to your local machine: + ```bash + git clone https://github.com/your-username/openscreen.git + ``` + +3. **Create a New Branch** + - Create a branch for your feature or bug fix: + ```bash + git checkout -b feature/your-feature-name + ``` + +4. **Make Changes** + - Make your changes. + +5. **Test Your Changes** + - Test your changes thoroughly to ensure they work as expected and do not break existing functionality. + +6. **Commit Your Changes** + - Commit your changes with a clear and concise commit message: + ```bash + git add . + git commit -m "Add a brief description of your changes" + ``` + +7. **Push Your Changes** + - Push your branch to your forked repository: + ```bash + git push origin feature/your-feature-name + ``` + +8. **Open a Pull Request** + - Go to the original repository and open a pull request from your branch. Provide a clear description of your changes and the problem they solve. + +## Reporting Issues + +If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/siddharthvaddem/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. + +## Style Guide + +- Write clear, concise, and descriptive commit messages. +- Include comments where necessary to explain complex code. + +## License + +By contributing to this project, you agree that your contributions will be licensed under the [MIT License](./LICENSE). + +Thank you for your contributions! \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a9f8d8c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Siddharth Vaddem + +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..7009a22 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +> [!WARNING] +> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need. + +

+ OpenScreen Logo +
+
+ siddharthvaddem%2Fopenscreen | Trendshift +
+
+ + Ask DeepWiki + +   + + Join Discord + +

+ +#

OpenScreen

+ +

OpenScreen is your free, open-source alternative to Screen Studio (sort of).

+ +If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well! + +Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job! + +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it. + +

+ OpenScreen App Preview 3 + OpenScreen App Preview 4 +

+ +## Core Features +- Record a specific window, region, or your whole screen. +- Record microphone and system audio. +- Webcam overlay with picture-in-picture, drag-to-position, and shape options. +- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position. +- Wallpapers, solid colors, gradients, or a custom background. +- Motion blur for smoother pan and zoom transitions. +- Crop, trim, and per-segment speed control on the timeline. +- Blur effects to hide sensitive parts of the screen. +- Cursor and click highlighting. +- Text, arrow, and image annotations. +- Save and reopen projects without re-recording. +- Export to MP4 or GIF in multiple aspect ratios and resolutions. +- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese. + +## Installation + +Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. + +### macOS + +The easiest way to install on macOS is via [Homebrew](https://brew.sh): + +```bash +brew install --cask siddharthvaddem/openscreen/openscreen +``` + +Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it. + +To update later: `brew upgrade --cask openscreen` +To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data) + +#### Manual install (if you prefer) + +If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation: + +```bash +xattr -rd com.apple.quarantine /Applications/Openscreen.app +``` + +Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command. + +After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app. + +### Windows + +Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): + +```bash +winget install SiddharthVaddem.OpenScreen +``` + +To update later: `winget upgrade SiddharthVaddem.OpenScreen` +To uninstall: `winget uninstall SiddharthVaddem.OpenScreen` + +If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases). + +### Linux + +Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro: + +**Debian / Ubuntu / Pop!_OS (`.deb`)** +```bash +sudo apt install ./Openscreen-Linux-latest.deb +``` + +**Arch / Manjaro (`.pacman`)** +```bash +sudo pacman -U Openscreen-Linux-latest.pacman +``` + +**Any distro (`.AppImage`)** +```bash +chmod +x Openscreen-Linux-*.AppImage +./Openscreen-Linux-*.AppImage +``` + +**NixOS / Nix (flake)** + +Try without installing: +```bash +nix run github:siddharthvaddem/openscreen +``` + +Install into your user profile: +```bash +nix profile install github:siddharthvaddem/openscreen +``` + +For a NixOS system config (flake): +```nix +{ + inputs.openscreen.url = "github:siddharthvaddem/openscreen"; + + outputs = { nixpkgs, openscreen, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + modules = [ + openscreen.nixosModules.default + { programs.openscreen.enable = true; } + ]; + }; + }; +} +``` + +For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`. + +You may need to grant screen recording permissions depending on your desktop environment. + +**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`: +```bash +./Openscreen-Linux-*.AppImage --no-sandbox +``` + +### Limitations + +System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks: + +- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works). +- **Windows**: Works out of the box. +- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work). + +## Built with +- Electron +- React +- TypeScript +- Vite +- PixiJS +- dnd-timeline + +--- + + +## Documentation + +See the documentation here: +[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen) +Refresh if outdated. + +## Contributing + +Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed. + +## Star History + + + + + + Star History Chart + + + +## License + +This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..517be72 --- /dev/null +++ b/biome.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "formatWithErrors": true, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noAdjacentSpacesInRegex": "error", + "noBannedTypes": "error", + "noExtraBooleanCast": "error", + "noUselessCatch": "error", + "noUselessEscapeInRegex": "error", + "noUselessThisAlias": "error", + "noUselessTypeConstraint": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "error", + "noInvalidConstructorSuper": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedVariables": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "useIsNan": "error", + "useValidForDirection": "error", + "useValidTypeof": "error", + "useYield": "error" + }, + "style": { + "noNamespace": "off", + "useArrayLiterals": "error", + "useAsConstAssertion": "error", + "useComponentExportOnlyModules": "off" + }, + "suspicious": { + "noAssignInExpressions": "error", + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noControlCharactersInRegex": "error", + "noDebugger": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateElseIf": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "warn", + "noExplicitAny": "warn", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noIrregularWhitespace": "error", + "noMisleadingCharacterClass": "error", + "noMisleadingInstantiator": "error", + "noNonNullAssertedOptionalChain": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noSparseArray": "error", + "noTsIgnore": "error", + "noUnsafeDeclarationMerging": "error", + "noUnsafeNegation": "error", + "noUselessRegexBackrefs": "error", + "noWith": "error", + "useGetterReturn": "error" + } + }, + "includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"] + }, + "javascript": { "formatter": { "quoteStyle": "double" } }, + "overrides": [ + { + "includes": ["*.ts", "*.tsx", "*.mts", "*.cts"], + "linter": { + "rules": { + "complexity": { "noArguments": "error" }, + "correctness": { + "noConstAssign": "off", + "noGlobalObjectCalls": "off", + "noInvalidBuiltinInstantiation": "off", + "noInvalidConstructorSuper": "off", + "noSetterReturn": "off", + "noUndeclaredVariables": "off", + "noUnreachable": "off", + "noUnreachableSuper": "off" + }, + "style": { "useConst": "error" }, + "suspicious": { + "noDuplicateClassMembers": "off", + "noDuplicateObjectKeys": "off", + "noDuplicateParameters": "off", + "noFunctionAssign": "off", + "noImportAssign": "off", + "noRedeclare": "off", + "noUnsafeNegation": "off", + "noVar": "error", + "useGetterReturn": "off" + } + } + } + } + ], + "assist": { + "enabled": true, + "actions": { "source": { "organizeImports": "on" } } + } +} diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..4840e53 --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,17 @@ +!macro customInstall + DetailPrint "Installing OpenScreen OCR Windows service" + nsExec::ExecToLog '"$SYSDIR\sc.exe" stop OpenScreenOCR' + nsExec::ExecToLog '"$SYSDIR\sc.exe" delete OpenScreenOCR' + Sleep 1000 + ExpandEnvStrings $0 "%ProgramData%\OpenScreen\ocr-runtime" + CreateDirectory "$0" + nsExec::ExecToLog '"$SYSDIR\sc.exe" create OpenScreenOCR binPath= "\"$INSTDIR\resources\electron\native\bin\win32-x64\openscreen-ocr-service-wrapper.exe\" --service --exe \"$INSTDIR\resources\ocr-service\openscreen-ocr-service.exe\" --resources \"$INSTDIR\resources\" --data \"$0\"" start= auto DisplayName= "OpenScreen OCR Service"' + nsExec::ExecToLog '"$SYSDIR\sc.exe" description OpenScreenOCR "Local OCR service used by OpenScreen guide capture."' + nsExec::ExecToLog '"$SYSDIR\sc.exe" start OpenScreenOCR' +!macroend + +!macro customUnInstall + DetailPrint "Removing OpenScreen OCR Windows service" + nsExec::ExecToLog '"$SYSDIR\sc.exe" stop OpenScreenOCR' + nsExec::ExecToLog '"$SYSDIR\sc.exe" delete OpenScreenOCR' +!macroend diff --git a/components.json b/components.json new file mode 100644 index 0000000..f6dc1d5 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.cjs", + "css": "src/index.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/docs/architecture/native-bridge.md b/docs/architecture/native-bridge.md new file mode 100644 index 0000000..ef320f7 --- /dev/null +++ b/docs/architecture/native-bridge.md @@ -0,0 +1,39 @@ +# Native Bridge Architecture + +## Goal + +Provide a single, resilient source of truth for platform-native capabilities while keeping Electron transport thin and renderer APIs unified. + +## Layers + +1. Native adapters +Platform-specific providers implement stable domain interfaces such as cursor telemetry or system asset discovery. + +2. Main-process services +Services orchestrate adapters, own runtime state, and expose domain-level operations. + +3. Unified IPC transport +Renderer code talks to a single `native-bridge:invoke` channel using versioned contracts. + +4. Renderer client +React code should consume `src/native/client.ts` rather than binding directly to ad hoc Electron APIs. + +## Principles + +- Single source of truth: runtime-native state lives in the Electron main process. +- Capability-first: renderer can query support before attempting native behavior. +- Versioned contracts: requests and responses are explicit and evolve predictably. +- Resilience: every response uses a consistent result envelope with stable error codes. + +## Current rollout + +This repository now contains the initial scaffold: + +- shared contracts in `src/native/contracts.ts` +- renderer SDK in `src/native/client.ts` +- main-process state store in `electron/native-bridge/store.ts` +- cursor telemetry adapter in `electron/native-bridge/cursor/telemetryCursorAdapter.ts` +- domain services in `electron/native-bridge/services/*` +- unified handler registration in `electron/ipc/nativeBridge.ts` + +The legacy `window.electronAPI` surface still exists for backward compatibility. New native-facing features should prefer the unified bridge client. \ No newline at end of file diff --git a/docs/engineering/auto-user-guide-roadmap.md b/docs/engineering/auto-user-guide-roadmap.md new file mode 100644 index 0000000..cb8469b --- /dev/null +++ b/docs/engineering/auto-user-guide-roadmap.md @@ -0,0 +1,935 @@ +# Quy trình triển khai Auto User Guide Generation + +Mục tiêu của tính năng này là biến OpenScreen từ công cụ quay màn hình thành công cụ tự tạo tài liệu hướng dẫn sử dụng phần mềm. Người dùng bật Guide Mode, quay thao tác như bình thường, hệ thống ghi lại thời điểm click hoặc hotkey, trích ảnh từ video sau khi quay xong, chạy OCR local để đọc chữ trên giao diện, sau đó dùng AI tạo bản nháp hướng dẫn từng bước. + +Tài liệu này được viết để có thể bắt đầu coding ngay: có kiến trúc, schema, file cần thêm/sửa, thứ tự task, tiêu chí test và định nghĩa MVP. + +## Trạng Thái MVP Hiện Tại + +- Đã có Guide Mode trong HUD, ghi click/marker vào `.guide.json`. +- Đã có GuidePanel trong editor để chạy: prepare events, capture snapshots, OCR, generate draft, export Markdown/HTML. +- Đã có local deterministic draft để test không cần DeepSeek key. +- DeepSeek được gọi khi chọn provider `DeepSeek` và có `DEEPSEEK_API_KEY`. +- OCR local mặc định gọi `OPENSCREEN_GUIDE_OCR_URL` hoặc `http://127.0.0.1:8866/ocr`. +- Verification hiện tại: targeted guide tests pass, `npm test` pass, `npm run build-vite` pass, `npm run i18n:check` pass. + +## Mục Tiêu Sản Phẩm + +Flow người dùng: + +1. Bật Guide Mode. +2. Quay màn hình phần mềm cần hướng dẫn. +3. Trong lúc quay, hệ thống tự ghi timestamp các click chuột. +4. Người dùng có thể bấm một hotkey/nút marker nếu muốn đánh dấu bước thủ công. +5. Sau khi dừng quay, hệ thống trích ảnh màn hình từ video tại các timestamp đó. +6. OCR local đọc text trên ảnh giao diện. +7. Hệ thống map vị trí click tới text/control gần nhất. +8. AI Agent tạo tài liệu dạng từng bước. +9. Người dùng review, sửa nội dung, export Markdown/HTML. + +Ví dụ output: + +```md +# Hướng dẫn xuất báo cáo + +## Bước 1: Mở phần cài đặt + +Nhấn nút **Settings** ở thanh điều hướng bên trái. + +## Bước 2: Chọn Export + +Trong màn hình Settings, chọn **Export report**. +``` + +## Phạm Vi MVP + +MVP cần làm: + +- Bật/tắt Guide Mode trước khi quay. +- Tận dụng recorder hiện tại, không viết recorder mới. +- Tận dụng `.cursor.json` hiện tại để lấy click timestamp. +- Thêm marker bằng hotkey hoặc nút trên HUD. +- Tạo sidecar `.guide.json` riêng cho guide. +- Trích screenshot sau khi quay xong, từ video đã lưu. +- OCR local bằng PaddleOCR service. +- Tạo step candidate từ click position + OCR blocks. +- Gọi DeepSeek bằng text metadata, không gửi ảnh mặc định. +- Có panel review trong editor. +- Export Markdown và HTML. + +Không làm trong MVP: + +- Không chụp screenshot realtime trong lúc quay nếu chưa có benchmark cần thiết. +- Không gửi raw screenshot lên cloud AI mặc định. +- Không sửa schema `.cursor.json` nếu không bắt buộc. +- Không build full UI automation engine. +- Không làm PDF/DOCX ngay. +- Không bundle OCR runtime vào app packaged ngay. + +## Code Hiện Có Cần Tận Dụng + +Các điểm đã có trong codebase: + +- Recording orchestration: `src/hooks/useScreenRecorder.ts` +- Launch/HUD UI: `src/components/launch/LaunchWindow.tsx` +- Source selection: `src/components/launch/SourceSelector.tsx` +- Editor chính: `src/components/video-editor/VideoEditor.tsx` +- Project/session persistence: `src/components/video-editor/projectPersistence.ts` +- Cursor contracts: `src/native/contracts.ts` +- Hook đọc cursor data: `src/native/hooks/useCursorRecordingData.ts` +- IPC main handlers: `electron/ipc/handlers.ts` +- Native bridge: `electron/ipc/nativeBridge.ts` +- Cursor service: `electron/native-bridge/services/cursorService.ts` +- Windows cursor recording: `electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts` +- macOS cursor recording: `electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts` +- Frame/export primitives: `src/lib/exporter/frameRenderer.ts` + +Nhận định kỹ thuật: + +- Windows/macOS native cursor recording đã có dữ liệu click. +- Cursor sample hiện có thể có `interactionType: "click" | "mouseup" | "move"`. +- Editor hiện đã dùng click timestamp để render hiệu ứng click. +- Vì schema cursor đang được nhiều nơi dùng, MVP nên tạo `.guide.json` riêng thay vì mở rộng `.cursor.json`. + +## Kiến Trúc Tổng Thể + +```mermaid +flowchart TD + A["User bật Guide Mode"] --> B["Quay video bằng recorder hiện tại"] + B --> C["Cursor recorder ghi click timestamp"] + B --> D["Hotkey/HUD marker ghi manual event"] + C --> E["Dừng quay"] + D --> E + E --> F["Guide assembler tạo .guide.json"] + F --> G["Snapshot extractor seek video và xuất PNG"] + G --> H["PaddleOCR local đọc text + bounding boxes"] + H --> I["Target mapper map click tới OCR text/control"] + I --> J["DeepSeek/local LLM viết draft guide"] + J --> K["GuidePanel cho user review/sửa"] + K --> L["Export Markdown/HTML"] +``` + +Quyết định chính: + +- Realtime recording chỉ ghi event/timestamp, không xử lý OCR/AI. +- Screenshot được trích từ video sau khi quay, tránh ảnh hưởng performance recorder. +- OCR chạy local-first. +- DeepSeek chỉ nhận text metadata trừ khi user opt-in gửi ảnh. +- Guide data nằm cạnh recording artifact. + +## File Cần Thêm + +```text +src/guide/ + contracts.ts + eventBuilder.ts + targetMapper.ts + promptBuilder.ts + generatedGuideSchema.ts + snapshot/ + extractGuideSnapshots.ts + export/ + markdownExporter.ts + htmlExporter.ts + __tests__/ + eventBuilder.test.ts + targetMapper.test.ts + promptBuilder.test.ts + markdownExporter.test.ts + +src/components/video-editor/guide/ + GuidePanel.tsx + GuideStepList.tsx + GuideStepEditor.tsx + GuideSnapshotPreview.tsx + +electron/guide/ + guideStore.ts + guidePaths.ts + guideIpc.ts + ocr/ + paddleOcrClient.ts + ai/ + deepseekGuideClient.ts +``` + +File hiện có khả năng phải sửa: + +- `src/hooks/useScreenRecorder.ts` +- `src/components/launch/LaunchWindow.tsx` +- `src/components/video-editor/VideoEditor.tsx` +- `electron/ipc/handlers.ts` +- `electron/preload.ts` +- file khai báo type cho `window.electronAPI` +- `package.json` nếu thêm script test hoặc dependency nhỏ + +## Artifact Đầu Ra + +Với video `recording-123.mp4`, hệ thống tạo: + +```text +recording-123.mp4 +recording-123.cursor.json +recording-123.guide.json +recording-123-guide/ + step-001.png + step-002.png + ocr.json + guide.md + guide.html +``` + +Quy tắc: + +- `.cursor.json` vẫn là dữ liệu cursor gốc. +- `.guide.json` là source of truth cho guide workflow. +- Folder `recording-123-guide/` chứa file phát sinh từ guide. +- `guide.md` và `guide.html` có thể được tạo lại từ `.guide.json`. + +## Contract Chính + +Tạo `src/guide/contracts.ts`. + +```ts +export type GuideEventKind = "click" | "hotkey" | "manual"; + +export type GuideEventSource = + | "cursor-recording" + | "guide-hotkey" + | "review-ui"; + +export interface GuideEvent { + id: string; + recordingId: string; + kind: GuideEventKind; + source: GuideEventSource; + timeMs: number; + x?: number; + y?: number; + normalizedX?: number; + normalizedY?: number; + button?: "left" | "right" | "middle" | "unknown"; + label?: string; + screenshotOffsetMs?: number; + createdAt: string; +} + +export interface GuideSnapshot { + id: string; + eventId: string; + timeMs: number; + offsetMs: number; + path: string; + width: number; + height: number; +} + +export interface OcrBlock { + id: string; + snapshotId: string; + text: string; + confidence: number; + box: { + x: number; + y: number; + width: number; + height: number; + }; +} + +export interface GuideStepCandidate { + id: string; + eventId: string; + snapshotId?: string; + timeMs: number; + action: "click" | "choose" | "type" | "wait" | "manual"; + targetText?: string; + targetRole?: "button" | "menu" | "tab" | "field" | "link" | "unknown"; + nearbyText: string[]; + confidence: number; +} + +export interface GeneratedGuideStep { + id: string; + order: number; + title: string; + instruction: string; + screenshotPath?: string; + sourceCandidateId?: string; +} + +export interface GeneratedGuide { + title: string; + summary?: string; + steps: GeneratedGuideStep[]; +} + +export interface GuideSession { + schemaVersion: 1; + recordingId: string; + videoPath: string; + cursorPath?: string; + guidePath: string; + outputDir: string; + status: + | "recording" + | "events-ready" + | "snapshots-ready" + | "ocr-ready" + | "draft-ready" + | "reviewed"; + events: GuideEvent[]; + snapshots: GuideSnapshot[]; + ocrBlocks: OcrBlock[]; + candidates: GuideStepCandidate[]; + generatedGuide?: GeneratedGuide; + createdAt: string; + updatedAt: string; +} +``` + +Quy tắc dữ liệu: + +- `timeMs` luôn tính theo timeline video cuối cùng. +- `x/y` là tọa độ pixel nếu có. +- `normalizedX/Y` dùng để chống lệch khi video scale. +- `screenshotOffsetMs` mặc định `500`, nghĩa là lấy ảnh sau click 0.5 giây để bắt trạng thái UI sau thao tác. +- AI output chỉ là draft, user edit mới là nội dung cuối. + +## IPC Cần Thêm + +MVP dùng app-level Electron IPC, không cần đưa vào native bridge vì đây là workflow cấp ứng dụng. + +Preload API đề xuất: + +```ts +window.electronAPI.guide = { + startSession(recordingId: string): Promise; + addMarker(input: AddGuideMarkerInput): Promise; + finalizeEvents(input: FinalizeGuideEventsInput): Promise; + writeSnapshot(input: WriteGuideSnapshotInput): Promise; + runOcr(input: RunGuideOcrInput): Promise; + generateDraft(input: GenerateGuideDraftInput): Promise; + saveGuide(input: SaveGuideInput): Promise; + exportMarkdown(input: ExportGuideInput): Promise<{ path: string }>; + exportHtml(input: ExportGuideInput): Promise<{ path: string }>; +}; +``` + +Input types: + +```ts +export interface AddGuideMarkerInput { + recordingId: string; + timeMs: number; + kind: "hotkey" | "manual"; + label?: string; +} + +export interface FinalizeGuideEventsInput { + recordingId: string; + videoPath: string; + cursorPath?: string; +} + +export interface WriteGuideSnapshotInput { + recordingId: string; + eventId: string; + timeMs: number; + offsetMs: number; + pngBytes: ArrayBuffer; + width: number; + height: number; +} + +export interface RunGuideOcrInput { + recordingId: string; + snapshotIds?: string[]; +} + +export interface GenerateGuideDraftInput { + recordingId: string; + language: "vi" | "en"; + provider: "deepseek" | "local"; +} + +export interface SaveGuideInput { + recordingId: string; + generatedGuide: GeneratedGuide; +} + +export interface ExportGuideInput { + recordingId: string; +} +``` + +## Phase 1: Contracts, Store, IPC + +Mục tiêu: tạo khung lưu trữ `.guide.json` mà chưa đụng recorder. + +Task coding: + +1. Tạo `src/guide/contracts.ts`. +2. Tạo `electron/guide/guidePaths.ts`. +3. Tạo `electron/guide/guideStore.ts`. +4. Tạo `electron/guide/guideIpc.ts`. +5. Register guide IPC trong `electron/ipc/handlers.ts`. +6. Expose API trong `electron/preload.ts`. +7. Bổ sung type cho `window.electronAPI.guide`. + +Yêu cầu kỹ thuật: + +- Ghi file atomically: write temp file rồi rename. +- Validate `schemaVersion`. +- Không throw raw error ra renderer, trả error code ổn định. +- Không yêu cầu AI/OCR trong phase này. + +Acceptance: + +- Tạo được guide session fake bằng IPC. +- Đọc/ghi `.guide.json` round-trip không mất dữ liệu. +- Input thiếu `recordingId` hoặc `videoPath` bị reject rõ ràng. + +Test: + +- `guideStore` tạo path đúng. +- `guideStore` đọc file lỗi schema và trả error. +- IPC handler reject input thiếu field. + +## Phase 2: Build Event Từ Cursor Click + +Mục tiêu: lấy click event từ `.cursor.json` hiện tại. + +Task coding: + +1. Tạo `src/guide/eventBuilder.ts`. +2. Thêm hàm `buildGuideEventsFromCursor`. +3. Lọc sample có `interactionType === "click"`. +4. Convert sang `GuideEvent`. +5. De-duplicate click trong cửa sổ `250ms`. +6. Sort theo `timeMs`. +7. Merge với marker thủ công nếu có. + +Pseudo-code: + +```ts +export function buildGuideEventsFromCursor(input: { + recordingId: string; + samples: CursorRecordingSample[]; + videoWidth?: number; + videoHeight?: number; +}): GuideEvent[] { + const events = input.samples + .filter((sample) => sample.interactionType === "click") + .map((sample) => ({ + id: createGuideEventId(input.recordingId, sample.timeMs), + recordingId: input.recordingId, + kind: "click" as const, + source: "cursor-recording" as const, + timeMs: sample.timeMs, + x: sample.cx, + y: sample.cy, + normalizedX: normalize(sample.cx, input.videoWidth), + normalizedY: normalize(sample.cy, input.videoHeight), + button: "left" as const, + screenshotOffsetMs: 500, + createdAt: new Date().toISOString(), + })); + + return sortGuideEvents(dedupeGuideEvents(events)); +} +``` + +Acceptance: + +- 5 click samples tạo 5 guide events. +- `move` và `mouseup` không tạo step. +- Double click hoặc click bounce không tạo quá nhiều step nếu nằm trong dedupe window. +- Không có cursor click thì vẫn dùng được manual marker. + +Test: + +- convert click sample. +- bỏ qua move/mouseup. +- dedupe theo thời gian. +- sort đúng thứ tự. +- xử lý sample thiếu tọa độ. + +## Phase 3: Guide Mode UI Và Manual Marker + +Mục tiêu: user bật được Guide Mode và đánh dấu bước thủ công. + +Task coding: + +1. Thêm Guide Mode toggle trong `LaunchWindow.tsx`. +2. Truyền trạng thái guide vào flow recording trong `useScreenRecorder.ts`. +3. Khi start recording và Guide Mode on, gọi `guide.startSession(recordingId)`. +4. Thêm nút marker trong HUD. +5. Thêm global hotkey ở Electron main, ví dụ `CommandOrControl+Shift+G`. +6. Khi bấm marker/hotkey, gọi `guide.addMarker`. +7. Khi stop recording, gọi `guide.finalizeEvents`. + +Lưu ý: + +- Global hotkey phải nằm ở Electron main vì app đang được quay có thể đang focus. +- Nếu register hotkey fail, UI vẫn dùng nút marker. +- Không làm thay đổi behavior khi Guide Mode off. + +Acceptance: + +- Guide Mode off: quay/sửa/export vẫn như cũ. +- Guide Mode on: stop recording tạo `.guide.json`. +- Hotkey tạo event đúng timestamp. +- Cancel recording không để lại guide artifact rác. + +## Phase 4: Snapshot Extraction + +Mục tiêu: trích ảnh PNG cho từng event sau khi quay xong. + +Quyết định MVP: + +- Không chụp realtime trong lúc quay. +- Dùng video đã lưu, seek tới timestamp cần lấy. +- Thực hiện trong renderer/editor bằng hidden `