diff --git a/.github/ISSUE_TEMPLATE/1-bug-report-form.yml b/.github/ISSUE_TEMPLATE/1-bug-report-form.yml index 75c939fe..4515b4a3 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report-form.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report-form.yml @@ -35,6 +35,7 @@ body: What version of our software are you running? (Go to ✦ in the menu bar > Settings > About) options: + - Select a version - v2.7-rc.3 - v2.7-rc.2 - v2.7-rc.1 diff --git a/.github/scripts/extract_version.py b/.github/scripts/extract_version.py new file mode 100644 index 00000000..b04806cf --- /dev/null +++ b/.github/scripts/extract_version.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +import re +import semver +import subprocess +import sys +from argparse import ArgumentParser + + +SEMVER_RE = re.compile(r"v?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?") + + +def find_first_valid(text: str): + for cand in SEMVER_RE.findall(text or ""): + s = cand.lstrip("v") + try: + parsed = semver.VersionInfo.parse(s) + return s, parsed + except Exception: + continue + return None, None + + +def write_github_output(version: str | None, is_beta_flag: bool) -> None: + out = os.environ.get("GITHUB_OUTPUT") + if not out: + return + try: + with open(out, "a", encoding="utf-8") as f: + f.write(f"version={version or ''}\n") + f.write(f"is_beta={str(is_beta_flag).lower()}\n") + except Exception: + pass + + +def main(argv=None) -> int: + p = ArgumentParser() + p.add_argument("-c", "--comment", help="Comment body to scan (defaults: $COMMENT or stdin)") + args = p.parse_args(argv) + + comment = args.comment or os.environ.get("COMMENT") + if not comment: + comment = sys.stdin.read() or "" + + version, parsed = find_first_valid(comment) + + beta = getattr(parsed, "prerelease", None) + + # Write GitHub Actions outputs if available (GITHUB_OUTPUT) + write_github_output(version, bool(beta)) + + # For CLI consumption print simple key=value lines (and a human line) + print(f"version={version or ''}") + print(f"is_beta={str(bool(beta)).lower()}") + print(f"Found version: {version} (beta: {bool(beta)})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/remove_beta.py b/.github/scripts/remove_beta.py new file mode 100644 index 00000000..1b22a7bf --- /dev/null +++ b/.github/scripts/remove_beta.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Remove the last beta item from an appcast XML file. +Usage: remove_beta.py path/to/appcast.xml + +This script mirrors the inline Python used previously in the workflow. +""" +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def remove_last_beta_item(appcast_path: Path) -> int: + if not appcast_path.exists(): + print(f"Appcast file not found: {appcast_path}") + return 1 + + try: + tree = ET.parse(appcast_path) + root = tree.getroot() + + channel = root.find('channel') + if channel is None: + print('No channel found in appcast') + return 0 + + items = channel.findall('item') + removed = False + for item in reversed(items): + enclosure = item.find('enclosure') + if enclosure is not None: + version = enclosure.get('sparkle:version', '') + if 'beta' in version.lower(): + channel.remove(item) + removed = True + break + + if removed: + tree.write(appcast_path, encoding='utf-8', xml_declaration=True) + print('Removed beta item from appcast') + else: + print('No beta item found in appcast') + + return 0 + + except Exception as e: + print(f'Error processing appcast: {e}') + return 2 + + +if __name__ == '__main__': + if len(sys.argv) < 2: + print('Usage: remove_beta.py path/to/appcast.xml') + sys.exit(1) + + path = Path(sys.argv[1]) + sys.exit(remove_last_beta_item(path)) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..af2ce61a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,474 @@ +name: "Deploy Boring Notch" +on: + issue_comment: + types: [created] + +concurrency: + group: build-boringnotch-${{ github.ref }} + cancel-in-progress: true + +env: + projname: boringNotch + beta-channel-name: "beta" + EXPORT_METHOD: "development" +permissions: + contents: read + pull-requests: write + +jobs: + preparation: + name: Preparation job + if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '/release') }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + is_beta: ${{ steps.check-beta.outputs.is_beta }} + version: ${{ steps.extract-version.outputs.version }} + build_number: ${{ steps.extract-version.outputs.build_number }} + title: ${{ steps.generate-release-notes.outputs.title }} + release_notes: ${{ steps.generate-release-notes.outputs.release_notes }} + steps: + - uses: xt0rted/pull-request-comment-branch@v1 + id: comment-branch + + - name: Add reaction to comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ github.event.comment.id }} + reactions: eyes + + - uses: actions/github-script@v6 + with: + result-encoding: string + script: | + const commenter = context.payload.comment.user.login; + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Check the commenter's repository permission level (need write or admin) + try { + const perm = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter }); + const level = perm.data.permission; // admin, write, read, none + if (level !== 'admin') { + core.setFailed("Commenter is not an admin on the repository"); + } + } catch (err) { + core.setFailed("Failed to determine collaborator permission level: " + err.message); + } + + // Check if the PR is ready to be merged + const pr = await github.rest.pulls.get({ + owner: owner, + repo: repo, + pull_number: context.issue.number, + }); + if (pr.data.draft || !pr.data.mergeable) { + core.setFailed("PR is not ready to be merged"); + } + - uses: actions/checkout@v3 + if: success() + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + + - name: Ensure scripts directory exists + run: | + if [ ! -f ".github/scripts/extract_version.py" ]; then + echo "Script not found in PR branch, fetching from base branch" + git fetch origin ${{ steps.comment-branch.outputs.base_ref }} + git checkout origin/${{ steps.comment-branch.outputs.base_ref }} -- .github/scripts/ + fi + + - name: Extract version from comment or Xcode project + id: extract-version + shell: bash + run: | + set -euo pipefail + + # Ensure semver is installed deterministically + python3 -m pip install --upgrade --no-cache-dir semver + + export COMMENT="${{ github.event.comment.body }}" + export projname="${{ env.projname }}" + + # Run script: if it exits non-zero, this step will fail and stop the job + OUTPUT=$(python3 .github/scripts/extract_version.py -c "$COMMENT") + + # Parse outputs (script prints version=... and is_beta=...) + VERSION=$(echo "$OUTPUT" | grep -m1 '^version=' | sed 's/^version=//') + IS_BETA=$(echo "$OUTPUT" | grep -m1 '^is_beta=' | sed 's/^is_beta=//') + + BUILD_NUMBER="${GITHUB_RUN_NUMBER}" + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "is_beta=$IS_BETA" >> $GITHUB_OUTPUT + echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT + echo "Using Actions run number as build number: $BUILD_NUMBER for version: $VERSION" + + - name: Generate release notes and title + id: generate-release-notes + uses: actions/github-script@v6 + with: + result-encoding: string + script: | + // Fetch the PR and use its title/body for release notes + const owner = context.repo.owner; + const repo = context.repo.repo; + const prNumber = context.issue.number; + + const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const title = pr.data.title || ( (context.payload.comment && context.payload.comment.body && context.payload.comment.body.includes('beta')) ? 'Beta Release' : 'Release'); + const body = pr.data.body || "- No release notes provided"; + + // Set step outputs for downstream jobs + core.setOutput('title', title); + core.setOutput('release_notes', body); + + // Also write the release notes to a file for tools that expect a file + const fs = require('fs'); + fs.writeFileSync('release_notes.md', body); + + - name: Check if version already released + run: | + NEW_VERSION="v${{ steps.extract-version.outputs.version }}" + git fetch --tags + + if git rev-parse "$NEW_VERSION" >/dev/null 2>&1; then + echo "Version $NEW_VERSION already exists as a tag" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "Version $NEW_VERSION not found in tags, continuing..." + fi + + - name: Sync branch (for stable releases) + if: ${{ steps.check-beta.outputs.is_beta == 'false' }} + uses: devmasx/merge-branch@master + with: + type: now + from_branch: ${{ steps.comment-branch.outputs.base_ref }} + target_branch: ${{ steps.comment-branch.outputs.head_ref }} + github_token: ${{ github.token }} + message: "Sync branch before release" + + build: + name: Build and sign app + permissions: + contents: write + runs-on: macos-latest + needs: preparation + env: + DEVELOPMENT_TEAM: ${{ vars.DEVELOPMENT_TEAM_ID }} + CODE_SIGN_IDENTITY: "Apple Development" + steps: + - uses: xt0rted/pull-request-comment-branch@v1 + id: comment-branch + - uses: actions/checkout@v3 + if: success() + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + persist-credentials: true + - name: Resolve Swift packages + run: xcodebuild -resolvePackageDependencies -project ${{ env.projname }}.xcodeproj + - name: Install Apple certificate + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + CERT_PATH=$RUNNER_TEMP/build_certificate.p12 + KC=$RUNNER_TEMP/app-signing.keychain-db + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KC" + security set-keychain-settings -lut 21600 "$KC" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KC" + security import "$CERT_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KC" + security list-keychain -d user -s "$KC" + - name: Switch Xcode version + run: | + sudo xcode-select -s "/Applications/Xcode_16.4.app" + /usr/bin/xcodebuild -version + - name: Set version and build number in project + run: | + sed -i '' "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${{ needs.preparation.outputs.version }}/g" ${{ env.projname }}.xcodeproj/project.pbxproj + sed -i '' "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = ${{ needs.preparation.outputs.build_number }}/g" ${{ env.projname }}.xcodeproj/project.pbxproj + + - name: Commit version changes + run: | + git add ${{ env.projname }}.xcodeproj/project.pbxproj + git commit -m "Set version to v${{ needs.preparation.outputs.version }} (build ${{ needs.preparation.outputs.build_number }})" || echo "No changes to commit" + git push origin HEAD:${{ steps.comment-branch.outputs.head_ref }} || true + - name: Build and archive + run: | + xcodebuild clean archive \ + -project ${{ env.projname }}.xcodeproj \ + -scheme ${{ env.projname }} \ + -archivePath ${{ env.projname }} \ + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ + CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \ + -allowProvisioningUpdates + - name: Export app + env: + DEVELOPMENT_TEAM: ${{ vars.DEVELOPMENT_TEAM_ID }} + run: | + TEMP_PLIST="$RUNNER_TEMP/export_options.plist" + cat > "$TEMP_PLIST" < + + + + method + ${EXPORT_METHOD} + signingStyle + automatic + teamID + $DEVELOPMENT_TEAM + + + EOF + xcodebuild -exportArchive -archivePath "${{ env.projname }}.xcarchive" -exportPath Release -exportOptionsPlist "$TEMP_PLIST" + - name: Check for generate_appcast in repository + run: | + TOOL_PATH="Configuration/sparkle/generate_appcast" + if [ ! -x "$TOOL_PATH" ]; then + echo "Configuration/sparkle/generate_appcast missing or not executable" >&2 + exit 1 + fi + echo "Found generate_appcast at $TOOL_PATH" + - name: Create DMG + run: | + cd Release + hdiutil create -volname "boringNotch ${{ needs.preparation.outputs.version }}" \ + -srcfolder "${{ env.projname }}.app" \ + -ov -format UDZO \ + "${{ env.projname }}.dmg" + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.projname }}.dmg + path: Release/${{ env.projname }}.dmg + + - name: Upload .app artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.projname }}.app + path: Release/${{ env.projname }}.app + + publish: + name: Publish Release + runs-on: macos-latest + needs: [preparation, build] + steps: + - uses: xt0rted/pull-request-comment-branch@v1 + id: comment-branch + - uses: actions/checkout@v3 + if: success() + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + - name: Download DMG artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.projname }}.dmg + path: Release + - name: Create HTML release notes + run: | + mkdir -p Release + cat > Release/boringNotch.html < + + + + Boring Notch ${{ needs.preparation.outputs.version }} + + +

BoringNotch ${{ needs.preparation.outputs.version }}

+
+ ${{ needs.preparation.outputs.release_notes }} +
+ + + EOF + - name: Check for generate_appcast in repository + run: | + TOOL_PATH="Configuration/sparkle/generate_appcast" + if [ ! -x "$TOOL_PATH" ]; then + echo "Configuration/sparkle/generate_appcast missing or not executable" >&2 + exit 1 + fi + echo "Found generate_appcast at $TOOL_PATH" + - name: Generate signed appcast + run: | + set -euo pipefail + GITHUB_REPO="https://github.com/TheBoredTeam/boring.notch/releases" + DOWNLOAD_PREFIX="https://github.com/TheBoredTeam/boring.notch/releases/download/v${{ needs.preparation.outputs.version }}/" + + CHANNEL_ARG="" + if [[ "${{ needs.preparation.outputs.is_beta }}" == "true" ]]; then + CHANNEL_ARG="--channel ${{ env.beta-channel-name }}" + fi + + printf '%s' "${{ secrets.PRIVATE_SPARKLE_KEY }}" | ./Configuration/sparkle/generate_appcast \ + --ed-key-file - \ + --link "$GITHUB_REPO" \ + --download-url-prefix "$DOWNLOAD_PREFIX" \ + $CHANNEL_ARG \ + -o updater/appcast.xml \ + Release/ + - name: Commit appcast (and pbxproj for stable) + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add updater/appcast.xml + if [[ "${{ needs.preparation.outputs.is_beta }}" == "false" ]]; then + git add ${{ env.projname }}.xcodeproj/project.pbxproj || true + git commit -m "Update version to v${{ needs.preparation.outputs.version }}" || echo "No changes to commit" + else + # git checkout main + # git add updater/appcast.xml + # git commit -m "Update appcast with beta release for v${{ needs.preparation.outputs.version }}" || echo "No changes to commit" + # git push origin main + fi + - name: Create GitHub release + uses: softprops/action-gh-release@v1 + if: false + with: + name: v${{ needs.preparation.outputs.version }} - ${{ needs.preparation.outputs.title }} + tag_name: v${{ needs.preparation.outputs.version }} + fail_on_unmatched_files: true + body: ${{ needs.preparation.outputs.release_notes }} + files: Release/boringNotch.dmg + prerelease: ${{ needs.preparation.outputs.is_beta }} + draft: false + + upgrade-brew: + name: Upgrade Homebrew formula + runs-on: macos-latest + needs: [preparation, publish] + if: ${{ needs.preparation.outputs.is_beta == 'false' }} + steps: + - name: Generate Homebrew cask + run: | + DMG_URL="https://github.com/TheBoredTeam/boring.notch/releases/download/v${{ needs.preparation.outputs.version }}/boringNotch.dmg" + NEW_SHA256=$(curl -sL "$DMG_URL" | shasum -a 256 | cut -d' ' -f1) + cat > boring-notch.rb <= :sonoma" + + app "boringNotch.app" + + zap trash: [ + "~/Library/Application Scripts/theboringteam.boringnotch/", + "~/Library/Containers/theboringteam.boringnotch/", + ] + end + EOF + - name: Upload Homebrew cask artifact + uses: actions/upload-artifact@v4 + with: + name: homebrew-cask-${{ needs.preparation.outputs.version }} + path: boring-notch.rb + + upgrade-brew-beta: + name: Upgrade Homebrew beta formula + runs-on: macos-latest + needs: [preparation, publish] + if: ${{ needs.preparation.outputs.is_beta == 'true' }} + steps: + - name: Generate Homebrew beta cask + run: | + DMG_URL="https://github.com/TheBoredTeam/boring.notch/releases/download/v${{ needs.preparation.outputs.version }}/boringNotch.dmg" + NEW_SHA256=$(curl -sL "$DMG_URL" | shasum -a 256 | cut -d' ' -f1) + cat > boring-notch@rc.rb <= :sonoma" + + app "boringNotch.app" + + zap trash: [ + "~/Library/Application Scripts/theboringteam.boringnotch/", + "~/Library/Containers/theboringteam.boringnotch/", + ] + end + EOF + - name: Upload Homebrew beta cask artifact + uses: actions/upload-artifact@v4 + with: + name: homebrew-cask-${{ needs.preparation.outputs.version }} + path: boring-notch@rc.rb + + ending: + name: Ending job + if: ${{ always() && github.event.issue.pull_request && (contains(github.event.comment.body, '/build') || contains(github.event.comment.body, '/release')) }} + runs-on: ubuntu-latest + needs: [preparation, build, publish, upgrade-brew] + steps: + - uses: xt0rted/pull-request-comment-branch@v1 + id: comment-branch + - uses: actions/checkout@v3 + if: ${{ contains(join(needs.*.result, ','), 'success') }} + with: + ref: ${{ steps.comment-branch.outputs.head_ref }} + - name: Merge PR (for stable releases) + uses: devmasx/merge-branch@master + if: ${{ needs.preparation.outputs.is_beta == 'false' && contains(join(needs.*.result, ','), 'success') && false }} + with: + type: now + from_branch: ${{ steps.comment-branch.outputs.head_ref }} + target_branch: ${{ steps.comment-branch.outputs.base_ref }} + github_token: ${{ github.token }} + message: "Release version v${{ needs.preparation.outputs.version }}" + - name: Add success reactions + if: ${{ !contains(join(needs.*.result, ','), 'failure') }} + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ github.event.comment.id }} + reactions: rocket + - name: Add negative reaction + if: ${{ contains(join(needs.*.result, ','), 'failure') }} + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ github.event.comment.id }} + reactions: confused + - name: Create summary + run: | + BUILD_TYPE="stable" + if [[ "${{ needs.preparation.outputs.is_beta }}" == "true" ]]; then + BUILD_TYPE="beta" + fi + ALL_RESULTS="${{ join(needs.*.result, ',') }}" + + if [[ "${ALL_RESULTS}" != *"failure"* ]]; then + echo "βœ… Successfully released boringNotch v${{ needs.preparation.outputs.version }} ($BUILD_TYPE build ${{ needs.preparation.outputs.build_number }})" >> $GITHUB_STEP_SUMMARY + echo "🍺 Homebrew cask updated" >> $GITHUB_STEP_SUMMARY + echo "πŸ“± Sparkle appcast updated" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Release failed for boringNotch v${{ needs.preparation.outputs.version }}" >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/.github/workflows/update-version-dropdown.yml b/.github/workflows/update-version-dropdown.yml index f90dfbff..1100edd3 100644 --- a/.github/workflows/update-version-dropdown.yml +++ b/.github/workflows/update-version-dropdown.yml @@ -20,10 +20,8 @@ jobs: - name: Get latest 5 versions with placeholder id: tags run: | - TAGS=$(git tag --sort=-v:refname | head -n 5 | jq -R . | jq -sc .) - PLACEHOLDER=$(echo '"Select a version"' | jq -s .) - # Merge placeholder + tags - VERSIONS=$(jq -s '.[0] + .[1]' <(echo "$PLACEHOLDER") <(echo "$TAGS")) + TAGS=$(git tag --sort=-v:refname | head -n 5 | jq -R -s -c 'split("\n")[:-1]') + VERSIONS=$(echo '["Select a version"]' | jq -c --argjson tags "$TAGS" '. + $tags') echo "versions=$VERSIONS" >> $GITHUB_OUTPUT - name: Issue Forms Dropdown Options diff --git a/Configuration/sparkle/generate_appcast b/Configuration/sparkle/generate_appcast new file mode 100755 index 00000000..f41a25b1 Binary files /dev/null and b/Configuration/sparkle/generate_appcast differ diff --git a/README.md b/README.md index 17e22b25..c3f9c819 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@


- Markdownify + Boring Notch
Boring Notch
@@ -8,6 +8,7 @@

+ TheBoringNotch Build & Test Discord Badge @@ -79,6 +80,7 @@ brew install --cask TheBoredTeam/boring-notch/boring-notch --no-quarantine - [x] Customizable gesture control πŸ‘†πŸ» - [x] Shelf functionality with AirDrop πŸ“š - [x] Notch sizing customization, finetuning on different display sizes πŸ–₯️ +- [ ] Reminders integration β˜‘οΈ - [ ] Customizable Layout options πŸ› οΈ - [ ] Extension system 🧩 - [ ] System HUD replacements (volume, brightness, backlight) πŸŽšοΈπŸ’‘βŒ¨οΈ diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index e8e49e97..5cdc15d9 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -117,6 +117,13 @@ B1D6FD432C6603730015F173 /* SoftwareUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; }; + 4FE1DB2D7EEFED053B532466 /* Note.swift in Sources */ = {isa = PBXBuildFile; fileRef = 414CD641EB6D5FB3744A39CC /* Note.swift */; }; + 67C6D19FF4A05CF117B8FADE /* ClipboardItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3365ED066F8195C12271BE /* ClipboardItem.swift */; }; + A3C18519B5AD6C4EAB732349 /* NotesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916220DCD8B4C4D58C9AF400 /* NotesManager.swift */; }; + 58A5C4D827454B5577722C2A /* ClipboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0BB900699E45C9B6C57F69D /* ClipboardManager.swift */; }; + B47BA39A4E404BA6F1E6440B /* NotchNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E50A104454AD58224725A73 /* NotchNotesView.swift */; }; + E11BB313197800BAC919D625 /* NotchClipboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382AA1CC5B768EFB6E40454 /* NotchClipboardView.swift */; }; + D23981D002AAD488350F7F7E /* NotesClipboardSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7062E9BACEF76833B4B524F3 /* NotesClipboardSettings.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -248,6 +255,13 @@ B1ECFA032C6FE58A002ACD87 /* CoreDisplay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreDisplay.framework; path = ../../../../../System/Library/Frameworks/CoreDisplay.framework; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; + 1E50A104454AD58224725A73 /* NotchNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchNotesView.swift; sourceTree = ""; }; + F382AA1CC5B768EFB6E40454 /* NotchClipboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchClipboardView.swift; sourceTree = ""; }; + 916220DCD8B4C4D58C9AF400 /* NotesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesManager.swift; sourceTree = ""; }; + C0BB900699E45C9B6C57F69D /* ClipboardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardManager.swift; sourceTree = ""; }; + 414CD641EB6D5FB3744A39CC /* Note.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Note.swift; sourceTree = ""; }; + 3B3365ED066F8195C12271BE /* ClipboardItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardItem.swift; sourceTree = ""; }; + 7062E9BACEF76833B4B524F3 /* NotesClipboardSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesClipboardSettings.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -374,6 +388,8 @@ F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, + 916220DCD8B4C4D58C9AF400 /* NotesManager.swift */, + C0BB900699E45C9B6C57F69D /* ClipboardManager.swift */, 149E0B962C737D00006418B1 /* WebcamManager.swift */, 147CB9562C8CCC980094C254 /* BoringExtensionManager.swift */, 14C08BB52C8DE42D000F8AA0 /* CalendarManager.swift */, @@ -493,6 +509,8 @@ 14D570C72C5F38760011E668 /* models */ = { isa = PBXGroup; children = ( + 414CD641EB6D5FB3744A39CC /* Note.swift */, + 3B3365ED066F8195C12271BE /* ClipboardItem.swift */, 1163988E2DF5CC870052E6AF /* CalendarModel.swift */, 1163988C2DF5CAB40052E6AF /* EventModel.swift */, B19016232CC15B4D00E3F12E /* Constants.swift */, @@ -581,6 +599,8 @@ 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9A987A032C73CA66005CA465 /* NotchShelfView.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, + 1E50A104454AD58224725A73 /* NotchNotesView.swift */, + F382AA1CC5B768EFB6E40454 /* NotchClipboardView.swift */, 14D570C52C5F38210011E668 /* BoringHeader.swift */, 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */, 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */, @@ -596,6 +616,7 @@ B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */, B1B112902C6A572100093D8F /* EditPanelView.swift */, B1C448972C972CC4001F0858 /* ListItemPopover.swift */, + 7062E9BACEF76833B4B524F3 /* NotesClipboardSettings.swift */, ); path = Settings; sourceTree = ""; @@ -751,6 +772,8 @@ 14D570B92C5E98A20011E668 /* drop.swift in Sources */, 1153BDA72D99B22200979FB0 /* YouTubeMusicController.swift in Sources */, 1163988F2DF5CC870052E6AF /* CalendarModel.swift in Sources */, + 4FE1DB2D7EEFED053B532466 /* Note.swift in Sources */, + 67C6D19FF4A05CF117B8FADE /* ClipboardItem.swift in Sources */, 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */, B19424092CD0FF01003E5DC2 /* LottieAnimationView.swift in Sources */, 9A987A072C73CA66005CA465 /* Ext+NSImage.swift in Sources */, @@ -763,6 +786,8 @@ B10A848A2C7BCC940088BFFC /* AirDropView.swift in Sources */, 149E0B9A2C737D40006418B1 /* WebcamView.swift in Sources */, 1471639A2C5D35FF0068B555 /* MusicManager.swift in Sources */, + A3C18519B5AD6C4EAB732349 /* NotesManager.swift in Sources */, + 58A5C4D827454B5577722C2A /* ClipboardManager.swift in Sources */, B1B112932C6A577E00093D8F /* MouseTracker.swift in Sources */, 9A987A062C73CA66005CA465 /* Ext+NSAlert.swift in Sources */, B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */, @@ -772,6 +797,8 @@ 1153BD8F2D986B1F00979FB0 /* MediaControllerProtocol.swift in Sources */, 9A987A092C73CA66005CA465 /* Ext+URL.swift in Sources */, 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */, + B47BA39A4E404BA6F1E6440B /* NotchNotesView.swift in Sources */, + E11BB313197800BAC919D625 /* NotchClipboardView.swift in Sources */, B172AAC02C95DA0B001623F1 /* InlineHUD.swift in Sources */, 14E9FEAA2C70BF610062E83F /* DownloadView.swift in Sources */, B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */, @@ -789,6 +816,7 @@ B10F84A32C6C9596009F3026 /* TestView.swift in Sources */, 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */, 11C5E3162DFE88510065821E /* SettingsView.swift in Sources */, + D23981D002AAD488350F7F7E /* NotesClipboardSettings.swift in Sources */, 14D570D52C5F710B0011E668 /* constants.swift in Sources */, B1628BA82CC40C4F003D8DF3 /* DragDropView.swift in Sources */, 1153BD932D986E4300979FB0 /* AppleMusicController.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index e80cf76d..9d94372a 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -98,8 +98,19 @@ struct ContentView: View { .panGesture(direction: .down) { translation, phase in handleDownGesture(translation: translation, phase: phase) } + .conditionalModifier(vm.notchState == .closed) { view in + view.onTapGesture { + doOpen() + } + } + .conditionalModifier(Defaults[.enableGestures] && vm.notchState == .closed) { view in + view + .panGesture(direction: .down) { translation, phase in + handleDownGesture(translation: translation, phase: phase) + } + } } - .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures] && vm.notchState == .open) { view in view .panGesture(direction: .up) { translation, phase in handleUpGesture(translation: translation, phase: phase) @@ -123,6 +134,13 @@ struct ContentView: View { isHovering = false } } + if newState == .closed { + NotificationCenter.default.post( + name: .boringNotchWindowKeyboardFocus, + object: nil, + userInfo: ["allow": false] + ) + } } .onChange(of: vm.isBatteryPopoverActive) { if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open { @@ -257,6 +275,12 @@ struct ContentView: View { NotchHomeView(albumArtNamespace: albumArtNamespace) case .shelf: NotchShelfView() + case .notes where Defaults[.enableNotes]: + NotchNotesView() + case .clipboard where Defaults[.enableClipboardHistory]: + NotchClipboardView() + default: + NotchHomeView(albumArtNamespace: albumArtNamespace) } } } diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index f893515e..94801cff 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -807,6 +807,26 @@ }, "shouldTranslate" : false }, + "%lld day%@" : { + "comment" : "The text that follows a slider in the \"Clipboard History\" section of the settings view, indicating the current value of the slider.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld day%2$@" + } + } + } + }, + "%lld items" : { + "comment" : "A label displaying the number of items in the clipboard history. The argument is the count of items in the history.", + "isCommentAutoGenerated" : true + }, + "%lld seconds" : { + "comment" : "A text label displaying the current value of the auto-save interval, formatted as an integer followed by the text \"seconds\".", + "isCommentAutoGenerated" : true + }, "%lld%%" : { "localizations" : { "ar" : { @@ -1609,6 +1629,10 @@ } } }, + "Add Application" : { + "comment" : "A button label that, when tapped, presents a sheet to add an application to the list of those whose clipboard data is not captured.", + "isCommentAutoGenerated" : true + }, "Add manually" : { "localizations" : { "ar" : { @@ -2709,6 +2733,10 @@ } } }, + "Auto-save interval" : { + "comment" : "A label describing the interval at which notes are automatically saved.", + "isCommentAutoGenerated" : true + }, "Automatically check for updates" : { "localizations" : { "ar" : { @@ -4609,6 +4637,30 @@ } } }, + "Clear All" : { + "comment" : "The title of a button that clears all excluded apps.", + "isCommentAutoGenerated" : true + }, + "Click Add to pick an application." : { + "comment" : "A description below the button to add an excluded application, explaining that users can do so by clicking the button.", + "isCommentAutoGenerated" : true + }, + "Clipboard" : { + "comment" : "The title of the settings page related to clipboard functionality.", + "isCommentAutoGenerated" : true + }, + "Clipboard events from these apps are ignored." : { + "comment" : "A description under the section header for managing excluded apps.", + "isCommentAutoGenerated" : true + }, + "Clipboard History" : { + "comment" : "A section in the settings for managing clipboard history.", + "isCommentAutoGenerated" : true + }, + "Clipboard history is disabled." : { + "comment" : "A message displayed when clipboard history is disabled.", + "isCommentAutoGenerated" : true + }, "Close" : { "localizations" : { "ar" : { @@ -5009,6 +5061,18 @@ } } }, + "Copied" : { + "comment" : "A tooltip that appears when the text is successfully copied to the clipboard.", + "isCommentAutoGenerated" : true + }, + "Copy" : { + "comment" : "A context menu option to copy a clipboard item.", + "isCommentAutoGenerated" : true + }, + "Copy something to start building history." : { + "comment" : "A message displayed when the clipboard is empty and history is enabled.", + "isCommentAutoGenerated" : true + }, "Corner radius scaling" : { "localizations" : { "ar" : { @@ -5109,6 +5173,14 @@ } } }, + "Create or select a note from the list to begin editing." : { + "comment" : "A description displayed when no note is selected in the Notch Notes app.", + "isCommentAutoGenerated" : true + }, + "Create your first note to get started" : { + "comment" : "A description text displayed when there are no notes created yet.", + "isCommentAutoGenerated" : true + }, "Custom height" : { "localizations" : { "ar" : { @@ -5709,6 +5781,14 @@ } } }, + "Delete" : { + "comment" : "A button that deletes the selected clipboard items.", + "isCommentAutoGenerated" : true + }, + "Delete note" : { + "comment" : "A button label that triggers the deletion of a note.", + "isCommentAutoGenerated" : true + }, "Description" : { "localizations" : { "ar" : { @@ -7112,6 +7192,10 @@ } } }, + "Enable history" : { + "comment" : "A button that enables clipboard history.", + "isCommentAutoGenerated" : true + }, "Enable HUD replacement" : { "localizations" : { "ar" : { @@ -7312,6 +7396,10 @@ } } }, + "Enable notes in Settings to take quick jot-downs from the notch." : { + "comment" : "A description below a button that allows the user to enable notes in settings.", + "isCommentAutoGenerated" : true + }, "Enable shelf" : { "localizations" : { "ar" : { @@ -11017,6 +11105,10 @@ } } }, + "Maximum items" : { + "comment" : "A label displayed above a slider that allows the user to set the maximum number of clipboard history items to retain.", + "isCommentAutoGenerated" : true + }, "Media" : { "localizations" : { "ar" : { @@ -12817,6 +12909,14 @@ } } }, + "New note" : { + "comment" : "A button label that says \"New note\".", + "isCommentAutoGenerated" : true + }, + "No apps excluded" : { + "comment" : "A message displayed when there are no excluded apps in the clipboard settings.", + "isCommentAutoGenerated" : true + }, "No custom animation available" : { "localizations" : { "ar" : { @@ -13217,6 +13317,18 @@ } } }, + "No note selected" : { + "comment" : "A message displayed when no note is selected in the Notch Notes app.", + "isCommentAutoGenerated" : true + }, + "No notes yet" : { + "comment" : "A message displayed when a user has not created any notes yet.", + "isCommentAutoGenerated" : true + }, + "No results" : { + "comment" : "A message displayed when there are no search results for notes.", + "isCommentAutoGenerated" : true + }, "Non-notch display height" : { "localizations" : { "ar" : { @@ -13717,6 +13829,14 @@ } } }, + "Notes" : { + "comment" : "A section header for settings related to notes.", + "isCommentAutoGenerated" : true + }, + "Notes are disabled" : { + "comment" : "A message displayed when the notes feature is disabled.", + "isCommentAutoGenerated" : true + }, "OK" : { "localizations" : { "ar" : { @@ -14217,6 +14337,10 @@ } } }, + "Open Settings" : { + "comment" : "A button that opens the app settings when pressed.", + "isCommentAutoGenerated" : true + }, "Open shelf by default if items are present" : { "localizations" : { "ar" : { @@ -14317,6 +14441,14 @@ } } }, + "Pin" : { + "comment" : "A context menu option to pin a note.", + "isCommentAutoGenerated" : true + }, + "Pin note" : { + "comment" : "A tooltip that appears when hovering over the pin button in the note header.", + "isCommentAutoGenerated" : true + }, "Plugged In" : { "localizations" : { "ar" : { @@ -14417,6 +14549,10 @@ } } }, + "Privacy" : { + "comment" : "A section in the Clipboard settings that discusses privacy concerns.", + "isCommentAutoGenerated" : true + }, "Progressbar style" : { "localizations" : { "ar" : { @@ -15317,6 +15453,14 @@ } } }, + "Retention period" : { + "comment" : "A label describing the slider for the retention period of clipboard history.", + "isCommentAutoGenerated" : true + }, + "Rich text snippet" : { + "comment" : "A placeholder text indicating a rich text item in the clipboard.", + "isCommentAutoGenerated" : true + }, "Running" : { "localizations" : { "ar" : { @@ -15417,6 +15561,10 @@ } } }, + "Search notes..." : { + "comment" : "A placeholder text for a text field used to search through notes.", + "isCommentAutoGenerated" : true + }, "Select the music source you want to use. You can change this later in the app settings." : { "localizations" : { "ar" : { @@ -17617,6 +17765,10 @@ } } }, + "Start typing…" : { + "comment" : "A placeholder text displayed within the text editor when it is empty.", + "isCommentAutoGenerated" : true + }, "Stopped" : { "localizations" : { "ar" : { @@ -18617,6 +18769,10 @@ } } }, + "Unpin" : { + "comment" : "A button that pins or unpins a note.", + "isCommentAutoGenerated" : true + }, "Upgrade to Pro" : { "localizations" : { "ar" : { @@ -19818,5 +19974,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/boringNotch/components/Notch/BoringNotchWindow.swift b/boringNotch/components/Notch/BoringNotchWindow.swift index 7f4ac42c..ea3f8352 100644 --- a/boringNotch/components/Notch/BoringNotchWindow.swift +++ b/boringNotch/components/Notch/BoringNotchWindow.swift @@ -7,7 +7,14 @@ import Cocoa +extension Notification.Name { + static let boringNotchWindowKeyboardFocus = Notification.Name("BoringNotchWindowKeyboardFocus") +} + class BoringNotchWindow: NSPanel { + private static var allowsKeyboardFocus: Bool = false + private var keyboardObserver: NSObjectProtocol? + override init( contentRect: NSRect, styleMask: NSWindow.StyleMask, @@ -34,17 +41,49 @@ class BoringNotchWindow: NSPanel { .canJoinAllSpaces, .ignoresCycle, ] - + isReleasedWhenClosed = false level = .mainMenu + 3 hasShadow = false + becomesKeyOnlyIfNeeded = true + + keyboardObserver = NotificationCenter.default.addObserver( + forName: .boringNotchWindowKeyboardFocus, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + let allow = notification.userInfo?["allow"] as? Bool ?? false + Self.allowsKeyboardFocus = allow + if allow { + if !self.isKeyWindow { + NSApp.activate(ignoringOtherApps: true) + self.makeKeyAndOrderFront(nil) + } + } else { + if self.isKeyWindow { + self.resignKey() + } + } + } } - + + deinit { + if let keyboardObserver { + NotificationCenter.default.removeObserver(keyboardObserver) + } + } + override var canBecomeKey: Bool { - false + Self.allowsKeyboardFocus } - + override var canBecomeMain: Bool { - false + Self.allowsKeyboardFocus + } + + override func resignKey() { + super.resignKey() + Self.allowsKeyboardFocus = false } } diff --git a/boringNotch/components/Notch/NotchClipboardView.swift b/boringNotch/components/Notch/NotchClipboardView.swift new file mode 100644 index 00000000..2b6ee886 --- /dev/null +++ b/boringNotch/components/Notch/NotchClipboardView.swift @@ -0,0 +1,252 @@ +import SwiftUI +import Defaults +import AppKit + +struct NotchClipboardView: View { + @ObservedObject private var clipboard = ClipboardManager.shared + @Default(.enableClipboardHistory) private var historyEnabled + @State private var selection = Set() + @State private var recentlyCopiedID: UUID? + @State private var copyResetWorkItem: DispatchWorkItem? + + private var items: [ClipboardItem] { + clipboard.filteredItems + } + + var body: some View { + content + .overlay(disabledOverlay) + .padding(.horizontal, 10) + .padding(.vertical, 12) + } + + private var content: some View { + ScrollView(.vertical, showsIndicators: true) { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(items) { item in + ClipboardCard( + item: item, + isSelected: selection.contains(item.id), + allowEditing: historyEnabled, + isCopied: recentlyCopiedID == item.id, + onDelete: { + clipboard.delete(id: item.id) + selection.remove(item.id) + }, + onTogglePin: { + clipboard.toggleFavorite(for: item.id) + }, + onCopy: { + handleCopy(item) + } + ) + .onTapGesture { + handleSelection(for: item.id, commandPressed: NSEvent.modifierFlags.contains(.command)) + handleCopy(item) + } + .contextMenu { + Button(item.isFavorite ? "Unpin" : "Pin") { + clipboard.toggleFavorite(for: item.id) + } + Button("Copy") { + clipboard.recopyToPasteboard(item) + } + Divider() + Button(role: .destructive) { + clipboard.delete(id: item.id) + selection.remove(item.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .disabled(!historyEnabled) + .overlay(alignment: .center) { + if items.isEmpty { + emptyState + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "doc.on.clipboard") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(historyEnabled ? "Copy something to start building history." : "Clipboard history is disabled.") + .foregroundStyle(.secondary) + if !historyEnabled { + Button("Enable history") { + historyEnabled = true + } + .buttonStyle(.borderedProminent) + } + } + .padding() + } + + private var disabledOverlay: some View { + Group { + if !historyEnabled { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.black.opacity(0.45)) + } + } + } + + private var columns: [GridItem] { + [GridItem(.adaptive(minimum: 180, maximum: .infinity), spacing: 8, alignment: .top)] + } + + private func handleSelection(for id: UUID, commandPressed: Bool) { + guard historyEnabled else { return } + if commandPressed { + if selection.contains(id) { + selection.remove(id) + } else { + selection.insert(id) + } + } else { + selection = [id] + } + } + + private func deleteSelection() { + guard !selection.isEmpty else { return } + clipboard.delete(ids: selection) + selection.removeAll() + } + + private func handleCopy(_ item: ClipboardItem) { + clipboard.recopyToPasteboard(item) + recentlyCopiedID = item.id + + copyResetWorkItem?.cancel() + let workItem = DispatchWorkItem { [recentlyCopiedIDSetter = { self.recentlyCopiedID = $0 }] in + DispatchQueue.main.async { + recentlyCopiedIDSetter(nil) + } + } + copyResetWorkItem = workItem + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 1.5, execute: workItem) + } +} + +private struct ClipboardCard: View { + let item: ClipboardItem + let isSelected: Bool + let allowEditing: Bool + let isCopied: Bool + let onDelete: () -> Void + let onTogglePin: () -> Void + let onCopy: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + preview + + HStack(spacing: 8) { + Spacer() + Button(action: onCopy) { + Image(systemName: isCopied ? "checkmark.circle.fill" : "doc.on.doc") + .font(.caption) + .foregroundStyle(isCopied ? Color.green : Color.primary) + } + .buttonStyle(.plain) + .help(isCopied ? "Copied" : "Copy") + + Button(action: onTogglePin) { + Image(systemName: item.isFavorite ? "pin.fill" : "pin") + .font(.caption) + .foregroundStyle(item.isFavorite ? .yellow : .primary) + } + .buttonStyle(.plain) + .help(item.isFavorite ? "Unpin" : "Pin") + + if allowEditing { + Button(action: onDelete) { + Image(systemName: "trash") + .font(.caption) + } + .buttonStyle(.plain) + .help("Delete") + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(width: 180, height: 100, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(isSelected ? 0.16 : 0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.white.opacity(0.08), lineWidth: isSelected ? 2 : 1) + ) + } + + private var preview: some View { + Group { + switch item.kind { + case .text, .html: + if let string = String(data: item.data, encoding: .utf8) { + Text(string) + .font(.subheadline) + .foregroundStyle(.primary) + .lineLimit(3) + } + case .rtf: + Text("Rich text snippet") + .font(.subheadline) + .foregroundStyle(.primary) + .lineLimit(3) + case .image: + if let image = NSImage(data: item.data) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + case .fileURL: + Text(item.preview) + .font(.subheadline) + .foregroundStyle(.primary) + .lineLimit(2) + } + } + } + + private var fallbackPreview: some View { + Text(item.preview) + .font(.callout) + .foregroundStyle(.secondary) + } +} + +private extension ClipboardKind { + var title: String { + switch self { + case .text: return "Text" + case .image: return "Image" + case .fileURL: return "File" + case .rtf: return "RTF" + case .html: return "HTML" + } + } + + var iconName: String { + switch self { + case .text: return "text.alignleft" + case .image: return "photo" + case .fileURL: return "doc" + case .rtf: return "doc.richtext" + case .html: return "curlybraces" + } + } +} diff --git a/boringNotch/components/Notch/NotchNotesView.swift b/boringNotch/components/Notch/NotchNotesView.swift new file mode 100644 index 00000000..6b8d38ab --- /dev/null +++ b/boringNotch/components/Notch/NotchNotesView.swift @@ -0,0 +1,373 @@ +import SwiftUI +import Defaults + +struct NotchNotesView: View { + @ObservedObject private var notesManager = NotesManager.shared + @Default(.enableNotes) private var notesEnabled + @FocusState private var editorFocused: Bool + + private var selectedNote: Note? { + notesManager.note(for: notesManager.selectedNoteID) + } + + var body: some View { + Group { + if notesEnabled { + GeometryReader { _ in + HStack(spacing: 0) { + editorPane + .frame(minWidth: 360, maxWidth: .infinity) + .layoutPriority(1) + .allowsHitTesting(true) + + Divider() + .background(Color.white.opacity(0.1)) + + sidebar + .frame(minWidth: 180, idealWidth: 210, maxWidth: 240) + .allowsHitTesting(true) + .background(Color.black.opacity(0.04)) + } + .allowsHitTesting(true) + } + } else { + disabledState + } + } + .onAppear { + warmupIfNeeded() + NotificationCenter.default.post( + name: .boringNotchWindowKeyboardFocus, + object: nil, + userInfo: ["allow": true] + ) + } + .onChange(of: notesEnabled) { enabled in + if enabled { + warmupIfNeeded() + } + } + .onChange(of: notesManager.selectedNoteID) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if selectedNote != nil { + editorFocused = true + } else { + editorFocused = false + } + } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if selectedNote != nil { + editorFocused = true + } else { + editorFocused = false + } + } + } + .onDisappear { + NotificationCenter.default.post( + name: .boringNotchWindowKeyboardFocus, + object: nil, + userInfo: ["allow": false] + ) + } + } + + // MARK: - Editor + + private var editorPane: some View { + Group { + if let note = selectedNote { + ZStack(alignment: .topLeading) { + TextEditor(text: binding(for: note)) + .focused($editorFocused) + .font(note.isMonospaced ? .system(.body, design: .monospaced) : .system(.body)) + .scrollContentBackground(.hidden) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white.opacity(editorFocused ? 0.12 : 0.08)) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.2), value: editorFocused) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(editorFocused ? Color.accentColor.opacity(0.35) : Color.white.opacity(0.12), lineWidth: editorFocused ? 2 : 1) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.2), value: editorFocused) + ) + .contentShape(Rectangle()) + .onTapGesture { + editorFocused = true + } + .frame(maxWidth: CGFloat.infinity, maxHeight: CGFloat.infinity) + + if note.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Start typing…") + .font(.body) + .foregroundStyle(.secondary.opacity(0.7)) + .padding(.horizontal, 20) + .padding(.vertical, 18) + .allowsHitTesting(false) + } + } + } else { + VStack(spacing: 12) { + Image(systemName: "square.and.pencil") + .font(.title2) + .foregroundStyle(.secondary) + Text("No note selected") + .font(.title3.weight(.semibold)) + Text("Create or select a note from the list to begin editing.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: CGFloat.infinity, maxHeight: CGFloat.infinity) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: CGFloat.infinity, maxHeight: CGFloat.infinity, alignment: .topLeading) + } + + // MARK: - Sidebar + + private var sidebar: some View { + VStack(spacing: 12) { + // Search and Add Button + HStack(spacing: 8) { + TextField("Search notes...", text: $notesManager.searchText) + .textFieldStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + + Button(action: createAndFocusNote) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color.blue) + ) + } + .buttonStyle(.plain) + .help("New note") + } + .padding(.horizontal, 12) + .padding(.top, 12) + + if notesManager.filteredNotes.isEmpty { + VStack(spacing: 12) { + Image(systemName: "note.text") + .font(.system(size: 32)) + .foregroundStyle(.secondary.opacity(0.6)) + + VStack(spacing: 4) { + Text(notesManager.searchText.isEmpty ? "No notes yet" : "No results") + .font(.headline) + .foregroundStyle(.primary) + + if notesManager.searchText.isEmpty { + Text("Create your first note to get started") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(notesManager.filteredNotes) { note in + sidebarRow(for: note) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + } + } + } + + private func sidebarRow(for note: Note) -> some View { + let isSelected = note.id == notesManager.selectedNoteID + + return Button { + notesManager.selectedNoteID = note.id + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + editorFocused = true + } + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(note.headingTitle) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + if !note.previewText.isEmpty { + Text(note.previewText) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack { + Text(timestamp(note.updatedAt)) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Spacer() + } + } + + VStack(spacing: 8) { + Button { + notesManager.togglePinned(id: note.id) + } label: { + Image(systemName: note.isPinned ? "pin.fill" : "pin") + .font(.system(size: 12)) + .foregroundStyle(note.isPinned ? Color.yellow : .secondary) + } + .buttonStyle(.plain) + .opacity(isSelected ? 1 : 0.6) + .help(note.isPinned ? "Unpin" : "Pin note") + + Button(role: .destructive) { + notesManager.deleteNotes(with: Set([note.id])) + warmupIfNeeded() + } label: { + Image(systemName: "trash") + .font(.system(size: 12)) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .opacity(isSelected ? 1 : 0.6) + .help("Delete note") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected ? Color.blue.opacity(0.2) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(isSelected ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .contextMenu { + Button(note.isPinned ? "Unpin" : "Pin") { + notesManager.togglePinned(id: note.id) + } + Button("Delete", role: .destructive) { + notesManager.deleteNotes(with: Set([note.id])) + warmupIfNeeded() + } + } + } + + // MARK: - Helpers + + private func binding(for note: Note) -> Binding { + Binding( + get: { notesManager.note(for: note.id)?.content ?? note.content }, + set: { newValue in + notesManager.updateNote(id: note.id) { $0.content = newValue } + } + ) + } + + private func createAndFocusNote() { + let note = notesManager.createNote(initialContent: "") + notesManager.selectedNoteID = note.id + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + editorFocused = true + } + } + + private func warmup() { + if notesManager.note(for: notesManager.selectedNoteID) == nil, + let first = notesManager.filteredNotes.first { + notesManager.selectedNoteID = first.id + } + } + + private func warmupIfNeeded() { + guard notesEnabled else { return } + warmup() + } + + private var disabledState: some View { + VStack(spacing: 12) { + Image(systemName: "note.text") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Notes are disabled") + .font(.title3.weight(.semibold)) + Text("Enable notes in Settings to take quick jot-downs from the notch.") + .foregroundStyle(.secondary) + Button("Open Settings") { + SettingsWindowController.shared.showWindow() + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: CGFloat.infinity, maxHeight: CGFloat.infinity) + } + + private func timestamp(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .shortened) + } +} + +private struct NoteListRow: View { + let note: Note + let lastUpdated: String + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(note.headingTitle) + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + if !note.previewText.isEmpty { + Text(note.previewText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .multilineTextAlignment(.leading) + } + + HStack { + Text(lastUpdated) + .font(.caption2) + .foregroundStyle(isSelected ? Color.white.opacity(0.8) : Color.secondary.opacity(0.6)) + + Spacer() + + if note.isPinned { + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + } + } + .frame(maxWidth: CGFloat.infinity, alignment: .leading) + } +} diff --git a/boringNotch/components/Settings/NotesClipboardSettings.swift b/boringNotch/components/Settings/NotesClipboardSettings.swift new file mode 100644 index 00000000..b38ebe04 --- /dev/null +++ b/boringNotch/components/Settings/NotesClipboardSettings.swift @@ -0,0 +1,222 @@ +import SwiftUI +import Defaults +import AppKit +import UniformTypeIdentifiers + +struct NotesSettings: View { + @Default(.notesAutoSaveInterval) private var autoSaveInterval + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable Notes", key: .enableNotes) + Defaults.Toggle("Default to monospace font", key: .notesDefaultMonospace) + Slider(value: $autoSaveInterval, in: 1...10, step: 1) { + Text("Auto-save interval") + } + Text("\(Int(autoSaveInterval)) seconds") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Notes") + } + } + .navigationTitle("Notes") + } +} + +struct ClipboardSettings: View { + @Default(.clipboardRetentionDays) private var retentionDays + @Default(.clipboardMaxItems) private var maxItems + @Default(.clipboardExcludedApps) private var excludedApps + + private var retentionBinding: Binding { + Binding( + get: { Double(retentionDays) }, + set: { retentionDays = Int($0.rounded()) } + ) + } + + private var maxItemsBinding: Binding { + Binding( + get: { Double(maxItems) }, + set: { maxItems = Int($0.rounded()) } + ) + } + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable clipboard history", key: .enableClipboardHistory) + Defaults.Toggle("Capture images", key: .clipboardCaptureImages) + Defaults.Toggle("Capture rich text", key: .clipboardCaptureRichText) + + Slider(value: retentionBinding, in: 1...30, step: 1) { + Text("Retention period") + } + Text("\(retentionDays) day\(retentionDays == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + + Slider(value: maxItemsBinding, in: 25...100, step: 5) { + Text("Maximum items") + } + Text("\(maxItems) items") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Clipboard History") + } + + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Clipboard events from these apps are ignored.") + .font(.caption) + .foregroundStyle(.secondary) + + if excludedApps.isEmpty { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + .overlay( + VStack(spacing: 6) { + Image(systemName: "app.dashed") + .font(.title3) + .foregroundStyle(.secondary) + Text("No apps excluded") + .font(.headline) + Text("Click Add to pick an application.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + ) + .frame(height: 110) + } else { + VStack(spacing: 6) { + ForEach(excludedApps, id: \.self) { bundleID in + ExcludedAppRow(bundleIdentifier: bundleID) { + removeBundleID(bundleID) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.15)) + ) + } + + HStack { + Button { + presentApplicationPicker() + } label: { + Label("Add Application", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + + if !excludedApps.isEmpty { + Button("Clear All", role: .destructive) { + excludedApps.removeAll() + } + .buttonStyle(.bordered) + } + } + } + } header: { + Text("Privacy") + } + } + .navigationTitle("Clipboard") + } + + private func presentApplicationPicker() { + let panel = NSOpenPanel() + panel.title = "Choose Application" + panel.message = "Pick an application whose clipboard activity should be ignored." + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.applicationBundle] + panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) + + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + guard let bundle = Bundle(url: url), let identifier = bundle.bundleIdentifier else { return } + if excludedApps.contains(identifier) { return } + DispatchQueue.main.async { + withAnimation { + excludedApps.append(identifier) + } + } + } + } + + private func removeBundleID(_ bundleID: String) { + guard let index = excludedApps.firstIndex(of: bundleID) else { return } + withAnimation { + excludedApps.remove(at: index) + } + } +} + +private struct ExcludedAppRow: View { + let bundleIdentifier: String + let onRemove: () -> Void + + private var appURL: URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + } + + private var displayName: String { + if let url = appURL, + let bundle = Bundle(url: url), + let name = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String + { + return name + } + return bundleIdentifier + } + + private var icon: NSImage? { + if let url = appURL { + return NSWorkspace.shared.icon(forFile: url.path) + } + return NSWorkspace.shared.icon(forFileType: NSFileTypeForHFSTypeCode(OSType(kGenericApplicationIcon))) + } + + var body: some View { + HStack(spacing: 12) { + Image(nsImage: icon ?? NSImage()) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.headline) + Text(bundleIdentifier) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + Button(role: .destructive, action: onRemove) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + } +} diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index 357a52e8..8fc79cfa 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -60,6 +60,22 @@ struct SettingsView: View { NavigationLink(value: "Shelf") { Label("Shelf", systemImage: "books.vertical") } + NavigationLink(value: "Notes") { + Label { + Text("Notes") + } icon: { + Image(systemName: "note.text") + .symbolRenderingMode(.monochrome) + } + } + NavigationLink(value: "Clipboard") { + Label { + Text("Clipboard") + } icon: { + Image(systemName: "doc.on.clipboard") + .symbolRenderingMode(.monochrome) + } + } NavigationLink(value: "Shortcuts") { Label("Shortcuts", systemImage: "keyboard") } @@ -90,6 +106,10 @@ struct SettingsView: View { Charge() case "Shelf": Shelf() + case "Notes": + NotesSettings() + case "Clipboard": + ClipboardSettings() case "Shortcuts": Shortcuts() case "Extensions": diff --git a/boringNotch/components/Tabs/TabSelectionView.swift b/boringNotch/components/Tabs/TabSelectionView.swift index b99d4af1..6cb79592 100644 --- a/boringNotch/components/Tabs/TabSelectionView.swift +++ b/boringNotch/components/Tabs/TabSelectionView.swift @@ -6,25 +6,23 @@ // import SwiftUI +import Defaults -struct TabModel: Identifiable { - let id = UUID() +struct TabModel { let label: String let icon: String let view: NotchViews } -let tabs = [ - TabModel(label: "Home", icon: "house.fill", view: .home), - TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) -] - struct TabSelectionView: View { @ObservedObject var coordinator = BoringViewCoordinator.shared + @Default(.enableNotes) private var notesEnabled + @Default(.enableClipboardHistory) private var clipboardEnabled @Namespace var animation var body: some View { + let tabs = availableTabs HStack(spacing: 0) { - ForEach(tabs) { tab in + ForEach(tabs, id: \.view) { tab in TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { withAnimation(.smooth) { coordinator.currentView = tab.view @@ -47,6 +45,34 @@ struct TabSelectionView: View { } } .clipShape(Capsule()) + .onAppear { sanitizeSelection(for: tabs) } + .onChange(of: notesEnabled) { _ in sanitizeSelection(for: availableTabs) } + .onChange(of: clipboardEnabled) { _ in sanitizeSelection(for: availableTabs) } + } + + private var availableTabs: [TabModel] { + var items: [TabModel] = [ + TabModel(label: "Home", icon: "house.fill", view: .home), + TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) + ] + if notesEnabled { + items.append(TabModel(label: "Notes", icon: "note.text", view: .notes)) + } + if clipboardEnabled { + items.append(TabModel(label: "Clipboard", icon: "doc.on.clipboard", view: .clipboard)) + } + return items + } + + private func sanitizeSelection(for tabs: [TabModel]) { + guard !tabs.isEmpty else { return } + if !tabs.contains(where: { $0.view == coordinator.currentView }) { + if let fallback = tabs.first?.view { + withAnimation(.smooth) { + coordinator.currentView = fallback + } + } + } } } diff --git a/boringNotch/enums/generic.swift b/boringNotch/enums/generic.swift index e70b4595..e20a93d2 100644 --- a/boringNotch/enums/generic.swift +++ b/boringNotch/enums/generic.swift @@ -24,9 +24,11 @@ public enum NotchState { case open } -public enum NotchViews { +public enum NotchViews: Hashable { case home case shelf + case notes + case clipboard } enum SettingsEnum { diff --git a/boringNotch/managers/ClipboardManager.swift b/boringNotch/managers/ClipboardManager.swift new file mode 100644 index 00000000..611efbf0 --- /dev/null +++ b/boringNotch/managers/ClipboardManager.swift @@ -0,0 +1,541 @@ +import AppKit +import Combine +import Defaults +import Foundation +import SQLite3 + +private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +private actor ClipboardStore { + private var db: OpaquePointer? + private let databaseURL: URL + + init(url: URL) { + databaseURL = url + ensureDirectory() + openDatabase() + createTablesIfNeeded() + } + + deinit { + if let db { + sqlite3_close(db) + } + } + + func fetchAll(limit: Int) -> [ClipboardItem] { + guard let db else { return [] } + let query = """ + SELECT id, kind, data, preview, createdAt, isFavorite, sourceApp, contentHash + FROM clipboard_items + ORDER BY createdAt DESC + LIMIT ? + """ + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { + logSQLiteError("prepare fetchAll") + return [] + } + + sqlite3_bind_int(statement, 1, Int32(limit)) + + var items: [ClipboardItem] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard + let idCString = sqlite3_column_text(statement, 0), + let kindCString = sqlite3_column_text(statement, 1), + let blobPointer = sqlite3_column_blob(statement, 2) + else { continue } + + let id = UUID(uuidString: String(cString: idCString)) + let kindRaw = String(cString: kindCString) + let dataSize = Int(sqlite3_column_bytes(statement, 2)) + let blobData = Data(bytes: blobPointer, count: dataSize) + let preview = String(cString: sqlite3_column_text(statement, 3)) + let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 4)) + let isFavorite = sqlite3_column_int(statement, 5) == 1 + let sourceApp: String? + if let text = sqlite3_column_text(statement, 6) { + sourceApp = String(cString: text) + } else { + sourceApp = nil + } + let hash = String(cString: sqlite3_column_text(statement, 7)) + + guard let kind = ClipboardKind(rawValue: kindRaw), let id else { continue } + + let item = ClipboardItem( + id: id, + kind: kind, + data: blobData, + preview: preview, + createdAt: createdAt, + isFavorite: isFavorite, + sourceApp: sourceApp, + contentHash: hash + ) + items.append(item) + } + + return items + } + + func upsert(_ item: ClipboardItem) { + guard let db else { return } + let sql = """ + INSERT INTO clipboard_items ( + id, kind, data, preview, createdAt, isFavorite, sourceApp, contentHash + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(contentHash) DO UPDATE SET + id = excluded.id, + kind = excluded.kind, + data = excluded.data, + preview = excluded.preview, + createdAt = excluded.createdAt, + sourceApp = excluded.sourceApp, + isFavorite = CASE WHEN clipboard_items.isFavorite = 1 THEN 1 ELSE excluded.isFavorite END + """ + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSQLiteError("prepare upsert") + return + } + + bindText(item.id.uuidString, index: 1, statement: statement) + bindText(item.kind.rawValue, index: 2, statement: statement) + item.data.withUnsafeBytes { buffer in + sqlite3_bind_blob(statement, 3, buffer.baseAddress, Int32(buffer.count), SQLITE_TRANSIENT) + } + bindText(item.preview, index: 4, statement: statement) + sqlite3_bind_double(statement, 5, item.createdAt.timeIntervalSince1970) + sqlite3_bind_int(statement, 6, item.isFavorite ? 1 : 0) + if let source = item.sourceApp { + bindText(source, index: 7, statement: statement) + } else { + sqlite3_bind_null(statement, 7) + } + bindText(item.contentHash, index: 8, statement: statement) + + if sqlite3_step(statement) != SQLITE_DONE { + logSQLiteError("execute upsert") + } + } + + func delete(ids: [UUID]) { + guard let db, !ids.isEmpty else { return } + let sql = "DELETE FROM clipboard_items WHERE id = ?" + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSQLiteError("prepare delete") + return + } + + for id in ids { + bindText(id.uuidString, index: 1, statement: statement) + if sqlite3_step(statement) != SQLITE_DONE { + logSQLiteError("execute delete") + } + sqlite3_reset(statement) + } + } + + func setFavorite(id: UUID, value: Bool) { + guard let db else { return } + let sql = "UPDATE clipboard_items SET isFavorite = ? WHERE id = ?" + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSQLiteError("prepare setFavorite") + return + } + + sqlite3_bind_int(statement, 1, value ? 1 : 0) + bindText(id.uuidString, index: 2, statement: statement) + + if sqlite3_step(statement) != SQLITE_DONE { + logSQLiteError("execute setFavorite") + } + } + + func prune(retentionDays: Int, maxItems: Int) { + guard let db else { return } + + let threshold = Date().addingTimeInterval(-Double(retentionDays) * 86_400).timeIntervalSince1970 + let deleteOldSQL = "DELETE FROM clipboard_items WHERE createdAt < ? AND isFavorite = 0" + + var deleteOldStatement: OpaquePointer? + if sqlite3_prepare_v2(db, deleteOldSQL, -1, &deleteOldStatement, nil) == SQLITE_OK { + sqlite3_bind_double(deleteOldStatement, 1, threshold) + if sqlite3_step(deleteOldStatement) != SQLITE_DONE { + logSQLiteError("execute prune old") + } + } + sqlite3_finalize(deleteOldStatement) + + let deleteOverflowSQL = """ + DELETE FROM clipboard_items + WHERE id IN ( + SELECT id FROM clipboard_items + WHERE isFavorite = 0 + ORDER BY createdAt DESC + LIMIT -1 OFFSET ? + ) + """ + + var deleteOverflowStatement: OpaquePointer? + if sqlite3_prepare_v2(db, deleteOverflowSQL, -1, &deleteOverflowStatement, nil) == SQLITE_OK { + sqlite3_bind_int(deleteOverflowStatement, 1, Int32(maxItems)) + if sqlite3_step(deleteOverflowStatement) != SQLITE_DONE { + logSQLiteError("execute prune overflow") + } + } + sqlite3_finalize(deleteOverflowStatement) + } + + private func ensureDirectory() { + let folderURL = databaseURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + } catch { + NSLog("ClipboardStore: Failed to create directory: \(error.localizedDescription)") + } + } + + private func openDatabase() { + if sqlite3_open(databaseURL.path, &db) != SQLITE_OK { + logSQLiteError("open database") + db = nil + } + } + + private func createTablesIfNeeded() { + guard let db else { return } + let createTableSQL = """ + CREATE TABLE IF NOT EXISTS clipboard_items ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + data BLOB NOT NULL, + preview TEXT NOT NULL, + createdAt REAL NOT NULL, + isFavorite INTEGER NOT NULL DEFAULT 0, + sourceApp TEXT, + contentHash TEXT NOT NULL UNIQUE + ) + """ + + if sqlite3_exec(db, createTableSQL, nil, nil, nil) != SQLITE_OK { + logSQLiteError("create table") + } + + let createIndexSQL = "CREATE INDEX IF NOT EXISTS idx_clipboard_createdAt ON clipboard_items(createdAt DESC)" + if sqlite3_exec(db, createIndexSQL, nil, nil, nil) != SQLITE_OK { + logSQLiteError("create index") + } + + let createHashIndexSQL = "CREATE UNIQUE INDEX IF NOT EXISTS idx_clipboard_hash ON clipboard_items(contentHash)" + if sqlite3_exec(db, createHashIndexSQL, nil, nil, nil) != SQLITE_OK { + logSQLiteError("create hash index") + } + } + + private func logSQLiteError(_ context: String) { + if let db, let errorCString = sqlite3_errmsg(db) { + let message = String(cString: errorCString) + NSLog("ClipboardStore: \(context) failed - \(message)") + } + } + + private func bindText(_ text: String, index: Int32, statement: OpaquePointer?) { + text.withCString { cString in + sqlite3_bind_text(statement, index, cString, -1, SQLITE_TRANSIENT) + } + } +} + +@MainActor +final class ClipboardManager: ObservableObject { + static let shared = ClipboardManager() + + @Published private(set) var items: [ClipboardItem] = [] + @Published var isMonitoring: Bool = false + @Published var searchText: String = "" + + private var pollingTimer: Timer? + private var changeCount = NSPasteboard.general.changeCount + private var cancellables = Set() + private let store: ClipboardStore + private let maxItemSizeBytes = 10 * 1_024 * 1_024 + + private init(fileManager: FileManager = .default) { + let supportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? documentsDirectory + let databaseURL = supportDirectory + .appendingPathComponent("boringNotch", isDirectory: true) + .appendingPathComponent("Clipboard", isDirectory: true) + .appendingPathComponent("clipboard.sqlite") + + store = ClipboardStore(url: databaseURL) + + setupSettingsObservers() + + Task { [weak self] in + guard let self else { return } + await self.reloadItems() + if Defaults[.enableClipboardHistory] { + self.startMonitoring() + } + } + } + + var filteredItems: [ClipboardItem] { + let sorted = items.sorted(by: ClipboardManager.sorter) + guard !searchText.isEmpty else { return sorted } + let query = searchText.lowercased() + return sorted.filter { item in + if item.preview.lowercased().contains(query) { return true } + if let source = item.sourceApp?.lowercased(), source.contains(query) { return true } + if item.kind == .text, let string = String(data: item.data, encoding: .utf8)?.lowercased(), + string.contains(query) + { + return true + } + return false + } + } + + private static func sorter(lhs: ClipboardItem, rhs: ClipboardItem) -> Bool { + if lhs.isFavorite != rhs.isFavorite { + return lhs.isFavorite && !rhs.isFavorite + } + return lhs.createdAt > rhs.createdAt + } + + func startMonitoring() { + guard !isMonitoring else { return } + isMonitoring = true + pollingTimer = Timer.scheduledTimer(withTimeInterval: 0.75, repeats: true) { [weak self] _ in + guard let self else { return } + self.checkForChanges() + } + } + + func stopMonitoring() { + isMonitoring = false + pollingTimer?.invalidate() + pollingTimer = nil + } + + func delete(id: UUID) { + delete(ids: Set([id])) + } + + func delete(ids: Set) { + guard !ids.isEmpty else { return } + Task { [weak self] in + guard let self else { return } + await self.store.delete(ids: Array(ids)) + await self.reloadItems() + } + } + + func toggleFavorite(for id: UUID) { + guard let item = items.first(where: { $0.id == id }) else { return } + Task { [weak self] in + await self?.store.setFavorite(id: id, value: !item.isFavorite) + await self?.reloadItems() + } + } + + func recopyToPasteboard(_ item: ClipboardItem) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + switch item.kind { + case .text, .html: + if let string = String(data: item.data, encoding: .utf8) { + pasteboard.setString(string, forType: item.kind == .text ? .string : .html) + } + case .rtf: + pasteboard.setData(item.data, forType: .rtf) + case .fileURL: + if let url = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSURL.self, from: item.data) as URL? { + pasteboard.writeObjects([url as NSURL]) + } + case .image: + if let image = NSImage(data: item.data) { + pasteboard.writeObjects([image]) + } + } + } + + func clearHistoryKeepingFavorites() { + Task { [weak self] in + guard let self else { return } + let favorites = self.items.filter { $0.isFavorite }.map { $0.id } + let nonFavorites = Set(self.items.map { $0.id }).subtracting(favorites) + await self.store.delete(ids: Array(nonFavorites)) + await self.reloadItems() + } + } + + private func checkForChanges() { + guard Defaults[.enableClipboardHistory] else { return } + let newCount = NSPasteboard.general.changeCount + guard newCount != changeCount else { return } + changeCount = newCount + captureClipboardContent() + } + + private func captureClipboardContent() { + guard shouldCaptureCurrentClipboard() else { return } + guard let item = buildClipboardItem() else { return } + + Task { [weak self] in + guard let self else { return } + await self.store.upsert(item) + await self.store.prune( + retentionDays: Defaults[.clipboardRetentionDays], + maxItems: Defaults[.clipboardMaxItems] + ) + await self.reloadItems() + } + } + + private func reloadItems() async { + let limit = Defaults[.clipboardMaxItems] + let items = await store.fetchAll(limit: limit) + self.items = items + } + + private func buildClipboardItem() -> ClipboardItem? { + let pasteboard = NSPasteboard.general + let sourceApp = NSWorkspace.shared.frontmostApplication?.localizedName + let now = Date() + + if let string = pasteboard.string(forType: .string), + let data = string.data(using: .utf8), validateSize(data.count) + { + return ClipboardItem( + kind: .text, + data: data, + preview: String(string.prefix(100)), + createdAt: now, + isFavorite: false, + sourceApp: sourceApp + ) + } + + if Defaults[.clipboardCaptureRichText], let data = pasteboard.data(forType: .rtf), validateSize(data.count) { + return ClipboardItem( + kind: .rtf, + data: data, + preview: "Rich Text", + createdAt: now, + isFavorite: false, + sourceApp: sourceApp + ) + } + + if let data = pasteboard.data(forType: .html), validateSize(data.count) { + return ClipboardItem( + kind: .html, + data: data, + preview: previewFromHTMLData(data), + createdAt: now, + isFavorite: false, + sourceApp: sourceApp + ) + } + + if Defaults[.clipboardCaptureImages], let image = NSImage(pasteboard: pasteboard) { + if let data = image.tiffRepresentation, validateSize(data.count) { + return ClipboardItem( + kind: .image, + data: data, + preview: "Image", + createdAt: now, + isFavorite: false, + sourceApp: sourceApp + ) + } + } + + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], let url = urls.first { + if let data = try? NSKeyedArchiver.archivedData(withRootObject: url as NSURL, requiringSecureCoding: true), + validateSize(data.count) + { + return ClipboardItem( + kind: .fileURL, + data: data, + preview: url.lastPathComponent, + createdAt: now, + isFavorite: false, + sourceApp: sourceApp + ) + } + } + + return nil + } + + private func shouldCaptureCurrentClipboard() -> Bool { + guard Defaults[.enableClipboardHistory] else { return false } + if let bundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + Defaults[.clipboardExcludedApps].contains(bundleID) + { + return false + } + return true + } + + private func validateSize(_ bytes: Int) -> Bool { + bytes <= maxItemSizeBytes + } + + private func previewFromHTMLData(_ data: Data) -> String { + guard let string = String(data: data, encoding: .utf8) else { return "HTML" } + let stripped = string.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) + return String(stripped.prefix(100)) + } + + private func setupSettingsObservers() { + Defaults.publisher(.enableClipboardHistory) + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + self.startMonitoring() + } else { + self.stopMonitoring() + } + } + .store(in: &cancellables) + + let retentionPublisher = Defaults.publisher(.clipboardRetentionDays).map { _ in () } + let maxPublisher = Defaults.publisher(.clipboardMaxItems).map { _ in () } + + retentionPublisher + .merge(with: maxPublisher) + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + Task { + await self.store.prune( + retentionDays: Defaults[.clipboardRetentionDays], + maxItems: Defaults[.clipboardMaxItems] + ) + await self.reloadItems() + } + } + .store(in: &cancellables) + } +} diff --git a/boringNotch/managers/NotesManager.swift b/boringNotch/managers/NotesManager.swift new file mode 100644 index 00000000..4da4685b --- /dev/null +++ b/boringNotch/managers/NotesManager.swift @@ -0,0 +1,175 @@ +import Foundation +import Defaults + +@MainActor +final class NotesManager: ObservableObject { + static let shared = NotesManager() + + @Published private(set) var notes: [Note] = [] + @Published var searchText: String = "" + @Published var selectedNoteID: UUID? + + var filteredNotes: [Note] { + let sorted = notes.sorted(by: NotesManager.sorter) + guard searchText.isEmpty else { + return sorted.filter { $0.content.localizedCaseInsensitiveContains(searchText) } + } + return sorted + } + + private let fileManager: FileManager + private let notesFolderURL: URL + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + private var saveQueue = DispatchQueue(label: "com.boringnotch.notes.save", qos: .utility) + private var saveWorkItems: [UUID: DispatchWorkItem] = [:] + + private init(fileManager: FileManager = .default) { + self.fileManager = fileManager + let supportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? documentsDirectory + notesFolderURL = supportDirectory.appendingPathComponent("boringNotch/Notes", isDirectory: true) + createNotesDirectoryIfNeeded() + loadNotes() + if notes.isEmpty && Defaults[.enableNotes] { + _ = createNote(initialContent: "") + } + } + + @discardableResult + func createNote(initialContent: String, isPinned: Bool = false, isMonospaced: Bool = Defaults[.notesDefaultMonospace]) -> Note { + let now = Date() + var note = Note(content: initialContent, createdAt: now, updatedAt: now, isPinned: isPinned, isMonospaced: isMonospaced) + notes.append(note) + selectedNoteID = note.id + scheduleSave(for: note) + return note + } + + func deleteNotes(with ids: Set) { + guard !ids.isEmpty else { return } + notes.removeAll { note in + if ids.contains(note.id) { + cancelPendingSave(for: note.id) + deleteFile(for: note.id) + return true + } + return false + } + if let current = selectedNoteID, ids.contains(current) { + selectedNoteID = notes.sorted(by: NotesManager.sorter).first?.id + } + } + + func updateNote(id: UUID, mutate block: (inout Note) -> Void) { + guard let index = notes.firstIndex(where: { $0.id == id }) else { return } + var note = notes[index] + block(¬e) + note.updatedAt = Date() + notes[index] = note + scheduleSave(for: note) + } + + func togglePinned(id: UUID) { + updateNote(id: id) { $0.isPinned.toggle() } + } + + func toggleMonospaced(id: UUID) { + updateNote(id: id) { $0.isMonospaced.toggle() } + } + + func note(for id: UUID?) -> Note? { + guard let id else { return nil } + return notes.first(where: { $0.id == id }) + } + + private func createNotesDirectoryIfNeeded() { + do { + try fileManager.createDirectory(at: notesFolderURL, withIntermediateDirectories: true) + } catch { + NSLog("NotesManager: Failed to create notes directory: \(error.localizedDescription)") + } + } + + private func loadNotes() { + do { + let files = try fileManager.contentsOfDirectory(at: notesFolderURL, includingPropertiesForKeys: nil) + let loaded = files.compactMap { url -> Note? in + guard url.pathExtension == "json" else { return nil } + do { + let data = try Data(contentsOf: url) + return try decoder.decode(Note.self, from: data) + } catch { + NSLog("NotesManager: Failed to decode note at \(url.lastPathComponent): \(error.localizedDescription)") + return nil + } + } + notes = loaded + selectedNoteID = loaded.sorted(by: NotesManager.sorter).first?.id + } catch { + NSLog("NotesManager: Failed to load notes: \(error.localizedDescription)") + notes = [] + } + } + + private func scheduleSave(for note: Note) { + cancelPendingSave(for: note.id) + let workItem = DispatchWorkItem { [weak self] in + self?.persist(note) + } + saveWorkItems[note.id] = workItem + let interval = Defaults[.notesAutoSaveInterval] + saveQueue.asyncAfter(deadline: .now() + interval, execute: workItem) + } + + private func cancelPendingSave(for id: UUID) { + if let item = saveWorkItems[id] { + item.cancel() + saveWorkItems.removeValue(forKey: id) + } + } + + private func persist(_ note: Note) { + let url = fileURL(for: note.id) + do { + let data = try encoder.encode(note) + try data.write(to: url, options: .atomic) + Task { @MainActor in + self.saveWorkItems.removeValue(forKey: note.id) + } + } catch { + NSLog("NotesManager: Failed to save note \(note.id): \(error.localizedDescription)") + } + } + + private func deleteFile(for id: UUID) { + let url = fileURL(for: id) + do { + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } catch { + NSLog("NotesManager: Failed to delete note file \(url.lastPathComponent): \(error.localizedDescription)") + } + } + + private func fileURL(for id: UUID) -> URL { + notesFolderURL.appendingPathComponent("\(id.uuidString).json", isDirectory: false) + } + + private static func sorter(lhs: Note, rhs: Note) -> Bool { + if lhs.isPinned != rhs.isPinned { + return lhs.isPinned && !rhs.isPinned + } + return lhs.updatedAt > rhs.updatedAt + } +} diff --git a/boringNotch/models/ClipboardItem.swift b/boringNotch/models/ClipboardItem.swift new file mode 100644 index 00000000..c6291321 --- /dev/null +++ b/boringNotch/models/ClipboardItem.swift @@ -0,0 +1,67 @@ +import Foundation +import CryptoKit + +enum ClipboardKind: String, Codable, CaseIterable { + case text + case image + case fileURL + case rtf + case html +} + +struct ClipboardItem: Identifiable, Codable, Equatable { + let id: UUID + let kind: ClipboardKind + var data: Data + var preview: String + let createdAt: Date + var isFavorite: Bool + var sourceApp: String? + var contentHash: String + + init( + id: UUID = UUID(), + kind: ClipboardKind, + data: Data, + preview: String? = nil, + createdAt: Date = Date(), + isFavorite: Bool = false, + sourceApp: String? = nil, + contentHash: String? = nil + ) { + self.id = id + self.kind = kind + self.data = data + self.preview = preview ?? ClipboardItem.previewText(for: kind, data: data) + self.createdAt = createdAt + self.isFavorite = isFavorite + self.sourceApp = sourceApp + self.contentHash = contentHash ?? ClipboardItem.hash(of: data) + } + + static func previewText(for kind: ClipboardKind, data: Data) -> String { + switch kind { + case .text, .html: + if let string = String(data: data, encoding: .utf8) { + return String(string.prefix(100)) + } + return "" + case .fileURL: + if let nsURL = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSURL.self, from: data), + let url = nsURL as URL? + { + return url.lastPathComponent + } + return "File" + case .image: + return "Image" + case .rtf: + return "Rich Text" + } + } + + static func hash(of data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index de8417e1..592b6976 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -141,14 +141,27 @@ extension Defaults.Keys { static let boringShelf = Key("boringShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true) static let shelfTapToOpen = Key("shelfTapToOpen", default: true) - + // MARK: Calendar static let calendarSelectionState = Key("calendarSelectionState", default: .all) static let hideAllDayEvents = Key("hideAllDayEvents", default: false) - + + // MARK: Notes + static let enableNotes = Key("enableNotes", default: true) + static let notesDefaultMonospace = Key("notesDefaultMonospace", default: false) + static let notesAutoSaveInterval = Key("notesAutoSaveInterval", default: 2) + + // MARK: Clipboard + static let enableClipboardHistory = Key("enableClipboardHistory", default: true) + static let clipboardRetentionDays = Key("clipboardRetentionDays", default: 7) + static let clipboardMaxItems = Key("clipboardMaxItems", default: 100) + static let clipboardCaptureImages = Key("clipboardCaptureImages", default: true) + static let clipboardCaptureRichText = Key("clipboardCaptureRichText", default: true) + static let clipboardExcludedApps = Key<[String]>("clipboardExcludedApps", default: []) + // MARK: Fullscreen Media Detection static let hideNotchOption = Key("hideNotchOption", default: .nowPlayingOnly) - + // MARK: Wobble Animation static let enableWobbleAnimation = Key("enableWobbleAnimation", default: false) diff --git a/boringNotch/models/Note.swift b/boringNotch/models/Note.swift new file mode 100644 index 00000000..4a881da4 --- /dev/null +++ b/boringNotch/models/Note.swift @@ -0,0 +1,50 @@ +import Foundation + +struct Note: Identifiable, Codable, Equatable { + let id: UUID + var content: String + let createdAt: Date + var updatedAt: Date + var isPinned: Bool + var isMonospaced: Bool + + var headingTitle: String { + content + .components(separatedBy: .newlines) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "Untitled Note" + } + + var previewText: String { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let lines = trimmed.components(separatedBy: .newlines) + guard lines.count > 1 else { return "" } + let remainder = lines.dropFirst().joined(separator: " ") + let condensed = remainder.trimmingCharacters(in: .whitespacesAndNewlines) + return String(condensed.prefix(200)) + } + + init( + id: UUID = UUID(), + content: String = "", + createdAt: Date = Date(), + updatedAt: Date = Date(), + isPinned: Bool = false, + isMonospaced: Bool = false + ) { + self.id = id + self.content = content + self.createdAt = createdAt + self.updatedAt = updatedAt + self.isPinned = isPinned + self.isMonospaced = isMonospaced + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/updater/appcast.xml b/updater/appcast.xml index 39123544..7e3918e8 100644 --- a/updater/appcast.xml +++ b/updater/appcast.xml @@ -1,6 +1,37 @@ - + + + v2.7 – Flying Rabbit RC 3 + Mon, 25 Aug 2025 10:05:48 +0530 + 2.7-rc.3+1 + 2.7-rc.3 + 14.0 + v2.7 Flying Rabbit RC 3 – Release Highlights! πŸ‡πŸͺ½

+ + ✨ What's New?
+ πŸ› οΈ Fixed LSUIElement issues for a better experience
+ πŸŽ›οΈ Updated Media Remote Adapter for improved device support
+ πŸ‘€ Default sneak peek enabled – easier previews!
+ πŸ›‘οΈ New SECURITY.md for better project safety
+ πŸ“† Calendar UI fixes and scrolling improvements for effortless scheduling
+ πŸ–ΌοΈ Artwork UI enhancements for a prettier look
+ πŸ“’ Improved calendar reminders so you never miss a thing
+ 🌍 Localization & dependency updates for global users

+ + πŸ™Œ New Contributors
+ πŸŽ‰ @CrossTR15YT joined the team!
+ πŸŽ‰ @naq7826 made their first contribution!

+ + πŸ”— Full Changelog: v2.7-rc.2...v2.7-rc.3
+ Thanks to all contributors: @Alexander5015, @naq7826, @CrossTR15YT

+ + Update now and hop into the new features! 🐰✨ + ]]>
+ + +
2.7-rc.2 Flying Rabbit πŸ‡πŸͺ½ Mon, 04 Aug 2025 11:50:17 +0530 @@ -340,3 +371,4 @@
+