Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ jobs:
- name: Test
run: swift test

- name: Install DMG packaging tool
run: |
create_dmg_bin="$(./script/install_create_dmg.sh "$RUNNER_TEMP/create-dmg")"
echo "CREATE_DMG_BIN=$create_dmg_bin" >> "$GITHUB_ENV"

- name: Validate automation
run: |
bash -n script/*.sh
Expand Down
20 changes: 13 additions & 7 deletions .github/workflows/notarization-continuation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,22 @@ jobs:
runs-on: macos-26
timeout-minutes: 45

env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}

steps:
- name: Check out release tooling
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
persist-credentials: false

- name: Validate release secrets
env:
APPCAST_DEPLOY_KEY: ${{ secrets.APPCAST_DEPLOY_KEY }}
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
DAYLINE_SPARKLE_PRIVATE_KEY: ${{ secrets.DAYLINE_SPARKLE_PRIVATE_KEY }}
run: |
for name in \
Expand Down Expand Up @@ -74,6 +73,9 @@ jobs:
run: swift build

- name: Import Developer ID certificate
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
certificate_path="$RUNNER_TEMP/developer-id.p12"
keychain_path="$RUNNER_TEMP/dayline-signing.keychain-db"
Expand Down Expand Up @@ -106,6 +108,10 @@ jobs:
echo "SIGNING_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV"

- name: Write App Store Connect API key
env:
APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
printf '%s' "$APP_STORE_CONNECT_KEY_P8_BASE64" | /usr/bin/base64 -D > "$key_path"
Expand Down
17 changes: 12 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ jobs:

env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}

steps:
- name: Check out tagged source
Expand All @@ -53,6 +48,11 @@ jobs:
- name: Validate release secrets
env:
APPCAST_DEPLOY_KEY: ${{ secrets.APPCAST_DEPLOY_KEY }}
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
DAYLINE_SPARKLE_PRIVATE_KEY: ${{ secrets.DAYLINE_SPARKLE_PRIVATE_KEY }}
run: |
for name in \
Expand Down Expand Up @@ -84,6 +84,9 @@ jobs:
echo "APPCAST_DEPLOY_KEY_PATH=$key_path" >> "$GITHUB_ENV"

- name: Import Developer ID certificate
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
certificate_path="$RUNNER_TEMP/developer-id.p12"
keychain_path="$RUNNER_TEMP/dayline-signing.keychain-db"
Expand Down Expand Up @@ -116,6 +119,10 @@ jobs:
echo "SIGNING_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV"

- name: Write App Store Connect API key
env:
APP_STORE_CONNECT_KEY_P8_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_P8_BASE64 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
printf '%s' "$APP_STORE_CONNECT_KEY_P8_BASE64" | /usr/bin/base64 -D > "$key_path"
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,12 +513,20 @@ CI also runs `swift build` and `swift test` for pull requests and pushes to

### Manual fallback

Create local unsigned or development-signed artifacts:
Install the DMG layout tool with Homebrew before packaging locally:

```sh
brew install create-dmg
./script/package_release.sh
```

Alternatively, use the checksum-pinned bootstrap used by CI:

```sh
CREATE_DMG_BIN="$(./script/install_create_dmg.sh /tmp/dayline-create-dmg)" \
./script/package_release.sh
```

Official notarization should run through GitHub Actions so the preserved artifact,
submission IDs, and continuation state stay together. The scripts reject dirty,
untagged, mismatched, corrupt, or duplicate public releases.
Expand Down
4 changes: 4 additions & 0 deletions Resources/DaylineDMGBackground.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 25 additions & 6 deletions Sources/Dayline/Services/UpdateService.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Combine
import Foundation
@preconcurrency import Sparkle
Expand All @@ -20,6 +21,7 @@ final class UpdateService: NSObject, ObservableObject {
}

private var updaterController: SPUStandardUpdaterController?
private var injectedActivateApplicationAction: (() -> Void)?
private var injectedCheckForUpdatesAction: (() -> Void)?
private var automaticallyDownloadsObservation: NSKeyValueObservation?
private var canCheckForUpdatesObservation: NSKeyValueObservation?
Expand Down Expand Up @@ -71,8 +73,13 @@ final class UpdateService: NSObject, ObservableObject {
}

/// Creates an isolated updater action for unit tests without starting Sparkle.
init(canCheckForUpdates: Bool, checkForUpdatesAction: @escaping () -> Void) {
init(
canCheckForUpdates: Bool,
activateApplicationAction: @escaping () -> Void = {},
checkForUpdatesAction: @escaping () -> Void
) {
self.canCheckForUpdates = canCheckForUpdates
injectedActivateApplicationAction = activateApplicationAction
injectedCheckForUpdatesAction = checkForUpdatesAction
super.init()
}
Expand All @@ -83,16 +90,28 @@ final class UpdateService: NSObject, ObservableObject {
updaterController?.updater.automaticallyDownloadsUpdates = isEnabled
}

/// Runs a user-initiated update check, letting Sparkle present its standard UI.
/// Runs a user-initiated update check after the menu-bar sheet finishes dismissing.
func checkForUpdates() {
guard canCheckForUpdates else {
return
}
if let injectedCheckForUpdatesAction {
injectedCheckForUpdatesAction()
return

DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
if let injectedActivateApplicationAction {
injectedActivateApplicationAction()
} else {
NSApplication.shared.activate(ignoringOtherApps: true)
}

if let injectedCheckForUpdatesAction {
injectedCheckForUpdatesAction()
} else {
updaterController?.checkForUpdates(nil)
}
}
updaterController?.checkForUpdates(nil)
}

/// Keeps the footer reminder for an update Sparkle has staged to install on quit.
Expand Down
4 changes: 2 additions & 2 deletions Tests/DaylineTests/AppleCalendarEventCreationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ struct AppleCalendarEventCreationTests {
let store = StatusStore(mockData: MockData.make())
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10)))
let inclusiveEnd = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 11)))
let start = calendar.startOfDay(for: Date())
let inclusiveEnd = try #require(calendar.date(byAdding: .day, value: 1, to: start))

try await store.createAppleCalendarEvent(draft: AppleCalendarEventCreateDraft(
title: "Conference",
Expand Down
48 changes: 41 additions & 7 deletions Tests/DaylineTests/UpdateServiceTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Dispatch
import Testing
@testable import Dayline

Expand All @@ -11,25 +12,58 @@ struct UpdateServiceTests {
#expect(service.availableVersion == "9.9.9")
}

@Test func enabledInjectedUpdaterInvokesConfiguredCheckAction() {
var invocationCount = 0
let service = UpdateService(canCheckForUpdates: true) {
invocationCount += 1
}
@Test(.timeLimit(.minutes(1)))
func enabledInjectedUpdaterActivatesThenChecksOnNextMainLoopTurn() async {
var actions: [String] = []
let (checkEvents, checkEventContinuation) = AsyncStream<Void>.makeStream()
let service = UpdateService(
canCheckForUpdates: true,
activateApplicationAction: {
actions.append("activate")
},
checkForUpdatesAction: {
actions.append("check")
checkEventContinuation.yield()
}
)

#expect(service.isUpdaterAvailable)
service.checkForUpdates()
#expect(invocationCount == 1)
#expect(actions.isEmpty)

let receivedCheck = await withTaskGroup(of: Bool.self) { group in
group.addTask {
var iterator = checkEvents.makeAsyncIterator()
return await iterator.next() != nil
}
group.addTask {
try? await Task.sleep(for: .seconds(1))
return false
}
let result = await group.next() ?? false
group.cancelAll()
checkEventContinuation.finish()
return result
}

#expect(receivedCheck)
#expect(actions == ["activate", "check"])
}

@Test func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() {
@Test(.timeLimit(.minutes(1)))
func disabledInjectedUpdaterDoesNotInvokeConfiguredCheckAction() async {
var invocationCount = 0
let service = UpdateService(canCheckForUpdates: false) {
invocationCount += 1
}

#expect(service.isUpdaterAvailable)
service.checkForUpdates()
await withCheckedContinuation { continuation in
DispatchQueue.main.async {
continuation.resume()
}
}
#expect(invocationCount == 0)
}

Expand Down
38 changes: 37 additions & 1 deletion script/app_bundle_contract_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@ MOCK_PLIST="$ROOT_DIR/dist/Dayline Mock.app/Contents/Info.plist"
RELEASE_PLIST="$ROOT_DIR/dist/release/Dayline.app/Contents/Info.plist"
DEV_APP="$ROOT_DIR/dist/Dayline Dev.app"
RELEASE_APP="$ROOT_DIR/dist/release/Dayline.app"
RELEASE_DMG="$ROOT_DIR/dist/artifacts/Dayline-0.1.0-dev.dmg"
TEST_GOOGLE_CLIENT_ID="1234567890-dayline-dev-contract.apps.googleusercontent.com" # autoreview:allow-secret
TEST_GOOGLE_SCHEME="com.googleusercontent.apps.1234567890-dayline-dev-contract"
INSTALLER_LOCK_DIR="${TMPDIR:-/tmp}/dayline-dev-installer.lock"
LOG_FILE="$(mktemp -t dayline-bundle-contract.XXXXXX)"
MALFORMED_PLIST="$(mktemp -t dayline-malformed-plist.XXXXXX)"
trap 'rm -f "$LOG_FILE" "$MALFORMED_PLIST"; rm -rf "$INSTALLER_LOCK_DIR"' EXIT
DMG_MOUNT=""

cleanup() {
if [[ -n "$DMG_MOUNT" && -d "$DMG_MOUNT" ]]; then
/usr/bin/hdiutil detach "$DMG_MOUNT" >/dev/null 2>&1 || true
rmdir "$DMG_MOUNT" 2>/dev/null || true
fi
rm -f "$LOG_FILE" "$MALFORMED_PLIST"
rm -rf "$INSTALLER_LOCK_DIR"
}
trap cleanup EXIT

cd "$ROOT_DIR"

Expand Down Expand Up @@ -142,4 +153,29 @@ assert_url_scheme "$RELEASE_PLIST" dayline
assert_url_scheme "$RELEASE_PLIST" com.googleusercontent.apps.551177930544-9sl0govp6ok205csb939j4p2dhckrgbk
assert_eventkit_entitlement "$RELEASE_APP"

DMG_MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/dayline-dmg-contract.XXXXXX")"
/usr/bin/hdiutil attach -readonly -nobrowse -mountpoint "$DMG_MOUNT" "$RELEASE_DMG" >/dev/null
[[ -d "$DMG_MOUNT/Dayline.app" ]] || fail "release DMG is missing Dayline.app"
[[ -L "$DMG_MOUNT/Applications" ]] || fail "release DMG is missing the Applications drop link"
[[ "$(readlink "$DMG_MOUNT/Applications")" == "/Applications" ]] ||
fail "release DMG Applications link has the wrong destination"
[[ -f "$DMG_MOUNT/.DS_Store" ]] || fail "release DMG is missing Finder layout metadata"
[[ -f "$DMG_MOUNT/.background/DaylineDMGBackground.png" ]] ||
fail "release DMG is missing its arrow background"
[[ "$(/usr/bin/sips -g pixelWidth "$DMG_MOUNT/.background/DaylineDMGBackground.png" 2>/dev/null | awk '/pixelWidth/ { print $2 }')" == "640" ]] ||
fail "release DMG background width is not 640 pixels"
[[ "$(/usr/bin/sips -g pixelHeight "$DMG_MOUNT/.background/DaylineDMGBackground.png" 2>/dev/null | awk '/pixelHeight/ { print $2 }')" == "320" ]] ||
fail "release DMG background height is not 320 pixels"
strings "$DMG_MOUNT/.DS_Store" | grep -Fq "DaylineDMGBackground.png" ||
fail "release DMG Finder metadata does not reference its background"
grep -Fq -- '--window-size 640 390' "$ROOT_DIR/script/package_release.sh" ||
fail "release DMG window size changed without updating its layout contract"
grep -Fq -- '--icon "$APP_NAME.app" 160 160' "$ROOT_DIR/script/package_release.sh" ||
fail "release DMG app icon position changed without updating its layout contract"
grep -Fq -- '--app-drop-link 480 160' "$ROOT_DIR/script/package_release.sh" ||
fail "release DMG Applications position changed without updating its layout contract"
/usr/bin/hdiutil detach "$DMG_MOUNT" >/dev/null
rmdir "$DMG_MOUNT"
DMG_MOUNT=""

echo "app_bundle_contract_test: passed"
16 changes: 16 additions & 0 deletions script/install_create_dmg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail

VERSION="1.3.0"
ARCHIVE_SHA256="c50d2bc97c3d6292642bac55f530d247eaf4bf65ee605f26b4caf339383e381c"
INSTALL_DIR="${1:?usage: install_create_dmg.sh <install-directory>}"
ARCHIVE="$INSTALL_DIR/create-dmg.tar.gz"

mkdir -p "$INSTALL_DIR"
curl --fail --location --proto '=https' --tlsv1.2 \
"https://github.com/create-dmg/create-dmg/archive/refs/tags/v$VERSION.tar.gz" \
--output "$ARCHIVE"
printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE" | shasum -a 256 --check >&2
tar -xzf "$ARCHIVE" --strip-components=1 --directory "$INSTALL_DIR"
chmod +x "$INSTALL_DIR/create-dmg"
printf '%s\n' "$INSTALL_DIR/create-dmg"
Loading
Loading