diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e935f4a --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Notarization & Code Signing (Required for build_dmg) +# ===================================================== + +# Developer ID Application certificate identity +# Format: "Developer ID Application: Your Name (TEAM_ID)" +# Find in Keychain Access or run: security find-identity -v -p codesigning +DEVELOPER_ID_APPLICATION="Developer ID Application: Your Name (XXXXXXXXXX)" + +# Apple Team ID +# Find at: https://developer.apple.com/account#MembershipDetailsCard +APPLE_TEAM_ID="XXXXXXXXXX" + +# Notarization keychain profile name +# This references credentials stored securely in your macOS Keychain +# One-time setup (run this command once): +# xcrun notarytool store-credentials "notary-profile" \ +# --apple-id "your@email.com" \ +# --team-id "XXXXXXXXXX" +# You'll be prompted for an app-specific password from: +# https://appleid.apple.com/account/manage +NOTARY_KEYCHAIN_PROFILE="notary-profile" + +# AWS S3 Upload (Optional) +# ========================= +AWS_ACCESS_KEY_ID="your-access-key" +AWS_SECRET_ACCESS_KEY="your-secret-key" +AWS_BUCKET_NAME="your-bucket-name" +AWS_REGION="us-east-1" diff --git a/Gaze/Extensions/StringExtensions.swift b/Gaze/Extensions/StringExtensions.swift new file mode 100644 index 0000000..edb2c0c --- /dev/null +++ b/Gaze/Extensions/StringExtensions.swift @@ -0,0 +1,26 @@ +extension String { + var titleCase: String { + // The compiler didn't like this chained - `Too long to type-check` + + guard !self.isEmpty else { + return "" + } + + let words = self.split(separator: " ") + .map { word in + guard !word.isEmpty else { return String(word) } + return String(word.prefix(1)).uppercased() + String(word.dropFirst()) + } + .joined(separator: " ") + + let result = words.split(separator: "-") + .map { word in + guard !word.isEmpty else { return String(word) } + return String(word.prefix(1)).uppercased() + String(word.dropFirst()) + } + .joined(separator: "-") + + return result + } +} + diff --git a/Gaze/Views/Components/PreviewTrigger.swift b/Gaze/Views/Components/PreviewTrigger.swift new file mode 100644 index 0000000..e69de29 diff --git a/Gaze/Views/Components/SliderSection.swift b/Gaze/Views/Components/SliderSection.swift new file mode 100644 index 0000000..42ded7f --- /dev/null +++ b/Gaze/Views/Components/SliderSection.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct SliderSection: View { + @Binding var intervalMinutes: Int + @Binding var countdownSeconds: Int + @Binding var enabled: Bool + + let intervalRange: ClosedRange + let countdownRange: ClosedRange? + let type: String + let previewFunc: () -> Void + let reminderText: String + + init( + intervalMinutes: Binding, + countdownSeconds: Binding, + intervalRange: ClosedRange, + countdownRange: ClosedRange? = nil, + enabled: Binding, + type: String, + reminderText: String, + previewFunc: @escaping () -> Void + ) { + self._intervalMinutes = intervalMinutes + self._countdownSeconds = countdownSeconds + self.intervalRange = intervalRange + self.countdownRange = countdownRange + self._enabled = enabled + self.type = type + self.reminderText = reminderText + self.previewFunc = previewFunc + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Toggle("Enable \(type.titleCase) Reminders", isOn: $enabled) + .font(.headline) + + if enabled { + VStack(alignment: .leading, spacing: 12) { + Text("Remind me every:") + .font(.subheadline) + .foregroundColor(.secondary) + HStack { + Slider( + value: Binding( + get: { Double(intervalMinutes) }, + set: { intervalMinutes = Int($0) } + ), + in: + Double(intervalRange.lowerBound)...Double(intervalRange.upperBound), + step: 5.0) + Text("\(intervalMinutes) min") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + + if let range = countdownRange { + Text("Look away for:") + .font(.subheadline) + .foregroundColor(.secondary) + HStack { + Slider( + value: Binding( + get: { Double(countdownSeconds) }, + set: { countdownSeconds = Int($0) } + ), in: Double(range.lowerBound)...Double(range.upperBound), + step: 5.0) + Text("\(countdownSeconds) sec") + .frame(width: 60, alignment: .trailing) + .monospacedDigit() + } + } + } + } + }.padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + + if enabled { + Text( + reminderText + ) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } else { + Text( + "\(type) reminders are currently disabled." + ) + .font(.caption) + .foregroundColor(.secondary) + } + + Button(action: { + previewFunc() + }) { + HStack(spacing: 8) { + Image(systemName: "eye") + .foregroundColor(.white) + Text("Preview Reminder") + .font(.headline) + .foregroundColor(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10) + ) + } +} diff --git a/Gaze/Views/MenuBar/MenuBarContentView.swift b/Gaze/Views/MenuBar/MenuBarContentView.swift index dcf1f3e..b97952e 100644 --- a/Gaze/Views/MenuBar/MenuBarContentView.swift +++ b/Gaze/Views/MenuBar/MenuBarContentView.swift @@ -189,7 +189,7 @@ struct MenuBarContentView: View { // Show all timers using unified identifier system ForEach(getSortedTimerIdentifiers(timerEngine: timerEngine), id: \.self) { identifier in - if let state = timerEngine.timerStates[identifier] { + if timerEngine.timerStates[identifier] != nil { TimerStatusRowWithIndividualControls( identifier: identifier, timerEngine: timerEngine, diff --git a/Gaze/Views/Setup/LookAwaySetupView.swift b/Gaze/Views/Setup/LookAwaySetupView.swift index b5211e7..e224bcc 100644 --- a/Gaze/Views/Setup/LookAwaySetupView.swift +++ b/Gaze/Views/Setup/LookAwaySetupView.swift @@ -5,8 +5,8 @@ // Created by Mike Freno on 1/7/26. // -import SwiftUI import AppKit +import SwiftUI #if os(iOS) import UIKit @@ -58,83 +58,21 @@ struct LookAwaySetupView: View { .foregroundColor(.white) } .padding() - .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.accentColor), in: .rect(cornerRadius: 8)) - VStack(alignment: .leading, spacing: 20) { - Toggle("Enable Look Away Reminders", isOn: $enabled) - .font(.headline) + SliderSection( + intervalMinutes: $intervalMinutes, + countdownSeconds: $countdownSeconds, + intervalRange: 5...90, + countdownRange: 10...30, + enabled: $enabled, + type: "Look away", + reminderText: + "You will be reminded every \(intervalMinutes) minutes to look in the distance for \(countdownSeconds) seconds", + previewFunc: showPreviewWindow + ) - if enabled { - VStack(alignment: .leading, spacing: 12) { - Text("Remind me every:") - .font(.subheadline) - .foregroundColor(.secondary) - - HStack { - Slider( - value: Binding( - get: { Double(intervalMinutes) }, - set: { intervalMinutes = Int($0) } - ), in: 5...90, step: 5) - - Text("\(intervalMinutes) min") - .frame(width: 60, alignment: .trailing) - .monospacedDigit() - } - - Text("Look away for:") - .font(.subheadline) - .foregroundColor(.secondary) - - HStack { - Slider( - value: Binding( - get: { Double(countdownSeconds) }, - set: { countdownSeconds = Int($0) } - ), in: 10...30, step: 5) - - Text("\(countdownSeconds) sec") - .frame(width: 60, alignment: .trailing) - .monospacedDigit() - } - } - } - } - .padding() - .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) - - if enabled { - Text( - "You will be reminded every \(intervalMinutes) minutes to look in the distance for \(countdownSeconds) seconds" - ) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } else { - Text( - "Look away reminders are currently disabled." - ) - .font(.caption) - .foregroundColor(.secondary) - } - - // Preview button - Button(action: { - showPreviewWindow() - }) { - HStack(spacing: 8) { - Image(systemName: "eye") - .foregroundColor(.white) - Text("Preview Reminder") - .font(.headline) - .foregroundColor(.white) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .glassEffectIfAvailable(GlassStyle.regular.tint(.accentColor).interactive(), in: .rect(cornerRadius: 10)) } Spacer() @@ -143,34 +81,35 @@ struct LookAwaySetupView: View { .padding() .background(.clear) } - + private func showPreviewWindow() { guard let screen = NSScreen.main else { return } - + let window = NSWindow( contentRect: screen.frame, styleMask: [.borderless, .fullSizeContentView], backing: .buffered, defer: false ) - + window.level = .floating window.isOpaque = false window.backgroundColor = .clear window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.acceptsMouseMovedEvents = true - - let contentView = LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak window] in + + let contentView = LookAwayReminderView(countdownSeconds: countdownSeconds) { + [weak window] in window?.close() } - + window.contentView = NSHostingView(rootView: contentView) window.makeFirstResponder(window.contentView) - + let windowController = NSWindowController(window: window) windowController.showWindow(nil) window.makeKeyAndOrderFront(nil) - + previewWindowController = windowController } } diff --git a/build_dmg b/build_dmg index 407d3f8..f94c483 100755 --- a/build_dmg +++ b/build_dmg @@ -6,6 +6,63 @@ if [ -f .env ]; then export $(grep -v '^#' .env | xargs) fi +# Validate required code signing and notarization credentials +echo "๐Ÿ” Validating credentials..." +MISSING_CREDS=() + +if [ -z "$DEVELOPER_ID_APPLICATION" ]; then + MISSING_CREDS+=("DEVELOPER_ID_APPLICATION") +fi + +if [ -z "$NOTARY_KEYCHAIN_PROFILE" ]; then + MISSING_CREDS+=("NOTARY_KEYCHAIN_PROFILE") +fi + +if [ -z "$APPLE_TEAM_ID" ]; then + MISSING_CREDS+=("APPLE_TEAM_ID") +fi + +if [ ${#MISSING_CREDS[@]} -gt 0 ]; then + echo "โŒ ERROR: Missing required credentials in .env file:" + for cred in "${MISSING_CREDS[@]}"; do + echo " - $cred" + done + echo "" + echo "Required environment variables:" + echo " DEVELOPER_ID_APPLICATION='Developer ID Application: Your Name (TEAM_ID)'" + echo " APPLE_TEAM_ID='XXXXXXXXXX'" + echo " NOTARY_KEYCHAIN_PROFILE='notary-profile'" + echo "" + echo "Setup instructions:" + echo " 1. Find your Developer ID certificate:" + echo " security find-identity -v -p codesigning" + echo "" + echo " 2. Store notarization credentials in keychain (one-time setup):" + echo " xcrun notarytool store-credentials \"notary-profile\" \\" + echo " --apple-id \"your@email.com\" \\" + echo " --team-id \"TEAM_ID\"" + echo "" + echo " You'll be prompted for an app-specific password." + echo " Generate one at: https://appleid.apple.com/account/manage" + echo "" + echo " 3. Set NOTARY_KEYCHAIN_PROFILE='notary-profile' in .env" + exit 1 +fi + +# Verify keychain profile exists +echo "๐Ÿ” Verifying keychain profile..." +if ! xcrun notarytool history --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" &>/dev/null; then + echo "โŒ ERROR: Keychain profile '$NOTARY_KEYCHAIN_PROFILE' not found or invalid" + echo "" + echo "Create the profile with:" + echo " xcrun notarytool store-credentials \"$NOTARY_KEYCHAIN_PROFILE\" \\" + echo " --apple-id \"your@email.com\" \\" + echo " --team-id \"$APPLE_TEAM_ID\"" + exit 1 +fi + +echo "โœ… All credentials validated" + # Extract version from Xcode project (Release configuration) PROJECT_FILE="Gaze.xcodeproj/project.pbxproj" VERSION=$(grep -A 1 "MARKETING_VERSION" "$PROJECT_FILE" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1) @@ -22,9 +79,11 @@ if [ -z "$BUILD_NUMBER" ]; then BUILD_NUMBER="1" fi -echo "๐Ÿ“ฆ Building Gaze DMG for v${VERSION} (build ${BUILD_NUMBER})" +echo "๐Ÿ“ฆ Building Gaze v${VERSION} (build ${BUILD_NUMBER}) for distribution" RELEASES_DIR="./releases" +ARCHIVE_PATH="./build/Gaze.xcarchive" +EXPORT_PATH="./build/export" APPCAST_OUTPUT="${RELEASES_DIR}/appcast.xml" FEED_URL="https://freno.me/api/Gaze/appcast.xml" DOWNLOAD_URL_PREFIX="https://freno.me/api/downloads/" @@ -38,13 +97,169 @@ if [ -z "$SPARKLE_BIN" ]; then SPARKLE_BIN="" fi -# Create releases directory +# Create build and releases directories mkdir -p "$RELEASES_DIR" +mkdir -p "$(dirname "$ARCHIVE_PATH")" -# Remove old DMG if exists +# Clean previous builds +echo "" +echo "๐Ÿงน Cleaning previous builds..." +rm -rf "$ARCHIVE_PATH" +rm -rf "$EXPORT_PATH" rm -f "$DMG_NAME" -echo "Creating DMG..." +# Step 1: Archive the application +echo "" +echo "๐Ÿ“ฆ Creating archive..." +xcodebuild archive \ + -project Gaze.xcodeproj \ + -scheme Gaze \ + -configuration Release \ + -archivePath "$ARCHIVE_PATH" \ + CODE_SIGN_IDENTITY="$DEVELOPER_ID_APPLICATION" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + | xcpretty || xcodebuild archive \ + -project Gaze.xcodeproj \ + -scheme Gaze \ + -configuration Release \ + -archivePath "$ARCHIVE_PATH" \ + CODE_SIGN_IDENTITY="$DEVELOPER_ID_APPLICATION" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" + +if [ ! -d "$ARCHIVE_PATH" ]; then + echo "โŒ ERROR: Archive creation failed" + exit 1 +fi + +echo "โœ… Archive created successfully" + +# Step 2: Create exportOptions.plist +echo "" +echo "๐Ÿ“ Creating export options..." +cat > /tmp/exportOptions.plist < + + + + method + developer-id + signingStyle + manual + teamID + $APPLE_TEAM_ID + signingCertificate + Developer ID Application + stripSwiftSymbols + + uploadSymbols + + + +EOF + +# Step 3: Export the archive +echo "" +echo "๐Ÿ“ค Exporting signed application..." +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist /tmp/exportOptions.plist \ + | xcpretty || xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist /tmp/exportOptions.plist + +if [ ! -d "$EXPORT_PATH/Gaze.app" ]; then + echo "โŒ ERROR: Export failed - Gaze.app not found" + exit 1 +fi + +echo "โœ… Application exported and signed" + +# Step 4: Verify code signature +echo "" +echo "๐Ÿ” Verifying code signature..." +codesign --verify --deep --strict --verbose=2 "$EXPORT_PATH/Gaze.app" +if [ $? -eq 0 ]; then + echo "โœ… Code signature valid" +else + echo "โŒ ERROR: Code signature verification failed" + exit 1 +fi + +# Show signature details +echo "" +echo "๐Ÿ“‹ Signature details:" +codesign -dv --verbose=4 "$EXPORT_PATH/Gaze.app" 2>&1 | grep -E "Authority|TeamIdentifier|Identifier" + +# Step 5: Create ZIP archive for notarization +echo "" +echo "๐Ÿ“ฆ Creating ZIP archive for notarization..." +APP_ZIP="/tmp/Gaze-notarize.zip" +rm -f "$APP_ZIP" +ditto -c -k --keepParent "$EXPORT_PATH/Gaze.app" "$APP_ZIP" + +if [ ! -f "$APP_ZIP" ]; then + echo "โŒ ERROR: Failed to create ZIP archive" + exit 1 +fi + +echo "โœ… ZIP archive created" + +# Step 6: Notarize the application +echo "" +echo "๐Ÿ” Submitting application for notarization..." +NOTARIZE_OUTPUT=$(xcrun notarytool submit "$APP_ZIP" \ + --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" \ + --wait \ + --timeout 30m 2>&1) + +echo "$NOTARIZE_OUTPUT" + +if echo "$NOTARIZE_OUTPUT" | grep -q "status: Accepted"; then + echo "โœ… Application notarization accepted" + + # Extract submission ID for logging + SUBMISSION_ID=$(echo "$NOTARIZE_OUTPUT" | grep "id:" | head -1 | awk '{print $2}') + echo " Submission ID: $SUBMISSION_ID" +else + echo "โŒ ERROR: Application notarization failed" + echo "" + echo "Notarization output:" + echo "$NOTARIZE_OUTPUT" + exit 1 +fi + +# Clean up temporary ZIP +rm -f "$APP_ZIP" + +# Step 7: Staple notarization ticket to app +echo "" +echo "๐Ÿ“Ž Stapling notarization ticket to application..." +xcrun stapler staple "$EXPORT_PATH/Gaze.app" +if [ $? -eq 0 ]; then + echo "โœ… Notarization ticket stapled to application" +else + echo "โŒ ERROR: Failed to staple notarization ticket" + exit 1 +fi + +# Step 8: Verify with Gatekeeper +echo "" +echo "๐Ÿ” Verifying Gatekeeper acceptance..." +spctl --assess --type execute --verbose=4 "$EXPORT_PATH/Gaze.app" 2>&1 +if [ $? -eq 0 ]; then + echo "โœ… Application passes Gatekeeper verification" +else + echo "โš ๏ธ Warning: Gatekeeper assessment returned non-zero status" + echo " This may be expected for some configurations" +fi + +# Step 9: Create DMG from notarized app +echo "" +echo "๐Ÿ’ฟ Creating DMG..." create-dmg \ --volname "Gaze Installer" \ --eula "./LICENSE" \ @@ -55,7 +270,85 @@ create-dmg \ --icon "Gaze.app" 160 200 \ --app-drop-link 440 200 \ "$DMG_NAME" \ - "./Gaze.app" + "$EXPORT_PATH/Gaze.app" + +if [ ! -f "$DMG_NAME" ]; then + echo "โŒ ERROR: DMG creation failed" + exit 1 +fi + +echo "โœ… DMG created successfully" + +# Step 10: Sign the DMG +echo "" +echo "๐Ÿ” Signing DMG..." +codesign --sign "$DEVELOPER_ID_APPLICATION" \ + --timestamp \ + --options runtime \ + --force \ + "$DMG_NAME" + +if [ $? -eq 0 ]; then + echo "โœ… DMG signed successfully" +else + echo "โŒ ERROR: DMG signing failed" + exit 1 +fi + +# Verify DMG signature +codesign --verify --deep --strict --verbose=2 "$DMG_NAME" +if [ $? -eq 0 ]; then + echo "โœ… DMG signature valid" +else + echo "โŒ ERROR: DMG signature verification failed" + exit 1 +fi + +# Step 11: Notarize the DMG +echo "" +echo "๐Ÿ” Submitting DMG for notarization..." +DMG_NOTARIZE_OUTPUT=$(xcrun notarytool submit "$DMG_NAME" \ + --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" \ + --wait \ + --timeout 30m 2>&1) + +echo "$DMG_NOTARIZE_OUTPUT" + +if echo "$DMG_NOTARIZE_OUTPUT" | grep -q "status: Accepted"; then + echo "โœ… DMG notarization accepted" + + # Extract submission ID for logging + DMG_SUBMISSION_ID=$(echo "$DMG_NOTARIZE_OUTPUT" | grep "id:" | head -1 | awk '{print $2}') + echo " Submission ID: $DMG_SUBMISSION_ID" +else + echo "โŒ ERROR: DMG notarization failed" + echo "" + echo "Notarization output:" + echo "$DMG_NOTARIZE_OUTPUT" + exit 1 +fi + +# Step 12: Staple notarization ticket to DMG +echo "" +echo "๐Ÿ“Ž Stapling notarization ticket to DMG..." +xcrun stapler staple "$DMG_NAME" +if [ $? -eq 0 ]; then + echo "โœ… Notarization ticket stapled to DMG" +else + echo "โŒ ERROR: Failed to staple notarization ticket to DMG" + exit 1 +fi + +# Step 13: Final verification +echo "" +echo "๐Ÿ” Final DMG verification..." +spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG_NAME" 2>&1 +if [ $? -eq 0 ]; then + echo "โœ… DMG passes all Gatekeeper checks" +else + echo "โš ๏ธ Warning: Gatekeeper assessment returned non-zero status" + echo " This may be expected for disk images" +fi # Copy DMG to releases directory echo "Moving DMG to releases directory..." @@ -181,15 +474,27 @@ fi echo "" echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" -echo "Release artifacts created:" +echo "โœ… Release build complete and notarized!" +echo "" +echo "Release artifacts:" echo " ๐Ÿ“ฆ DMG: $RELEASES_DIR/$DMG_NAME" if [ -f "$APPCAST_OUTPUT" ]; then echo " ๐Ÿ“‹ Appcast: $APPCAST_OUTPUT" fi +echo " ๐Ÿ—๏ธ Archive: $ARCHIVE_PATH" +echo " ๐Ÿ“ Export: $EXPORT_PATH/Gaze.app" +echo "" +echo "Verification:" +echo " โœ… Application code signed with Developer ID" +echo " โœ… Application notarized by Apple" +echo " โœ… DMG signed with Developer ID" +echo " โœ… DMG notarized by Apple" +echo " โœ… Gatekeeper approved" echo "" echo "Next steps:" echo " 1. Upload DMG to: ${DOWNLOAD_URL_PREFIX}$DMG_NAME" echo " 2. Upload appcast to: $FEED_URL" echo " 3. Verify appcast is accessible and valid" -echo " 4. Test update from previous version" +echo " 4. Test installation on clean macOS system" +echo " 5. Test update from previous version" echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”"