From a507ef37f0f9314981c8334aa3e4653a983232be Mon Sep 17 00:00:00 2001
From: Aram Rahimi
Date: Thu, 30 Jul 2026 03:30:39 +0200
Subject: [PATCH 1/3] Prepare Ledge for open-source distribution
---
.github/ISSUE_TEMPLATE/bug_report.yml | 47 ++
.github/ISSUE_TEMPLATE/config.yml | 5 +
.github/ISSUE_TEMPLATE/feature_request.yml | 24 +
.github/dependabot.yml | 13 +
.github/pull_request_template.md | 11 +
.github/release.yml | 20 +
.github/workflows/ci.yml | 33 ++
.github/workflows/release.yml | 171 +++++++
.gitignore | 3 +
CODE_OF_CONDUCT.md | 23 +
CONTRIBUTING.md | 34 ++
LICENSE | 21 +
MacDynamicIsland.xcodeproj/project.pbxproj | 462 ++++++++++++++++--
.../xcshareddata/swiftpm/Package.resolved | 15 +
MacDynamicIsland/App/AppDelegate.swift | 28 ++
MacDynamicIsland/Core/IslandModel.swift | 62 ++-
MacDynamicIsland/Info.plist | 12 +-
.../Services/CalendarService.swift | 65 +++
.../UI/IslandPanelController.swift | 28 +-
MacDynamicIsland/UI/IslandViews.swift | 147 +++++-
MacDynamicIslandTests/IslandModelTests.swift | 129 +++++
README.md | 35 +-
RELEASE.md | 127 +++++
SECURITY.md | 20 +
Scripts/build-release.sh | 71 +++
Scripts/ci.sh | 58 +++
Scripts/generate-cask.sh | 50 ++
Scripts/package-release.sh | 164 +++++++
Scripts/verify-distribution.sh | 102 ++++
29 files changed, 1934 insertions(+), 46 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/dependabot.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 .github/release.yml
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 CODE_OF_CONDUCT.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 MacDynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
create mode 100644 RELEASE.md
create mode 100644 SECURITY.md
create mode 100755 Scripts/build-release.sh
create mode 100755 Scripts/ci.sh
create mode 100755 Scripts/generate-cask.sh
create mode 100755 Scripts/package-release.sh
create mode 100755 Scripts/verify-distribution.sh
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..6bb41a2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,47 @@
+name: Bug report
+description: Report reproducible behavior that is not working as expected.
+title: "[Bug]: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: Please search existing issues before filing a new report.
+ - type: input
+ id: version
+ attributes:
+ label: Ledge version
+ placeholder: "1.0.0"
+ validations:
+ required: true
+ - type: input
+ id: macos
+ attributes:
+ label: macOS and Mac model
+ placeholder: "macOS 15.6, MacBook Pro 14-inch (M3 Pro)"
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Reproduction steps
+ description: Include the smallest reliable sequence that triggers the problem.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ validations:
+ required: true
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional context
+ description: Add screenshots or relevant Console logs. Remove personal data and credentials.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..7b0ed81
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Report a security vulnerability
+ url: https://github.com/aramr/Ledge/security/advisories/new
+ about: Submit security concerns privately.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..c57a491
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,24 @@
+name: Feature request
+description: Suggest a focused improvement to Ledge.
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What user problem would this solve?
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed behavior
+ description: Describe the interaction and expected result.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..a62bb6a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+version: 2
+updates:
+ - package-ecosystem: swift
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..a55813c
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## Summary
+
+Describe the user-visible change and why it is needed.
+
+## Verification
+
+- [ ] `Scripts/ci.sh` passes
+- [ ] I tested the affected flow on macOS 15 or later
+- [ ] I added or updated tests where practical
+- [ ] I updated user-facing documentation when needed
+- [ ] This change does not add credentials, generated builds, or personal data
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..754858e
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,20 @@
+changelog:
+ exclude:
+ labels:
+ - skip-changelog
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ - title: Fixes
+ labels:
+ - bug
+ - title: Documentation
+ labels:
+ - documentation
+ - title: Dependencies
+ labels:
+ - dependencies
+ - title: Other changes
+ labels:
+ - "*"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4506f5a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-and-test:
+ runs-on: macos-26
+ timeout-minutes: 30
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - name: Select Xcode 26
+ run: |
+ xcode_path="$(find /Applications -maxdepth 1 -type d -name 'Xcode_26*.app' | sort -V | tail -1)"
+ test -n "$xcode_path"
+ sudo xcode-select --switch "$xcode_path/Contents/Developer"
+ xcodebuild -version
+
+ - name: Build and test
+ run: Scripts/ci.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..f423c84
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,171 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v[0-9]+.[0-9]+.[0-9]+"
+
+permissions:
+ contents: write
+ id-token: write
+ attestations: write
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ release:
+ if: github.repository == 'aramr/Ledge'
+ runs-on: macos-26
+ timeout-minutes: 60
+
+ env:
+ APPLE_TEAM_ID: M7PFX75L8L
+ SIGNING_IDENTITY: Developer ID Application
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+ with:
+ fetch-depth: 0
+
+ - name: Select Xcode 26
+ run: |
+ xcode_path="$(find /Applications -maxdepth 1 -type d -name 'Xcode_26*.app' | sort -V | tail -1)"
+ test -n "$xcode_path"
+ sudo xcode-select --switch "$xcode_path/Contents/Developer"
+ xcodebuild -version
+
+ - name: Validate release version
+ id: version
+ run: |
+ version="${GITHUB_REF_NAME#v}"
+ project_version="$(xcodebuild -project MacDynamicIsland.xcodeproj -scheme Ledge -configuration Release -showBuildSettings | awk -F ' = ' '/ MARKETING_VERSION = / { print $2; exit }')"
+ test "$version" = "$project_version"
+ echo "value=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Validate release credentials
+ env:
+ CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
+ API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
+ API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
+ SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
+ HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
+ run: |
+ missing=()
+ for variable_name in \
+ CERTIFICATE_P12_BASE64 \
+ CERTIFICATE_PASSWORD \
+ API_KEY_P8_BASE64 \
+ API_KEY_ID \
+ API_ISSUER_ID \
+ SPARKLE_PRIVATE_KEY \
+ HOMEBREW_TAP_DEPLOY_KEY
+ do
+ if test -z "${!variable_name}"; then
+ missing+=("$variable_name")
+ fi
+ done
+ if (( ${#missing[@]} > 0 )); then
+ printf 'Missing release credential: %s\n' "${missing[@]}" >&2
+ exit 1
+ fi
+
+ - name: Run release gates
+ run: Scripts/ci.sh
+
+ - name: Import Developer ID certificate
+ env:
+ CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ run: |
+ test -n "$CERTIFICATE_P12_BASE64"
+ test -n "$CERTIFICATE_PASSWORD"
+ certificate_path="$RUNNER_TEMP/developer-id.p12"
+ keychain_path="$RUNNER_TEMP/ledge-signing.keychain-db"
+ keychain_password="$(openssl rand -hex 32)"
+ printf '%s' "$CERTIFICATE_P12_BASE64" | base64 -D > "$certificate_path"
+ security create-keychain -p "$keychain_password" "$keychain_path"
+ security set-keychain-settings -lut 21600 "$keychain_path"
+ security unlock-keychain -p "$keychain_password" "$keychain_path"
+ security import "$certificate_path" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
+ security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain_path"
+ security list-keychains -d user -s "$keychain_path"
+ security find-identity -v -p codesigning "$keychain_path"
+
+ - name: Prepare notarization credentials
+ env:
+ API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
+ run: |
+ test -n "$API_KEY_P8_BASE64"
+ printf '%s' "$API_KEY_P8_BASE64" | base64 -D > "$RUNNER_TEMP/AuthKey.p8"
+ chmod 600 "$RUNNER_TEMP/AuthKey.p8"
+
+ - name: Build signed archive
+ run: Scripts/build-release.sh "$RUNNER_TEMP/LedgeRelease"
+
+ - name: Notarize and package release
+ env:
+ APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
+ APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
+ APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
+ SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
+ SPARKLE_BIN_DIRECTORY: ${{ runner.temp }}/LedgeRelease/DerivedData/SourcePackages/artifacts/sparkle/Sparkle/bin
+ run: |
+ Scripts/package-release.sh "$RUNNER_TEMP/LedgeRelease/Export/Ledge.app" dist
+
+ - name: Attest release artifacts
+ uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
+ with:
+ subject-path: |
+ dist/Ledge-${{ steps.version.outputs.value }}.dmg
+ dist/Ledge-${{ steps.version.outputs.value }}.zip
+ dist/appcast.xml
+ dist/SHA256SUMS.txt
+
+ - name: Publish GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ steps.version.outputs.value }}
+ run: |
+ assets=(
+ "dist/Ledge-$VERSION.dmg"
+ "dist/Ledge-$VERSION.zip"
+ "dist/appcast.xml"
+ "dist/SHA256SUMS.txt"
+ )
+ if test -f "dist/Ledge-$VERSION-dSYM.zip"; then
+ assets+=("dist/Ledge-$VERSION-dSYM.zip")
+ fi
+ gh release create "$GITHUB_REF_NAME" "${assets[@]}" \
+ --generate-notes \
+ --title "Ledge $VERSION" \
+ --verify-tag
+
+ - name: Update Homebrew tap
+ env:
+ HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
+ VERSION: ${{ steps.version.outputs.value }}
+ run: |
+ test -n "$HOMEBREW_TAP_DEPLOY_KEY"
+ mkdir -p "$HOME/.ssh"
+ chmod 700 "$HOME/.ssh"
+ printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > "$HOME/.ssh/ledge-homebrew-tap"
+ chmod 600 "$HOME/.ssh/ledge-homebrew-tap"
+ ssh-keyscan github.com >> "$HOME/.ssh/known_hosts"
+ export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/ledge-homebrew-tap -o IdentitiesOnly=yes"
+ brew tap aramr/tap git@github.com:aramr/homebrew-tap.git
+ tap_directory="$(brew --repository aramr/tap)"
+ mkdir -p "$tap_directory/Casks"
+ dmg_sha256="$(shasum -a 256 "dist/Ledge-$VERSION.dmg" | awk '{print $1}')"
+ Scripts/generate-cask.sh "$VERSION" "$dmg_sha256" > "$tap_directory/Casks/ledge.rb"
+ brew style "$tap_directory/Casks/ledge.rb"
+ brew audit --cask --strict aramr/tap/ledge
+ git -C "$tap_directory" config user.name "github-actions[bot]"
+ git -C "$tap_directory" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git -C "$tap_directory" add Casks/ledge.rb
+ git -C "$tap_directory" commit -m "Update Ledge to $VERSION"
+ git -C "$tap_directory" push
diff --git a/.gitignore b/.gitignore
index 5497745..18d61ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
DerivedData/
+.derivedData/
+.distributionDerivedData/
+.build/
.DS_Store
xcuserdata/
*.xcuserstate
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..891dbfe
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,23 @@
+# Contributor Covenant Code of Conduct
+
+## Our pledge
+
+We pledge to make participation in this project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our standards
+
+Examples of behavior that contributes to a positive environment include demonstrating empathy, respecting differing opinions, giving and accepting constructive feedback, taking responsibility for mistakes, and focusing on what is best for the community.
+
+Unacceptable behavior includes sexualized language or attention, trolling or insulting comments, harassment, publishing others' private information without permission, and other conduct that could reasonably be considered inappropriate in a professional setting.
+
+## Enforcement
+
+Project maintainers are responsible for clarifying and enforcing these standards. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately through the contact method in `SECURITY.md`. All reports will be reviewed promptly and fairly.
+
+Maintainers may remove, edit, or reject contributions and may temporarily or permanently ban contributors for behavior they deem inappropriate, threatening, offensive, or harmful.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..079c16a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,34 @@
+# Contributing to Ledge
+
+Thanks for helping improve Ledge.
+
+## Before opening a change
+
+- Search existing issues and pull requests first.
+- Open an issue before starting a large feature or architectural change.
+- Keep pull requests focused and avoid unrelated formatting changes.
+- Never commit credentials, signing material, generated build products, or user data.
+
+## Development
+
+Ledge requires macOS 15 or later and Xcode 26 or later.
+
+```sh
+git clone https://github.com/aramr/Ledge.git
+cd Ledge
+Scripts/ci.sh
+```
+
+You can also open `MacDynamicIsland.xcodeproj`, select the Ledge scheme, and run the app on My Mac. Debug builds use `com.aramrahimi.Ledge.debug`; production releases use `com.aramrahimi.Ledge`.
+
+## Pull requests
+
+Every pull request should:
+
+- explain the user-visible behavior and motivation;
+- include tests for new model or service behavior where practical;
+- pass `Scripts/ci.sh`;
+- preserve the local-first privacy model described in `PRIVACY.md`;
+- update documentation when installation, settings, or user-facing behavior changes.
+
+By contributing, you agree that your contribution is licensed under the MIT License.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..bb3fa51
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Aram Rahimi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MacDynamicIsland.xcodeproj/project.pbxproj b/MacDynamicIsland.xcodeproj/project.pbxproj
index 4d884ed..d061339 100644
--- a/MacDynamicIsland.xcodeproj/project.pbxproj
+++ b/MacDynamicIsland.xcodeproj/project.pbxproj
@@ -38,6 +38,7 @@
10000000000000000000001E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000021 /* PrivacyInfo.xcprivacy */; };
10000000000000000000001F /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000022 /* PRIVACY.md */; };
100000000000000000000020 /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000024 /* LaunchAtLoginService.swift */; };
+ 100000000000000000000021 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = D000000000000000000000002 /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -89,59 +90,464 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
- 400000000000000000000001 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (10000000000000000000001A /* IOBluetooth.framework in Frameworks */,); runOnlyForDeploymentPostprocessing = 0; };
- 400000000000000000000002 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (10000000000000000000000D /* XCTest.framework in Frameworks */,); runOnlyForDeploymentPostprocessing = 0; };
+ 400000000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 10000000000000000000001A /* IOBluetooth.framework in Frameworks */,
+ 100000000000000000000021 /* Sparkle in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 400000000000000000000002 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 10000000000000000000000D /* XCTest.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
- 500000000000000000000001 = {isa = PBXGroup; children = (500000000000000000000002 /* Ledge */, 500000000000000000000007 /* LedgeTests */, 500000000000000000000008 /* Frameworks */, 500000000000000000000009 /* Products */, 200000000000000000000022 /* PRIVACY.md */,); sourceTree = ""; };
- 500000000000000000000002 /* Ledge */ = {isa = PBXGroup; children = (500000000000000000000003 /* App */, 500000000000000000000004 /* Core */, 500000000000000000000005 /* Services */, 500000000000000000000006 /* UI */, 20000000000000000000001E /* Assets.xcassets */, 20000000000000000000000D /* Info.plist */, 200000000000000000000021 /* PrivacyInfo.xcprivacy */, 200000000000000000000023 /* Ledge.entitlements */,); name = Ledge; path = MacDynamicIsland; sourceTree = ""; };
- 500000000000000000000003 /* App */ = {isa = PBXGroup; children = (200000000000000000000001 /* main.swift */, 200000000000000000000002 /* AppDelegate.swift */,); path = App; sourceTree = ""; };
- 500000000000000000000004 /* Core */ = {isa = PBXGroup; children = (200000000000000000000003 /* MediaSession.swift */, 200000000000000000000004 /* IslandModel.swift */, 200000000000000000000012 /* IslandContent.swift */, 200000000000000000000017 /* AppSettings.swift */,); path = Core; sourceTree = ""; };
- 500000000000000000000005 /* Services */ = {isa = PBXGroup; children = (200000000000000000000005 /* NowPlayingScriptClient.swift */, 200000000000000000000006 /* SystemMediaSessionProvider.swift */, 200000000000000000000011 /* SystemAudioWaveformProvider.swift */, 200000000000000000000013 /* CalendarService.swift */, 200000000000000000000014 /* ClipboardHistoryService.swift */, 20000000000000000000001C /* BluetoothConnectionService.swift */, 200000000000000000000015 /* SpotifyFallbackService.swift */, 200000000000000000000016 /* CodexUsageService.swift */, 200000000000000000000019 /* ClaudeUsageService.swift */, 20000000000000000000001B /* ClaudeCodeStatusLineBridge.swift */, 200000000000000000000024 /* LaunchAtLoginService.swift */, 200000000000000000000007 /* PreviewMediaSessionProvider.swift */, 200000000000000000000008 /* FocusMonitor.swift */,); path = Services; sourceTree = ""; };
- 500000000000000000000006 /* UI */ = {isa = PBXGroup; children = (200000000000000000000009 /* IslandViews.swift */, 20000000000000000000001A /* AgentProviderIcon.swift */, 20000000000000000000000A /* IslandPanelController.swift */, 200000000000000000000018 /* SettingsWindowController.swift */, 20000000000000000000001F /* OnboardingWindowController.swift */,); path = UI; sourceTree = ""; };
- 500000000000000000000007 /* LedgeTests */ = {isa = PBXGroup; children = (20000000000000000000000B /* MediaSessionTests.swift */, 20000000000000000000000C /* IslandModelTests.swift */,); name = LedgeTests; path = MacDynamicIslandTests; sourceTree = ""; };
- 500000000000000000000008 /* Frameworks */ = {isa = PBXGroup; children = (20000000000000000000000F /* XCTest.framework */, 20000000000000000000001D /* IOBluetooth.framework */,); name = Frameworks; sourceTree = ""; };
- 500000000000000000000009 /* Products */ = {isa = PBXGroup; children = (20000000000000000000000E /* Ledge.app */, 200000000000000000000010 /* LedgeTests.xctest */,); name = Products; sourceTree = ""; };
+ 500000000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ 500000000000000000000002 /* Ledge */,
+ 500000000000000000000007 /* LedgeTests */,
+ 500000000000000000000008 /* Frameworks */,
+ 500000000000000000000009 /* Products */,
+ 200000000000000000000022 /* PRIVACY.md */,
+ );
+ sourceTree = "";
+ };
+ 500000000000000000000002 /* Ledge */ = {
+ isa = PBXGroup;
+ children = (
+ 500000000000000000000003 /* App */,
+ 500000000000000000000004 /* Core */,
+ 500000000000000000000005 /* Services */,
+ 500000000000000000000006 /* UI */,
+ 20000000000000000000001E /* Assets.xcassets */,
+ 20000000000000000000000D /* Info.plist */,
+ 200000000000000000000021 /* PrivacyInfo.xcprivacy */,
+ 200000000000000000000023 /* Ledge.entitlements */,
+ );
+ name = Ledge;
+ path = MacDynamicIsland;
+ sourceTree = "";
+ };
+ 500000000000000000000003 /* App */ = {
+ isa = PBXGroup;
+ children = (
+ 200000000000000000000001 /* main.swift */,
+ 200000000000000000000002 /* AppDelegate.swift */,
+ );
+ path = App;
+ sourceTree = "";
+ };
+ 500000000000000000000004 /* Core */ = {
+ isa = PBXGroup;
+ children = (
+ 200000000000000000000003 /* MediaSession.swift */,
+ 200000000000000000000004 /* IslandModel.swift */,
+ 200000000000000000000012 /* IslandContent.swift */,
+ 200000000000000000000017 /* AppSettings.swift */,
+ );
+ path = Core;
+ sourceTree = "";
+ };
+ 500000000000000000000005 /* Services */ = {
+ isa = PBXGroup;
+ children = (
+ 200000000000000000000005 /* NowPlayingScriptClient.swift */,
+ 200000000000000000000006 /* SystemMediaSessionProvider.swift */,
+ 200000000000000000000011 /* SystemAudioWaveformProvider.swift */,
+ 200000000000000000000013 /* CalendarService.swift */,
+ 200000000000000000000014 /* ClipboardHistoryService.swift */,
+ 20000000000000000000001C /* BluetoothConnectionService.swift */,
+ 200000000000000000000015 /* SpotifyFallbackService.swift */,
+ 200000000000000000000016 /* CodexUsageService.swift */,
+ 200000000000000000000019 /* ClaudeUsageService.swift */,
+ 20000000000000000000001B /* ClaudeCodeStatusLineBridge.swift */,
+ 200000000000000000000024 /* LaunchAtLoginService.swift */,
+ 200000000000000000000007 /* PreviewMediaSessionProvider.swift */,
+ 200000000000000000000008 /* FocusMonitor.swift */,
+ );
+ path = Services;
+ sourceTree = "";
+ };
+ 500000000000000000000006 /* UI */ = {
+ isa = PBXGroup;
+ children = (
+ 200000000000000000000009 /* IslandViews.swift */,
+ 20000000000000000000001A /* AgentProviderIcon.swift */,
+ 20000000000000000000000A /* IslandPanelController.swift */,
+ 200000000000000000000018 /* SettingsWindowController.swift */,
+ 20000000000000000000001F /* OnboardingWindowController.swift */,
+ );
+ path = UI;
+ sourceTree = "";
+ };
+ 500000000000000000000007 /* LedgeTests */ = {
+ isa = PBXGroup;
+ children = (
+ 20000000000000000000000B /* MediaSessionTests.swift */,
+ 20000000000000000000000C /* IslandModelTests.swift */,
+ );
+ name = LedgeTests;
+ path = MacDynamicIslandTests;
+ sourceTree = "";
+ };
+ 500000000000000000000008 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 20000000000000000000000F /* XCTest.framework */,
+ 20000000000000000000001D /* IOBluetooth.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 500000000000000000000009 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 20000000000000000000000E /* Ledge.app */,
+ 200000000000000000000010 /* LedgeTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
- 600000000000000000000001 /* Ledge */ = {isa = PBXNativeTarget; buildConfigurationList = 800000000000000000000002; buildPhases = (900000000000000000000001 /* Sources */, 400000000000000000000001 /* Frameworks */, 900000000000000000000003 /* Resources */,); buildRules = (); dependencies = (); name = Ledge; productName = Ledge; productReference = 20000000000000000000000E /* Ledge.app */; productType = "com.apple.product-type.application"; };
- 600000000000000000000002 /* LedgeTests */ = {isa = PBXNativeTarget; buildConfigurationList = 800000000000000000000003; buildPhases = (900000000000000000000002 /* Sources */, 400000000000000000000002 /* Frameworks */, 900000000000000000000004 /* Resources */,); buildRules = (); dependencies = (A00000000000000000000001 /* PBXTargetDependency */,); name = LedgeTests; productName = LedgeTests; productReference = 200000000000000000000010 /* LedgeTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; };
+ 600000000000000000000001 /* Ledge */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 800000000000000000000002 /* Build configuration list for PBXNativeTarget "Ledge" */;
+ buildPhases = (
+ 900000000000000000000001 /* Sources */,
+ 400000000000000000000001 /* Frameworks */,
+ 900000000000000000000003 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Ledge;
+ packageProductDependencies = (
+ D000000000000000000000002 /* Sparkle */,
+ );
+ productName = Ledge;
+ productReference = 20000000000000000000000E /* Ledge.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 600000000000000000000002 /* LedgeTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 800000000000000000000003 /* Build configuration list for PBXNativeTarget "LedgeTests" */;
+ buildPhases = (
+ 900000000000000000000002 /* Sources */,
+ 400000000000000000000002 /* Frameworks */,
+ 900000000000000000000004 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ A00000000000000000000001 /* PBXTargetDependency */,
+ );
+ name = LedgeTests;
+ productName = LedgeTests;
+ productReference = 200000000000000000000010 /* LedgeTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
- 700000000000000000000001 /* Project object */ = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600; TargetAttributes = {600000000000000000000001 = {CreatedOnToolsVersion = 26.0;}; 600000000000000000000002 = {CreatedOnToolsVersion = 26.0; TestTargetID = 600000000000000000000001;};};}; buildConfigurationList = 800000000000000000000001; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base,); mainGroup = 500000000000000000000001; productRefGroup = 500000000000000000000009 /* Products */; projectDirPath = ""; projectRoot = ""; targets = (600000000000000000000001 /* Ledge */, 600000000000000000000002 /* LedgeTests */,); };
+ 700000000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2600;
+ LastUpgradeCheck = 2600;
+ TargetAttributes = {
+ 600000000000000000000001 = {
+ CreatedOnToolsVersion = 26.0;
+ };
+ 600000000000000000000002 = {
+ CreatedOnToolsVersion = 26.0;
+ TestTargetID = 600000000000000000000001;
+ };
+ };
+ };
+ buildConfigurationList = 800000000000000000000001 /* Build configuration list for PBXProject "MacDynamicIsland" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 500000000000000000000001;
+ packageReferences = (
+ D000000000000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
+ );
+ productRefGroup = 500000000000000000000009 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 600000000000000000000001 /* Ledge */,
+ 600000000000000000000002 /* LedgeTests */,
+ );
+ };
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
- 900000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (10000000000000000000001B /* Assets.xcassets in Resources */,10000000000000000000001E /* PrivacyInfo.xcprivacy in Resources */,10000000000000000000001F /* PRIVACY.md in Resources */,); runOnlyForDeploymentPostprocessing = 0; };
- 900000000000000000000004 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
+ 900000000000000000000003 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 10000000000000000000001B /* Assets.xcassets in Resources */,
+ 10000000000000000000001E /* PrivacyInfo.xcprivacy in Resources */,
+ 10000000000000000000001F /* PRIVACY.md in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 900000000000000000000004 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
- 900000000000000000000001 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (100000000000000000000001,100000000000000000000002,100000000000000000000003,100000000000000000000004,10000000000000000000000F,100000000000000000000014,100000000000000000000005,100000000000000000000006,10000000000000000000000E,100000000000000000000010,100000000000000000000011,100000000000000000000019,100000000000000000000012,100000000000000000000013,100000000000000000000016,100000000000000000000018,100000000000000000000020,100000000000000000000007,100000000000000000000008,100000000000000000000009,100000000000000000000017,10000000000000000000000A,100000000000000000000015,10000000000000000000001C,); runOnlyForDeploymentPostprocessing = 0; };
- 900000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (10000000000000000000000B,10000000000000000000000C,); runOnlyForDeploymentPostprocessing = 0; };
+ 900000000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 100000000000000000000001 /* main.swift in Sources */,
+ 100000000000000000000002 /* AppDelegate.swift in Sources */,
+ 100000000000000000000003 /* MediaSession.swift in Sources */,
+ 100000000000000000000004 /* IslandModel.swift in Sources */,
+ 10000000000000000000000F /* IslandContent.swift in Sources */,
+ 100000000000000000000014 /* AppSettings.swift in Sources */,
+ 100000000000000000000005 /* NowPlayingScriptClient.swift in Sources */,
+ 100000000000000000000006 /* SystemMediaSessionProvider.swift in Sources */,
+ 10000000000000000000000E /* SystemAudioWaveformProvider.swift in Sources */,
+ 100000000000000000000010 /* CalendarService.swift in Sources */,
+ 100000000000000000000011 /* ClipboardHistoryService.swift in Sources */,
+ 100000000000000000000019 /* BluetoothConnectionService.swift in Sources */,
+ 100000000000000000000012 /* SpotifyFallbackService.swift in Sources */,
+ 100000000000000000000013 /* CodexUsageService.swift in Sources */,
+ 100000000000000000000016 /* ClaudeUsageService.swift in Sources */,
+ 100000000000000000000018 /* ClaudeCodeStatusLineBridge.swift in Sources */,
+ 100000000000000000000020 /* LaunchAtLoginService.swift in Sources */,
+ 100000000000000000000007 /* PreviewMediaSessionProvider.swift in Sources */,
+ 100000000000000000000008 /* FocusMonitor.swift in Sources */,
+ 100000000000000000000009 /* IslandViews.swift in Sources */,
+ 100000000000000000000017 /* AgentProviderIcon.swift in Sources */,
+ 10000000000000000000000A /* IslandPanelController.swift in Sources */,
+ 100000000000000000000015 /* SettingsWindowController.swift in Sources */,
+ 10000000000000000000001C /* OnboardingWindowController.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 900000000000000000000002 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 10000000000000000000000B /* MediaSessionTests.swift in Sources */,
+ 10000000000000000000000C /* IslandModelTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
- A00000000000000000000001 /* PBXTargetDependency */ = {isa = PBXTargetDependency; target = 600000000000000000000001 /* Ledge */; targetProxy = 300000000000000000000001 /* PBXContainerItemProxy */; };
+ A00000000000000000000001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 600000000000000000000001 /* Ledge */;
+ targetProxy = 300000000000000000000001 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
- B00000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_OPTIMIZATION_LEVEL = 0; MACOSX_DEPLOYMENT_TARGET = 15.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; }; name = Debug; };
- B00000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; COPY_PHASE_STRIP = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; GCC_C_LANGUAGE_STANDARD = gnu17; MACOSX_DEPLOYMENT_TARGET = 15.0; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; }; name = Release; };
- B00000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MacDynamicIsland/Ledge.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MacDynamicIsland/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.MacDynamicIsland; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; }; name = Debug; };
- B00000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MacDynamicIsland/Ledge.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; ENABLE_CODE_COVERAGE = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MacDynamicIsland/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.MacDynamicIsland; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; VALIDATE_PRODUCT = YES; }; name = Release; };
- B00000000000000000000005 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.LedgeTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ledge.app/Contents/MacOS/Ledge"; }; name = Debug; };
- B00000000000000000000006 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.LedgeTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ledge.app/Contents/MacOS/Ledge"; }; name = Release; };
+ B00000000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+ B00000000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ COPY_PHASE_STRIP = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+ B00000000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = MacDynamicIsland/Ledge.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = M7PFX75L8L;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MacDynamicIsland/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.Ledge.debug;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ };
+ name = Debug;
+ };
+ B00000000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = MacDynamicIsland/Ledge.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = M7PFX75L8L;
+ ENABLE_CODE_COVERAGE = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MacDynamicIsland/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.Ledge;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ B00000000000000000000005 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@loader_path/../Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.LedgeTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ledge.app/Contents/MacOS/Ledge";
+ };
+ name = Debug;
+ };
+ B00000000000000000000006 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@loader_path/../Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.aramrahimi.LedgeTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ledge.app/Contents/MacOS/Ledge";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
- 800000000000000000000001 /* Build configuration list for PBXProject */ = {isa = XCConfigurationList; buildConfigurations = (B00000000000000000000001 /* Debug */, B00000000000000000000002 /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
- 800000000000000000000002 /* Build configuration list for PBXNativeTarget "Ledge" */ = {isa = XCConfigurationList; buildConfigurations = (B00000000000000000000003 /* Debug */, B00000000000000000000004 /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
- 800000000000000000000003 /* Build configuration list for PBXNativeTarget "LedgeTests" */ = {isa = XCConfigurationList; buildConfigurations = (B00000000000000000000005 /* Debug */, B00000000000000000000006 /* Release */,); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
+ 800000000000000000000001 /* Build configuration list for PBXProject "MacDynamicIsland" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B00000000000000000000001 /* Debug */,
+ B00000000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 800000000000000000000002 /* Build configuration list for PBXNativeTarget "Ledge" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B00000000000000000000003 /* Debug */,
+ B00000000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 800000000000000000000003 /* Build configuration list for PBXNativeTarget "LedgeTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B00000000000000000000005 /* Debug */,
+ B00000000000000000000006 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ D000000000000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/sparkle-project/Sparkle";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.9.2;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ D000000000000000000000002 /* Sparkle */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D000000000000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */;
+ productName = Sparkle;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 700000000000000000000001 /* Project object */;
}
diff --git a/MacDynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MacDynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..feb7b4b
--- /dev/null
+++ b/MacDynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/MacDynamicIsland/App/AppDelegate.swift b/MacDynamicIsland/App/AppDelegate.swift
index 922b9ab..2f5c1d4 100644
--- a/MacDynamicIsland/App/AppDelegate.swift
+++ b/MacDynamicIsland/App/AppDelegate.swift
@@ -1,4 +1,5 @@
import AppKit
+import Sparkle
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
@@ -17,6 +18,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let codexUsageService = CodexUsageService()
private let claudeUsageService = ClaudeUsageService()
private let launchAtLoginService = LaunchAtLoginService()
+ private lazy var updaterController = SPUStandardUpdaterController(
+ startingUpdater: true,
+ updaterDelegate: nil,
+ userDriverDelegate: nil
+ )
private var activeProvider: (any MediaSessionProviding)?
private var panelController: IslandPanelController?
@@ -45,11 +51,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
configureContentServices()
isAgenticPreviewLaunch = ProcessInfo.processInfo.arguments.contains("--agentic-preview")
requestedPreviewOnLaunch = ProcessInfo.processInfo.arguments.contains("--preview")
+ || ProcessInfo.processInfo.arguments.contains("--side-bubble-preview")
|| ProcessInfo.processInfo.environment["LEDGE_PREVIEW"] == "1"
|| ProcessInfo.processInfo.environment["MAC_DYNAMIC_ISLAND_PREVIEW"] == "1"
let isFeaturePreview = isAgenticPreviewLaunch
|| ProcessInfo.processInfo.arguments.contains("--bluetooth-preview")
+ || ProcessInfo.processInfo.arguments.contains("--side-bubble-preview")
let shouldPresentOnboarding = ProcessInfo.processInfo.arguments.contains("--onboarding")
|| (settings.needsOnboarding && !isFeaturePreview)
if shouldPresentOnboarding {
@@ -108,6 +116,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)
}
}
+
+ if ProcessInfo.processInfo.arguments.contains("--side-bubble-preview") {
+ model.setTimerMinutes(2)
+ model.startTimer()
+ }
#endif
if ProcessInfo.processInfo.arguments.contains("--settings") {
@@ -205,6 +218,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
calendarService.onEventsChange = { [weak model] events in
model?.calendarEvents = events
}
+ calendarService.onCurrentDayChange = { [weak model] day in
+ model?.refreshCalendarDay(day)
+ }
model.onCalendarAccessRequest = { [weak calendarService] in
calendarService?.requestAccess()
}
@@ -304,6 +320,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
settings.target = self
menu.addItem(settings)
+ let checkForUpdates = NSMenuItem(
+ title: "Check for Updates…",
+ action: #selector(checkForUpdates),
+ keyEquivalent: ""
+ )
+ checkForUpdates.target = self
+ menu.addItem(checkForUpdates)
+
menu.addItem(.separator())
let quit = NSMenuItem(title: "Quit Ledge", action: #selector(quit), keyEquivalent: "q")
@@ -357,6 +381,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
settingsWindowController?.present()
}
+ @objc private func checkForUpdates() {
+ updaterController.checkForUpdates(nil)
+ }
+
@objc private func showOnboarding() {
model.isOnboardingGreetingPresented = true
launchAtLoginService.refreshStatus()
diff --git a/MacDynamicIsland/Core/IslandModel.swift b/MacDynamicIsland/Core/IslandModel.swift
index ca11afe..3460ec4 100644
--- a/MacDynamicIsland/Core/IslandModel.swift
+++ b/MacDynamicIsland/Core/IslandModel.swift
@@ -12,6 +12,8 @@ enum IslandPhase: Equatable {
final class IslandModel {
static let canvasSize = CGSize(width: 820, height: 420)
static let homeExpandedSurfaceSize = CGSize(width: 760, height: 202)
+ static let sideMediaBubbleSize = CGSize(width: 40, height: 32)
+ static let sideMediaBubbleGap: CGFloat = 8
let settings: AppSettings
var snapshot: MediaSessionSnapshot = .empty
@@ -40,6 +42,7 @@ final class IslandModel {
}
}
var calendarAccessState: CalendarAccessState = .unknown
+ var currentCalendarDay = Calendar.current.startOfDay(for: .now)
var selectedCalendarDate = Calendar.current.startOfDay(for: .now)
var displayedCalendarMonth = Calendar.current.dateInterval(of: .month, for: .now)?.start ?? .now
var calendarEvents: [CalendarEventItem] = []
@@ -174,6 +177,17 @@ final class IslandModel {
}
var homeMediaUsesSpotifyFallback: Bool {
+ // A running timer owns the primary compact surface, so Spotify is no
+ // longer redundant even when Spotify itself is frontmost. Prefer its
+ // live fallback for the companion bubble and the Home view it opens,
+ // unless MediaRemote is already reporting Spotify directly.
+ if isTimerActive,
+ hasSpotifyFallback,
+ spotifyFallbackSnapshot.isPlaying,
+ !(hasPrimaryMediaSnapshot && Self.isSpotify(snapshot)) {
+ return true
+ }
+
// When compact media exists, Home follows the same priority decision.
if hasActiveMedia {
return usesSpotifyFallback
@@ -219,6 +233,29 @@ final class IslandModel {
&& hasActiveMedia
}
+ var shouldShowSideMediaBubble: Bool {
+ phase == .compact
+ && isTimerActive
+ && homeMediaSnapshot.isPlaying
+ && visibleTabs.contains(.home)
+ && !isOnboardingGreetingPresented
+ && !isShowingBluetoothConnection
+ }
+
+ var sideMediaBubbleFrame: CGRect {
+ let compactRightEdge = Self.canvasSize.width / 2 + timerCompactSurfaceSize.width / 2
+ return CGRect(
+ x: compactRightEdge + Self.sideMediaBubbleGap,
+ y: (timerCompactSurfaceSize.height - Self.sideMediaBubbleSize.height) / 2,
+ width: Self.sideMediaBubbleSize.width,
+ height: Self.sideMediaBubbleSize.height
+ )
+ }
+
+ var sideMediaBubbleCenter: CGPoint {
+ CGPoint(x: sideMediaBubbleFrame.midX, y: sideMediaBubbleFrame.midY)
+ }
+
var shouldCaptureLiveWaveform: Bool {
guard homeMediaSnapshot.isPlaying else { return false }
if isShowingCompactMedia { return true }
@@ -331,7 +368,7 @@ final class IslandModel {
if inside {
guard isEnabled else { return }
- if isTimerActive {
+ if isTimerActive && !isExpanded {
selectedTab = .timer
isCalendarDetailPresented = false
}
@@ -357,6 +394,14 @@ final class IslandModel {
isExpanded.toggle()
}
+ func openSideMediaBubble() {
+ guard shouldShowSideMediaBubble else { return }
+ collapseTask?.cancel()
+ isCalendarDetailPresented = false
+ selectTab(.home)
+ isExpanded = true
+ }
+
func send(_ command: MediaCommand) {
onCommand?(command)
}
@@ -470,6 +515,21 @@ final class IslandModel {
onCalendarAccessRequest?()
}
+ func refreshCalendarDay(_ date: Date = .now) {
+ let calendar = Calendar.current
+ let newCurrentDay = calendar.startOfDay(for: date)
+ guard newCurrentDay != currentCalendarDay else { return }
+
+ let wasFollowingCurrentDay = selectedCalendarDate == currentCalendarDay
+ currentCalendarDay = newCurrentDay
+
+ // Keep the default "today" selection moving across midnight, while
+ // preserving a date the user deliberately selected.
+ if wasFollowingCurrentDay {
+ selectCalendarDate(newCurrentDay)
+ }
+ }
+
func selectCalendarDate(_ date: Date) {
let calendar = Calendar.current
selectedCalendarDate = calendar.startOfDay(for: date)
diff --git a/MacDynamicIsland/Info.plist b/MacDynamicIsland/Info.plist
index f275285..806af5c 100644
--- a/MacDynamicIsland/Info.plist
+++ b/MacDynamicIsland/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 1
+ $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
LSApplicationCategoryType
@@ -39,8 +39,14 @@
NSCalendarsFullAccessUsageDescription
Ledge shows your upcoming events and calendar in its expanded view.
NSHumanReadableCopyright
- Copyright © 2026. All rights reserved.
+ Copyright © 2026 Aram Rahimi
NSPrincipalClass
NSApplication
+ SUFeedURL
+ https://github.com/aramr/Ledge/releases/latest/download/appcast.xml
+ SUEnableAutomaticChecks
+
+ SUPublicEDKey
+ Pp/GIxuthq8MLJsxpV6bF2OUz6LNoMW5FU2J3k7fJrI=
diff --git a/MacDynamicIsland/Services/CalendarService.swift b/MacDynamicIsland/Services/CalendarService.swift
index 5928ac8..c0e9e33 100644
--- a/MacDynamicIsland/Services/CalendarService.swift
+++ b/MacDynamicIsland/Services/CalendarService.swift
@@ -6,11 +6,15 @@ import Foundation
final class CalendarService {
private let store = EKEventStore()
private var storeObserver: NSObjectProtocol?
+ private var dayChangeObservers: [NSObjectProtocol] = []
+ private var wakeObserver: NSObjectProtocol?
+ private var dayChangeTimer: Timer?
private var selectedDate = Calendar.current.startOfDay(for: .now)
private var displayedMonth = Calendar.current.dateInterval(of: .month, for: .now)?.start ?? .now
var onAccessStateChange: ((CalendarAccessState) -> Void)?
var onEventsChange: (([CalendarEventItem]) -> Void)?
+ var onCurrentDayChange: ((Date) -> Void)?
func start() {
storeObserver = NotificationCenter.default.addObserver(
@@ -21,6 +25,8 @@ final class CalendarService {
Task { @MainActor in self?.reload() }
}
+ observeCurrentDayChanges()
+ refreshCurrentDay()
updateAuthorizationState()
}
@@ -29,6 +35,14 @@ final class CalendarService {
NotificationCenter.default.removeObserver(storeObserver)
}
storeObserver = nil
+ dayChangeObservers.forEach { NotificationCenter.default.removeObserver($0) }
+ dayChangeObservers = []
+ if let wakeObserver {
+ NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
+ }
+ wakeObserver = nil
+ dayChangeTimer?.invalidate()
+ dayChangeTimer = nil
}
func requestAccess() {
@@ -55,6 +69,57 @@ final class CalendarService {
reload()
}
+ private func observeCurrentDayChanges() {
+ let notificationCenter = NotificationCenter.default
+ let names: [Notification.Name] = [
+ .NSCalendarDayChanged,
+ .NSSystemClockDidChange,
+ .NSSystemTimeZoneDidChange
+ ]
+
+ dayChangeObservers = names.map { name in
+ notificationCenter.addObserver(
+ forName: name,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.refreshCurrentDay() }
+ }
+ }
+
+ wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
+ forName: NSWorkspace.didWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.refreshCurrentDay() }
+ }
+ }
+
+ private func refreshCurrentDay() {
+ let calendar = Calendar.current
+ let today = calendar.startOfDay(for: .now)
+ onCurrentDayChange?(today)
+ scheduleNextDayRefresh(after: today, calendar: calendar)
+ }
+
+ private func scheduleNextDayRefresh(after today: Date, calendar: Calendar) {
+ dayChangeTimer?.invalidate()
+ guard let nextDay = calendar.date(byAdding: .day, value: 1, to: today) else {
+ dayChangeTimer = nil
+ return
+ }
+
+ let timer = Timer(
+ timeInterval: max(nextDay.timeIntervalSinceNow + 0.5, 1),
+ repeats: false
+ ) { [weak self] _ in
+ Task { @MainActor in self?.refreshCurrentDay() }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ dayChangeTimer = timer
+ }
+
private func updateAuthorizationState() {
switch EKEventStore.authorizationStatus(for: .event) {
case .fullAccess:
diff --git a/MacDynamicIsland/UI/IslandPanelController.swift b/MacDynamicIsland/UI/IslandPanelController.swift
index 01f71bd..4e37a00 100644
--- a/MacDynamicIsland/UI/IslandPanelController.swift
+++ b/MacDynamicIsland/UI/IslandPanelController.swift
@@ -14,6 +14,7 @@ final class IslandPanelController {
private var globalMouseMonitor: Any?
private var localMouseMonitor: Any?
private var pointerIsInside: Bool?
+ private var pointerIsInsideSideBubble = false
init(model: IslandModel) {
self.model = model
@@ -73,6 +74,8 @@ final class IslandPanelController {
_ = model.surfaceSize
_ = model.hasActiveMedia
_ = model.snapshot.identifier
+ _ = model.shouldShowSideMediaBubble
+ _ = model.sideMediaBubbleFrame
_ = model.selectedTab
_ = model.selectedClipboardEntryIDs
} onChange: { [weak self] in
@@ -153,11 +156,23 @@ final class IslandPanelController {
height: size.height
)
let isInside = panel.isVisible && surfaceFrame.contains(NSEvent.mouseLocation)
+ let localBubbleFrame = model.sideMediaBubbleFrame
+ let bubbleFrame = NSRect(
+ x: panel.frame.minX + localBubbleFrame.minX,
+ y: panel.frame.maxY - localBubbleFrame.maxY,
+ width: localBubbleFrame.width,
+ height: localBubbleFrame.height
+ )
+ let isInsideSideBubble = panel.isVisible
+ && model.shouldShowSideMediaBubble
+ && bubbleFrame.contains(NSEvent.mouseLocation)
panel.ignoresMouseEvents = Self.shouldIgnoreMouseEvents(
phase: model.phase,
isOnboardingGreetingPresented: model.isOnboardingGreetingPresented,
- pointerIsInsideSurface: isInside
+ pointerIsInsideSurface: isInside,
+ pointerIsInsideSideBubble: isInsideSideBubble
)
+ pointerIsInsideSideBubble = isInsideSideBubble
guard pointerIsInside != isInside else { return }
pointerIsInside = isInside
model.setPointerInside(isInside)
@@ -166,7 +181,8 @@ final class IslandPanelController {
static func shouldIgnoreMouseEvents(
phase: IslandPhase,
isOnboardingGreetingPresented: Bool,
- pointerIsInsideSurface: Bool
+ pointerIsInsideSurface: Bool,
+ pointerIsInsideSideBubble: Bool = false
) -> Bool {
// The AppKit panel is intentionally canvas-sized so the island can
// expand without rebuilding its SwiftUI hierarchy. During onboarding,
@@ -176,7 +192,10 @@ final class IslandPanelController {
if isOnboardingGreetingPresented {
return !pointerIsInsideSurface
}
- return phase != .expanded
+ if phase == .expanded {
+ return false
+ }
+ return !pointerIsInsideSideBubble
}
private func updatePanel(animated: Bool) {
@@ -199,7 +218,8 @@ final class IslandPanelController {
panel.ignoresMouseEvents = Self.shouldIgnoreMouseEvents(
phase: phase,
isOnboardingGreetingPresented: model.isOnboardingGreetingPresented,
- pointerIsInsideSurface: pointerIsInside ?? false
+ pointerIsInsideSurface: pointerIsInside ?? false,
+ pointerIsInsideSideBubble: pointerIsInsideSideBubble
)
if !panel.isVisible || !animated {
diff --git a/MacDynamicIsland/UI/IslandViews.swift b/MacDynamicIsland/UI/IslandViews.swift
index d7e1e5f..1e0f278 100644
--- a/MacDynamicIsland/UI/IslandViews.swift
+++ b/MacDynamicIsland/UI/IslandViews.swift
@@ -98,6 +98,9 @@ struct IslandRootView: View {
.animation(surfaceAnimation, value: model.phase)
.animation(surfaceAnimation, value: model.surfaceSize)
}
+
+ SideMediaBubble(model: model)
+ .zIndex(4)
}
.frame(
width: IslandModel.canvasSize.width,
@@ -738,6 +741,131 @@ private struct WaveformBarStack: View {
}
}
+private struct SideMediaBubble: View {
+ @Bindable var model: IslandModel
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var isDetached = false
+ @State private var isVisible = false
+ @State private var isArtworkVisible = false
+
+ var body: some View {
+ Button(action: model.openSideMediaBubble) {
+ ZStack {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(notchBlack)
+ .overlay {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .stroke(.white.opacity(0.08), lineWidth: 0.75)
+ }
+
+ ArtworkView(
+ snapshot: model.homeMediaSnapshot,
+ size: 24,
+ cornerRadius: 7
+ )
+ .scaleEffect(isArtworkVisible ? 1 : 0.88)
+ .opacity(isArtworkVisible ? 1 : 0)
+ }
+ .frame(
+ width: IslandModel.sideMediaBubbleSize.width,
+ height: IslandModel.sideMediaBubbleSize.height
+ )
+ .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+ .buttonStyle(SideMediaBubbleButtonStyle())
+ // Begin as a small bud joined to the timer's right edge. The delayed,
+ // slightly under-damped spring lets it grow, separate, and settle into
+ // its final gap after the primary island has finished retracting.
+ .scaleEffect(
+ x: isDetached ? 1 : 0.5,
+ y: isDetached ? 1 : 0.68,
+ anchor: .leading
+ )
+ .offset(x: isDetached ? 0 : -14)
+ .opacity(isVisible ? 1 : 0)
+ .shadow(
+ color: .black.opacity(isVisible ? 0.24 : 0),
+ radius: 7,
+ y: 2
+ )
+ // Position after local transforms so scaling is anchored to the
+ // bubble itself rather than the full island canvas.
+ .position(model.sideMediaBubbleCenter)
+ .allowsHitTesting(
+ model.shouldShowSideMediaBubble && isDetached && isVisible
+ )
+ .accessibilityLabel(
+ "Open media controls for \(model.homeMediaSnapshot.title)"
+ )
+ .task(id: model.shouldShowSideMediaBubble) {
+ guard model.shouldShowSideMediaBubble else {
+ // Expansion always phases the companion away at its current
+ // position. It must not travel toward either the notch or the
+ // expanded Home artwork.
+ withAnimation(.easeOut(duration: 0.12)) {
+ isVisible = false
+ }
+ try? await Task.sleep(for: .milliseconds(120))
+ guard !model.shouldShowSideMediaBubble else { return }
+ isDetached = false
+ isArtworkVisible = false
+ return
+ }
+
+ isDetached = false
+ isVisible = false
+ isArtworkVisible = false
+ do {
+ try await Task.sleep(
+ for: .milliseconds(reduceMotion ? 170 : 390)
+ )
+ } catch {
+ return
+ }
+ guard model.shouldShowSideMediaBubble else { return }
+
+ // Reveal the attached black bud without a cross-fade. Because it
+ // initially overlaps the notch edge, the first visible change is
+ // growth of the notch itself rather than artwork appearing beside
+ // it.
+ isVisible = true
+ withAnimation(detachmentAnimation) {
+ isDetached = true
+ }
+
+ do {
+ try await Task.sleep(
+ for: .milliseconds(reduceMotion ? 35 : 90)
+ )
+ } catch {
+ return
+ }
+ guard model.shouldShowSideMediaBubble else { return }
+ withAnimation(.easeOut(duration: reduceMotion ? 0.1 : 0.16)) {
+ isArtworkVisible = true
+ }
+ }
+ }
+
+ private var detachmentAnimation: Animation {
+ reduceMotion
+ ? .easeOut(duration: 0.16)
+ : .spring(response: 0.46, dampingFraction: 0.72, blendDuration: 0)
+ }
+}
+
+private struct SideMediaBubbleButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed ? 0.94 : 1)
+ .brightness(configuration.isPressed ? 0.04 : 0)
+ .animation(
+ .spring(response: 0.2, dampingFraction: 0.82),
+ value: configuration.isPressed
+ )
+ }
+}
+
private struct SharedArtwork: View {
@Bindable var model: IslandModel
@@ -786,8 +914,13 @@ private struct SharedArtwork: View {
return CGPoint(x: surfaceOriginX + 100, y: 116)
}
- private var artworkAnimation: Animation {
- model.phase == .expanded
+ private var artworkAnimation: Animation? {
+ // While Timer owns the compact island, the separate side bubble is the
+ // only visible compact artwork. Keep this hidden shared copy at its
+ // destination and reveal it there, rather than animating it across the
+ // screen during either a Timer hover or a bubble click.
+ guard !model.isTimerActive else { return nil }
+ return model.phase == .expanded
? .spring(response: 0.42, dampingFraction: 0.86, blendDuration: 0)
: .spring(response: 0.38, dampingFraction: 0.94, blendDuration: 0)
}
@@ -2028,7 +2161,10 @@ private struct CalendarDayStrip: View {
day,
inSameDayAs: model.selectedCalendarDate
)
- let isToday = calendar.isDateInToday(day)
+ let isToday = calendar.isDate(
+ day,
+ inSameDayAs: model.currentCalendarDay
+ )
Button {
select(day, using: proxy)
@@ -2071,11 +2207,10 @@ private struct CalendarDayStrip: View {
}
.onScrollGeometryChange(for: Optional.self) { geometry in
let calendar = Calendar.current
- let today = calendar.startOfDay(for: .now)
let dayOffset = calendar.dateComponents(
[.day],
from: timelineAnchor,
- to: today
+ to: model.currentCalendarDay
).day ?? 0
let todayIndex = dayOffset + timelineRadius
if todayIndex < 0 {
@@ -2140,7 +2275,7 @@ private struct CalendarDayStrip: View {
}
private func selectToday(using proxy: ScrollViewProxy) {
- select(Calendar.current.startOfDay(for: .now), using: proxy)
+ select(model.currentCalendarDay, using: proxy)
}
private func center(
diff --git a/MacDynamicIslandTests/IslandModelTests.swift b/MacDynamicIslandTests/IslandModelTests.swift
index d5fe245..80c7258 100644
--- a/MacDynamicIslandTests/IslandModelTests.swift
+++ b/MacDynamicIslandTests/IslandModelTests.swift
@@ -5,6 +5,42 @@ import XCTest
@MainActor
final class IslandModelTests: XCTestCase {
+ func testCalendarDayRefreshAdvancesSelectionWhenItWasFollowingToday() {
+ let model = IslandModel()
+ let firstDay = Calendar.current.date(
+ from: DateComponents(year: 2026, month: 7, day: 28)
+ )!
+ let nextDay = Calendar.current.date(
+ from: DateComponents(year: 2026, month: 7, day: 29)
+ )!
+
+ model.refreshCalendarDay(firstDay)
+ model.refreshCalendarDay(nextDay)
+
+ XCTAssertEqual(model.currentCalendarDay, Calendar.current.startOfDay(for: nextDay))
+ XCTAssertEqual(model.selectedCalendarDate, Calendar.current.startOfDay(for: nextDay))
+ }
+
+ func testCalendarDayRefreshPreservesAnExplicitDateSelection() {
+ let model = IslandModel()
+ let firstDay = Calendar.current.date(
+ from: DateComponents(year: 2026, month: 7, day: 28)
+ )!
+ let nextDay = Calendar.current.date(
+ from: DateComponents(year: 2026, month: 7, day: 29)
+ )!
+ let selectedDay = Calendar.current.date(
+ from: DateComponents(year: 2026, month: 7, day: 21)
+ )!
+
+ model.refreshCalendarDay(firstDay)
+ model.selectCalendarDate(selectedDay)
+ model.refreshCalendarDay(nextDay)
+
+ XCTAssertEqual(model.currentCalendarDay, Calendar.current.startOfDay(for: nextDay))
+ XCTAssertEqual(model.selectedCalendarDate, Calendar.current.startOfDay(for: selectedDay))
+ }
+
func testPlayingBackgroundAppProducesCompactIsland() {
let model = IslandModel()
model.snapshot = playingSnapshot(source: "com.apple.Music")
@@ -169,6 +205,14 @@ final class IslandModelTests: XCTestCase {
pointerIsInsideSurface: true
)
)
+ XCTAssertFalse(
+ IslandPanelController.shouldIgnoreMouseEvents(
+ phase: .compact,
+ isOnboardingGreetingPresented: false,
+ pointerIsInsideSurface: false,
+ pointerIsInsideSideBubble: true
+ )
+ )
}
func testHoverExpandsIdleNotch() {
@@ -195,6 +239,91 @@ final class IslandModelTests: XCTestCase {
XCTAssertEqual(model.surfaceSize, CGSize(width: 760, height: 202))
}
+ func testActiveTimerAndMediaProduceSideMediaBubble() {
+ let model = IslandModel()
+ model.renderedNotchSize = CGSize(width: 184, height: 32)
+ model.snapshot = playingSnapshot(source: "com.apple.Music")
+ model.frontmostBundleIdentifier = "com.apple.Safari"
+ model.timerEndDate = .now.addingTimeInterval(60)
+
+ XCTAssertEqual(model.phase, .compact)
+ XCTAssertFalse(model.isShowingCompactMedia)
+ XCTAssertTrue(model.shouldShowSideMediaBubble)
+ XCTAssertEqual(
+ model.sideMediaBubbleFrame,
+ CGRect(x: 596, y: 6, width: 40, height: 32)
+ )
+ }
+
+ func testSideMediaBubbleOpensExpandedHome() {
+ let model = IslandModel()
+ model.snapshot = playingSnapshot(source: "com.apple.Music")
+ model.timerEndDate = .now.addingTimeInterval(60)
+ model.selectedTab = .timer
+
+ model.openSideMediaBubble()
+ // The pointer lands inside the newly expanded surface after the
+ // bubble click; that hover refresh must not redirect back to Timer.
+ model.setPointerInside(true)
+
+ XCTAssertTrue(model.isExpanded)
+ XCTAssertEqual(model.phase, .expanded)
+ XCTAssertEqual(model.selectedTab, .home)
+ XCTAssertFalse(model.shouldShowSideMediaBubble)
+ }
+
+ func testSideMediaBubbleRequiresActivelyPlayingMedia() {
+ let model = IslandModel()
+ model.snapshot = playingSnapshot(source: "com.apple.Music")
+ model.snapshot.playbackRate = 0
+ model.timerEndDate = .now.addingTimeInterval(60)
+
+ XCTAssertFalse(model.shouldShowSideMediaBubble)
+ }
+
+ func testTimerSideMediaBubbleIncludesForegroundPrimarySpotify() {
+ let model = IslandModel()
+ model.snapshot = playingSnapshot(source: "com.spotify.client")
+ model.snapshot.sourceName = "Spotify"
+ model.frontmostBundleIdentifier = "com.spotify.client"
+ model.timerEndDate = .now.addingTimeInterval(60)
+
+ XCTAssertFalse(model.hasActiveMedia)
+ XCTAssertEqual(model.phase, .compact)
+ XCTAssertTrue(model.shouldShowSideMediaBubble)
+ XCTAssertEqual(model.homeMediaSnapshot.identifier, model.snapshot.identifier)
+ }
+
+ func testTimerSideMediaBubbleIncludesForegroundSpotifyFallback() {
+ let model = IslandModel()
+ model.spotifyFallbackSnapshot = playingSnapshot(source: "com.spotify.client")
+ model.frontmostBundleIdentifier = "com.spotify.client"
+ model.timerEndDate = .now.addingTimeInterval(60)
+
+ XCTAssertFalse(model.hasActiveMedia)
+ XCTAssertTrue(model.homeMediaUsesSpotifyFallback)
+ XCTAssertTrue(model.shouldShowSideMediaBubble)
+ XCTAssertEqual(
+ model.homeMediaSnapshot.identifier,
+ model.spotifyFallbackSnapshot.identifier
+ )
+ }
+
+ func testTimerSideMediaBubblePrefersPlayingSpotifyOverBrowserMediaRemote() {
+ let model = IslandModel()
+ model.snapshot = playingSnapshot(source: "com.apple.Safari")
+ model.spotifyFallbackSnapshot = playingSnapshot(source: "com.spotify.client")
+ model.frontmostBundleIdentifier = "com.spotify.client"
+ model.timerEndDate = .now.addingTimeInterval(60)
+
+ XCTAssertTrue(model.homeMediaUsesSpotifyFallback)
+ XCTAssertTrue(model.shouldShowSideMediaBubble)
+ XCTAssertEqual(
+ model.homeMediaSnapshot.sourceBundleIdentifier,
+ "com.spotify.client"
+ )
+ }
+
func testPausedMediaCanResumeFromExpandedIsland() {
let model = IslandModel()
model.snapshot = playingSnapshot(source: "com.apple.WebKit.GPU")
diff --git a/README.md b/README.md
index 18e651e..2b9564d 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,12 @@
in one polished surface that stays out of the way until you need it.
+
+
+
+
+
+
## What is Ledge?
Ledge is a native macOS menu-bar app that turns the area around the camera notch into a compact, interactive workspace. It remains visually quiet while idle, surfaces useful information when something is active, and expands into a dashboard when you hover or click.
@@ -55,7 +61,23 @@ Control active media, view artwork and live playback progress, and check upcomin
- **Notch-aware design:** uses the physical MacBook notch when available and falls back to a centered island on external displays.
- **Native macOS behavior:** menu-bar-only, no Dock icon, all-Spaces support, full-screen compatibility, and permission-aware integrations.
-## Prerequisites
+## Installation
+
+### Homebrew
+
+```sh
+brew install --cask aramr/tap/ledge
+```
+
+Homebrew installs Ledge into Applications. Future versions can be installed through Homebrew or from **Check for Updates…** in Ledge’s menu-bar menu.
+
+### Direct download
+
+Download `Ledge-.dmg` from the [latest GitHub Release](https://github.com/aramr/Ledge/releases/latest), open it, and drag Ledge into Applications. Every official DMG is Developer ID signed, notarized by Apple, and published with a SHA-256 checksum and GitHub artifact attestation.
+
+Ledge requires macOS 15 or later. It is designed for MacBooks with a camera notch and provides a centered fallback island on other Macs and external displays.
+
+## Build requirements
- macOS 15 or later
- Xcode 26 or later, including the Xcode command-line tools
@@ -139,8 +161,7 @@ xcodebuild test \
Run the complete local security and release gates:
```sh
-Scripts/security-check.sh
-Scripts/release-check.sh
+Scripts/ci.sh
```
Project layout:
@@ -156,4 +177,10 @@ Project layout:
Ledge is local-first. It does not require a Ledge account or backend, does not transmit clipboard or Calendar contents, and never writes captured system audio to disk. Media metadata and agent-usage snapshots are session-only. See [PRIVACY.md](PRIVACY.md) for the complete data-handling notice.
-Ledge is intended for direct Developer ID distribution rather than the Mac App Store because cross-application Now Playing support relies on a private macOS framework through the system automation host. Review [RELEASE.md](RELEASE.md) before creating a public build, and verify the final signed artifact with `Scripts/verify-distribution.sh`.
+Review [RELEASE.md](RELEASE.md) before creating a public build, and verify the final signed artifact with `Scripts/verify-distribution.sh`.
+
+## Contributing and license
+
+Issues and focused pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
+
+Ledge is available under the [MIT License](LICENSE).
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..b9680d0
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,127 @@
+# Ledge Release Guide
+
+Ledge publishes signed, notarized universal macOS builds through GitHub Releases. The same DMG powers direct downloads and the `aramr/homebrew-tap` cask. Sparkle uses the release ZIP and `appcast.xml` for in-app updates.
+
+## One-time owner setup
+
+### 1. Apple distribution identity
+
+Join the Apple Developer Program and create a **Developer ID Application** certificate in Xcode:
+
+1. Open Xcode → Settings → Accounts.
+2. Select the Ledge team, then Manage Certificates.
+3. Add a Developer ID Application certificate.
+4. Export the certificate and private key from Keychain Access as a password-protected `.p12`.
+
+Base64-encode the `.p12` and add these Actions secrets to `aramr/Ledge`:
+
+- `MACOS_CERTIFICATE_P12_BASE64`
+- `MACOS_CERTIFICATE_PASSWORD`
+
+From a terminal authenticated with GitHub CLI:
+
+```sh
+base64 -i /path/to/DeveloperIDApplication.p12 |
+ gh secret set MACOS_CERTIFICATE_P12_BASE64 --repo aramr/Ledge
+gh secret set MACOS_CERTIFICATE_PASSWORD --repo aramr/Ledge
+```
+
+`APPLE_TEAM_ID` is currently committed in the release workflow as `M7PFX75L8L`.
+
+### 2. Apple notarization key
+
+Create a **team** App Store Connect API key with permission to submit Developer ID software for notarization. Individual API keys are not supported by `notarytool`. Add:
+
+- `APPLE_API_KEY_P8_BASE64` — base64-encoded `.p8` contents
+- `APPLE_API_KEY_ID`
+- `APPLE_API_ISSUER_ID`
+
+```sh
+base64 -i /path/to/AuthKey_KEYID.p8 |
+ gh secret set APPLE_API_KEY_P8_BASE64 --repo aramr/Ledge
+gh secret set APPLE_API_KEY_ID --repo aramr/Ledge
+gh secret set APPLE_API_ISSUER_ID --repo aramr/Ledge
+```
+
+The release workflow writes the key only to the ephemeral runner and removes it when the runner is destroyed.
+
+### 3. Sparkle update signing
+
+Ledge uses a dedicated Ed25519 key under the Keychain account `com.aramrahimi.Ledge`. Its public key is committed as `SUPublicEDKey`; its private key is stored in the repository secret `SPARKLE_PRIVATE_KEY`.
+
+Back up the Keychain key securely. Losing both the Keychain item and GitHub secret would require an update-signing key rotation.
+
+### 4. Homebrew tap
+
+The public repository `aramr/homebrew-tap` accepts release updates through a write-enabled SSH deploy key scoped only to that repository. Its private half is stored in Ledge as:
+
+- `HOMEBREW_TAP_DEPLOY_KEY`
+
+The release workflow publishes the immutable GitHub Release, generates and audits `Casks/ledge.rb`, then pushes it to the tap over SSH. If the key is ever rotated, replace both the tap deploy key and this Actions secret.
+
+### 5. GitHub repository protections
+
+- Require the CI workflow before merging into `main`.
+- Require pull requests for `main`.
+- Keep private vulnerability reporting enabled.
+- Keep release immutability enabled.
+- Keep secret scanning, push protection, and Dependabot security updates enabled.
+- Keep Actions limited to GitHub-owned actions pinned to full commit SHAs.
+- Restrict who can create tags matching `v*`.
+
+## Publishing a release
+
+1. Update `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in the Ledge Release build settings.
+2. Merge the release commit into `main` and confirm CI passes.
+3. Create and push a signed semantic-version tag:
+
+```sh
+git tag -s v1.0.0 -m "Ledge 1.0.0"
+git push origin v1.0.0
+```
+
+The release workflow:
+
+1. validates the tag against `MARKETING_VERSION`;
+2. runs tests and creates a universal Developer ID archive;
+3. notarizes and staples the app and DMG;
+4. creates the DMG, Sparkle ZIP, appcast, checksums, and dSYM archive;
+5. verifies signatures, Hardened Runtime, entitlements, architectures, Gatekeeper, and notarization;
+6. creates GitHub artifact attestations;
+7. publishes the immutable GitHub Release with categorized release notes;
+8. updates and audits the Homebrew cask.
+
+All build, signing, notarization, verification, and attestation gates run before publication. If the later Homebrew update fails, the signed direct download and Sparkle update remain available from GitHub while the tap can be repaired independently.
+
+## Local release verification
+
+Run the unsigned CI gates:
+
+```sh
+Scripts/ci.sh
+```
+
+After exporting a Developer ID archive, package it using either a Keychain notary profile:
+
+```sh
+export NOTARYTOOL_PROFILE=LedgeNotary
+export SPARKLE_PRIVATE_KEY="$(security find-generic-password -a com.aramrahimi.Ledge -s https://sparkle-project.org -w)"
+Scripts/build-release.sh build/release
+Scripts/package-release.sh build/release/Export/Ledge.app
+```
+
+or the App Store Connect API key variables used by CI. Never commit exported certificates, API keys, Sparkle private keys, or generated release artifacts.
+
+## Installation verification
+
+On a clean macOS user account:
+
+```sh
+brew install --cask aramr/tap/ledge
+```
+
+Also download the DMG from the GitHub Release, drag Ledge to Applications, and confirm both installations launch without a Gatekeeper override. Verify an asset’s provenance with:
+
+```sh
+gh attestation verify Ledge-1.0.0.dmg --repo aramr/Ledge
+```
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..1d4b866
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,20 @@
+# Security Policy
+
+## Supported versions
+
+Security fixes are provided for the latest published Ledge release.
+
+## Reporting a vulnerability
+
+Please do not open a public issue for a suspected vulnerability. Submit a private report through [GitHub Security Advisories](https://github.com/aramr/Ledge/security/advisories/new) with:
+
+- affected version and macOS version;
+- reproduction steps or a proof of concept;
+- the expected and observed impact;
+- any suggested remediation.
+
+You should receive an acknowledgement within seven days. Please allow time for investigation and a coordinated fix before disclosing the issue publicly.
+
+## Release integrity
+
+Official binaries are published only through GitHub Releases and the `aramr/homebrew-tap` Homebrew tap. Release apps are Developer ID signed, notarized by Apple, and accompanied by SHA-256 checksums and GitHub artifact attestations.
diff --git a/Scripts/build-release.sh b/Scripts/build-release.sh
new file mode 100755
index 0000000..00bde00
--- /dev/null
+++ b/Scripts/build-release.sh
@@ -0,0 +1,71 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+ROOT="${0:A:h:h}"
+PROJECT="$ROOT/MacDynamicIsland.xcodeproj"
+OUTPUT_ROOT="${1:-$ROOT/build/release}"
+OUTPUT_ROOT="${OUTPUT_ROOT:A}"
+ARCHIVE_PATH="$OUTPUT_ROOT/Ledge.xcarchive"
+DERIVED_DATA="$OUTPUT_ROOT/DerivedData"
+EXPORT_PATH="$OUTPUT_ROOT/Export"
+EXPORT_OPTIONS="$OUTPUT_ROOT/ExportOptions.plist"
+TEAM_ID="${APPLE_TEAM_ID:-M7PFX75L8L}"
+SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
+
+if [[ -e "$ARCHIVE_PATH" || -e "$DERIVED_DATA" || -e "$EXPORT_PATH" || -e "$EXPORT_OPTIONS" ]]; then
+ echo "Release build output already exists: $OUTPUT_ROOT" >&2
+ exit 1
+fi
+
+mkdir -p "$OUTPUT_ROOT"
+
+xcodebuild \
+ -resolvePackageDependencies \
+ -project "$PROJECT" \
+ -scheme Ledge \
+ -derivedDataPath "$DERIVED_DATA"
+
+xcodebuild \
+ archive \
+ -project "$PROJECT" \
+ -scheme Ledge \
+ -configuration Release \
+ -destination 'generic/platform=macOS' \
+ -archivePath "$ARCHIVE_PATH" \
+ -derivedDataPath "$DERIVED_DATA" \
+ ARCHS='arm64 x86_64' \
+ ONLY_ACTIVE_ARCH=NO \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="$SIGNING_IDENTITY" \
+ DEVELOPMENT_TEAM="$TEAM_ID" \
+ OTHER_CODE_SIGN_FLAGS='--timestamp'
+
+plutil -create xml1 "$EXPORT_OPTIONS"
+plutil -insert destination -string export "$EXPORT_OPTIONS"
+plutil -insert method -string developer-id "$EXPORT_OPTIONS"
+plutil -insert signingStyle -string manual "$EXPORT_OPTIONS"
+plutil -insert signingCertificate -string "$SIGNING_IDENTITY" "$EXPORT_OPTIONS"
+plutil -insert teamID -string "$TEAM_ID" "$EXPORT_OPTIONS"
+plutil -insert stripSwiftSymbols -bool YES "$EXPORT_OPTIONS"
+
+xcodebuild \
+ -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_PATH" \
+ -exportOptionsPlist "$EXPORT_OPTIONS"
+
+APP="$EXPORT_PATH/Ledge.app"
+[[ -d "$APP" ]] || {
+ echo "Developer ID export did not contain Ledge.app." >&2
+ exit 1
+}
+
+codesign --verify --deep --strict --verbose=2 "$APP"
+
+ARCHITECTURES="$(lipo -archs "$APP/Contents/MacOS/Ledge")"
+[[ " $ARCHITECTURES " == *" arm64 "* ]]
+[[ " $ARCHITECTURES " == *" x86_64 "* ]]
+
+echo "Signed release archive created at $ARCHIVE_PATH"
+echo "Developer ID app exported to $APP"
diff --git a/Scripts/ci.sh b/Scripts/ci.sh
new file mode 100755
index 0000000..93cf902
--- /dev/null
+++ b/Scripts/ci.sh
@@ -0,0 +1,58 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+ROOT="${0:A:h:h}"
+PROJECT="$ROOT/MacDynamicIsland.xcodeproj"
+DERIVED_DATA="$(mktemp -d "${TMPDIR:-/tmp}/LedgeCI.XXXXXX")"
+
+cleanup() {
+ rm -rf "$DERIVED_DATA"
+}
+trap cleanup EXIT
+
+plutil -lint \
+ "$ROOT/MacDynamicIsland/Info.plist" \
+ "$ROOT/MacDynamicIsland/Ledge.entitlements" \
+ "$ROOT/MacDynamicIsland/PrivacyInfo.xcprivacy"
+
+xcodebuild \
+ -quiet \
+ -resolvePackageDependencies \
+ -project "$PROJECT" \
+ -scheme Ledge \
+ -derivedDataPath "$DERIVED_DATA"
+
+LLVM_PROFILE_FILE="$DERIVED_DATA/%p.profraw" xcodebuild \
+ -quiet \
+ -project "$PROJECT" \
+ -scheme Ledge \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -derivedDataPath "$DERIVED_DATA" \
+ ENABLE_CODE_COVERAGE=NO \
+ CODE_SIGNING_ALLOWED=NO \
+ test
+
+xcodebuild \
+ -quiet \
+ -project "$PROJECT" \
+ -scheme Ledge \
+ -configuration Release \
+ -destination 'generic/platform=macOS' \
+ -derivedDataPath "$DERIVED_DATA" \
+ ARCHS='arm64 x86_64' \
+ ONLY_ACTIVE_ARCH=NO \
+ CODE_SIGNING_ALLOWED=NO \
+ build
+
+APP="$DERIVED_DATA/Build/Products/Release/Ledge.app"
+[[ -d "$APP" ]]
+[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")" == "com.aramrahimi.Ledge" ]]
+[[ -d "$APP/Contents/Frameworks/Sparkle.framework" ]]
+
+ARCHITECTURES="$(lipo -archs "$APP/Contents/MacOS/Ledge")"
+[[ " $ARCHITECTURES " == *" arm64 "* ]]
+[[ " $ARCHITECTURES " == *" x86_64 "* ]]
+
+echo "CI checks passed."
diff --git a/Scripts/generate-cask.sh b/Scripts/generate-cask.sh
new file mode 100755
index 0000000..0c8ece1
--- /dev/null
+++ b/Scripts/generate-cask.sh
@@ -0,0 +1,50 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+if [[ $# -ne 2 ]]; then
+ echo "Usage: ${0:t} VERSION DMG_SHA256" >&2
+ exit 64
+fi
+
+VERSION="$1"
+SHA256="$2"
+
+[[ "$VERSION" == <->.<->.<-> ]] || {
+ echo "Version must use semantic versioning, for example 1.2.3." >&2
+ exit 1
+}
+[[ ${#SHA256} -eq 64 && "$SHA256" != *[^0-9a-f]* ]] || {
+ echo "Expected a lowercase SHA-256 digest." >&2
+ exit 1
+}
+
+cat <&2
+ exit 64
+fi
+
+ROOT="${0:A:h:h}"
+APP="${1:A}"
+OUTPUT_DIRECTORY="${2:-$ROOT/dist}"
+OUTPUT_DIRECTORY="${OUTPUT_DIRECTORY:A}"
+EXPORT_DIRECTORY="${APP:h}"
+RELEASE_ROOT="${EXPORT_DIRECTORY:h}"
+DSYM="${DSYM_PATH:-$RELEASE_ROOT/Ledge.xcarchive/dSYMs/Ledge.app.dSYM}"
+SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
+SPARKLE_ACCOUNT="${SPARKLE_ACCOUNT:-com.aramrahimi.Ledge}"
+
+[[ -d "$APP" ]] || {
+ echo "Exported Ledge.app is missing from ${APP:h}" >&2
+ exit 1
+}
+[[ "$APP" == *.app ]] || {
+ echo "The release input must be an exported app bundle." >&2
+ exit 1
+}
+[[ -n "${SPARKLE_PRIVATE_KEY:-}" ]] || {
+ echo "SPARKLE_PRIVATE_KEY is required." >&2
+ exit 1
+}
+
+if [[ -n "${NOTARYTOOL_PROFILE:-}" ]]; then
+ NOTARY_ARGUMENTS=(--keychain-profile "$NOTARYTOOL_PROFILE")
+else
+ [[ -f "${APPLE_API_KEY_PATH:-}" ]] || {
+ echo "APPLE_API_KEY_PATH must point to an App Store Connect API key." >&2
+ exit 1
+ }
+ [[ -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER_ID:-}" ]] || {
+ echo "APPLE_API_KEY_ID and APPLE_API_ISSUER_ID are required." >&2
+ exit 1
+ }
+ NOTARY_ARGUMENTS=(
+ --key "$APPLE_API_KEY_PATH"
+ --key-id "$APPLE_API_KEY_ID"
+ --issuer "$APPLE_API_ISSUER_ID"
+ )
+fi
+
+VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")"
+BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")"
+[[ -n "$VERSION" && -n "$BUILD" ]]
+
+ZIP="$OUTPUT_DIRECTORY/Ledge-$VERSION.zip"
+DMG="$OUTPUT_DIRECTORY/Ledge-$VERSION.dmg"
+DSYM_ZIP="$OUTPUT_DIRECTORY/Ledge-$VERSION-dSYM.zip"
+APPCAST="$OUTPUT_DIRECTORY/appcast.xml"
+CHECKSUMS="$OUTPUT_DIRECTORY/SHA256SUMS.txt"
+
+for output in "$ZIP" "$DMG" "$DSYM_ZIP" "$APPCAST" "$CHECKSUMS"; do
+ [[ ! -e "$output" ]] || {
+ echo "Release output already exists: $output" >&2
+ exit 1
+ }
+done
+
+mkdir -p "$OUTPUT_DIRECTORY"
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/LedgePackage.XXXXXX")"
+cleanup() {
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+SUBMISSION_ZIP="$WORK/Ledge-notarization.zip"
+ditto -c -k --keepParent --sequesterRsrc "$APP" "$SUBMISSION_ZIP"
+
+submit_for_notarization() {
+ local submission_path="$1"
+ local submission_result
+ local submission_id
+ local submission_status
+
+ submission_result="$(
+ xcrun notarytool submit \
+ "$submission_path" \
+ "${NOTARY_ARGUMENTS[@]}" \
+ --wait \
+ --output-format json
+ )"
+ submission_id="$(print -r -- "$submission_result" | jq -r '.id // empty')"
+ submission_status="$(print -r -- "$submission_result" | jq -r '.status // empty')"
+ print "Notarization status for ${submission_path:t}: $submission_status"
+
+ if [[ "$submission_status" != "Accepted" ]]; then
+ if [[ -n "$submission_id" ]]; then
+ xcrun notarytool log \
+ "$submission_id" \
+ "${NOTARY_ARGUMENTS[@]}" \
+ --output-format json >&2 || true
+ fi
+ return 1
+ fi
+}
+
+submit_for_notarization "$SUBMISSION_ZIP"
+xcrun stapler staple "$APP"
+xcrun stapler validate "$APP"
+
+ditto -c -k --keepParent --sequesterRsrc "$APP" "$ZIP"
+
+if [[ -d "$DSYM" ]]; then
+ ditto -c -k --keepParent --sequesterRsrc "$DSYM" "$DSYM_ZIP"
+fi
+
+DMG_ROOT="$WORK/dmg"
+mkdir -p "$DMG_ROOT"
+ditto "$APP" "$DMG_ROOT/Ledge.app"
+ln -s /Applications "$DMG_ROOT/Applications"
+hdiutil create \
+ -volname Ledge \
+ -srcfolder "$DMG_ROOT" \
+ -format UDZO \
+ -imagekey zlib-level=9 \
+ "$DMG"
+codesign --force --sign "$SIGNING_IDENTITY" --timestamp "$DMG"
+submit_for_notarization "$DMG"
+xcrun stapler staple "$DMG"
+
+"$ROOT/Scripts/verify-distribution.sh" "$APP" "$DMG"
+
+SPARKLE_BIN_DIRECTORY="${SPARKLE_BIN_DIRECTORY:-}"
+if [[ -z "$SPARKLE_BIN_DIRECTORY" ]]; then
+ SPARKLE_BIN_DIRECTORY="$(find "$RELEASE_ROOT/DerivedData/SourcePackages/artifacts" -type d -path '*/Sparkle/bin' -print -quit 2>/dev/null)"
+fi
+[[ -x "$SPARKLE_BIN_DIRECTORY/generate_appcast" ]] || {
+ echo "Sparkle generate_appcast was not found; set SPARKLE_BIN_DIRECTORY." >&2
+ exit 1
+}
+
+UPDATE_DIRECTORY="$WORK/updates"
+mkdir -p "$UPDATE_DIRECTORY"
+ditto "$ZIP" "$UPDATE_DIRECTORY/${ZIP:t}"
+print -r -- "$SPARKLE_PRIVATE_KEY" | \
+ "$SPARKLE_BIN_DIRECTORY/generate_appcast" \
+ --account "$SPARKLE_ACCOUNT" \
+ --ed-key-file - \
+ --download-url-prefix "https://github.com/aramr/Ledge/releases/download/v$VERSION/" \
+ --link "https://github.com/aramr/Ledge" \
+ --maximum-versions 1 \
+ --maximum-deltas 0 \
+ "$UPDATE_DIRECTORY"
+ditto "$UPDATE_DIRECTORY/appcast.xml" "$APPCAST"
+
+(
+ cd "$OUTPUT_DIRECTORY"
+ shasum -a 256 "${ZIP:t}" "${DMG:t}" > "${CHECKSUMS:t}"
+ if [[ -f "${DSYM_ZIP:t}" ]]; then
+ shasum -a 256 "${DSYM_ZIP:t}" >> "${CHECKSUMS:t}"
+ fi
+ shasum -a 256 "${APPCAST:t}" >> "${CHECKSUMS:t}"
+)
+
+echo "Release artifacts created in $OUTPUT_DIRECTORY"
diff --git a/Scripts/verify-distribution.sh b/Scripts/verify-distribution.sh
new file mode 100755
index 0000000..360be1c
--- /dev/null
+++ b/Scripts/verify-distribution.sh
@@ -0,0 +1,102 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+fail() {
+ echo "Distribution verification failed: $1" >&2
+ exit 1
+}
+
+if [[ $# -lt 1 || $# -gt 2 ]]; then
+ echo "Usage: ${0:t} /path/to/Ledge.app [/path/to/Ledge.dmg]" >&2
+ exit 64
+fi
+
+APP="${1:A}"
+DMG="${2:-}"
+EXPECTED_TEAM_ID="${APPLE_TEAM_ID:-M7PFX75L8L}"
+
+[[ -d "$APP" && "$APP" == *.app ]] || fail "expected a Ledge app bundle."
+[[ -f "$APP/Contents/MacOS/Ledge" ]] || fail "the main executable is missing."
+[[ -d "$APP/Contents/Frameworks/Sparkle.framework" ]] || fail "Sparkle.framework is missing."
+
+plutil -lint \
+ "$APP/Contents/Info.plist" \
+ "$APP/Contents/Resources/PrivacyInfo.xcprivacy" >/dev/null
+
+[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")" == "com.aramrahimi.Ledge" ]] || \
+ fail "the production bundle identifier is incorrect."
+[[ -n "$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$APP/Contents/Info.plist")" ]] || \
+ fail "the Sparkle public key is missing."
+[[ "$(/usr/libexec/PlistBuddy -c 'Print :SUFeedURL' "$APP/Contents/Info.plist")" == https://github.com/aramr/Ledge/releases/latest/download/appcast.xml ]] || \
+ fail "the Sparkle feed URL is incorrect."
+
+ARCHITECTURES="$(lipo -archs "$APP/Contents/MacOS/Ledge")"
+[[ " $ARCHITECTURES " == *" arm64 "* ]] || fail "arm64 architecture is missing."
+[[ " $ARCHITECTURES " == *" x86_64 "* ]] || fail "x86_64 architecture is missing."
+
+SPARKLE="$APP/Contents/Frameworks/Sparkle.framework/Versions/B"
+SIGNED_COMPONENTS=(
+ "$SPARKLE/XPCServices/Installer.xpc"
+ "$SPARKLE/XPCServices/Downloader.xpc"
+ "$SPARKLE/Autoupdate"
+ "$SPARKLE/Updater.app"
+ "$APP/Contents/Frameworks/Sparkle.framework"
+ "$APP"
+)
+
+for component in "${SIGNED_COMPONENTS[@]}"; do
+ [[ -e "$component" ]] || fail "signed component is missing: $component"
+ codesign --verify --strict --verbose=2 "$component"
+ SIGNATURE_INFO="$(codesign --display --verbose=4 "$component" 2>&1)"
+ [[ "$SIGNATURE_INFO" == *"Authority=Developer ID Application:"* ]] || \
+ fail "$component is not signed with Developer ID Application."
+ [[ "$SIGNATURE_INFO" == *"Timestamp="* ]] || \
+ fail "$component does not include a secure timestamp."
+ [[ "$SIGNATURE_INFO" == *"flags="*"runtime"* ]] || \
+ fail "Hardened Runtime is not enabled for $component."
+ [[ "$SIGNATURE_INFO" == *"TeamIdentifier=$EXPECTED_TEAM_ID"* ]] || \
+ fail "$component is signed by an unexpected team."
+done
+
+codesign --verify --deep --strict --verbose=2 "$APP"
+
+ENTITLEMENTS="$(mktemp "${TMPDIR:-/tmp}/LedgeEntitlements.XXXXXX")"
+cleanup() {
+ rm -f "$ENTITLEMENTS"
+}
+trap cleanup EXIT
+codesign --display --entitlements :- "$APP" > "$ENTITLEMENTS" 2>/dev/null
+plutil -lint "$ENTITLEMENTS" >/dev/null
+
+for entitlement in \
+ com.apple.security.automation.apple-events \
+ com.apple.security.device.audio-input \
+ com.apple.security.personal-information.calendars; do
+ [[ "$(/usr/libexec/PlistBuddy -c "Print :$entitlement" "$ENTITLEMENTS" 2>/dev/null)" == "true" ]] || \
+ fail "required entitlement $entitlement is missing."
+done
+
+for entitlement in \
+ com.apple.security.get-task-allow \
+ com.apple.security.cs.disable-library-validation \
+ com.apple.security.cs.allow-unsigned-executable-memory \
+ com.apple.security.cs.allow-jit \
+ com.apple.security.cs.debugger; do
+ if /usr/libexec/PlistBuddy -c "Print :$entitlement" "$ENTITLEMENTS" >/dev/null 2>&1; then
+ fail "unsafe release entitlement $entitlement is present."
+ fi
+done
+
+spctl --assess --type execute --verbose=4 "$APP"
+xcrun stapler validate "$APP"
+
+if [[ -n "$DMG" ]]; then
+ DMG="${DMG:A}"
+ [[ -f "$DMG" && "$DMG" == *.dmg ]] || fail "expected a disk image."
+ codesign --verify --verbose=2 "$DMG"
+ spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG"
+ xcrun stapler validate "$DMG"
+fi
+
+echo "Distribution verification passed."
From 48fa4cae194bd5b76432a6f9b042c8fd5395f629 Mon Sep 17 00:00:00 2001
From: Aram Rahimi
Date: Thu, 30 Jul 2026 04:39:05 +0200
Subject: [PATCH 2/3] Fix timer and Home tab transitions
---
MacDynamicIsland/Core/IslandModel.swift | 6 +-
MacDynamicIsland/UI/IslandViews.swift | 154 ++++++++++++++-----
MacDynamicIslandTests/IslandModelTests.swift | 24 +++
3 files changed, 144 insertions(+), 40 deletions(-)
diff --git a/MacDynamicIsland/Core/IslandModel.swift b/MacDynamicIsland/Core/IslandModel.swift
index 3460ec4..266bfbe 100644
--- a/MacDynamicIsland/Core/IslandModel.swift
+++ b/MacDynamicIsland/Core/IslandModel.swift
@@ -333,7 +333,11 @@ final class IslandModel {
return CGSize(width: 760, height: 390)
}
- switch selectedTab {
+ return expandedSurfaceSize(for: selectedTab)
+ }
+
+ func expandedSurfaceSize(for tab: IslandTab) -> CGSize {
+ switch tab {
case .home:
return Self.homeExpandedSurfaceSize
case .clipboard:
diff --git a/MacDynamicIsland/UI/IslandViews.swift b/MacDynamicIsland/UI/IslandViews.swift
index 1e0f278..52d98ff 100644
--- a/MacDynamicIsland/UI/IslandViews.swift
+++ b/MacDynamicIsland/UI/IslandViews.swift
@@ -72,7 +72,10 @@ struct IslandRootView: View {
// spacing appear to stretch during the island spring.
SharedArtwork(model: model)
.opacity(showsSharedMediaElements ? 1 : 0)
- .animation(contentFadeAnimation, value: showsSharedMediaElements)
+ .animation(
+ sharedArtworkOpacityAnimation,
+ value: showsSharedMediaElements
+ )
.allowsHitTesting(false)
.zIndex(2)
@@ -176,6 +179,16 @@ struct IslandRootView: View {
.easeOut(duration: model.phase == .expanded ? 0.14 : 0.09)
}
+ private var sharedArtworkOpacityAnimation: Animation? {
+ // Timer compact mode uses only the detached side bubble. Remove the
+ // expanded artwork in the phase-change transaction so it cannot flash
+ // at the compact media coordinate while the bubble waits to detach.
+ if model.isTimerActive && model.phase != .expanded {
+ return nil
+ }
+ return contentFadeAnimation
+ }
+
private var compactContentAnimation: Animation {
if model.isTimerStartTransitioning {
return reduceMotion
@@ -203,12 +216,15 @@ struct IslandRootView: View {
}
private var renderedExpandedSurfaceSize: CGSize {
- // Hold the outgoing timer setup at its original coordinates while the
- // shell retracts. Once hidden, the running timer can adopt its smaller
- // expanded layout without producing visible reflow.
- model.isTimerStartTransitioning
- ? IslandModel.homeExpandedSurfaceSize
- : model.expandedSurfaceSize
+ if model.isCalendarDetailPresented {
+ return model.expandedSurfaceSize
+ }
+
+ // All tabs share one stationary content canvas. Timer can still use
+ // its smaller 680×132 silhouette because the root mask follows
+ // model.surfaceSize, while the tab bar and crossfade stay at the same
+ // coordinates used by Home, Clipboard, and Agent limits.
+ return IslandModel.homeExpandedSurfaceSize
}
private var expandedContentScale: CGFloat {
@@ -743,6 +759,16 @@ private struct WaveformBarStack: View {
private struct SideMediaBubble: View {
@Bindable var model: IslandModel
+
+ var body: some View {
+ if model.shouldShowSideMediaBubble {
+ SideMediaBubbleContent(model: model)
+ }
+ }
+}
+
+private struct SideMediaBubbleContent: View {
+ @Bindable var model: IslandModel
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isDetached = false
@State private var isVisible = false
@@ -797,21 +823,7 @@ private struct SideMediaBubble: View {
.accessibilityLabel(
"Open media controls for \(model.homeMediaSnapshot.title)"
)
- .task(id: model.shouldShowSideMediaBubble) {
- guard model.shouldShowSideMediaBubble else {
- // Expansion always phases the companion away at its current
- // position. It must not travel toward either the notch or the
- // expanded Home artwork.
- withAnimation(.easeOut(duration: 0.12)) {
- isVisible = false
- }
- try? await Task.sleep(for: .milliseconds(120))
- guard !model.shouldShowSideMediaBubble else { return }
- isDetached = false
- isArtworkVisible = false
- return
- }
-
+ .task {
isDetached = false
isVisible = false
isArtworkVisible = false
@@ -1077,37 +1089,88 @@ private struct ExpandedTabbedView: View {
.frame(height: 50)
} else {
IslandTabBar(model: model)
- .frame(height: 44)
+ .frame(width: tabBarWidth, height: 44)
+ .animation(
+ .spring(response: 0.38, dampingFraction: 0.88),
+ value: tabBarWidth
+ )
}
ZStack {
+ // Use a flexible zero-content base and place retained tabs in
+ // overlays. Overlay children keep their own geometry without
+ // contributing their hidden size to the VStack. Otherwise
+ // Home's 158-point content height forces the active timer's
+ // 88-point content slot taller and pushes the tab bar upward.
+ Color.clear
+ .overlay(alignment: .top) {
+ tabLayer(for: .home) {
+ HomeTabView(model: model)
+ }
+ }
+ .overlay(alignment: .top) {
+ tabLayer(for: .clipboard) {
+ ClipboardTabView(model: model)
+ }
+ }
+ .overlay(alignment: .top) {
+ tabLayer(for: .timer) {
+ TimerTabView(model: model)
+ }
+ }
+ .overlay(alignment: .top) {
+ tabLayer(for: .agentic) {
+ AgenticTabView(model: model)
+ }
+ }
+ .opacity(model.isCalendarDetailPresented ? 0 : 1)
+ .allowsHitTesting(!model.isCalendarDetailPresented)
+ .accessibilityHidden(model.isCalendarDetailPresented)
+
if model.isCalendarDetailPresented {
CalendarDetailView(model: model)
.transition(contentTransition)
- } else {
- switch model.selectedTab {
- case .home:
- HomeTabView(model: model)
- .transition(contentTransition)
- case .clipboard:
- ClipboardTabView(model: model)
- .transition(contentTransition)
- case .timer:
- TimerTabView(model: model)
- .transition(contentTransition)
- case .agentic:
- AgenticTabView(model: model)
- .transition(contentTransition)
- }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
}
- .animation(.spring(response: 0.38, dampingFraction: 0.88), value: model.selectedTab)
.animation(.spring(response: 0.4, dampingFraction: 0.9), value: model.isCalendarDetailPresented)
}
+ private var tabBarWidth: CGFloat {
+ model.selectedTab == .timer && model.isTimerActive
+ ? 680
+ : IslandModel.homeExpandedSurfaceSize.width
+ }
+
+ private func tabLayer(
+ for tab: IslandTab,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ let isSelected = model.selectedTab == tab
+ let surfaceSize = model.expandedSurfaceSize(for: tab)
+
+ return content()
+ // Each tab keeps its own final geometry while the centered shell
+ // changes size. In particular, the outgoing 680-point running
+ // timer no longer reflows inside Home's growing 760-point frame.
+ .frame(
+ width: surfaceSize.width,
+ height: max(0, surfaceSize.height - 44),
+ alignment: .top
+ )
+ .opacity(isSelected ? 1 : 0)
+ .scaleEffect(isSelected ? 1 : 0.985, anchor: .top)
+ .allowsHitTesting(isSelected)
+ .accessibilityHidden(!isSelected)
+ .zIndex(isSelected ? 1 : 0)
+ .animation(
+ .spring(response: 0.38, dampingFraction: 0.88),
+ value: isSelected
+ )
+ }
+
private var contentTransition: AnyTransition {
// Directional moves compete with the centered notch morph when a tab
// is selected as expansion begins. A restrained crossfade and scale
@@ -2114,6 +2177,7 @@ private struct CalendarDayStrip: View {
let onVisibleDateChange: (Date) -> Void
@State private var timelineAnchor: Date
@State private var todayNavigationDirection: TodayNavigationDirection?
+ @State private var isInitialScrollPositioned = false
private let timelineRadius = 730
@@ -2201,6 +2265,7 @@ private struct CalendarDayStrip: View {
/ CalendarDayStripLayout.pitch).rounded()
)
} action: { _, centeredIndex in
+ guard isInitialScrollPositioned else { return }
let timelineDays = days
guard timelineDays.indices.contains(centeredIndex) else { return }
onVisibleDateChange(timelineDays[centeredIndex])
@@ -2231,6 +2296,7 @@ private struct CalendarDayStrip: View {
}
return nil
} action: { _, newDirection in
+ guard isInitialScrollPositioned else { return }
todayNavigationDirection = newDirection
}
.mask {
@@ -2248,9 +2314,19 @@ private struct CalendarDayStrip: View {
.frame(height: 40)
}
.frame(height: 54)
+ .opacity(isInitialScrollPositioned ? 1 : 0)
.onAppear {
+ isInitialScrollPositioned = false
+ todayNavigationDirection = nil
DispatchQueue.main.async {
center(model.selectedCalendarDate, using: proxy, animated: false)
+ DispatchQueue.main.async {
+ // Do not expose the ScrollView's temporary leading-edge
+ // geometry. It otherwise reports the start of the
+ // four-year timeline for one frame before scrollTo
+ // reaches the selected day.
+ isInitialScrollPositioned = true
+ }
}
}
.onChange(of: model.selectedCalendarDate) { _, newDate in
diff --git a/MacDynamicIslandTests/IslandModelTests.swift b/MacDynamicIslandTests/IslandModelTests.swift
index 80c7258..96f791b 100644
--- a/MacDynamicIslandTests/IslandModelTests.swift
+++ b/MacDynamicIslandTests/IslandModelTests.swift
@@ -368,6 +368,10 @@ final class IslandModelTests: XCTestCase {
// Expanded geometry is available before hover so SwiftUI can keep the
// hidden interface laid out at its final width during the shell morph.
XCTAssertEqual(model.expandedSurfaceSize, CGSize(width: 760, height: 202))
+ XCTAssertEqual(
+ model.expandedSurfaceSize(for: .home),
+ CGSize(width: 760, height: 202)
+ )
model.setPointerInside(true)
XCTAssertEqual(model.surfaceSize, CGSize(width: 760, height: 202))
@@ -385,6 +389,26 @@ final class IslandModelTests: XCTestCase {
XCTAssertEqual(model.surfaceSize, CGSize(width: 760, height: 390))
}
+ func testActiveTimerTabRetainsItsOwnLayoutSizeWhenHomeIsSelected() {
+ let model = IslandModel()
+ model.timerEndDate = .now.addingTimeInterval(60)
+ model.setPointerInside(true)
+
+ XCTAssertEqual(model.selectedTab, .timer)
+ XCTAssertEqual(
+ model.expandedSurfaceSize(for: .timer),
+ CGSize(width: 680, height: 132)
+ )
+
+ model.selectTab(.home)
+
+ XCTAssertEqual(model.surfaceSize, CGSize(width: 760, height: 202))
+ XCTAssertEqual(
+ model.expandedSurfaceSize(for: .timer),
+ CGSize(width: 680, height: 132)
+ )
+ }
+
func testSelectingAgenticTabRequestsFreshCodexUsage() {
let model = IslandModel()
var refreshCount = 0
From 85f261d172476ccc646b8e64b6a6c011c44ce4c9 Mon Sep 17 00:00:00 2001
From: Aram Rahimi
Date: Thu, 30 Jul 2026 04:44:41 +0200
Subject: [PATCH 3/3] Start automatic update checks on launch
---
MacDynamicIsland/App/AppDelegate.swift | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/MacDynamicIsland/App/AppDelegate.swift b/MacDynamicIsland/App/AppDelegate.swift
index 2f5c1d4..ff9cbb7 100644
--- a/MacDynamicIsland/App/AppDelegate.swift
+++ b/MacDynamicIsland/App/AppDelegate.swift
@@ -44,6 +44,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}
+ // Materialize the lazy controller on every normal launch so Sparkle
+ // can schedule automatic checks before the user invokes the manual
+ // "Check for Updates…" menu item.
+ _ = updaterController
+
configureStatusItem()
configureModel()