diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cfee36d65..2a3000ef8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @siddharthvaddem +* @EtienneLescot diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 79f39d4de..9d8ea49e1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,43 +1,36 @@ -# 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 - -``` +## Summary + + +## Related issue + + + +Fixes # + +## Type of change +- [ ] Bug fix +- [ ] Feature +- [ ] Enhancement +- [ ] Documentation +- [ ] Refactor / maintenance +- [ ] Performance +- [ ] Security + +## Release impact +- [ ] Patch +- [ ] Minor +- [ ] Major / breaking change +- [ ] No release note needed + +## Desktop impact +- [ ] Windows +- [ ] macOS +- [ ] Linux +- [ ] Installer / packaging +- [ ] Not platform-specific + +## Screenshots / video + ## 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 index 73029f6bc..44a73e2ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,36 +1,49 @@ - name: Build Electron App on: + push: + tags: + - "v*" workflow_dispatch: inputs: arch: - description: 'Architecture to build' + description: "macOS architecture to build" required: true - default: 'both' + default: "both" type: choice options: - arm64 - x64 - both + release_tag: + description: "Optional release tag to create or update, e.g. v1.5.0" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }} + cancel-in-progress: false jobs: build-windows: + name: Windows installer runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '22' + node-version: 22 + cache: npm - name: Install dependencies run: npm ci - # Cache the downloaded caption model so we don't re-fetch from HuggingFace every run - # (and to avoid 429s when the platform matrix builds hit it at once). - name: Cache caption assets uses: actions/cache@v4 with: @@ -38,151 +51,129 @@ jobs: key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - name: Build Windows app - run: npm run build:win - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload Windows build + run: npm run build:win -- --publish never + + - name: Upload Windows installer uses: actions/upload-artifact@v4 with: - name: windows-installer - path: release/**/*.exe + name: openscreen-windows + path: release/**/Openscreen.Setup.*.exe + if-no-files-found: error retention-days: 30 build-macos: + name: macOS ${{ matrix.arch }} DMG runs-on: macos-latest strategy: + fail-fast: false matrix: - arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }} - + arch: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.arch != 'both') && format('["{0}"]', github.event.inputs.arch) || '["arm64", "x64"]') }} 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' + python-version: "3.11" - # ─── Install Dependencies ───────────────────────────────── - name: Install dependencies run: npm ci - # ─── Ensure sharp prebuilt binary (+ bundled libvips) ───── - # `npm ci` can leave sharp without its prebuilt binary/vendored libvips, - # which makes electron-builder's native rebuild fail ("vips-cpp.42 not - # found"). Re-fetch the prebuilt (never compile from source). - name: Ensure sharp prebuilt run: npm rebuild sharp env: npm_config_build_from_source: "false" - # ─── Cache caption assets ───────────────────────────────── - # Avoid re-fetching the Whisper model from HuggingFace every run (and 429s under the matrix). - name: Cache caption assets uses: actions/cache@v4 with: path: caption-assets key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - # ─── 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: Resolve macOS signing + id: signing + env: + MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} + MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + - name: Import code signing certificate + if: steps.signing.outputs.enabled == 'true' 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) + 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 \ + 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" + rm -f "$RUNNER_TEMP/certificate.p12" - # Clean up the .p12 file - rm -f $RUNNER_TEMP/certificate.p12 - - # ─── Build Vite + Electron ──────────────────────────────── - name: Build Vite + Electron run: npx tsc && npx vite build - # ─── Build native macOS helpers ─────────────────────────── - # The package step below calls electron-builder directly (not `npm run build:mac`), - # so the Swift screencapturekit + cursor helpers must be compiled here first or the - # packaged app ships without them (no recording, "cursor helper couldn't be found"). - # Build the matrix arch into electron/native/bin/darwin- so each DMG gets its - # own native binary. - name: Build native macOS helpers run: npm run build:native:mac env: OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} - # ─── 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 + run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never env: - CSC_NAME: "Samir Patil (N26FZ4GW28)" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_NAME: ${{ secrets.MAC_CSC_NAME }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.signing.outputs.enabled == 'true' && 'true' || 'false' }} - # ─── Read version from package.json ─────────────────────── - name: Get version id: version - run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT + run: | + VERSION="$(node -e "console.log(require('./package.json').version)")" + echo "version=$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 + 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}/" + find "release/${VERSION}" -maxdepth 4 -print || true exit 1 fi - echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT - echo "Found: $APP_BUNDLE" + echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" - # ─── Verify .app signature ──────────────────────────────── - name: Verify .app code signature + if: steps.signing.outputs.enabled == 'true' run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}" - # ─── Create DMG ─────────────────────────────────────────── - name: Create DMG id: dmg run: | @@ -193,6 +184,8 @@ jobs: DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" STAGING="${RELEASE_DIR}/dmg-staging" + rm -rf "$STAGING" + rm -f "$DMG_OUTPUT" mkdir -p "$STAGING" cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" ln -s /Applications "$STAGING/Applications" @@ -206,22 +199,18 @@ jobs: "$DMG_OUTPUT" rm -rf "$STAGING" + echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT" - echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT - echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT - - # ─── Sign DMG ───────────────────────────────────────────── - name: Sign DMG + if: steps.signing.outputs.enabled == 'true' run: | codesign --force \ - --sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \ + --sign "${{ secrets.MAC_CSC_NAME }}" \ --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 + if: steps.signing.outputs.enabled == 'true' run: | xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ --apple-id "${{ secrets.APPLE_ID }}" \ @@ -230,50 +219,47 @@ jobs: --wait timeout-minutes: 15 - # ─── Staple ─────────────────────────────────────────────── - name: Staple notarization ticket + if: steps.signing.outputs.enabled == 'true' run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" - # ─── Validate ───────────────────────────────────────────── - name: Validate stapled DMG + if: steps.signing.outputs.enabled == 'true' 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 + - name: Upload macOS DMG uses: actions/upload-artifact@v4 with: name: openscreen-mac-${{ matrix.arch }} path: ${{ steps.dmg.outputs.dmg_path }} + if-no-files-found: error retention-days: 30 - # ─── Cleanup Keychain ───────────────────────────────────── - name: Cleanup keychain - if: always() - run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true + if: always() && steps.signing.outputs.enabled == 'true' + run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true build-linux: + name: Linux packages runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '22' + node-version: 22 + cache: npm - 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 - # Cache the downloaded caption model so we don't re-fetch from HuggingFace every run - # (and to avoid 429s when the platform matrix builds hit it at once). - name: Cache caption assets uses: actions/cache@v4 with: @@ -281,17 +267,84 @@ jobs: key: caption-assets-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - name: Build Linux app - run: npm run build:linux - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run build:linux -- --publish never - - name: Upload Linux build + - name: Upload Linux packages uses: actions/upload-artifact@v4 with: - name: linux-installer + name: openscreen-linux path: | release/**/*.AppImage release/**/*.zsync release/**/*.deb release/**/*.pacman + if-no-files-found: error retention-days: 30 + + publish-release: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: + - build-windows + - build-macos + - build-linux + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Resolve release tag + id: release + env: + INPUT_TAG: ${{ github.event.inputs.release_tag }} + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="${INPUT_TAG}" + fi + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Release tag must look like v1.5.0; got '${TAG}'" + exit 1 + fi + + VERSION="${TAG#v}" + PACKAGE_VERSION="$(node -p 'require("./package.json").version')" + if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then + echo "::error::package.json version ${PACKAGE_VERSION} does not match release tag ${TAG}" + exit 1 + fi + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Download installers + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Publish release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + run: | + mapfile -t FILES < <(find artifacts -type f | sort) + if [[ "${#FILES[@]}" -eq 0 ]]; then + echo "::error::No installer artifacts were downloaded" + exit 1 + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "${FILES[@]}" --clobber + else + gh release create "$TAG" "${FILES[@]}" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --generate-notes + fi + + gh release edit "$TAG" \ + --draft=false \ + --latest \ + --title "$TAG" diff --git a/.github/workflows/bump-nix-package.yml b/.github/workflows/bump-nix-package.yml index 5ff3c73e6..57e48e51a 100644 --- a/.github/workflows/bump-nix-package.yml +++ b/.github/workflows/bump-nix-package.yml @@ -111,7 +111,7 @@ jobs: - \`version\` → \`${VERSION}\` - \`npmDepsHash\` → \`${HASH}\` (computed via \`prefetch-npm-deps package-lock.json\`) - Merge this so Nix users (NixOS, Home Manager, \`nix run github:siddharthvaddem/openscreen\`) pick up the new release. + Merge this so Nix users (NixOS, Home Manager, \`nix run github:${{ github.repository }}\`) pick up the new release. > 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/discord.yaml b/.github/workflows/discord.yaml index 6da25d0d6..950abae3d 100644 --- a/.github/workflows/discord.yaml +++ b/.github/workflows/discord.yaml @@ -26,6 +26,8 @@ jobs: steps: - name: Sync PR activity to Discord forum thread id: sync + # Discord sync is helpful automation, but it should not block PR validation. + continue-on-error: true uses: actions/github-script@v7 env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -210,15 +212,10 @@ jobs: } 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); - } + core.warning(msg); return; } @@ -421,7 +418,7 @@ jobs: core.info(`Posted update to Discord thread ${threadId}.`); } catch (err) { const msg = err && err.message ? err.message : String(err); - core.setFailed(msg); + core.warning(`Discord sync failed, but this optional automation will not block PR validation: ${msg}`); const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL; if (alertWebhook) { diff --git a/.github/workflows/merged-pr-bookkeeping.yml b/.github/workflows/merged-pr-bookkeeping.yml new file mode 100644 index 000000000..8b0671325 --- /dev/null +++ b/.github/workflows/merged-pr-bookkeeping.yml @@ -0,0 +1,252 @@ +name: Merged PR issue bookkeeping + +on: + pull_request_target: + types: [closed] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + mark-linked-issues-fixed: + name: Mark linked issues fixed in main + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Update closing issues + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullRequest = context.payload.pull_request; + const pullNumber = pullRequest.number; + + const fixedInMainLabel = { + name: "status: fixed in main", + color: "0E8A16", + description: "Work is merged into main but may not be in a downloadable release yet.", + }; + const pendingReleaseLabel = { + name: "status: pending release", + color: "FBCA04", + description: "Merged change is waiting for a packaged desktop release.", + }; + const labelsToRemove = ["status: in progress", "status: needs triage"]; + const nextReleaseMilestoneTitle = "Next Release"; + + async function ensureLabel(label) { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: label.name, + }); + } catch (error) { + if (error.status !== 404) throw error; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: label.name, + color: label.color, + description: label.description, + }); + core.info(`Created label '${label.name}'.`); + } catch (createError) { + if (createError.status !== 422) throw createError; + core.info(`Label '${label.name}' already exists.`); + } + } + } + + async function ensureMilestone(title) { + const milestones = await github.paginate(github.rest.issues.listMilestones, { + owner, + repo, + state: "all", + per_page: 100, + }); + const existing = milestones.find((milestone) => milestone.title === title); + if (existing?.state === "open") return existing; + if (existing) { + const reopened = await github.rest.issues.updateMilestone({ + owner, + repo, + milestone_number: existing.number, + state: "open", + }); + core.info(`Reopened milestone '${title}'.`); + return reopened.data; + } + + try { + const created = await github.rest.issues.createMilestone({ + owner, + repo, + title, + description: "Merged changes queued for the next packaged desktop release.", + }); + core.info(`Created milestone '${title}'.`); + return created.data; + } catch (error) { + if (error.status !== 422) throw error; + const refreshed = await github.paginate(github.rest.issues.listMilestones, { + owner, + repo, + state: "all", + per_page: 100, + }); + const milestone = refreshed.find((item) => item.title === title); + if (!milestone) throw error; + return milestone; + } + } + + async function getClosingIssueRefs() { + const query = ` + query($owner: String!, $repo: String!, $pullNumber: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullNumber) { + closingIssuesReferences(first: 100, after: $cursor) { + nodes { + number + repository { + name + owner { + login + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const issueRefs = new Map(); + let cursor = null; + let hasNextPage = true; + + while (hasNextPage) { + const result = await github.graphql(query, { + owner, + repo, + pullNumber, + cursor, + }); + const refs = result.repository.pullRequest.closingIssuesReferences; + for (const issue of refs.nodes) { + const issueOwner = issue.repository.owner.login; + const issueRepo = issue.repository.name; + issueRefs.set(`${issueOwner}/${issueRepo}#${issue.number}`, { + owner: issueOwner, + repo: issueRepo, + number: issue.number, + }); + } + hasNextPage = refs.pageInfo.hasNextPage; + cursor = refs.pageInfo.endCursor; + } + + return [...issueRefs.values()]; + } + + async function hasBookkeepingComment(issueNumber, marker) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return comments.some((comment) => comment.body && comment.body.includes(marker)); + } + + await ensureLabel(fixedInMainLabel); + await ensureLabel(pendingReleaseLabel); + const fallbackMilestone = pullRequest.milestone || await ensureMilestone(nextReleaseMilestoneTitle); + + const issueRefs = await getClosingIssueRefs(); + if (issueRefs.length === 0) { + core.info(`PR #${pullNumber} did not declare closing issue references. Nothing to update.`); + return; + } + + for (const issueRef of issueRefs) { + if ( + issueRef.owner.toLowerCase() !== owner.toLowerCase() || + issueRef.repo.toLowerCase() !== repo.toLowerCase() + ) { + core.warning( + `Skipping cross-repository closing reference ${issueRef.owner}/${issueRef.repo}#${issueRef.number}; ` + + `this workflow only updates issues in ${owner}/${repo}.`, + ); + continue; + } + + const issueNumber = issueRef.number; + const issueResponse = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + const issue = issueResponse.data; + const existingLabels = issue.labels.map((label) => + typeof label === "string" ? label : label.name, + ); + const milestoneTitle = issue.milestone?.title || fallbackMilestone.title; + const milestoneNumber = issue.milestone?.number || fallbackMilestone.number; + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [fixedInMainLabel.name, pendingReleaseLabel.name], + }); + + for (const label of labelsToRemove) { + if (!existingLabels.includes(label)) continue; + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: label, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + milestone: milestoneNumber, + state: "closed", + state_reason: "completed", + }); + + const marker = ``; + if (!(await hasBookkeepingComment(issueNumber, marker))) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: [ + marker, + `Fixed by #${pullNumber} and merged into \`main\`.`, + "", + `This change is assigned to the \`${milestoneTitle}\` release milestone and is not necessarily available in the latest downloadable desktop release yet. It is currently marked as \`${pendingReleaseLabel.name}\` until a packaged release containing it is published.`, + ].join("\n"), + }); + } + + core.info(`Updated issue #${issueNumber} for merged PR #${pullNumber}.`); + } diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml index 62b4b7adf..12fd0404a 100644 --- a/.github/workflows/publish-winget.yml +++ b/.github/workflows/publish-winget.yml @@ -2,7 +2,7 @@ name: Publish release to WinGet on: release: - types: [released] + types: [published] workflow_dispatch: inputs: tag: @@ -13,14 +13,13 @@ on: jobs: publish: runs-on: windows-latest - if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.WINGET_IDENTIFIER != '' 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"). + identifier: ${{ vars.WINGET_IDENTIFIER }} + # Matches the Windows installer asset attached to each release, + # e.g. "Openscreen.Setup.1.5.0.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 index 3d65cb0aa..030b893bd 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -16,11 +16,11 @@ permissions: jobs: update-cask: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease + if: (github.event_name == 'workflow_dispatch' || !github.event.release.prerelease) && vars.HOMEBREW_TAP_OWNER != '' && vars.HOMEBREW_TAP_REPO != '' env: - TAP_OWNER: siddharthvaddem - TAP_REPO: homebrew-openscreen - CASK_NAME: openscreen + TAP_OWNER: ${{ vars.HOMEBREW_TAP_OWNER }} + TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + CASK_NAME: ${{ vars.HOMEBREW_CASK_NAME || 'openscreen' }} steps: - name: Resolve tag and version id: meta @@ -143,10 +143,10 @@ jobs: zap trash: [ "~/Library/Application Support/Openscreen", - "~/Library/Caches/com.siddharthvaddem.openscreen", + "~/Library/Caches/com.etiennelescot.openscreen", "~/Library/Logs/Openscreen", - "~/Library/Preferences/com.siddharthvaddem.openscreen.plist", - "~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState", + "~/Library/Preferences/com.etiennelescot.openscreen.plist", + "~/Library/Saved Application State/com.etiennelescot.openscreen.savedState", ] end EOF diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 651c80f3d..d4d82f7b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,33 @@ Thank you for considering contributing to this project! By contributing, you hel ## 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. +If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/EtienneLescot/openscreen/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. + +## Issue lifecycle + +Issues are closed when the corresponding fix or feature is merged into `main`. + +For desktop users, this does not always mean the change is already available in the latest downloadable release. When relevant, closed issues are marked as `status: fixed in main` and `status: pending release`. + +Once a GitHub Release containing the change is published, the issue can be marked as `status: released`. + +The next version number is not always known when a PR is merged. In that case, issues are assigned to the `Next Release` milestone. When preparing a release, this milestone can be renamed to the actual version, such as `v1.6.0` or `v2.0.0`, and a new `Next Release` milestone can be created. + +When a PR fully resolves an issue, link it with a GitHub closing keyword: + +```txt +Fixes #123 +Closes #123 +Resolves #123 +``` + +If a PR only partially addresses an issue, use a non-closing reference instead: + +```txt +Refs #123 +Part of #123 +Related to #123 +``` ## Style Guide @@ -54,4 +80,4 @@ If you encounter a bug or have a feature request, please open an issue in the [I 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 +Thank you for your contributions! diff --git a/README.md b/README.md index 867f21634..13ef2abb7 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,31 @@ -> [!WARNING] -> This started as a side project that blew up; not production grade and you'll hit bugs, but hopefully it covers what you need. **This project will soon be archived.** +> [!NOTE] +> This repository is an independent continuation of OpenScreen. +> +> OpenScreen was originally created by [Siddharth Vaddem](https://github.com/siddharthvaddem). The original repository was archived after v1.5.0 and remains available here: [siddharthvaddem/openscreen](https://github.com/siddharthvaddem/openscreen). +> +> This fork continues development under the OpenScreen name with the original author's approval, while remaining fully MIT open source. +> [!WARNING] +> OpenScreen is not production-grade software. You should expect bugs, rough edges, and occasional breaking changes.

OpenScreen Logo -
-
- siddharthvaddem%2Fopenscreen | Trendshift - - -

#

OpenScreen

-

OpenScreen is your free, open-source alternative to Screen Studio.

+

OpenScreen is a free, open-source tool for creating polished screen recordings, product demos, and walkthroughs.

+ +OpenScreen was originally positioned as a free, open-source alternative to Screen Studio: something you can use to create quick, polished product demos and walkthroughs for X, Reddit, YouTube, documentation, landing pages, or internal demos. -If you don't want to pay $29/month for Screen Studio but want a version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit or Youtube. OpenScreen does not offer every Screen Studio feature, but covers a lot of the core functionality. +It is not a 1:1 clone of Screen Studio. Screen Studio is an excellent commercial product. OpenScreen focuses on covering the core open-source workflow: recording, zooms, cursor effects, webcam overlay, captions, editing, annotations, and export. -Screen Studio is an awesome product and this is definitely not a 1:1 clone. If you just want something fully free and open source, this project should cover most of your needs. +The goal of this continuation is to keep OpenScreen alive as a fully open-source project and progressively evolve it toward a broader recording and editing workflow. -**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the License. +**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it. Please respect the license. > [!NOTE] ->Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall. +> Software should be accessible. OpenScreen has no paid tiers, premium features, upsells, or functionality locked behind a paywall.

@@ -49,24 +51,11 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. If y ## Installation -Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. +Download the latest installer for your platform from the [GitHub Releases](https://github.com/EtienneLescot/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: +Download the `.dmg` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). If Gatekeeper blocks 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 @@ -81,20 +70,11 @@ After running this command, proceed to **System Preferences > Security & Privacy ### 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). +Download the `.exe` installer directly from the [Releases page](https://github.com/EtienneLescot/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: +Three packages are published to the [Releases page](https://github.com/EtienneLescot/openscreen/releases) for each version. Pick the one that matches your distro: **Debian / Ubuntu / Pop!_OS (`.deb`)** ```bash @@ -116,18 +96,18 @@ chmod +x Openscreen-Linux-*.AppImage Try without installing: ```bash -nix run github:siddharthvaddem/openscreen +nix run github:EtienneLescot/openscreen ``` Install into your user profile: ```bash -nix profile install github:siddharthvaddem/openscreen +nix profile install github:EtienneLescot/openscreen ``` For a NixOS system config (flake): ```nix { - inputs.openscreen.url = "github:siddharthvaddem/openscreen"; + inputs.openscreen.url = "github:EtienneLescot/openscreen"; outputs = { nixpkgs, openscreen, ... }: { nixosConfigurations. = nixpkgs.lib.nixosSystem { @@ -161,6 +141,17 @@ Everything in the editor and export is the same on macOS, Windows, and Linux: zo - **Windows**: works out of the box. - **Linux**: needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not capture system audio (mic should still work). +## Official links + +This repository is the community-maintained continuation of OpenScreen. + +Official / trusted links: + +* Original archived repository: https://github.com/siddharthvaddem/openscreen +* Community continuation: https://github.com/EtienneLescot/openscreen + +For safety, download OpenScreen only from the official GitHub Releases linked from this repository. Third-party websites using the OpenScreen name are not affiliated with this continuation unless explicitly listed here. + --- ## License diff --git a/electron-builder.json5 b/electron-builder.json5 index 16a7f2c60..0ff145383 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,7 +1,7 @@ // @see - https://www.electron.build/configuration/configuration { "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "com.siddharthvaddem.openscreen", + "appId": "com.etiennelescot.openscreen", "asar": true, // .node binaries can't be dlopen'd from inside an asar — must live unpacked. "asarUnpack": [ @@ -74,24 +74,26 @@ "NSCameraUseContinuityCameraDeviceType": true } }, - "linux": { - "target": [ - "AppImage", - "deb", - "pacman" - ], - "icon": "icons/icons/png", - "artifactName": "${productName}-Linux-${version}.${ext}", - "category": "AudioVideo" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico", - "extraResources": [ - { - "from": "electron/native/bin", + "linux": { + "target": [ + "AppImage", + "deb", + "pacman" + ], + "icon": "icons/icons/png", + "artifactName": "${productName}-Linux-${version}.${ext}", + "maintainer": "Etienne Lescot ", + "category": "AudioVideo" + }, + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}.Setup.${version}.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", "to": "electron/native/bin", "filter": ["win32-*/*"] } diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 18d44f1ae..a4972be61 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -43,6 +43,8 @@ interface Window { }>; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void; + onSourceSelectorClosed: (callback: () => void) => () => void; requestCameraAccess: () => Promise<{ success: boolean; granted: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1eb569efe..d02c6cdec 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1336,6 +1336,10 @@ export function registerIpcHandlers( selectedDesktopSource = null; } } + const mainWin = getMainWindow(); + if (mainWin && !mainWin.isDestroyed()) { + mainWin.webContents.send("selected-source-changed", selectedSource); + } const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { sourceSelectorWin.close(); diff --git a/electron/main.ts b/electron/main.ts index bfb949ed7..c2d7a4d48 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,6 +19,7 @@ import { } from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; +import { acquireStableInstanceLock } from "./singleInstanceLock"; import { createCountdownOverlayWindow, createEditorWindow, @@ -92,6 +93,10 @@ const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize); const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize); function createWindow() { + if (mainWindow && !mainWindow.isDestroyed()) { + return; + } + mainWindow = createHudOverlayWindow(); } @@ -108,6 +113,19 @@ function showMainWindow() { createWindow(); } +const stableInstanceLock = acquireStableInstanceLock(); +const hasElectronSingleInstanceLock = app.requestSingleInstanceLock(); +const hasSingleInstanceLock = Boolean(stableInstanceLock && hasElectronSingleInstanceLock); + +if (hasSingleInstanceLock) { + app.on("second-instance", () => { + showMainWindow(); + }); +} else { + stableInstanceLock?.release(); + app.quit(); +} + function isEditorWindow(window: BrowserWindow) { return window.webContents.getURL().includes("windowType=editor"); } @@ -411,6 +429,9 @@ function createSourceSelectorWindowWrapper() { sourceSelectorWindow = createSourceSelectorWindow(); sourceSelectorWindow.on("closed", () => { sourceSelectorWindow = null; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("source-selector-closed"); + } }); return sourceSelectorWindow; } @@ -451,9 +472,12 @@ app.on("activate", () => { app.on("will-quit", () => { unregisterAllGlobalShortcuts(); + stableInstanceLock?.release(); }); -app.whenReady().then(async () => { +const appReady = hasSingleInstanceLock ? app.whenReady() : null; + +appReady?.then(async () => { // Force "regular" activation policy so the Dock icon appears. The HUD overlay // (transparent, frameless, skipTaskbar) is the first window, and AppKit would // otherwise classify us as an accessory app. diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 4b9a24a4e..32c5d6ef5 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -37,7 +37,7 @@ target_compile_definitions(wgc-capture PRIVATE _WIN32_WINNT=0x0A00 ) -target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8 /await) +target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8) target_link_libraries(wgc-capture PRIVATE d3d11 diff --git a/electron/preload.ts b/electron/preload.ts index 96cc831fd..fefcac044 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,6 +53,16 @@ contextBridge.exposeInMainWorld("electronAPI", { getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => { + const listener = (_event: unknown, source: ProcessedDesktopSource) => callback(source); + ipcRenderer.on("selected-source-changed", listener); + return () => ipcRenderer.removeListener("selected-source-changed", listener); + }, + onSourceSelectorClosed: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("source-selector-closed", listener); + return () => ipcRenderer.removeListener("source-selector-closed", listener); + }, requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, diff --git a/electron/singleInstanceLock.test.ts b/electron/singleInstanceLock.test.ts new file mode 100644 index 000000000..4df35c72b --- /dev/null +++ b/electron/singleInstanceLock.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { acquireStableInstanceLock } from "./singleInstanceLock"; + +const testDirs: string[] = []; + +function createTestLockDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-lock-test-")); + testDirs.push(dir); + return path.join(dir, "app.lock"); +} + +afterEach(() => { + for (const dir of testDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("acquireStableInstanceLock", () => { + it("prevents a second lock while the owning process is still running", () => { + const lockDir = createTestLockDir(); + const firstLock = acquireStableInstanceLock({ lockDir }); + + expect(firstLock).not.toBeNull(); + expect(acquireStableInstanceLock({ lockDir })).toBeNull(); + + firstLock?.release(); + }); + + it("reclaims a stale lock when its process is gone", () => { + const lockDir = createTestLockDir(); + fs.mkdirSync(lockDir); + fs.writeFileSync(path.join(lockDir, "pid"), "99999999\n"); + + const lock = acquireStableInstanceLock({ lockDir }); + + expect(lock).not.toBeNull(); + expect(fs.readFileSync(path.join(lockDir, "pid"), "utf8")).toBe(`${process.pid}\n`); + + lock?.release(); + }); + + it("does not remove a fresh empty lock directory", () => { + const lockDir = createTestLockDir(); + fs.mkdirSync(lockDir); + + expect(acquireStableInstanceLock({ lockDir })).toBeNull(); + expect(fs.existsSync(lockDir)).toBe(true); + }); +}); diff --git a/electron/singleInstanceLock.ts b/electron/singleInstanceLock.ts new file mode 100644 index 000000000..d1a0a01af --- /dev/null +++ b/electron/singleInstanceLock.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const LOCK_DIR_PREFIX = "openscreen-single-instance"; +const PID_FILE_NAME = "pid"; +const EMPTY_LOCK_STALE_MS = 30_000; + +export type StableInstanceLock = { + lockDir: string; + release: () => void; +}; + +type LockOptions = { + lockDir?: string; + pid?: number; + now?: () => number; +}; + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function readLockPid(lockDir: string): number | null { + try { + const rawPid = fs.readFileSync(path.join(lockDir, PID_FILE_NAME), "utf8").trim(); + const pid = Number(rawPid); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isEmptyLockStale(lockDir: string, now: () => number): boolean { + try { + const stat = fs.statSync(lockDir); + return now() - stat.mtimeMs > EMPTY_LOCK_STALE_MS; + } catch { + return false; + } +} + +function releaseLock(lockDir: string, pid: number) { + if (readLockPid(lockDir) !== pid) { + return; + } + fs.rmSync(lockDir, { recursive: true, force: true }); +} + +function getCurrentUserLockKey() { + if (typeof process.getuid === "function") { + return `uid-${process.getuid()}`; + } + + try { + const username = os.userInfo().username.replace(/[^a-zA-Z0-9._-]/g, "_"); + return username || "default"; + } catch { + return "default"; + } +} + +export function getStableInstanceLockDir() { + return path.join(os.tmpdir(), `${LOCK_DIR_PREFIX}-${getCurrentUserLockKey()}.lock`); +} + +export function acquireStableInstanceLock(options: LockOptions = {}): StableInstanceLock | null { + const lockDir = options.lockDir ?? getStableInstanceLockDir(); + const pid = options.pid ?? process.pid; + const now = options.now ?? Date.now; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + fs.mkdirSync(lockDir, { mode: 0o700 }); + fs.writeFileSync(path.join(lockDir, PID_FILE_NAME), `${pid}\n`, { flag: "wx" }); + return { + lockDir, + release: () => releaseLock(lockDir, pid), + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + + const existingPid = readLockPid(lockDir); + if (existingPid && isProcessRunning(existingPid)) { + return null; + } + if (!existingPid && !isEmptyLockStale(lockDir, now)) { + return null; + } + + fs.rmSync(lockDir, { recursive: true, force: true }); + } + } + + return null; +} diff --git a/nix/hm-module.nix b/nix/hm-module.nix index b04f82793..9ed0a7d4f 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -1,7 +1,7 @@ # Home Manager module for OpenScreen # Usage in flake-based Home Manager config: # -# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# inputs.openscreen.url = "github:EtienneLescot/openscreen"; # # { inputs, ... }: { # imports = [ inputs.openscreen.homeManagerModules.default ]; diff --git a/nix/module.nix b/nix/module.nix index 3282d2d4f..0cb7242ca 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -1,7 +1,7 @@ # NixOS module for OpenScreen # Usage in flake-based NixOS config: # -# inputs.openscreen.url = "github:siddharthvaddem/openscreen"; +# inputs.openscreen.url = "github:EtienneLescot/openscreen"; # # { inputs, ... }: { # imports = [ inputs.openscreen.nixosModules.default ]; diff --git a/nix/package.nix b/nix/package.nix index bca77c91b..4570ef859 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -116,7 +116,7 @@ buildNpmPackage { meta = { description = "Desktop screen recorder with built-in editor"; - homepage = "https://github.com/siddharthvaddem/openscreen"; + homepage = "https://github.com/EtienneLescot/openscreen"; license = lib.licenses.mit; mainProgram = "openscreen"; platforms = lib.platforms.linux; diff --git a/package.json b/package.json index 54e0c833d..dffae9025 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,15 @@ "npm": "10.9.4" }, "author": { - "name": "Sid", - "email": "svaddem@asu.edu" + "name": "Siddharth Vaddem", + "url": "https://github.com/siddharthvaddem" }, + "maintainers": [ + { + "name": "Etienne Lescot", + "url": "https://github.com/EtienneLescot" + } + ], "scripts": { "dev": "vite", "build": "tsc && vite build && electron-builder", diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 29df4d842..e0e3219bb 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -18,8 +18,37 @@ function findVcVarsAll() { return explicit; } + const vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; + if (fs.existsSync(vswhere)) { + const result = spawnSync( + vswhere, + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + { encoding: "utf8", windowsHide: true }, + ); + const installPath = result.stdout?.trim(); + if (result.status === 0 && installPath) { + const candidate = path.join(installPath, "VC", "Auxiliary", "Build", "vcvarsall.bat"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + const roots = [ process.env.VSINSTALLDIR, + "C:\\Program Files\\Microsoft Visual Studio\\2026\\Community", + "C:\\Program Files\\Microsoft Visual Studio\\2026\\Professional", + "C:\\Program Files\\Microsoft Visual Studio\\2026\\Enterprise", + "C:\\Program Files (x86)\\Microsoft Visual Studio\\2026\\BuildTools", + "C:\\Program Files (x86)\\Microsoft Visual Studio\\2026\\Community", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise", diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx new file mode 100644 index 000000000..6440f1d41 --- /dev/null +++ b/src/components/launch/LaunchWindow.test.tsx @@ -0,0 +1,330 @@ +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../ui/tooltip"; +import { LaunchWindow } from "./LaunchWindow"; + +type SelectedSourceChangedListener = Parameters< + Window["electronAPI"]["onSelectedSourceChanged"] +>[0]; + +const recorderState = vi.hoisted(() => ({ + value: { + recording: false, + paused: false, + elapsedSeconds: 0, + toggleRecording: vi.fn(), + togglePaused: vi.fn(), + canPauseRecording: false, + restartRecording: vi.fn(), + cancelRecording: vi.fn(), + microphoneEnabled: false, + setMicrophoneEnabled: vi.fn(), + microphoneDeviceId: undefined, + setMicrophoneDeviceId: vi.fn(), + setMicrophoneDeviceName: vi.fn(), + webcamEnabled: false, + setWebcamEnabled: vi.fn(async () => true), + webcamDeviceId: undefined, + setWebcamDeviceId: vi.fn(), + setWebcamDeviceName: vi.fn(), + systemAudioEnabled: false, + setSystemAudioEnabled: vi.fn(), + cursorCaptureMode: "editable-overlay", + setCursorCaptureMode: vi.fn(), + }, +})); + +let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; +let sourceSelectorClosedListeners: Array<() => void> = []; + +vi.mock("../../hooks/useScreenRecorder", () => ({ + useScreenRecorder: () => recorderState.value, +})); + +vi.mock("../../hooks/useMicrophoneDevices", () => ({ + useMicrophoneDevices: () => ({ + devices: [], + selectedDeviceId: "default", + setSelectedDeviceId: vi.fn(), + }), +})); + +vi.mock("../../hooks/useCameraDevices", () => ({ + useCameraDevices: () => ({ + devices: [], + selectedDeviceId: "", + setSelectedDeviceId: vi.fn(), + isLoading: false, + error: null, + }), +})); + +vi.mock("../../hooks/useAudioLevelMeter", () => ({ + useAudioLevelMeter: () => ({ level: 0 }), +})); + +vi.mock("../../lib/requestCameraAccess", () => ({ + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), +})); + +vi.mock("@/native", () => ({ + nativeBridgeClient: { + system: { + getPlatform: vi.fn(async () => "darwin"), + }, + }, +})); + +vi.mock("@/i18n/loader", () => ({ + getAvailableLocales: () => ["en"], + getLocaleName: () => "English", +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ + locale: "en", + setLocale: vi.fn(), + systemLocaleSuggestion: null, + acceptSystemLocaleSuggestion: vi.fn(), + dismissSystemLocaleSuggestion: vi.fn(), + resolveSystemLocaleSuggestion: vi.fn(), + }), + useScopedT: () => (key: string) => { + const translations: Record = { + "sourceSelector.defaultSourceName": "Screen", + "recording.selectSource": "Please select a source to record", + "tooltips.useVerticalTray": "Use vertical tray", + "tooltips.useHorizontalTray": "Use horizontal tray", + "audio.enableSystemAudio": "Enable system audio", + "audio.disableSystemAudio": "Disable system audio", + "audio.enableMicrophone": "Enable microphone", + "audio.disableMicrophone": "Disable microphone", + "audio.defaultMicrophone": "Default Microphone", + "webcam.enableWebcam": "Enable webcam", + "webcam.disableWebcam": "Disable webcam", + "webcam.defaultCamera": "Default Camera", + "webcam.searching": "Searching...", + "webcam.noneFound": "No camera found", + "webcam.unavailable": "Camera unavailable", + "cursor.useEditableCursor": "Use editable cursor", + "cursor.useSystemCursor": "Use system cursor", + "tooltips.openStudio": "Open Studio", + "tooltips.hideHUD": "Hide HUD", + "tooltips.closeApp": "Close App", + language: "Language", + }; + return translations[key] ?? key; + }, +})); + +function renderLaunchWindow() { + return render( + + + , + ); +} + +function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSource"]) { + window.electronAPI = { + ...window.electronAPI, + getSelectedSource, + openSourceSelector: vi.fn(async () => ({ opened: true })), + requestScreenAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + getPlatform: vi.fn(async () => "darwin"), + setHudOverlaySize: vi.fn(), + setHudOverlayIgnoreMouseEvents: vi.fn(), + moveHudOverlayBy: vi.fn(), + hudOverlayHide: vi.fn(), + hudOverlayClose: vi.fn(), + switchToEditor: vi.fn(async () => undefined), + onSelectedSourceChanged: vi.fn((callback) => { + selectedSourceChangedListeners.push(callback); + return () => { + selectedSourceChangedListeners = selectedSourceChangedListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + onSourceSelectorClosed: vi.fn((callback) => { + sourceSelectorClosedListeners.push(callback); + return () => { + sourceSelectorClosedListeners = sourceSelectorClosedListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + } as typeof window.electronAPI; +} + +const displayOneSource = { + id: "screen:1:0", + name: "Display 1", + display_id: "1", + thumbnail: null, + appIcon: null, +} satisfies ProcessedDesktopSource; + +async function waitForSourceSelectionSubscription() { + await waitFor(() => { + expect(selectedSourceChangedListeners.length).toBeGreaterThan(0); + }); +} + +function emitSelectedSourceChanged(source: ProcessedDesktopSource) { + act(() => { + selectedSourceChangedListeners.forEach((listener) => listener(source)); + }); +} + +function emitSourceSelectorClosed() { + act(() => { + sourceSelectorClosedListeners.forEach((listener) => listener()); + }); +} + +describe("LaunchWindow record button", () => { + beforeEach(() => { + class ResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } + } + vi.stubGlobal("ResizeObserver", ResizeObserver); + recorderState.value.toggleRecording.mockClear(); + selectedSourceChangedListeners = []; + sourceSelectorClosedListeners = []; + stubElectronAPI(vi.fn(async () => null)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("opens the source selector instead of disabling the primary action when no source is selected", async () => { + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + + expect(recordButton).toBeEnabled(); + expect(recordButton).toHaveAttribute("title", "Please select a source to record"); + + fireEvent.click(recordButton); + + await waitFor(() => { + expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("records immediately after source selection when the record button opened the picker", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + + it("does not record after manual source selection", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("clears record-after-selection intent when the source picker closes without a selection", async () => { + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + emitSourceSelectorClosed(); + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("clears record-after-selection intent when opening the source picker fails", async () => { + window.electronAPI.openSourceSelector = vi.fn(async () => { + throw new Error("source selector failed"); + }); + + renderLaunchWindow(); + await waitForSourceSelectionSubscription(); + + fireEvent.click(await screen.findByTestId("launch-record-button")); + + await waitFor(() => { + expect(window.electronAPI.openSourceSelector).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await Promise.resolve(); + }); + + emitSelectedSourceChanged(displayOneSource); + + await waitFor(() => { + expect(screen.getByTestId("launch-record-button")).toHaveAttribute("title", "Display 1"); + }); + expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); + }); + + it("handles selected source polling failures", async () => { + const error = new Error("selected source unavailable"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + stubElectronAPI( + vi.fn(async () => { + throw error; + }), + ); + + renderLaunchWindow(); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith("Failed to refresh selected source:", error); + }); + + warnSpy.mockRestore(); + }); + + it("starts recording when a source is already selected", async () => { + stubElectronAPI(vi.fn(async () => displayOneSource)); + + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + await waitFor(() => { + expect(recordButton).toHaveAttribute("title", "Display 1"); + }); + + fireEvent.click(recordButton); + + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a93514c5a..511ab8e5c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -420,21 +420,37 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); - const [selectedSource, setSelectedSource] = useState("Screen"); + const defaultSourceName = t("sourceSelector.defaultSourceName"); + const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [, setRecordPointerDownCount] = useState(0); + const recordAfterSourceSelectionRef = useRef(false); + + const applySelectedSource = useCallback( + (source: ProcessedDesktopSource | null) => { + if (source) { + setSelectedSource(source.name); + setHasSelectedSource(true); + return; + } + + setSelectedSource(defaultSourceName); + setHasSelectedSource(false); + }, + [defaultSourceName], + ); useEffect(() => { const checkSelectedSource = async () => { - if (window.electronAPI) { + if (!window.electronAPI) { + return; + } + + try { const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } + applySelectedSource(source); + } catch (error) { + console.warn("Failed to refresh selected source:", error); } }; @@ -442,15 +458,55 @@ export function LaunchWindow() { const interval = setInterval(checkSelectedSource, 500); return () => clearInterval(interval); - }, []); + }, [applySelectedSource]); + + useEffect(() => { + const cleanupSourceChanged = window.electronAPI?.onSelectedSourceChanged?.((source) => { + applySelectedSource(source); + if (!recordAfterSourceSelectionRef.current || recording) { + return; + } + + recordAfterSourceSelectionRef.current = false; + toggleRecording(); + }); + const cleanupSelectorClosed = window.electronAPI?.onSourceSelectorClosed?.(() => { + recordAfterSourceSelectionRef.current = false; + }); + + return () => { + cleanupSourceChanged?.(); + cleanupSelectorClosed?.(); + }; + }, [applySelectedSource, recording, toggleRecording]); const openSourceSelector = async () => { if (window.electronAPI) { - await openSourceSelectorWithPermissionRetry({ + return await openSourceSelectorWithPermissionRetry({ openSourceSelector: () => window.electronAPI.openSourceSelector(), requestScreenAccess: () => window.electronAPI.requestScreenAccess(), }); } + + return { opened: false, reason: "electron-api-unavailable" }; + }; + + const handleRecordButtonClick = () => { + if (!hasSelectedSource && !recording) { + recordAfterSourceSelectionRef.current = true; + void openSourceSelector() + .then((result) => { + if (!result.opened) { + recordAfterSourceSelectionRef.current = false; + } + }) + .catch(() => { + recordAfterSourceSelectionRef.current = false; + }); + return; + } + + toggleRecording(); }; const sendHudOverlayHide = () => { @@ -476,6 +532,45 @@ export function LaunchWindow() { } }; const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); + const dragAnimationFrameRef = useRef(null); + const pendingDragDeltaRef = useRef({ x: 0, y: 0 }); + const flushHudDragMove = useCallback(() => { + dragAnimationFrameRef.current = null; + const { x, y } = pendingDragDeltaRef.current; + pendingDragDeltaRef.current = { x: 0, y: 0 }; + if (x === 0 && y === 0) return; + window.electronAPI?.moveHudOverlayBy?.(x, y); + }, []); + const scheduleHudDragMove = useCallback( + (deltaX: number, deltaY: number) => { + pendingDragDeltaRef.current = { + x: pendingDragDeltaRef.current.x + deltaX, + y: pendingDragDeltaRef.current.y + deltaY, + }; + + if (dragAnimationFrameRef.current === null) { + dragAnimationFrameRef.current = window.requestAnimationFrame(flushHudDragMove); + } + }, + [flushHudDragMove], + ); + const flushPendingHudDragMove = useCallback(() => { + if (dragAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(dragAnimationFrameRef.current); + dragAnimationFrameRef.current = null; + } + const { x, y } = pendingDragDeltaRef.current; + pendingDragDeltaRef.current = { x: 0, y: 0 }; + if (x === 0 && y === 0) return; + window.electronAPI?.moveHudOverlayBy?.(x, y); + }, []); + useEffect(() => { + return () => { + if (dragAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(dragAnimationFrameRef.current); + } + }; + }, []); const handleHudDragPointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -489,10 +584,11 @@ export function LaunchWindow() { const deltaX = event.screenX - lastPosition.x; const deltaY = event.screenY - lastPosition.y; dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; - window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY); + scheduleHudDragMove(deltaX, deltaY); }; const handleHudDragPointerEnd = (event: React.PointerEvent) => { dragLastPositionRef.current = null; + flushPendingHudDragMove(); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } @@ -844,32 +940,41 @@ export function LaunchWindow() { {/* Record/Stop group */} - + + {recording && (

{ window.electronAPI?.openExternalUrl( - "https://github.com/siddharthvaddem/openscreen/issues/new/choose", + "https://github.com/EtienneLescot/openscreen/issues/new/choose", ); }} className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors" @@ -767,7 +767,7 @@ export function SettingsPanel({