diff --git a/.github/ISSUE_TEMPLATE/1-bug-report-form.yml b/.github/ISSUE_TEMPLATE/1-bug-report-form.yml
index f1ddcff6..06237b11 100644
--- a/.github/ISSUE_TEMPLATE/1-bug-report-form.yml
+++ b/.github/ISSUE_TEMPLATE/1-bug-report-form.yml
@@ -30,12 +30,13 @@ body:
- type: dropdown
id: version
attributes:
- label: BoringNotch Version
+ label: Boring Notch Version
description: >-
What version of our software are you running? (Go to ✦ in the menu bar >
Settings > About)
options:
- Select a version
+ - v2.7.3
- v2.7.2
- v2.7.1
- v2.7
@@ -50,6 +51,31 @@ body:
description: Go to > About This Mac
validations:
required: true
+ - type: dropdown
+ id: music-source
+ attributes:
+ label: Music Source? (If relevant)
+ description: >-
+ If this issue is related to music, what music source did you select in Boring Notch settings?
+ options:
+ - Now Playing
+ - Apple Music
+ - Spotify
+ - YouTube Music
+ validations:
+ required: false
+ - type: input
+ id: music-app
+ attributes:
+ label: Music App (If using Now Playing)
+ validations:
+ required: false
+ - type: input
+ id: music-website
+ attributes:
+ label: Website (If using browser for music)
+ validations:
+ required: false
- type: textarea
id: logs
attributes:
diff --git a/.github/ISSUE_TEMPLATE/1-feature-request-form.yml b/.github/ISSUE_TEMPLATE/1-feature-request-form.yml
new file mode 100644
index 00000000..71c73fad
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1-feature-request-form.yml
@@ -0,0 +1,80 @@
+name: Feature Request
+description: Suggest a new idea or enhancement for Boring Notch
+title: "[FEATURE] "
+labels: []
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## Feature Request
+
+ Thanks for helping make **Boring Notch** better! Please check [existing requests](https://github.com/TheBoredTeam/boring.notch/issues?q=is%3Aissue) before submitting to avoid duplicates.
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Is your feature request related to a problem?
+ description: |
+ Please provide a clear description of the problem or pain point this feature would address.
+
+ **Example:** "I often miss my next meeting because I work in full-screen mode and can't see the system clock or calendar notifications."
+ placeholder: "I'm frustrated when... or It would be great if..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution
+ attributes:
+ label: Describe the solution you'd like
+ description: |
+ How should Boring Notch handle this? Describe the behavior, UI, or interaction you imagine in detail.
+
+ **Example:** "When a meeting is starting in 5 minutes, the notch could expand slightly to show a countdown timer or the meeting title."
+ placeholder: "I would like the notch to..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: use-cases
+ attributes:
+ label: Use cases & user scenarios
+ description: |
+ Describe specific scenarios where this feature would be valuable. Who would benefit from this and how often would it be used?
+
+ **Example:** "Remote workers who attend 5+ video meetings daily would use this constantly. Students could benefit during online classes."
+ placeholder: "This would help users who..."
+ validations:
+ required: false
+
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context & screenshots
+ description: |
+ Add any other context, mockups, wireframes, or screenshots about the feature request here. Visuals are incredibly helpful!
+
+ You can drag and drop images directly into this field.
+ placeholder: "Here is a mockup of how the hover state should look..."
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: contribution
+ attributes:
+ label: Willingness to contribute
+ description: "We love community contributions! Would you be willing to help build this?"
+ options:
+ - label: "Yes, I can write the code for this feature"
+ - label: "Yes, I can help with design/mockups"
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Pre-submission checklist
+ description: "Please confirm the following before submitting:"
+ options:
+ - label: "I have searched existing issues to ensure this isn't a duplicate"
+ required: true
+ - label: "I have provided sufficient detail for the team to understand the request"
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index f63b4ac3..4fc5911d 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,3 +1,4 @@
+
---
name: Feature request
about: Suggest an idea for this project
diff --git a/.github/workflows/manual_build.yml b/.github/workflows/manual_build.yml
index 33014bbd..9afd06c6 100644
--- a/.github/workflows/manual_build.yml
+++ b/.github/workflows/manual_build.yml
@@ -75,13 +75,24 @@ jobs:
EOF
xcodebuild -exportArchive -archivePath "${{ env.projname }}.xcarchive" -exportPath Release -exportOptionsPlist "$TEMP_PLIST"
+ - name: Install dmgbuild (use virtualenv)
+ run: |
+ # Create an isolated venv
+ VENV_DIR="$RUNNER_TEMP/venv"
+ python3 -m venv "$VENV_DIR"
+ source "$VENV_DIR/bin/activate"
+ python -m pip install --upgrade pip setuptools wheel
+ python -m pip install "dmgbuild[badge_icons]"
+
- name: Create DMG
run: |
- cd Release
- hdiutil create -volname "boringNotch" \
- -srcfolder "${{ env.projname }}.app" \
- -ov -format UDZO \
- "${{ env.projname }}.dmg"
+ # Ensure venv created in previous step is used (dmgbuild installed there)
+ VENV_DIR="$RUNNER_TEMP/venv"
+ if [ -d "$VENV_DIR" ]; then
+ export PATH="$VENV_DIR/bin:$PATH"
+ fi
+ chmod +x Configuration/dmg/create_dmg.sh
+ ./Configuration/dmg/create_dmg.sh "Release/${{ env.projname }}.app" "Release/${{ env.projname }}.dmg" "${{ env.projname }}"
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cf8ca82f..b02383cb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -245,13 +245,25 @@ jobs:
exit 1
fi
echo "Found generate_appcast at $TOOL_PATH"
+ - name: Install dmgbuild (use virtualenv)
+ run: |
+ # Create an isolated venv
+ VENV_DIR="$RUNNER_TEMP/venv"
+ python3 -m venv "$VENV_DIR"
+ # Install into the venv
+ source "$VENV_DIR/bin/activate"
+ python -m pip install --upgrade pip setuptools wheel
+ python -m pip install "dmgbuild[badge_icons]"
+
- name: Create DMG
run: |
- cd Release
- hdiutil create -volname "boringNotch ${{ needs.preparation.outputs.version }}" \
- -srcfolder "${{ env.projname }}.app" \
- -ov -format UDZO \
- "${{ env.projname }}.dmg"
+ # Ensure venv created in previous step is used (dmgbuild installed there)
+ VENV_DIR="$RUNNER_TEMP/venv"
+ if [ -d "$VENV_DIR" ]; then
+ export PATH="$VENV_DIR/bin:$PATH"
+ fi
+ chmod +x Configuration/dmg/create_dmg.sh
+ ./Configuration/dmg/create_dmg.sh "Release/${{ env.projname }}.app" "Release/${{ env.projname }}.dmg" "boringNotch ${{ needs.preparation.outputs.version }}"
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..332e5b2c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,130 @@
+# Contributing
+
+Thank you for taking the time to contribute! ❤️
+
+These guidelines help streamline the contribution process for everyone involved. By following them, you'll make it easier for maintainers to review your work and collaborate with you effectively.
+
+You can contribute in many ways: writing code, improving documentation, reporting bugs, requesting features, or creating tutorials and blog posts. Every contribution, large or small, helps make Boring Notch better.
+
+## Table of Contents
+
+- [Localizations](#localizations)
+- [Contributing Code](#contributing-code)
+ - [Before You Start](#before-you-start)
+ - [Setting Up Your Environment](#setting-up-your-environment)
+ - [Making Changes](#making-changes)
+ - [Pull Requests](#pull-requests)
+
+- [Reporting Bugs](#reporting-bugs)
+- [Feature Requests](#feature-requests)
+- [Getting Help](#getting-help)
+
+## Localizations
+
+Please submit all translations to [Crowdin](https://crowdin.com/project/boring-notch). New strings added to the `dev` branch from code changes will sync automatically to Crowdin, and Crowdin will automatically open a new PR with translations to allow us to integrate them.
+
+## Contributing Code
+
+### Before You Start
+
+- **Check existing issues**: Before creating a new issue or starting work, search existing issues to avoid duplicates.
+- **Discuss major changes**: For significant features or major changes, please open an issue first to discuss your approach with maintainers and the community.
+
+
+### Setting Up Your Environment
+
+1. **Fork the repository**: Click the "Fork" button at the top of the repository page to create your own copy.
+
+2. **Clone your fork**:
+ ```bash
+ git clone https://github.com/{your-username}/boring.notch.git
+ cd boring.notch
+ ```
+ Replace `{your-username}` with your GitHub username.
+
+3. **Switch to the `dev` branch**:
+ ```bash
+ git checkout dev
+ ```
+ All code contributions should be based on the `dev` branch, not `main`. (documentation corrections or improvements can be based on `main`)
+
+5. **Create a new feature branch**:
+ ```bash
+ git checkout -b feature/{your-feature-name}
+ ```
+ Replace `{your-feature-name}` with a descriptive name. Use lowercase letters, numbers, and hyphens only (e.g., `feature/add-dark-mode` or `fix/notification-crash`).
+
+### Making Changes
+
+1. **Make your changes**: Implement your feature or bug fix. Write clean, well-documented code
+
+2. **Test your changes**: Ensure your changes work as expected and don't break existing functionality.
+
+3. **Commit your changes**:
+ ```bash
+ git add .
+ git commit -m "Add descriptive commit message"
+ ```
+ Write clear, concise commit messages that explain what your changes do and why.
+
+4. **Keep your branch up to date**:
+ Regularly sync your branch with the latest changes from the `dev` branch to avoid conflicts.
+
+5. **Push to your fork**:
+ ```bash
+ git push origin feature/{your-feature-name}
+ ```
+
+### Pull Requests
+
+1. **Create a pull request**: Go to the original repository and click "New Pull Request." Select your feature branch and set the base branch to `dev`.
+
+2. **Write a detailed description**: Your PR should include:
+ - A clear title summarizing the changes
+ - A detailed description of what was changed and why
+ - Reference to any related issues (e.g., "Fixes #123" or "Relates to #456")
+ - Screenshots or screen recordings for UI changes
+
+3. **Respond to feedback**: Maintainers may request changes.
+
+4. **Be patient**: Reviews take time. Maintainers will get to your PR as soon as they can.
+
+
+
+## Reporting Bugs
+
+When reporting bugs, please include:
+
+- A clear, descriptive title
+- Steps to reproduce the issue
+- Expected behavior vs. actual behavior
+- Screenshots or error messages if applicable
+- Your environment details (OS version, app version, etc.)
+
+## Feature Requests
+
+Feature requests are welcome! Please:
+
+- Check if the feature has already been requested
+- Clearly describe the feature and its use case
+- Explain why this feature would be valuable to users
+- Be open to discussion and alternative approaches
+
+## Getting Help
+
+If you need help or have questions:
+
+- Check the project documentation
+- Search existing issues for similar questions
+- Open a new issue with the "question" label
+- Join our [community Discord server](https://discord.com/servers/boring-notch-1269588937320566815)
+
+---
+
+Thank you for contributing to Boring Notch! Your efforts help make this project better for everyone. 🎉
diff --git a/Configuration/dmg/.background/background.tiff b/Configuration/dmg/.background/background.tiff
new file mode 100644
index 00000000..f532a06e
Binary files /dev/null and b/Configuration/dmg/.background/background.tiff differ
diff --git a/Configuration/dmg/create_dmg.sh b/Configuration/dmg/create_dmg.sh
new file mode 100755
index 00000000..dcfd4346
--- /dev/null
+++ b/Configuration/dmg/create_dmg.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Minimal wrapper to create a DMG using dmgbuild.
+# Usage: ./create_dmg.sh
+
+APP_PATH="${1:?App path required}"
+DMG_OUTPUT="${2:?DMG output path required}"
+VOLUME_NAME="${3:?Volume name required}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SETTINGS="$SCRIPT_DIR/dmgbuild_settings.py"
+
+BACKGROUND_DIR="$SCRIPT_DIR/.background"
+
+die() {
+ echo "Error: $*" >&2
+ exit 1
+}
+
+abs_path() {
+ python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$1"
+}
+
+ensure_dmgbuild_and_badge_support() {
+ if command -v dmgbuild >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if ! command -v pip3 >/dev/null 2>&1; then
+ die "dmgbuild is not installed and pip3 is not available. Please install dmgbuild."
+ fi
+
+ echo "dmgbuild not found — installing via pip3 (user scope)..."
+ python3 -m pip install --user "dmgbuild[badge_icons]" || python3 -m pip install "dmgbuild[badge_icons]"
+ USER_BIN="$(python3 -c 'import site,sys; print(site.getuserbase() + "/bin")')"
+ export PATH="$USER_BIN:$PATH"
+}
+
+find_app_icns() {
+ local app="$1"
+ local info_plist="$app/Contents/Info.plist"
+
+ if [ ! -f "$info_plist" ]; then
+ return 1
+ fi
+
+ local icon_file=""
+ icon_file="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$info_plist" 2>/dev/null || true)"
+ if [ -z "$icon_file" ]; then
+ icon_file="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$info_plist" 2>/dev/null || true)"
+ fi
+
+ if [ -n "$icon_file" ]; then
+ if [[ "$icon_file" != *.icns ]]; then
+ icon_file="$icon_file.icns"
+ fi
+ if [ -f "$app/Contents/Resources/$icon_file" ]; then
+ echo "$app/Contents/Resources/$icon_file"
+ return 0
+ fi
+ fi
+
+ # Fallback: any .icns inside the app bundle
+ local candidate
+ candidate="$(find "$app/Contents/Resources" -maxdepth 1 -name '*.icns' -print -quit 2>/dev/null || true)"
+ if [ -n "$candidate" ] && [ -f "$candidate" ]; then
+ echo "$candidate"
+ return 0
+ fi
+
+ return 1
+}
+
+if [ ! -f "$SETTINGS" ]; then
+ die "dmgbuild settings not found: $SETTINGS"
+fi
+
+ensure_dmgbuild_and_badge_support
+
+export DMG_APP_PATH="$(abs_path "$APP_PATH")"
+export DMG_VOLUME_NAME="$VOLUME_NAME"
+
+BACKGROUND_TIFF="$BACKGROUND_DIR/background.tiff"
+
+export DMG_BACKGROUND="$(abs_path "$BACKGROUND_TIFF")"
+
+# Badge icon: use the app's icon for badging the volume icon
+if DMG_ICON_ICNS="$(find_app_icns "$DMG_APP_PATH" 2>/dev/null)"; then
+ export DMG_BADGE_ICON="$(abs_path "$DMG_ICON_ICNS")"
+ echo "Using badge icon for DMG volume."
+else
+ echo "No app icon found, skipping badge."
+fi
+
+echo "Creating DMG via dmgbuild: app=$DMG_APP_PATH output=$DMG_OUTPUT volume=$DMG_VOLUME_NAME"
+
+# Validate inputs early to give clearer errors for common typos
+if [ ! -e "$DMG_APP_PATH" ]; then
+ echo "Error: App path not found: $DMG_APP_PATH" >&2
+ echo "Make sure you passed the correct .app path (e.g. Release/boringNotch.app)" >&2
+ exit 2
+fi
+
+if [ ! -d "$DMG_APP_PATH" ]; then
+ echo "Error: App path exists but is not a directory: $DMG_APP_PATH" >&2
+ exit 3
+fi
+
+dmgbuild -s "$SETTINGS" "$DMG_VOLUME_NAME" "$DMG_OUTPUT"
+
+exit $?
diff --git a/Configuration/dmg/dmgbuild_settings.py b/Configuration/dmg/dmgbuild_settings.py
new file mode 100644
index 00000000..97799c72
--- /dev/null
+++ b/Configuration/dmg/dmgbuild_settings.py
@@ -0,0 +1,51 @@
+import os
+
+# dmgbuild settings file. This is read by the `dmgbuild` CLI (or Python API).
+# It uses environment variables exported by the shell wrapper script:
+# - DMG_APP_PATH: path to the .app bundle to put in the DMG
+# - DMG_VOLUME_NAME: volume name to display when the DMG is mounted
+# - DMG_BACKGROUND: absolute path to the background image to use
+
+APP_PATH = os.environ.get('DMG_APP_PATH')
+VOLUME_NAME = os.environ.get('DMG_VOLUME_NAME', 'boringNotch')
+BACKGROUND = os.environ.get('DMG_BACKGROUND', '')
+BADGE_ICON = os.environ.get('DMG_BADGE_ICON', '')
+
+# If DMG_BACKGROUND not provided, default to the hiDPI TIFF in .background.
+if not BACKGROUND:
+ base = os.path.join(os.path.dirname(__file__), '.background', 'background.tiff')
+
+# Basic DMG metadata
+volume_name = VOLUME_NAME
+format = 'UDZO'
+compression_level = 9
+
+# Files and symlinks to include in the DMG
+files = [APP_PATH] if APP_PATH else []
+symlinks = {'Applications': '/Applications'}
+
+# Background image path (dmgbuild will copy this file into the DMG's .background)
+background = BACKGROUND
+
+
+# Window rectangle: ((left, top), (right, bottom))
+window_rect = ((0, 0), (660, 400))
+
+# Icon size (points)
+icon_size = 128
+
+# Icon locations: map filename (or bundle name) -> (x, y) in window coords
+app_basename = os.path.basename(APP_PATH) if APP_PATH else 'boringNotch.app'
+icon_locations = {
+ app_basename: (150, 180),
+ 'Applications': (510, 180),
+}
+
+# Misc Finder options
+show_statusbar = False
+show_tabview = False
+show_toolbar = False
+
+# Optionally set a custom icon for the DMG volume (leave empty to skip)
+if BADGE_ICON and os.path.exists(BADGE_ICON):
+ badge_icon = BADGE_ICON
diff --git a/README.md b/README.md
index 928e89ec..964129be 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
+
Boring Notch
@@ -13,7 +13,7 @@
-
+
@@ -126,33 +126,7 @@ brew install --cask TheBoredTeam/boring-notch/boring-notch --no-quarantine
## 🤝 Contributing
-We’re all about good vibes and awesome contributions! Here’s how you can join the fun:
-
-1. **Fork the Repo**: Click that shiny "Fork" button and make your own version.
-2. **Clone Your Fork**:
- ```bash
- git clone https://github.com/{your-name}/boring.notch.git
- # Replace {your-name} with your GitHub username
- ```
-3. **Make sure to use the `dev` branch as base.**
-4. **Create a New Branch**:
- ```bash
- git checkout -b feature/{your-feature-name}
- # Replace {your-feature-name} with a descriptive and concise name for your branch
- # It is best practice to use only alphanumeric characters, write words in lowercase
- # and seperate words with a single hyphen
- ```
-5. **Make Your Changes**: Add that feature or fix that bug.
-6. **Commit Your Changes**:
- ```bash
- git commit -m "insert descriptive message here"
- ```
-7. **Push to Your Fork**:
- ```bash
- git push origin feature/{your-feature-name}
- # Remember to replace {your-feature-name} with the name you chose
- ```
-8. **Create a Pull Request**: Head to the original repository and click on "New Pull Request." Fill in the required details, **make sure the base branch is set to `dev`**, and submit your PR. Let’s see what you’ve got!
+We’re all about good vibes and awesome contributions! Read [CONTRIBUTING.md](CONTRIBUTING.md) to learn how you can join the fun!
## Join our Discord Server
diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj
index 7b3413a0..7a006bbb 100644
--- a/boringNotch.xcodeproj/project.pbxproj
+++ b/boringNotch.xcodeproj/project.pbxproj
@@ -66,18 +66,20 @@
11CFC6632E09918400748C80 /* MusicControllerSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFC6622E09917B00748C80 /* MusicControllerSelectionView.swift */; };
11CFC6652E09C7B300748C80 /* OnboardingFinishView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFC6642E09C7B300748C80 /* OnboardingFinishView.swift */; };
11D58EA22E760AE100FA8377 /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D58EA12E760AE100FA8377 /* ImageService.swift */; };
- 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */; };
- 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */; };
- 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */; };
- 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */; };
- 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */; };
- 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */; };
- 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */; };
- 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */; };
- 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26672EDD0CDF001EA0CF /* AboutView.swift */; };
- 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */; };
- 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */; };
- 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */; };
+ 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */; };
+ 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */; };
+ 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */; };
+ 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */; };
+ 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */; };
+ 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */; };
+ 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */; };
+ 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */; };
+ 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26672EDD0CDF001EA0CF /* AboutView.swift */; };
+ 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */; };
+ 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */; };
+ 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */; };
+ E1A200022F01000100ABCDEF /* WellnessSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A200012F01000100ABCDEF /* WellnessSettingsView.swift */; };
+ E1A100022F00000100ABCDEF /* EyeBreakReminderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A100012F00000100ABCDEF /* EyeBreakReminderManager.swift */; };
11EFCD702E8E92D600D0B974 /* ShelfItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */; };
11F747CE2EC75CEA00F841DB /* DragPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */; };
11F7485B2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -250,11 +252,12 @@
11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySettingsView.swift; sourceTree = ""; };
11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; };
11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; };
- 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDSettingsView.swift; sourceTree = ""; };
- 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; };
- 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpers.swift; sourceTree = ""; };
- 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSettingsView.swift; sourceTree = ""; };
- 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; };
+ 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDSettingsView.swift; sourceTree = ""; };
+ 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; };
+ 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpers.swift; sourceTree = ""; };
+ 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSettingsView.swift; sourceTree = ""; };
+ 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; };
+ E1A200012F01000100ABCDEF /* WellnessSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessSettingsView.swift; sourceTree = ""; };
11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemViewModel.swift; sourceTree = ""; };
11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPreviewView.swift; sourceTree = ""; };
11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = BoringNotchXPCHelper.xpc; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -326,6 +329,7 @@
B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdater.swift; sourceTree = ""; };
B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; };
B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; };
+ E1A100012F00000100ABCDEF /* EyeBreakReminderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EyeBreakReminderManager.swift; sourceTree = ""; };
F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; };
/* End PBXFileReference section */
@@ -472,12 +476,13 @@
11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */,
11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */,
11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */,
- 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */,
- 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */,
- 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */,
- );
- path = Views;
- sourceTree = "";
+ 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */,
+ 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */,
+ 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */,
+ E1A200012F01000100ABCDEF /* WellnessSettingsView.swift */,
+ );
+ path = Views;
+ sourceTree = "";
};
11F748672EC9AC9600F841DB /* XPCHelperClient */ = {
isa = PBXGroup;
@@ -546,6 +551,7 @@
children = (
11DB26652EDD0BE1001EA0CF /* LyricsService.swift */,
11D58EA12E760AE100FA8377 /* ImageService.swift */,
+ E1A100012F00000100ABCDEF /* EyeBreakReminderManager.swift */,
F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */,
112FB7342CCF16F70015238C /* NotchSpaceManager.swift */,
147163992C5D35FF0068B555 /* MusicManager.swift */,
@@ -957,6 +963,7 @@
B1D365D02C6A9A6C0047BDBC /* SystemEventIndicatorModifier.swift in Sources */,
1194E87C2EA19E09009C82D6 /* ImageProcessingService.swift in Sources */,
1194E8872EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift in Sources */,
+ E1A100022F00000100ABCDEF /* EyeBreakReminderManager.swift in Sources */,
14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */,
9A0887322C7A693000C160EA /* TabButton.swift in Sources */,
1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */,
@@ -1013,11 +1020,12 @@
11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */,
11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */,
11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */,
- 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */,
- 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */,
- 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */,
- 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */,
- 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */,
+ 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */,
+ 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */,
+ 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */,
+ 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */,
+ E1A200022F01000100ABCDEF /* WellnessSettingsView.swift in Sources */,
+ 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */,
B17266E32C65F7FB0031BA0D /* WhatsNewView.swift in Sources */,
14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */,
14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */,
diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift
index b8e8e235..fcaea0ad 100644
--- a/boringNotch/ContentView.swift
+++ b/boringNotch/ContentView.swift
@@ -23,9 +23,13 @@ struct ContentView: View {
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var brightnessManager = BrightnessManager.shared
@ObservedObject var volumeManager = VolumeManager.shared
+ @ObservedObject var eyeBreakReminder = EyeBreakReminderManager.shared
@State private var hoverTask: Task?
@State private var isHovering: Bool = false
@State private var anyDropDebounceTask: Task?
+ @State private var didAutoOpenForEyeBreak: Bool = false
+ @State private var isClosingEyeBreakPanel: Bool = false
+ @State private var eyeBreakPanelSnapshot: EyeBreakBannerState?
@State private var gestureProgress: CGFloat = .zero
@@ -170,13 +174,13 @@ struct ContentView: View {
}
}
.onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in
- if vm.notchState == .open && !isHovering && !vm.isBatteryPopoverActive {
+ if vm.notchState == .open && !isHovering && !vm.isBatteryPopoverActive && eyeBreakReminder.banner == nil {
hoverTask?.cancel()
hoverTask = Task {
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
await MainActor.run {
- if self.vm.notchState == .open && !self.isHovering && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose {
+ if self.vm.notchState == .open && !self.isHovering && !self.vm.isBatteryPopoverActive && self.eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose {
self.vm.close()
}
}
@@ -191,13 +195,13 @@ struct ContentView: View {
}
}
.onChange(of: vm.isBatteryPopoverActive) {
- if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open && !SharingStateManager.shared.preventNotchClose {
+ if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open && eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose {
hoverTask?.cancel()
hoverTask = Task {
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
await MainActor.run {
- if !self.vm.isBatteryPopoverActive && !self.isHovering && self.vm.notchState == .open && !SharingStateManager.shared.preventNotchClose {
+ if !self.vm.isBatteryPopoverActive && !self.isHovering && self.vm.notchState == .open && self.eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose {
self.vm.close()
}
}
@@ -235,6 +239,38 @@ struct ContentView: View {
.background(dragDetector)
.preferredColorScheme(.dark)
.environmentObject(vm)
+ .onChange(of: eyeBreakReminder.banner != nil) { _, hasBanner in
+ if hasBanner {
+ eyeBreakPanelSnapshot = eyeBreakReminder.banner
+ isClosingEyeBreakPanel = false
+ guard vm.notchState == .closed else { return }
+ didAutoOpenForEyeBreak = true
+ withAnimation(animationSpring) {
+ vm.open()
+ }
+ } else if didAutoOpenForEyeBreak {
+ didAutoOpenForEyeBreak = false
+ guard vm.notchState == .open else { return }
+ guard !SharingStateManager.shared.preventNotchClose else { return }
+ if eyeBreakPanelSnapshot == nil {
+ eyeBreakPanelSnapshot = EyeBreakBannerState(
+ remainingSeconds: 0,
+ breakDurationSeconds: 1,
+ snoozeMinutes: Defaults[.eyeBreakSnoozeMinutes]
+ )
+ }
+ isClosingEyeBreakPanel = true
+ withAnimation(animationSpring) {
+ vm.close()
+ }
+ }
+ }
+ .onChange(of: vm.notchState) { _, newState in
+ if newState == .closed {
+ isClosingEyeBreakPanel = false
+ eyeBreakPanelSnapshot = nil
+ }
+ }
.onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in
anyDropDebounceTask?.cancel()
@@ -369,11 +405,19 @@ struct ContentView: View {
.zIndex(1)
if vm.notchState == .open {
VStack {
- switch coordinator.currentView {
- case .home:
- NotchHomeView(albumArtNamespace: albumArtNamespace)
- case .shelf:
- ShelfView()
+ if let banner = currentEyeBreakPanelState {
+ EyeBreakReminderPanel(
+ banner: banner,
+ onSkip: { handleEyeBreakAction { eyeBreakReminder.skipBreak() } },
+ onSnooze: { handleEyeBreakAction { eyeBreakReminder.snoozeBreak() } }
+ )
+ } else {
+ switch coordinator.currentView {
+ case .home:
+ NotchHomeView(albumArtNamespace: albumArtNamespace)
+ case .shelf:
+ ShelfView()
+ }
}
}
.transition(
@@ -533,6 +577,29 @@ struct ContentView: View {
}
}
+ private func handleEyeBreakAction(_ action: () -> Void) {
+ eyeBreakPanelSnapshot = eyeBreakReminder.banner
+ isClosingEyeBreakPanel = true
+ action()
+ didAutoOpenForEyeBreak = false
+ guard vm.notchState == .open else { return }
+ guard !SharingStateManager.shared.preventNotchClose else { return }
+ withAnimation(animationSpring) {
+ isHovering = false
+ vm.close()
+ }
+ }
+
+ private var currentEyeBreakPanelState: EyeBreakBannerState? {
+ if let liveBanner = eyeBreakReminder.banner {
+ return liveBanner
+ }
+ if isClosingEyeBreakPanel {
+ return eyeBreakPanelSnapshot
+ }
+ return nil
+ }
+
// MARK: - Hover Management
private func handleHover(_ hovering: Bool) {
@@ -574,7 +641,7 @@ struct ContentView: View {
self.isHovering = false
}
- if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose {
+ if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && self.eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose {
self.vm.close()
}
}
@@ -636,6 +703,84 @@ struct ContentView: View {
}
}
+private struct EyeBreakReminderPanel: View {
+ let banner: EyeBreakBannerState
+ let onSkip: () -> Void
+ let onSnooze: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Label("Eye break", systemImage: "eye")
+ .font(.headline)
+ .foregroundStyle(.white)
+ Spacer(minLength: 8)
+ Text(countdownText)
+ .font(.system(size: 26, weight: .bold, design: .monospaced))
+ .foregroundStyle(.white.opacity(0.95))
+ }
+
+ ProgressView(value: progressValue)
+ .tint(.white.opacity(0.9))
+ .controlSize(.small)
+
+ HStack(spacing: 10) {
+ Button("Skip", action: onSkip)
+ .buttonStyle(EyeBreakReminderButtonStyle(variant: .secondary))
+ Spacer()
+ Button("Snooze \(banner.snoozeMinutes)m", action: onSnooze)
+ .buttonStyle(EyeBreakReminderButtonStyle(variant: .primary))
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(Color.black.opacity(0.88))
+ )
+ .padding(.horizontal, 12)
+ .padding(.bottom, 12)
+ .shadow(color: .black.opacity(0.28), radius: 8, y: 3)
+ .animation(.smooth, value: banner.remainingSeconds)
+ }
+
+ private var countdownText: String {
+ let clamped = max(0, banner.remainingSeconds)
+ let minutes = clamped / 60
+ let seconds = clamped % 60
+ return String(format: "%02d:%02d", minutes, seconds)
+ }
+
+ private var progressValue: Double {
+ guard banner.breakDurationSeconds > 0 else { return 0 }
+ let completed = banner.breakDurationSeconds - max(0, banner.remainingSeconds)
+ return Double(completed) / Double(banner.breakDurationSeconds)
+ }
+}
+
+private struct EyeBreakReminderButtonStyle: ButtonStyle {
+ enum Variant {
+ case primary
+ case secondary
+ }
+
+ let variant: Variant
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white.opacity(variant == .primary ? 1.0 : 0.9))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ Capsule()
+ .fill(variant == .primary ? Color.white.opacity(0.22) : Color.white.opacity(0.12))
+ )
+ .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
+ .animation(.smooth(duration: 0.12), value: configuration.isPressed)
+ }
+}
+
struct FullScreenDropDelegate: DropDelegate {
@Binding var isTargeted: Bool
let onDrop: () -> Void
diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings
index 59470ffa..63a80e77 100644
--- a/boringNotch/Localizable.xcstrings
+++ b/boringNotch/Localizable.xcstrings
@@ -901,6 +901,12 @@
}
}
}
+ },
+ "%lld min" : {
+
+ },
+ "%lld sec" : {
+
},
"%lld%%" : {
"localizations" : {
@@ -1701,6 +1707,12 @@
}
}
}
+ },
+ "Active interval" : {
+
+ },
+ "Active timer" : {
+
},
"Add" : {
"extractionState" : "stale",
@@ -3103,6 +3115,9 @@
}
}
}
+ },
+ "Available only in DEBUG builds." : {
+
},
"Battery" : {
"localizations" : {
@@ -3803,6 +3818,9 @@
}
}
}
+ },
+ "Break countdown" : {
+
},
"Calendar" : {
"localizations" : {
@@ -6004,6 +6022,9 @@
}
}
}
+ },
+ "Counting time" : {
+
},
"Currently selected: %@" : {
"localizations" : {
@@ -6604,6 +6625,9 @@
}
}
}
+ },
+ "Debug" : {
+
},
"Default" : {
"localizations" : {
@@ -7508,6 +7532,9 @@
}
}
}
+ },
+ "Due pending" : {
+
},
"Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : {
"localizations" : {
@@ -7908,6 +7935,9 @@
}
}
}
+ },
+ "Enable eye-break reminders" : {
+
},
"Enable gestures" : {
"localizations" : {
@@ -8909,6 +8939,9 @@
}
}
}
+ },
+ "Eye break" : {
+
},
"Files dropped on the shelf will be shared via this service" : {
"localizations" : {
@@ -13917,6 +13950,9 @@
}
}
}
+ },
+ "No" : {
+
},
"No events" : {
"localizations" : {
@@ -14219,7 +14255,7 @@
}
}
},
- "Normalize scroll/gesture direction" : {
+ "Normalize gesture direction" : {
},
"Not Now" : {
@@ -15321,6 +15357,9 @@
}
}
}
+ },
+ "Pause media when reminder appears" : {
+
},
"Pick a Color" : {
"localizations" : {
@@ -15421,6 +15460,9 @@
}
}
}
+ },
+ "Play sound for reminder and completion" : {
+
},
"Plugged In" : {
"localizations" : {
@@ -16421,6 +16463,9 @@
}
}
}
+ },
+ "Reminder settings" : {
+
},
"Reminders" : {
"localizations" : {
@@ -16921,6 +16966,9 @@
}
}
}
+ },
+ "Reset" : {
+
},
"Reset to Defaults" : {
"localizations" : {
@@ -19495,6 +19543,9 @@
}
}
}
+ },
+ "Skip" : {
+
},
"Slider color" : {
"localizations" : {
@@ -19795,6 +19846,12 @@
}
}
}
+ },
+ "Snooze %lldm" : {
+
+ },
+ "Snooze duration" : {
+
},
"Software updates" : {
"localizations" : {
@@ -20797,6 +20854,9 @@
}
}
}
+ },
+ "Trigger now" : {
+
},
"Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : {
"localizations" : {
@@ -21498,6 +21558,9 @@
}
}
}
+ },
+ "Uses the 20-20-20 rule to reduce eye strain: every 20 minutes, look 20 feet away for 20 seconds." : {
+
},
"Using System Accent" : {
"localizations" : {
@@ -21998,6 +22061,9 @@
}
}
}
+ },
+ "Wellness" : {
+
},
"What's New" : {
"localizations" : {
@@ -22298,6 +22364,9 @@
}
}
}
+ },
+ "Yes" : {
+
},
"You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : {
"localizations" : {
diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift
index 811879d9..7eb29529 100644
--- a/boringNotch/components/Settings/SettingsView.swift
+++ b/boringNotch/components/Settings/SettingsView.swift
@@ -40,6 +40,9 @@ struct SettingsView: View {
NavigationLink(value: "Battery") {
Label("Battery", systemImage: "battery.100.bolt")
}
+ NavigationLink(value: "Wellness") {
+ Label("Wellness", systemImage: "eye")
+ }
NavigationLink(value: "Shelf") {
Label("Shelf", systemImage: "books.vertical")
}
@@ -72,6 +75,8 @@ struct SettingsView: View {
HUD()
case "Battery":
Charge()
+ case "Wellness":
+ WellnessSettings()
case "Shelf":
Shelf()
case "Shortcuts":
diff --git a/boringNotch/components/Settings/Views/WellnessSettingsView.swift b/boringNotch/components/Settings/Views/WellnessSettingsView.swift
new file mode 100644
index 00000000..d758699b
--- /dev/null
+++ b/boringNotch/components/Settings/Views/WellnessSettingsView.swift
@@ -0,0 +1,126 @@
+//
+// WellnessSettingsView.swift
+// boringNotch
+//
+// Created by Richard Kunkli on 07/08/2024.
+//
+
+import Defaults
+import SwiftUI
+
+struct WellnessSettings: View {
+ @Default(.eyeBreakEnabled) var eyeBreakEnabled
+ @Default(.eyeBreakIntervalMinutes) var eyeBreakIntervalMinutes
+ @Default(.eyeBreakDurationSeconds) var eyeBreakDurationSeconds
+ @Default(.eyeBreakSnoozeMinutes) var eyeBreakSnoozeMinutes
+#if DEBUG
+ @ObservedObject var eyeBreakReminder = EyeBreakReminderManager.shared
+#endif
+
+ var body: some View {
+ Form {
+ Section {
+ Defaults.Toggle(key: .eyeBreakEnabled) {
+ Text("Enable eye-break reminders")
+ }
+ } footer: {
+ Text("Uses the 20-20-20 rule to reduce eye strain: every 20 minutes, look 20 feet away for 20 seconds.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Section {
+ Stepper(value: $eyeBreakIntervalMinutes, in: 5...120, step: 1) {
+ HStack {
+ Text("Active interval")
+ Spacer()
+ Text("\(eyeBreakIntervalMinutes) min")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Stepper(value: $eyeBreakDurationSeconds, in: 5...120, step: 1) {
+ HStack {
+ Text("Break countdown")
+ Spacer()
+ Text("\(eyeBreakDurationSeconds) sec")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Stepper(value: $eyeBreakSnoozeMinutes, in: 1...60, step: 1) {
+ HStack {
+ Text("Snooze duration")
+ Spacer()
+ Text("\(eyeBreakSnoozeMinutes) min")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Defaults.Toggle(key: .eyeBreakSoundEnabled) {
+ Text("Play sound for reminder and completion")
+ }
+
+ Defaults.Toggle(key: .eyeBreakPauseMediaOnPopup) {
+ Text("Pause media when reminder appears")
+ }
+ } header: {
+ Text("Reminder settings")
+ }
+ .disabled(!eyeBreakEnabled)
+
+#if DEBUG
+ Section {
+ HStack {
+ Text("Active timer")
+ Spacer()
+ Text(formattedDuration(eyeBreakReminder.debugState.activeSecondsAccumulated))
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+ HStack {
+ Text("Counting time")
+ Spacer()
+ Text(eyeBreakReminder.debugState.isAccruingActiveTime ? "Yes" : "No")
+ .foregroundStyle(.secondary)
+ }
+ HStack {
+ Text("Due pending")
+ Spacer()
+ Text(eyeBreakReminder.debugState.duePending ? "Yes" : "No")
+ .foregroundStyle(.secondary)
+ }
+ HStack(spacing: 10) {
+ Button("Trigger now") {
+ eyeBreakReminder.debugTriggerReminderNow()
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button("Reset") {
+ eyeBreakReminder.debugResetState()
+ }
+ .buttonStyle(.bordered)
+ }
+ } header: {
+ Text("Debug")
+ } footer: {
+ Text("Available only in DEBUG builds.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+#endif
+ }
+ .accentColor(.effectiveAccent)
+ .navigationTitle("Wellness")
+ }
+
+#if DEBUG
+ private func formattedDuration(_ seconds: Int) -> String {
+ let clamped = max(0, seconds)
+ let hours = clamped / 3600
+ let minutes = (clamped % 3600) / 60
+ let remaining = clamped % 60
+ return String(format: "%02d:%02d:%02d", hours, minutes, remaining)
+ }
+#endif
+}
diff --git a/boringNotch/managers/EyeBreakReminderManager.swift b/boringNotch/managers/EyeBreakReminderManager.swift
new file mode 100644
index 00000000..9f725bd8
--- /dev/null
+++ b/boringNotch/managers/EyeBreakReminderManager.swift
@@ -0,0 +1,429 @@
+import AppKit
+import Combine
+import Defaults
+import Foundation
+
+struct EyeBreakBannerState: Equatable {
+ var remainingSeconds: Int
+ var breakDurationSeconds: Int
+ var snoozeMinutes: Int
+}
+
+struct EyeBreakDebugState: Equatable {
+ var activeSecondsAccumulated: Int = 0
+ var duePending: Bool = false
+ var snoozeUntil: Date? = nil
+ var isSystemSleeping: Bool = false
+ var isDisplaySleeping: Bool = false
+ var isSessionActive: Bool = true
+ var isAccruingActiveTime: Bool = false
+ var bannerVisible: Bool = false
+ var bannerRemainingSeconds: Int? = nil
+}
+
+@MainActor
+final class EyeBreakReminderManager: ObservableObject {
+ static let shared = EyeBreakReminderManager()
+
+ @Published private(set) var banner: EyeBreakBannerState?
+ @Published private(set) var debugState: EyeBreakDebugState = .init()
+
+ private var activeSecondsAccumulated: TimeInterval = 0
+ private var duePending: Bool = false
+ private var snoozeUntil: Date?
+ private var debugIgnoreActivityUntilDismiss: Bool = false
+ private var lastMonitorDate: Date?
+ private var didPauseMediaForReminder: Bool = false
+
+ private var isSystemSleeping: Bool = false
+ private var isDisplaySleeping: Bool = false
+ private var isSessionActive: Bool = true
+
+ private var monitorTask: Task?
+ private var countdownTask: Task?
+ private var mediaCommandTask: Task?
+
+ private var willSleepObserver: Any?
+ private var didWakeObserver: Any?
+ private var screensDidSleepObserver: Any?
+ private var screensDidWakeObserver: Any?
+ private var sessionResignObserver: Any?
+ private var sessionBecomeObserver: Any?
+ private var cancellables = Set()
+
+ private let accrualTickSeconds: TimeInterval = 5
+
+ private init() {
+ setupSystemStateObservers()
+ setupDefaultsObservers()
+ startMonitoringIfNeeded()
+ updateDebugState()
+ }
+
+ deinit {
+ monitorTask?.cancel()
+ countdownTask?.cancel()
+ mediaCommandTask?.cancel()
+
+ let workspaceCenter = NSWorkspace.shared.notificationCenter
+ if let observer = willSleepObserver {
+ workspaceCenter.removeObserver(observer)
+ }
+ if let observer = didWakeObserver {
+ workspaceCenter.removeObserver(observer)
+ }
+ if let observer = screensDidSleepObserver {
+ workspaceCenter.removeObserver(observer)
+ }
+ if let observer = screensDidWakeObserver {
+ workspaceCenter.removeObserver(observer)
+ }
+ if let observer = sessionResignObserver {
+ workspaceCenter.removeObserver(observer)
+ }
+ if let observer = sessionBecomeObserver {
+ workspaceCenter.removeObserver(observer)
+ }
+
+ cancellables.removeAll()
+ }
+
+ func completeBreak() {
+ guard banner != nil else { return }
+ resetCycle()
+ }
+
+ func skipBreak() {
+ guard banner != nil else { return }
+ resetCycle()
+ }
+
+ func snoozeBreak() {
+ guard banner != nil else { return }
+
+ dismissBanner()
+ resumeMediaIfNeeded()
+ duePending = true
+ snoozeUntil = Date().addingTimeInterval(TimeInterval(validSnoozeMinutes() * 60))
+ updateDebugState()
+ }
+
+ func debugTriggerReminderNow() {
+ snoozeUntil = nil
+ duePending = true
+ debugIgnoreActivityUntilDismiss = true
+ presentBreakBanner(force: true)
+ updateDebugState()
+ }
+
+ func debugResetState() {
+ clearAllState()
+ }
+
+ private func setupDefaultsObservers() {
+ Defaults.publisher(.eyeBreakEnabled)
+ .receive(on: RunLoop.main)
+ .sink { [weak self] change in
+ Task { @MainActor [weak self] in
+ guard let self = self else { return }
+ if !change.newValue {
+ self.clearAllState()
+ }
+ }
+ }
+ .store(in: &cancellables)
+ }
+
+ private func setupSystemStateObservers() {
+ let workspaceCenter = NSWorkspace.shared.notificationCenter
+
+ willSleepObserver = workspaceCenter.addObserver(
+ forName: NSWorkspace.willSleepNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self = self else { return }
+ self.isSystemSleeping = true
+ self.handleNonAccruingTransition()
+ }
+ }
+
+ didWakeObserver = workspaceCenter.addObserver(
+ forName: NSWorkspace.didWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.isSystemSleeping = false
+ }
+ }
+
+ screensDidSleepObserver = workspaceCenter.addObserver(
+ forName: NSWorkspace.screensDidSleepNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self = self else { return }
+ self.isDisplaySleeping = true
+ self.handleNonAccruingTransition()
+ }
+ }
+
+ screensDidWakeObserver = workspaceCenter.addObserver(
+ forName: NSWorkspace.screensDidWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.isDisplaySleeping = false
+ }
+ }
+
+ sessionResignObserver = workspaceCenter.addObserver(
+ forName: NSWorkspace.sessionDidResignActiveNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self = self else { return }
+ self.isSessionActive = false
+ self.handleNonAccruingTransition()
+ }
+ }
+
+ sessionBecomeObserver = workspaceCenter.addObserver(
+ forName: NSWorkspace.sessionDidBecomeActiveNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.isSessionActive = true
+ }
+ }
+ }
+
+ private func startMonitoringIfNeeded() {
+ guard monitorTask == nil else { return }
+
+ monitorTask = Task { @MainActor [weak self] in
+ guard let self = self else { return }
+
+ while !Task.isCancelled {
+ self.monitorTick()
+ try? await Task.sleep(for: .seconds(self.accrualTickSeconds))
+ }
+ }
+ }
+
+ private func monitorTick() {
+ let now = Date()
+ let previousTick = lastMonitorDate ?? now
+ let elapsedSinceLastTick = max(0, now.timeIntervalSince(previousTick))
+ let clampedElapsed = min(elapsedSinceLastTick, accrualTickSeconds * 3)
+
+ defer { updateDebugState() }
+ defer { lastMonitorDate = now }
+
+ guard Defaults[.eyeBreakEnabled] else {
+ clearAllState()
+ return
+ }
+
+ if banner != nil {
+ if !debugIgnoreActivityUntilDismiss && !canCountActiveTimeNow() {
+ resetCycle()
+ }
+ return
+ }
+
+ if let snoozeUntil, Date() < snoozeUntil {
+ return
+ }
+
+ if snoozeUntil != nil {
+ snoozeUntil = nil
+ duePending = true
+ }
+
+ if duePending {
+ if canCountActiveTimeNow() {
+ presentBreakBanner()
+ }
+ return
+ }
+
+ guard canCountActiveTimeNow() else { return }
+
+ activeSecondsAccumulated += clampedElapsed
+ if activeSecondsAccumulated >= TimeInterval(validIntervalMinutes() * 60) {
+ duePending = true
+ presentBreakBanner()
+ }
+ }
+
+ private func presentBreakBanner(force: Bool = false) {
+ guard banner == nil else { return }
+ if !force {
+ guard canCountActiveTimeNow() else { return }
+ }
+
+ let breakSeconds = validBreakDurationSeconds()
+ banner = EyeBreakBannerState(
+ remainingSeconds: breakSeconds,
+ breakDurationSeconds: breakSeconds,
+ snoozeMinutes: validSnoozeMinutes()
+ )
+
+ pauseMediaIfNeeded()
+
+ if Defaults[.eyeBreakSoundEnabled] {
+ playReminderStartSound()
+ }
+ updateDebugState()
+
+ countdownTask?.cancel()
+ countdownTask = Task { @MainActor [weak self] in
+ guard let self = self else { return }
+
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(1))
+ guard !Task.isCancelled else { return }
+
+ guard var currentBanner = self.banner else { return }
+
+ if !self.debugIgnoreActivityUntilDismiss && !self.canCountActiveTimeNow() {
+ self.resetCycle()
+ return
+ }
+
+ currentBanner.remainingSeconds -= 1
+
+ if currentBanner.remainingSeconds <= 0 {
+ self.onCountdownCompleted()
+ } else {
+ self.banner = currentBanner
+ self.updateDebugState()
+ }
+ }
+ }
+ }
+
+ private func onCountdownCompleted() {
+ if Defaults[.eyeBreakSoundEnabled] {
+ playReminderFinishSound()
+ }
+ resetCycle()
+ }
+
+ private func handleNonAccruingTransition() {
+ if banner != nil {
+ resetCycle()
+ }
+ }
+
+ private func resetCycle() {
+ dismissBanner()
+ resumeMediaIfNeeded()
+ activeSecondsAccumulated = 0
+ duePending = false
+ snoozeUntil = nil
+ debugIgnoreActivityUntilDismiss = false
+ lastMonitorDate = Date()
+ updateDebugState()
+ }
+
+ private func dismissBanner() {
+ countdownTask?.cancel()
+ countdownTask = nil
+ banner = nil
+ updateDebugState()
+ }
+
+ private func clearAllState() {
+ dismissBanner()
+ resumeMediaIfNeeded()
+ activeSecondsAccumulated = 0
+ duePending = false
+ snoozeUntil = nil
+ debugIgnoreActivityUntilDismiss = false
+ lastMonitorDate = Date()
+ updateDebugState()
+ }
+
+ private func updateDebugState() {
+ let accruing = Defaults[.eyeBreakEnabled] && !duePending && banner == nil && canCountActiveTimeNow()
+
+ debugState = EyeBreakDebugState(
+ activeSecondsAccumulated: Int(activeSecondsAccumulated),
+ duePending: duePending,
+ snoozeUntil: snoozeUntil,
+ isSystemSleeping: isSystemSleeping,
+ isDisplaySleeping: isDisplaySleeping,
+ isSessionActive: isSessionActive,
+ isAccruingActiveTime: accruing,
+ bannerVisible: banner != nil,
+ bannerRemainingSeconds: banner?.remainingSeconds
+ )
+ }
+
+ private func validIntervalMinutes() -> Int {
+ max(5, Defaults[.eyeBreakIntervalMinutes])
+ }
+
+ private func validBreakDurationSeconds() -> Int {
+ max(5, Defaults[.eyeBreakDurationSeconds])
+ }
+
+ private func validSnoozeMinutes() -> Int {
+ max(1, Defaults[.eyeBreakSnoozeMinutes])
+ }
+
+ private func canCountActiveTimeNow() -> Bool {
+ !isSystemSleeping && !isDisplaySleeping && isSessionActive
+ }
+
+ private func pauseMediaIfNeeded() {
+ guard Defaults[.eyeBreakPauseMediaOnPopup] else { return }
+ guard MusicManager.shared.isPlaying else { return }
+ didPauseMediaForReminder = true
+ enqueueMediaCommand {
+ await MusicManager.shared.pauseAsync()
+ }
+ }
+
+ private func resumeMediaIfNeeded() {
+ guard didPauseMediaForReminder else { return }
+ didPauseMediaForReminder = false
+ enqueueMediaCommand {
+ await MusicManager.shared.playAsync()
+ }
+ }
+
+ private func enqueueMediaCommand(_ operation: @escaping @MainActor () async -> Void) {
+ let previous = mediaCommandTask
+ mediaCommandTask = Task { @MainActor in
+ _ = await previous?.result
+ guard !Task.isCancelled else { return }
+ await operation()
+ }
+ }
+
+ private func playReminderStartSound() {
+ playSound(candidates: ["Pop", "Tink", "Glass"])
+ }
+
+ private func playReminderFinishSound() {
+ playSound(candidates: ["Hero", "Pop", "Glass"])
+ }
+
+ private func playSound(candidates: [String]) {
+ for candidate in candidates {
+ if let sound = NSSound(named: NSSound.Name(candidate)) {
+ sound.play()
+ return
+ }
+ }
+ }
+}
diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift
index 93ec9075..f4d7cdde 100644
--- a/boringNotch/managers/MusicManager.swift
+++ b/boringNotch/managers/MusicManager.swift
@@ -452,16 +452,26 @@ class MusicManager: ObservableObject {
func play() {
Task {
- await activeController?.play()
+ await playAsync()
}
}
func pause() {
Task {
- await activeController?.pause()
+ await pauseAsync()
}
}
+ @MainActor
+ func playAsync() async {
+ await activeController?.play()
+ }
+
+ @MainActor
+ func pauseAsync() async {
+ await activeController?.pause()
+ }
+
func toggleShuffle() {
Task {
await activeController?.toggleShuffle()
diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift
index f7e7ba4d..574dd616 100644
--- a/boringNotch/models/Constants.swift
+++ b/boringNotch/models/Constants.swift
@@ -34,7 +34,7 @@ enum HideNotchOption: String, Defaults.Serializable {
extension Notification.Name {
// MARK: - Media
static let mediaControllerChanged = Notification.Name("mediaControllerChanged")
-
+
// MARK: - Display
static let selectedScreenChanged = Notification.Name("SelectedScreenChanged")
static let notchHeightChanged = Notification.Name("NotchHeightChanged")
@@ -189,6 +189,14 @@ extension Defaults.Keys {
// MARK: Fullscreen Media Detection
static let hideNotchOption = Key("hideNotchOption", default: .nowPlayingOnly)
+ // MARK: Wellness
+ static let eyeBreakEnabled = Key("eyeBreakEnabled", default: true)
+ static let eyeBreakIntervalMinutes = Key("eyeBreakIntervalMinutes", default: 20)
+ static let eyeBreakDurationSeconds = Key("eyeBreakDurationSeconds", default: 20)
+ static let eyeBreakSnoozeMinutes = Key("eyeBreakSnoozeMinutes", default: 5)
+ static let eyeBreakSoundEnabled = Key("eyeBreakSoundEnabled", default: true)
+ static let eyeBreakPauseMediaOnPopup = Key("eyeBreakPauseMediaOnPopup", default: false)
+
// MARK: Media Controller
static let mediaController = Key("mediaController", default: defaultMediaController)
diff --git a/crowdin.yml b/crowdin.yml
index f1db027a..b23e632e 100644
--- a/crowdin.yml
+++ b/crowdin.yml
@@ -1,6 +1,4 @@
files:
- - source: /boringNotch/*.xcstrings
- translation: /boringNotch/%original_file_name%
+ - source: /boringNotch/Localizable.xcstrings
+ translation: /boringNotch/Localizable.xcstrings
multilingual: 1
-bundles:
- - 5