diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd100f9..3eb4a38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Check macOS version consistency + run: ./Scripts/ci/check-macos-version.sh + - name: Install SwiftFormat run: brew install swiftformat diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8bca23 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,178 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + APP_NAME: Hinto + SCHEME: Hinto + BUILD_PATH: build/Build/Products/Release + MACOS_MIN_VERSION: "13.0" # Keep in sync with Config/base.xcconfig + +jobs: + release: + runs-on: macos-latest + environment: production + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + ./Scripts/ci/validate-release-version.sh "$VERSION" + BUILD_NUMBER=$(./Scripts/ci/version-to-build-number.sh "$VERSION") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT + echo "Releasing version: $VERSION (build $BUILD_NUMBER)" + + - name: Install certificate + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=password + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security default-keychain -s "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -k "$KEYCHAIN_PATH" + + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + - name: Build + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + xcodebuild \ + -scheme $SCHEME \ + -configuration Release \ + -derivedDataPath build \ + CODE_SIGN_IDENTITY="Developer ID Application" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO \ + OTHER_CODE_SIGN_FLAGS="--timestamp --options runtime" \ + build + + - name: Notarize + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + cd "$BUILD_PATH" + ditto -c -k --keepParent "$APP_NAME.app" "$APP_NAME.zip" + + xcrun notarytool submit "$APP_NAME.zip" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + + xcrun stapler staple "$APP_NAME.app" + + - name: Create DMG + run: | + git clone https://github.com/create-dmg/create-dmg.git + + ./create-dmg/create-dmg \ + --volname "$APP_NAME" \ + --window-pos 200 120 \ + --window-size 500 320 \ + --icon-size 80 \ + --icon "$APP_NAME.app" 125 175 \ + --hide-extension "$APP_NAME.app" \ + --app-drop-link 375 175 \ + --no-internet-enable \ + "$APP_NAME.dmg" \ + "$BUILD_PATH/$APP_NAME.app" + + - name: Sign DMG for Sparkle + id: sparkle_sign + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + # Download Sparkle tools + SPARKLE_VERSION="2.6.4" + curl -L -o /tmp/Sparkle.tar.xz "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + mkdir -p /tmp/sparkle + tar -xf /tmp/Sparkle.tar.xz -C /tmp/sparkle + + # Sign the DMG with EdDSA key + SIGNATURE=$(/tmp/sparkle/bin/sign_update "$APP_NAME.dmg" --ed-key-file <(echo "$SPARKLE_PRIVATE_KEY")) + echo "signature=$SIGNATURE" >> $GITHUB_OUTPUT + echo "Sparkle signature: $SIGNATURE" + + # Get DMG size + DMG_SIZE=$(stat -f%z "$APP_NAME.dmg") + echo "dmg_size=$DMG_SIZE" >> $GITHUB_OUTPUT + + - name: Extract release notes from CHANGELOG + id: changelog + run: | + VERSION=${{ steps.version.outputs.version }} + + # Extract content between current version and next version header (or EOF) + NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^## \\[/{flag=0} flag" Resources/CHANGELOG.md | sed '/^$/d') + + # Handle multiline output + echo "notes<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.APP_NAME }}.dmg + body: | + ${{ steps.changelog.outputs.notes }} + + --- + **Installation:** Download `Hinto.dmg`, open it, and drag Hinto to your Applications folder. + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy appcast.xml to GitHub Pages + env: + VERSION: ${{ steps.version.outputs.version }} + BUILD_NUMBER: ${{ steps.version.outputs.build_number }} + SIGNATURE: ${{ steps.sparkle_sign.outputs.signature }} + DMG_SIZE: ${{ steps.sparkle_sign.outputs.dmg_size }} + MACOS_MIN_VERSION: ${{ env.MACOS_MIN_VERSION }} + REPO_URL: https://github.com/yhao3/hinto + APPCAST_URL: https://yhao3.github.io/hinto/appcast.xml + run: ./Scripts/ci/deploy-appcast.sh + + - name: Update website version + env: + VERSION: ${{ steps.version.outputs.version }} + HINTO_SITE_PAT: ${{ secrets.HINTO_SITE_PAT }} + run: | + curl -X POST \ + -H "Authorization: token $HINTO_SITE_PAT" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/yhao3/hinto-site/dispatches \ + -d "{\"event_type\":\"release\",\"client_payload\":{\"version\":\"$VERSION\"}}" + + - name: Cleanup + if: always() + run: | + security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true + rm -f $RUNNER_TEMP/certificate.p12 diff --git a/.gitignore b/.gitignore index 1d8ff03..de96e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ buildServer.json # Build tools create-dmg/ +*.dmg diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index db57bde..537cbc3 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -1,5 +1,6 @@ import Carbon import Cocoa +import Sparkle import SwiftUI final class AppDelegate: NSObject, NSApplicationDelegate { @@ -7,6 +8,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var modeController: ModeController? private var eventTapManager: EventTapManager? private var settingsWindow: NSWindow? + private var whatsNewWindowController: WhatsNewWindowController? + + private let updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) func applicationDidFinishLaunching(_: Notification) { log("Hinto: Starting up...") @@ -15,9 +23,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate { checkAccessibilityPermission() setupEventTap() setupModeController() + recordFirstLaunchVersion() log("Hinto: Ready! Press Cmd+Shift+Space to activate") } + // MARK: - Sparkle + + var updater: SPUUpdater { + updaterController.updater + } + + // MARK: - What's New + + private func recordFirstLaunchVersion() { + let prefs = Preferences.shared + if prefs.isFirstLaunch { + log("Hinto: First launch, recording version \(prefs.currentVersion)") + prefs.lastSeenVersion = prefs.currentVersion + } + } + + func showWhatsNewWindow(onDismiss: (() -> Void)? = nil) { + guard let entry = ChangelogParser.shared.currentVersionEntry() else { + log("Hinto: No changelog entry found for version \(Preferences.shared.currentVersion)") + onDismiss?() + return + } + + log("Hinto: Showing What's New window, entry version: \(entry.version), content length: \(entry.content.count)") + NSApp.setActivationPolicy(.regular) + whatsNewWindowController = WhatsNewWindowController(entry: entry, onDismiss: onDismiss) + whatsNewWindowController?.showWindow(nil) + } + func applicationWillTerminate(_: Notification) { eventTapManager?.stop() } @@ -121,6 +159,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // to show windows properly NSApp.setActivationPolicy(.regular) + // Show What's New on first Settings open after update + let prefs = Preferences.shared + if prefs.isFirstLaunchAfterUpdate { + log("Hinto: First Settings open after update, showing What's New") + showWhatsNewWindow { [weak self] in + self?.showSettingsWindow() + } + return + } + + showSettingsWindow() + } + + private func showSettingsWindow() { if settingsWindow == nil { let settingsView = SettingsView() let hostingController = NSHostingController(rootView: settingsView) diff --git a/App/Preferences.swift b/App/Preferences.swift index f782885..2ca94db 100644 --- a/App/Preferences.swift +++ b/App/Preferences.swift @@ -21,6 +21,7 @@ final class Preferences { static let hideLabelsWhenNothingSearched = "hide-labels-when-nothing-is-searched" static let hotkeyKeyCode = "hotkey-keycode" static let hotkeyModifiers = "hotkey-modifiers" + static let lastSeenVersion = "last-seen-version" } // MARK: - Notifications @@ -113,6 +114,29 @@ final class Preferences { set { defaults.set(newValue, forKey: Keys.labelTheme) } } + // MARK: - Version Tracking (What's New) + + /// Last version the user has seen (for What's New detection) + var lastSeenVersion: String { + get { defaults.string(forKey: Keys.lastSeenVersion) ?? "" } + set { defaults.set(newValue, forKey: Keys.lastSeenVersion) } + } + + /// Current app version from bundle + var currentVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } + + /// True if this is the very first launch (no lastSeenVersion recorded) + var isFirstLaunch: Bool { + lastSeenVersion.isEmpty + } + + /// True if app was updated since last launch + var isFirstLaunchAfterUpdate: Bool { + !lastSeenVersion.isEmpty && lastSeenVersion != currentVersion + } + // MARK: - Custom Label Colors var customLabelBackground: NSColor { diff --git a/Config/debug.xcconfig b/Config/debug.xcconfig index 9ebe3a2..5612ad3 100644 --- a/Config/debug.xcconfig +++ b/Config/debug.xcconfig @@ -5,6 +5,7 @@ CODE_SIGN_IDENTITY = Local Self-Signed CODE_SIGN_STYLE = Manual +CODE_SIGN_ENTITLEMENTS = Resources/Hinto-Debug.entitlements DEVELOPMENT_TEAM = OTHER_CODE_SIGN_FLAGS = --timestamp=none SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG diff --git a/Core/Updates/ChangelogParser.swift b/Core/Updates/ChangelogParser.swift new file mode 100644 index 0000000..d526de1 --- /dev/null +++ b/Core/Updates/ChangelogParser.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Represents a single version entry from CHANGELOG.md +struct ChangelogEntry { + let version: String + let date: String? + let content: String // Raw markdown content for this version +} + +/// Parses bundled CHANGELOG.md file +final class ChangelogParser { + static let shared = ChangelogParser() + + private init() {} + + /// Get the changelog entry for the current app version + /// - Parameter version: Optional version override. If nil, uses Bundle.main version. + func currentVersionEntry(for version: String? = nil) -> ChangelogEntry? { + let v = version + ?? Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ?? "0.0.0" + return entry(for: v) + } + + /// Get the changelog entry for a specific version + func entry(for version: String) -> ChangelogEntry? { + guard let content = loadChangelog() else { return nil } + return extractEntry(from: content, version: version) + } + + /// Get all changelog entries + func allEntries() -> [ChangelogEntry] { + guard let content = loadChangelog() else { return [] } + return extractAllEntries(from: content) + } + + // MARK: - Private + + private func loadChangelog() -> String? { + guard let url = Bundle.main.url(forResource: "CHANGELOG", withExtension: "md") else { + log("ChangelogParser: CHANGELOG.md not found in bundle") + return nil + } + + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + log("ChangelogParser: Failed to read CHANGELOG.md: \(error)") + return nil + } + } + + private func extractEntry(from content: String, version: String) -> ChangelogEntry? { + // Pattern: ## [0.1.0] - 2026-01-01 or ## [0.1.0] + let pattern = #"## \[\#(version)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*\n([\s\S]*?)(?=\n## \[|\z)"# + .replacingOccurrences(of: "#(version)", with: NSRegularExpression.escapedPattern(for: version)) + + log("ChangelogParser: Looking for version \(version), pattern: \(pattern)") + log("ChangelogParser: Content length: \(content.count)") + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + log("ChangelogParser: Failed to create regex") + return nil + } + + guard let match = regex.firstMatch(in: content, options: [], range: NSRange(content.startIndex..., in: content)) + else { + log("ChangelogParser: No match found for version \(version)") + return nil + } + + let dateRange = Range(match.range(at: 1), in: content) + let contentRange = Range(match.range(at: 2), in: content) + + let date = dateRange.map { String(content[$0]) } + let entryContent = contentRange.map { String(content[ + $0 + ]).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" + + return ChangelogEntry(version: version, date: date, content: entryContent) + } + + private func extractAllEntries(from content: String) -> [ChangelogEntry] { + // Pattern to match all version headers + let pattern = #"## \[(\d+\.\d+\.\d+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*\n([\s\S]*?)(?=\n## \[|\z)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return [] + } + + let matches = regex.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) + + return matches.compactMap { match -> ChangelogEntry? in + guard let versionRange = Range(match.range(at: 1), in: content) else { return nil } + + let version = String(content[versionRange]) + let dateRange = Range(match.range(at: 2), in: content) + let contentRange = Range(match.range(at: 3), in: content) + + let date = dateRange.map { String(content[$0]) } + let entryContent = contentRange + .map { String(content[$0]).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" + + return ChangelogEntry(version: version, date: date, content: entryContent) + } + } +} diff --git a/Hinto.xcodeproj/project.pbxproj b/Hinto.xcodeproj/project.pbxproj index f420866..661c388 100644 --- a/Hinto.xcodeproj/project.pbxproj +++ b/Hinto.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ B10000010000000000000001 /* HintoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000001 /* HintoApp.swift */; }; B10000010000000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000002 /* AppDelegate.swift */; }; - B10000010000000000000025 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000025 /* Logger.swift */; }; B10000010000000000000003 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000003 /* Preferences.swift */; }; B10000010000000000000004 /* UIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000004 /* UIElement.swift */; }; B10000010000000000000005 /* UITree.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000005 /* UITree.swift */; }; @@ -30,6 +29,7 @@ B10000010000000000000020 /* ModeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000020 /* ModeController.swift */; }; B10000010000000000000021 /* ScrollMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000021 /* ScrollMode.swift */; }; B10000010000000000000022 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000022 /* Assets.xcassets */; }; + B10000010000000000000025 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000025 /* Logger.swift */; }; B10000010000000000000029 /* ElementScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000029 /* ElementScanner.swift */; }; B10000010000000000000030 /* SearchPredicateScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000030 /* SearchPredicateScanner.swift */; }; B10000010000000000000031 /* TreeTraversalScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000031 /* TreeTraversalScanner.swift */; }; @@ -38,6 +38,12 @@ B10000010000000000000034 /* TimedScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000034 /* TimedScanner.swift */; }; B10000010000000000000035 /* PerfScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000035 /* PerfScanner.swift */; }; B10000010000000000000036 /* ScannerDecoratorRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000036 /* ScannerDecoratorRegistry.swift */; }; + B10000010000000000000037 /* ChangelogParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000037 /* ChangelogParser.swift */; }; + B10000010000000000000038 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000038 /* WhatsNewView.swift */; }; + B10000010000000000000039 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000039 /* CHANGELOG.md */; }; + B10000010000000000000040 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B1000000C000000000000001 /* Sparkle */; }; + B10000010000000000000041 /* CardButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000041 /* CardButtonStyle.swift */; }; + B10000010000000000000042 /* DesignConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000042 /* DesignConstants.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -69,7 +75,6 @@ B10000020000000000000026 /* base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = base.xcconfig; sourceTree = ""; }; B10000020000000000000027 /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = debug.xcconfig; sourceTree = ""; }; B10000020000000000000028 /* release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = release.xcconfig; sourceTree = ""; }; - B10000030000000000000001 /* Hinto.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hinto.app; sourceTree = BUILT_PRODUCTS_DIR; }; B10000020000000000000029 /* ElementScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementScanner.swift; sourceTree = ""; }; B10000020000000000000030 /* SearchPredicateScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPredicateScanner.swift; sourceTree = ""; }; B10000020000000000000031 /* TreeTraversalScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeTraversalScanner.swift; sourceTree = ""; }; @@ -78,6 +83,12 @@ B10000020000000000000034 /* TimedScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimedScanner.swift; sourceTree = ""; }; B10000020000000000000035 /* PerfScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfScanner.swift; sourceTree = ""; }; B10000020000000000000036 /* ScannerDecoratorRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerDecoratorRegistry.swift; sourceTree = ""; }; + B10000020000000000000037 /* ChangelogParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangelogParser.swift; sourceTree = ""; }; + B10000020000000000000038 /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = ""; }; + B10000020000000000000039 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + B10000030000000000000001 /* Hinto.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hinto.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000020000000000000041 /* CardButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardButtonStyle.swift; sourceTree = ""; }; + B10000020000000000000042 /* DesignConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignConstants.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +96,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B10000010000000000000040 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -104,16 +116,6 @@ ); sourceTree = ""; }; - B10000050000000000000015 /* Config */ = { - isa = PBXGroup; - children = ( - B10000020000000000000026 /* base.xcconfig */, - B10000020000000000000027 /* debug.xcconfig */, - B10000020000000000000028 /* release.xcconfig */, - ); - path = Config; - sourceTree = ""; - }; B10000050000000000000002 /* App */ = { isa = PBXGroup; children = ( @@ -132,6 +134,7 @@ B10000050000000000000009 /* Labels */, B10000050000000000000010 /* Events */, B10000050000000000000011 /* Actions */, + B10000050000000000000017 /* Updates */, ); path = Core; sourceTree = ""; @@ -139,9 +142,11 @@ B10000050000000000000004 /* UI */ = { isa = PBXGroup; children = ( + B10000050000000000000019 /* Components */, B10000050000000000000012 /* Overlay */, B10000050000000000000013 /* SearchBar */, B10000050000000000000014 /* Settings */, + B10000050000000000000018 /* WhatsNew */, ); path = UI; sourceTree = ""; @@ -161,6 +166,7 @@ B10000020000000000000022 /* Assets.xcassets */, B10000020000000000000023 /* Info.plist */, B10000020000000000000024 /* Hinto.entitlements */, + B10000020000000000000039 /* CHANGELOG.md */, ); path = Resources; sourceTree = ""; @@ -186,20 +192,6 @@ path = Accessibility; sourceTree = ""; }; - B10000050000000000000016 /* Scanners */ = { - isa = PBXGroup; - children = ( - B10000020000000000000030 /* SearchPredicateScanner.swift */, - B10000020000000000000031 /* TreeTraversalScanner.swift */, - B10000020000000000000032 /* HitTestScanner.swift */, - B10000020000000000000033 /* MenuBarScanner.swift */, - B10000020000000000000034 /* TimedScanner.swift */, - B10000020000000000000035 /* PerfScanner.swift */, - B10000020000000000000036 /* ScannerDecoratorRegistry.swift */, - ); - path = Scanners; - sourceTree = ""; - }; B10000050000000000000009 /* Labels */ = { isa = PBXGroup; children = ( @@ -254,6 +246,55 @@ path = Settings; sourceTree = ""; }; + B10000050000000000000015 /* Config */ = { + isa = PBXGroup; + children = ( + B10000020000000000000026 /* base.xcconfig */, + B10000020000000000000027 /* debug.xcconfig */, + B10000020000000000000028 /* release.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; + B10000050000000000000016 /* Scanners */ = { + isa = PBXGroup; + children = ( + B10000020000000000000030 /* SearchPredicateScanner.swift */, + B10000020000000000000031 /* TreeTraversalScanner.swift */, + B10000020000000000000032 /* HitTestScanner.swift */, + B10000020000000000000033 /* MenuBarScanner.swift */, + B10000020000000000000034 /* TimedScanner.swift */, + B10000020000000000000035 /* PerfScanner.swift */, + B10000020000000000000036 /* ScannerDecoratorRegistry.swift */, + ); + path = Scanners; + sourceTree = ""; + }; + B10000050000000000000017 /* Updates */ = { + isa = PBXGroup; + children = ( + B10000020000000000000037 /* ChangelogParser.swift */, + ); + path = Updates; + sourceTree = ""; + }; + B10000050000000000000018 /* WhatsNew */ = { + isa = PBXGroup; + children = ( + B10000020000000000000038 /* WhatsNewView.swift */, + ); + path = WhatsNew; + sourceTree = ""; + }; + B10000050000000000000019 /* Components */ = { + isa = PBXGroup; + children = ( + B10000020000000000000041 /* CardButtonStyle.swift */, + B10000020000000000000042 /* DesignConstants.swift */, + ); + path = Components; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -270,6 +311,9 @@ dependencies = ( ); name = Hinto; + packageProductDependencies = ( + B1000000C000000000000001 /* Sparkle */, + ); productName = Hinto; productReference = B10000030000000000000001 /* Hinto.app */; productType = "com.apple.product-type.application"; @@ -298,6 +342,9 @@ Base, ); mainGroup = B10000050000000000000001; + packageReferences = ( + B1000000D000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); productRefGroup = B10000050000000000000007 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -313,6 +360,7 @@ buildActionMask = 2147483647; files = ( B10000010000000000000022 /* Assets.xcassets in Resources */, + B10000010000000000000039 /* CHANGELOG.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -353,6 +401,10 @@ B10000010000000000000034 /* TimedScanner.swift in Sources */, B10000010000000000000035 /* PerfScanner.swift in Sources */, B10000010000000000000036 /* ScannerDecoratorRegistry.swift in Sources */, + B10000010000000000000037 /* ChangelogParser.swift in Sources */, + B10000010000000000000038 /* WhatsNewView.swift in Sources */, + B10000010000000000000041 /* CardButtonStyle.swift in Sources */, + B10000010000000000000042 /* DesignConstants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -541,6 +593,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + B1000000D000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B1000000C000000000000001 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = B1000000D000000000000001 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = B10000090000000000000001 /* Project object */; } diff --git a/Makefile b/Makefile index ccb26da..df1f8e0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ APP_NAME = Hinto SCHEME = Hinto BUILD_DIR = build -APP_PATH = $(BUILD_DIR)/Build/Products/Release/$(APP_NAME).app +APP_PATH = $(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app DMG_PATH = $(APP_NAME).dmg APPLE_TEAM_ID = JQ43BAV5D8 @@ -14,7 +14,7 @@ setup: build: @echo "Building $(APP_NAME)..." - @xcodebuild -scheme $(SCHEME) -configuration Release -derivedDataPath $(BUILD_DIR) build 2>&1 | grep -E "(error:|warning:|BUILD SUCCEEDED|BUILD FAILED)" || true + @xcodebuild -scheme $(SCHEME) -configuration Debug -derivedDataPath $(BUILD_DIR) build 2>&1 | grep -E "(error:|warning:|BUILD SUCCEEDED|BUILD FAILED)" || true run: kill build @echo "Opening $(APP_NAME)..." diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..689b87f --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,53 @@ +# Release Guide + +## Prerequisites + +1. **Apple Developer ID certificate** installed in Keychain +2. **Environment variables** - Copy and fill in: + ```bash + cp .env.local.example .env.local + ``` + +## One-Command Release + +```bash +./Scripts/release.sh 0.2.0 +``` + +This script: +1. Updates `Info.plist` version +2. Commits and pushes version bump +3. Builds, notarizes, creates DMG (`make release`) +4. Creates GitHub release with DMG +5. Updates website version and pushes + +## Manual Steps + +If you prefer manual release: + +### 1. Update version + +```bash +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 0.2.0" Resources/Info.plist +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion 0.2.0" Resources/Info.plist +``` + +### 2. Build and Notarize + +```bash +make release +``` + +### 3. Publish to GitHub + +```bash +gh release create v0.2.0 Hinto.dmg --title "Hinto v0.2.0" --generate-notes +``` + +### 4. Update Website + +```bash +cd ../hinto-site +# Edit src/pages/index.astro: const version = "v0.2.0" +git add -A && git commit -m "Update to v0.2.0" && git push +``` diff --git a/Resources/CHANGELOG.md b/Resources/CHANGELOG.md new file mode 100644 index 0000000..b028172 --- /dev/null +++ b/Resources/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-01-01 + +**Added** + +- Initial release +- Keyboard-driven UI navigation with hint labels +- Global hotkey activation (Cmd+Shift+Space) +- Label themes: Dark, Light, Blue +- Label size options: Small, Medium, Large +- Auto-click on exact label match +- Scroll mode with vim-style navigation (H/J/K/L) +- Menu bar icon with quick access menu +- Settings window with multiple tabs +- Launch at login option diff --git a/Resources/Hinto-Debug.entitlements b/Resources/Hinto-Debug.entitlements new file mode 100644 index 0000000..2207ba3 --- /dev/null +++ b/Resources/Hinto-Debug.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.disable-library-validation + + + diff --git a/Resources/Info.plist b/Resources/Info.plist index e462977..13a4b22 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -23,7 +23,7 @@ CFBundleShortVersionString 0.1.0 CFBundleVersion - 1 + 100 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion @@ -34,5 +34,15 @@ NSPrincipalClass NSApplication + + + SUFeedURL + https://yhao3.github.io/hinto/appcast.xml + SUPublicEDKey + HejEwZ2TcxMeniqJnQF0i+atjkftXgmXCVSJQwwr6K8= + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 diff --git a/Scripts/ci/check-macos-version.sh b/Scripts/ci/check-macos-version.sh new file mode 100755 index 0000000..422d587 --- /dev/null +++ b/Scripts/ci/check-macos-version.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +# Check that MACOS_MIN_VERSION is consistent across the project +# +# Locations checked: +# - Config/base.xcconfig (MACOSX_DEPLOYMENT_TARGET) +# - .github/workflows/release.yml (MACOS_MIN_VERSION) + +echo "=== Checking macOS minimum version consistency ===" + +# Extract versions from each source +XCCONFIG_VERSION=$(grep "MACOSX_DEPLOYMENT_TARGET" Config/base.xcconfig | sed 's/.*= *//' | tr -d ' ') +RELEASE_YML_VERSION=$(grep "MACOS_MIN_VERSION:" .github/workflows/release.yml | head -1 | sed 's/.*: *"//' | sed 's/".*//') + +echo "Config/base.xcconfig: $XCCONFIG_VERSION" +echo ".github/workflows/release.yml: $RELEASE_YML_VERSION" + +# Compare versions +ERRORS=0 + +if [ "$XCCONFIG_VERSION" != "$RELEASE_YML_VERSION" ]; then + echo "" + echo "ERROR: Version mismatch between base.xcconfig and release.yml" + ERRORS=$((ERRORS + 1)) +fi + +if [ $ERRORS -gt 0 ]; then + echo "" + echo "=== FAILED: Found $ERRORS inconsistencies ===" + echo "Please ensure MACOS_MIN_VERSION is the same in all locations." + exit 1 +fi + +echo "" +echo "=== PASSED: All versions match ($XCCONFIG_VERSION) ===" diff --git a/Scripts/ci/deploy-appcast.sh b/Scripts/ci/deploy-appcast.sh new file mode 100755 index 0000000..cb0dcd8 --- /dev/null +++ b/Scripts/ci/deploy-appcast.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -euo pipefail + +# Deploy appcast.xml to GitHub Pages +# +# Required environment variables: +# VERSION - Semantic version (e.g., 0.2.0) +# BUILD_NUMBER - Integer build number (e.g., 00200) +# SIGNATURE - Sparkle EdDSA signature +# DMG_SIZE - DMG file size in bytes +# MACOS_MIN_VERSION - Minimum macOS version (e.g., 13.0) +# REPO_URL - GitHub repo URL for downloads (e.g., https://github.com/yhao3/hinto) +# APPCAST_URL - Appcast URL (e.g., https://yhao3.github.io/hinto/appcast.xml) + +# Validate required variables +for var in VERSION BUILD_NUMBER SIGNATURE DMG_SIZE MACOS_MIN_VERSION REPO_URL APPCAST_URL; do + if [ -z "${!var:-}" ]; then + echo "Error: $var is not set" + exit 1 + fi +done + +PUB_DATE=$(date -R) + +echo "=== Generating new appcast item ===" + +cat > /tmp/new_item.xml << EOF + + Version ${VERSION} + ${PUB_DATE} + ${BUILD_NUMBER} + ${VERSION} + ${MACOS_MIN_VERSION} + + +EOF + +cat /tmp/new_item.xml + +echo "=== Switching to gh-pages ===" + +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +# Fetch gh-pages or create it +git fetch origin gh-pages:gh-pages 2>/dev/null || true +git checkout gh-pages || git checkout --orphan gh-pages + +echo "=== Extracting existing items ===" + +# Extract existing items (if any) +if [ -f appcast.xml ]; then + sed -n '//,/<\/item>/p' appcast.xml > /tmp/existing_items.xml || true + echo "Found existing items:" + cat /tmp/existing_items.xml +else + touch /tmp/existing_items.xml + echo "No existing appcast.xml found" +fi + +echo "=== Building new appcast.xml ===" + +# Build new appcast.xml with new item first, then existing items +cat > appcast.xml << HEADER + + + + Hinto Updates + ${APPCAST_URL} + Most recent updates to Hinto + en +HEADER + +# Append new item first +cat /tmp/new_item.xml >> appcast.xml + +# Append existing items (history) +cat /tmp/existing_items.xml >> appcast.xml + +# Close the XML +cat >> appcast.xml << 'FOOTER' + + +FOOTER + +echo "=== Final appcast.xml ===" +cat appcast.xml + +echo "=== Committing and pushing ===" + +git add appcast.xml +git commit -m "Update appcast.xml for v${VERSION}" || echo "No changes to commit" +git push origin gh-pages + +echo "=== Done ===" diff --git a/Scripts/ci/validate-release-version.sh b/Scripts/ci/validate-release-version.sh new file mode 100755 index 0000000..8b91574 --- /dev/null +++ b/Scripts/ci/validate-release-version.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -euo pipefail + +# Validate release version +# +# Usage: +# ./validate-release-version.sh 0.2.0 +# +# Checks: +# 1. Version format is X.Y.Z +# 2. Info.plist CFBundleShortVersionString matches +# 3. Info.plist CFBundleVersion matches build number +# 4. CHANGELOG.md has entry for this version + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + echo "Example: $0 0.2.0" >&2 + exit 1 +fi + +VERSION="$1" + +echo "=== Validating release version: $VERSION ===" + +# 1. Validate format (X.Y.Z) +if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid version format. Expected X.Y.Z (e.g., 0.2.0)" >&2 + exit 1 +fi +echo "Format: OK" + +# 2. Validate Info.plist CFBundleShortVersionString matches +PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist) +if [ "$PLIST_VERSION" != "$VERSION" ]; then + echo "::error::Info.plist CFBundleShortVersionString ($PLIST_VERSION) doesn't match tag ($VERSION)" >&2 + exit 1 +fi +echo "CFBundleShortVersionString: OK ($PLIST_VERSION)" + +# 3. Validate Info.plist CFBundleVersion matches build number +EXPECTED_BUILD_NUMBER=$("$SCRIPT_DIR/version-to-build-number.sh" "$VERSION") +PLIST_BUILD=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" Resources/Info.plist) +if [ "$PLIST_BUILD" != "$EXPECTED_BUILD_NUMBER" ]; then + echo "::error::Info.plist CFBundleVersion ($PLIST_BUILD) doesn't match expected build number ($EXPECTED_BUILD_NUMBER). Update Info.plist: CFBundleVersion$EXPECTED_BUILD_NUMBER" >&2 + exit 1 +fi +echo "CFBundleVersion: OK ($PLIST_BUILD)" + +# 4. Validate CHANGELOG.md has entry for this version +if ! grep -q "^## \[$VERSION\]" Resources/CHANGELOG.md; then + echo "::error::CHANGELOG.md missing entry for version $VERSION" >&2 + exit 1 +fi +echo "CHANGELOG.md: OK (found [$VERSION])" + +echo "=== Validation passed ===" diff --git a/Scripts/ci/version-to-build-number.sh b/Scripts/ci/version-to-build-number.sh new file mode 100755 index 0000000..1e8880d --- /dev/null +++ b/Scripts/ci/version-to-build-number.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Convert semantic version to integer build number +# +# Usage: +# ./version-to-build-number.sh 1.2.3 # Output: 10203 +# ./version-to-build-number.sh 0.2.0 # Output: 200 +# +# Format: MAJOR * 10000 + MINOR * 100 + PATCH +# 1.2.3 -> 10203 +# 0.12.5 -> 1205 +# 2.0.0 -> 20000 + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + echo "Example: $0 1.2.3" >&2 + exit 1 +fi + +VERSION="$1" + +# Validate format (X.Y.Z) +if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Error: Invalid version format '$VERSION'. Expected X.Y.Z" >&2 + exit 1 +fi + +# Convert to build number +BUILD_NUMBER=$(echo "$VERSION" | awk -F. '{ printf "%d", $1 * 10000 + $2 * 100 + $3 }') + +echo "$BUILD_NUMBER" diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 0000000..1220fa5 --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +# Usage: ./Scripts/release.sh 0.2.0 + +VERSION=$1 + +if [ -z "$VERSION" ]; then + echo "Usage: ./Scripts/release.sh " + echo "Example: ./Scripts/release.sh 0.2.0" + exit 1 +fi + +# Load environment variables +if [ -f .env.local ]; then + export $(grep -v '^#' .env.local | xargs) +fi + +if [ -z "$APPLE_ID" ] || [ -z "$APPLE_PASSWORD" ]; then + echo "Error: APPLE_ID and APPLE_PASSWORD must be set in .env.local" + exit 1 +fi + +echo "==> Releasing v$VERSION" + +# 1. Update Info.plist version +echo "==> Updating Info.plist..." +BUILD_NUMBER=$(./Scripts/ci/version-to-build-number.sh "$VERSION") +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" Resources/Info.plist +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" Resources/Info.plist +echo " Version: $VERSION (build $BUILD_NUMBER)" + +# 2. Commit version bump +echo "==> Committing version bump..." +git add Resources/Info.plist +git commit -m "Bump version to $VERSION" +git push + +# 3. Build, notarize, create DMG +echo "==> Building and notarizing..." +make release + +# 4. Create GitHub release +echo "==> Creating GitHub release..." +gh release create "v$VERSION" Hinto.dmg --title "Hinto v$VERSION" --generate-notes + +# 5. Update website +echo "==> Updating website..." +SITE_DIR="../hinto-site" +if [ -d "$SITE_DIR" ]; then + sed -i '' "s/const version = \"v[^\"]*\"/const version = \"v$VERSION\"/" "$SITE_DIR/src/pages/index.astro" + cd "$SITE_DIR" + git add -A + git commit -m "Update to v$VERSION" + git push + cd - +fi + +echo "==> Done! Released v$VERSION" diff --git a/Tests/SparkleConfigTests.swift b/Tests/SparkleConfigTests.swift new file mode 100644 index 0000000..e9443a6 --- /dev/null +++ b/Tests/SparkleConfigTests.swift @@ -0,0 +1,156 @@ +import XCTest + +/// Tests for Sparkle auto-update configuration +/// Verifies Info.plist has correct Sparkle keys for secure auto-updates +final class SparkleConfigTests: XCTestCase { + private var infoPlist: [String: Any]! + + override func setUp() { + super.setUp() + loadInfoPlist() + } + + override func tearDown() { + infoPlist = nil + super.tearDown() + } + + // MARK: - Feed URL Tests + + func test_feedURL_givenInfoPlist_thenIsProperlyConfigured() { + givenInfoPlistLoaded() + + thenFeedURLExists() + thenFeedURLIsValidURL() + thenFeedURLUsesHTTPS() + thenFeedURLPointsToAppcast() + } + + // MARK: - Public Key Tests + + func test_publicKey_givenInfoPlist_thenIsProperlyConfigured() { + givenInfoPlistLoaded() + + thenPublicKeyExists() + thenPublicKeyIsBase64Encoded() + thenPublicKeyIsNotPlaceholder() + } + + // MARK: - Auto-Check Tests + + func test_autoCheck_givenInfoPlist_thenIsProperlyConfigured() { + givenInfoPlistLoaded() + + thenAutoCheckIsEnabled() + thenCheckIntervalIsDaily() + thenCheckIntervalIsReasonable() + } +} + +// MARK: - Given + +extension SparkleConfigTests { + private func loadInfoPlist() { + let plistPath = URL(fileURLWithPath: #file) + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // Hinto/ + .appendingPathComponent("Resources/Info.plist") + + guard let data = try? Data(contentsOf: plistPath), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + else { + XCTFail("Failed to load Info.plist from \(plistPath.path)") + return + } + infoPlist = plist + } + + private func givenInfoPlistLoaded() { + XCTAssertNotNil(infoPlist, "Info.plist must be loaded") + } +} + +// MARK: - Then (Feed URL) + +extension SparkleConfigTests { + private func thenFeedURLExists() { + let feedURL = infoPlist["SUFeedURL"] as? String + XCTAssertNotNil(feedURL, "SUFeedURL must be set in Info.plist") + } + + private func thenFeedURLIsValidURL() { + guard let feedURL = infoPlist["SUFeedURL"] as? String else { + XCTFail("SUFeedURL not found") + return + } + XCTAssertNotNil(URL(string: feedURL), "SUFeedURL must be a valid URL") + } + + private func thenFeedURLUsesHTTPS() { + guard let feedURL = infoPlist["SUFeedURL"] as? String else { + XCTFail("SUFeedURL not found") + return + } + XCTAssertTrue(feedURL.hasPrefix("https://"), "SUFeedURL must use HTTPS for security") + } + + private func thenFeedURLPointsToAppcast() { + guard let feedURL = infoPlist["SUFeedURL"] as? String else { + XCTFail("SUFeedURL not found") + return + } + XCTAssertTrue(feedURL.hasSuffix("appcast.xml"), "SUFeedURL should point to appcast.xml") + } +} + +// MARK: - Then (Public Key) + +extension SparkleConfigTests { + private func thenPublicKeyExists() { + let publicKey = infoPlist["SUPublicEDKey"] as? String + XCTAssertNotNil(publicKey, "SUPublicEDKey must be set for EdDSA signature verification") + } + + private func thenPublicKeyIsBase64Encoded() { + guard let publicKey = infoPlist["SUPublicEDKey"] as? String else { + XCTFail("SUPublicEDKey not found") + return + } + // EdDSA public key is 32 bytes = 44 chars in base64 (with padding) + XCTAssertEqual(publicKey.count, 44, "EdDSA public key should be 44 characters in base64") + XCTAssertTrue(publicKey.hasSuffix("="), "Base64 encoded key should have padding") + } + + private func thenPublicKeyIsNotPlaceholder() { + guard let publicKey = infoPlist["SUPublicEDKey"] as? String else { + XCTFail("SUPublicEDKey not found") + return + } + XCTAssertNotEqual(publicKey, "YOUR_ED25519_PUBLIC_KEY", "SUPublicEDKey must not be a placeholder") + XCTAssertFalse(publicKey.isEmpty, "SUPublicEDKey must not be empty") + } +} + +// MARK: - Then (Auto-Check) + +extension SparkleConfigTests { + private func thenAutoCheckIsEnabled() { + let autoCheck = infoPlist["SUEnableAutomaticChecks"] as? Bool + XCTAssertEqual(autoCheck, true, "Automatic update checks should be enabled") + } + + private func thenCheckIntervalIsDaily() { + let interval = infoPlist["SUScheduledCheckInterval"] as? Int + XCTAssertEqual(interval, 86400, "Check interval should be 24 hours (86400 seconds)") + } + + private func thenCheckIntervalIsReasonable() { + guard let interval = infoPlist["SUScheduledCheckInterval"] as? Int else { + XCTFail("SUScheduledCheckInterval not found") + return + } + // Should be at least 1 hour and at most 1 week + XCTAssertGreaterThanOrEqual(interval, 3600, "Check interval should be at least 1 hour") + XCTAssertLessThanOrEqual(interval, 604_800, "Check interval should be at most 1 week") + } +} diff --git a/UI/Components/CardButtonStyle.swift b/UI/Components/CardButtonStyle.swift new file mode 100644 index 0000000..910ca63 --- /dev/null +++ b/UI/Components/CardButtonStyle.swift @@ -0,0 +1,136 @@ +import SwiftUI + +/// Card-style button used throughout the app +/// Glassmorphism design: frosted glass, subtle border, light reflection, Z-depth +struct CardButtonStyle: ButtonStyle { + @Environment(\.colorScheme) private var colorScheme + @State private var isHovering = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Design.Font.button) + .foregroundColor(colorScheme == .dark ? .white : Color(white: 0.1)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + ZStack { + // Glassmorphism: frosted glass background + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.background( + colorScheme, + isHovering: isHovering, + isPressed: configuration.isPressed + )) + + // Light reflection (top highlight) + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.reflectionGradient(colorScheme)) + } + ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke(Design.Glass.border(colorScheme, isHovering: isHovering), lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: Design.Glass.shadowColor(colorScheme), + radius: isHovering ? 6 : 4, + x: 0, + y: isHovering ? 3 : 2 + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(Design.Animation.quick, value: configuration.isPressed) + .animation(Design.Animation.quick, value: isHovering) + .onHover { hovering in + isHovering = hovering + } + } +} + +extension ButtonStyle where Self == CardButtonStyle { + static var hintoCard: CardButtonStyle { CardButtonStyle() } +} + +// MARK: - Checkbox Toggle Style (Glassmorphism) + +/// Custom checkbox style with glassmorphism design +/// Frosted glass, subtle border, light reflection, Z-depth +struct HintoCheckboxStyle: ToggleStyle { + @Environment(\.colorScheme) private var colorScheme + @State private var isHovering = false + + private let boxSize: CGFloat = 18 + private let cornerRadius: CGFloat = 5 + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: Design.Spacing.sm) { + // Checkbox box with Glassmorphism + ZStack { + // Glassmorphism: frosted glass background + RoundedRectangle(cornerRadius: cornerRadius) + .fill(configuration.isOn ? Design.Colors.accent : Design.Glass.background(colorScheme)) + .frame(width: boxSize, height: boxSize) + + // Light reflection for unchecked state + if !configuration.isOn { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Design.Glass.reflectionGradient(colorScheme)) + .frame(width: boxSize, height: boxSize) + } + + // Glassmorphism: subtle border + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(borderColor(isOn: configuration.isOn), lineWidth: 1) + .frame(width: boxSize, height: boxSize) + + // Checkmark + if configuration.isOn { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white) + .transition(.scale.combined(with: .opacity)) + } + } + // Z-depth shadow + .shadow( + color: configuration.isOn + ? Design.Colors.accent.opacity(0.3) + : Design.Glass.shadowColor(colorScheme), + radius: configuration.isOn ? 4 : 2, + x: 0, + y: 1 + ) + .scaleEffect(isHovering ? 1.05 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isOn) + .animation(Design.Animation.quick, value: isHovering) + + // Label + configuration.label + .font(Design.Font.body) + .foregroundColor(colorScheme == .dark ? .white : Color(white: 0.1)) + } + .contentShape(Rectangle()) + .onTapGesture { + configuration.isOn.toggle() + } + .onHover { hovering in + isHovering = hovering + } + } + + // Glassmorphism: subtle border + private func borderColor(isOn: Bool) -> Color { + if isOn { + return Design.Colors.accent + } + if isHovering { + return Design.Colors.accent.opacity(0.8) + } + return Design.Glass.border(colorScheme) + } +} + +extension ToggleStyle where Self == HintoCheckboxStyle { + static var hintoCheckbox: HintoCheckboxStyle { HintoCheckboxStyle() } +} diff --git a/UI/Components/DesignConstants.swift b/UI/Components/DesignConstants.swift new file mode 100644 index 0000000..435a17d --- /dev/null +++ b/UI/Components/DesignConstants.swift @@ -0,0 +1,226 @@ +import SwiftUI + +/// Unified design constants for consistent UI across the app +enum Design { + // MARK: - Corner Radius + + enum CornerRadius { + /// Extra small: 4px - for small badges, tags + static let xs: CGFloat = 4 + /// Small: 6px - for key badges, small inputs + static let sm: CGFloat = 6 + /// Medium: 8px - for buttons, cards + static let md: CGFloat = 8 + /// Large: 10px - for larger cards, sections + static let lg: CGFloat = 10 + /// Extra large: 12px - for windows, modals + static let xl: CGFloat = 12 + } + + // MARK: - Spacing + + enum Spacing { + /// 4px + static let xs: CGFloat = 4 + /// 8px + static let sm: CGFloat = 8 + /// 12px + static let md: CGFloat = 12 + /// 16px + static let lg: CGFloat = 16 + /// 20px + static let xl: CGFloat = 20 + /// 24px + static let xxl: CGFloat = 24 + /// 28px + static let xxxl: CGFloat = 28 + /// 32px + static let huge: CGFloat = 32 + } + + // MARK: - Animation + + enum Animation { + /// Quick interaction feedback: 0.15s + static let quick: SwiftUI.Animation = .easeOut(duration: 0.15) + /// Standard transition: 0.2s + static let standard: SwiftUI.Animation = .easeInOut(duration: 0.2) + } + + // MARK: - Shadow + + enum Shadow { + /// Subtle shadow for buttons + static func button(_ colorScheme: ColorScheme) -> some View { + Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1) + } + + /// Button shadow radius + static let buttonRadius: CGFloat = 2 + /// Button shadow offset + static let buttonY: CGFloat = 1 + } + + // MARK: - Font + + enum Font { + /// Title: 17pt semibold + static let title: SwiftUI.Font = .system(size: 17, weight: .semibold) + /// Body: 13pt regular + static let body: SwiftUI.Font = .system(size: 13, weight: .regular) + /// Button: 12pt medium + static let button: SwiftUI.Font = .system(size: 12, weight: .medium) + /// Caption: 11pt regular + static let caption: SwiftUI.Font = .system(size: 11, weight: .regular) + /// Small caption: 10pt + static let small: SwiftUI.Font = .system(size: 10, weight: .regular) + } + + // MARK: - Icon Size + + enum IconSize { + /// Small: 16px + static let sm: CGFloat = 16 + /// Medium: 20px + static let md: CGFloat = 20 + /// Large: 56px - for app icon in headers + static let lg: CGFloat = 56 + /// Extra large: 80px - for about view + static let xl: CGFloat = 80 + } + + // MARK: - Colors + + enum Colors { + /// Primary accent color (#047aff) + static let accent = Color(red: 0.016, green: 0.478, blue: 1.0) + /// Selected state background + static let selectedBackground = accent + /// Selected state shadow + static func selectedShadow(_ opacity: Double = 0.35) -> Color { + accent.opacity(opacity) + } + } + + // MARK: - Glassmorphism Colors + + /// Unified glassmorphism color system for consistent UI + enum Glass { + // MARK: - Background Opacity + + /// Glass background for dark mode + enum Dark { + /// Default state: very subtle glass + static let bgDefault: Double = 0.08 + /// Hover state: slightly more visible + static let bgHover: Double = 0.12 + /// Pressed state + static let bgPressed: Double = 0.15 + /// Light reflection on top + static let reflection: Double = 0.08 + /// Border default + static let borderDefault: Double = 0.15 + /// Border hover + static let borderHover: Double = 0.25 + /// Border selected/active + static let borderActive: Double = 0.3 + /// Shadow opacity + static let shadow: Double = 0.3 + } + + /// Glass background for light mode + enum Light { + /// Default state: frosted glass + static let bgDefault: Double = 0.7 + /// Hover state: more opaque + static let bgHover: Double = 0.85 + /// Pressed state + static let bgPressed: Double = 0.9 + /// Light reflection on top + static let reflection: Double = 0.5 + /// Border default + static let borderDefault: Double = 0.1 + /// Border hover + static let borderHover: Double = 0.15 + /// Border selected/active (uses accent color) + static let borderActive: Double = 0.5 + /// Shadow opacity + static let shadow: Double = 0.08 + } + + // MARK: - Helper Methods + + /// Get background opacity for default state + static func bgDefault(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.bgDefault : Light.bgDefault + } + + /// Get background opacity for hover state + static func bgHover(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.bgHover : Light.bgHover + } + + /// Get background opacity for pressed state + static func bgPressed(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.bgPressed : Light.bgPressed + } + + /// Get reflection opacity + static func reflection(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.reflection : Light.reflection + } + + /// Get border opacity for default state + static func borderDefault(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.borderDefault : Light.borderDefault + } + + /// Get border opacity for hover state + static func borderHover(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.borderHover : Light.borderHover + } + + /// Get shadow opacity + static func shadow(_ scheme: ColorScheme) -> Double { + scheme == .dark ? Dark.shadow : Light.shadow + } + + /// Get glass background color + static func background(_ scheme: ColorScheme, isHovering: Bool = false, isPressed: Bool = false) -> Color { + let opacity: Double + if isPressed { + opacity = bgPressed(scheme) + } else if isHovering { + opacity = bgHover(scheme) + } else { + opacity = bgDefault(scheme) + } + return Color.white.opacity(opacity) + } + + /// Get border color (default uses white for dark, black for light) + static func border(_ scheme: ColorScheme, isHovering: Bool = false) -> Color { + let opacity = isHovering ? borderHover(scheme) : borderDefault(scheme) + return scheme == .dark + ? Color.white.opacity(opacity) + : Color.black.opacity(opacity) + } + + /// Get shadow color + static func shadowColor(_ scheme: ColorScheme) -> Color { + Color.black.opacity(shadow(scheme)) + } + + /// Get light reflection gradient + static func reflectionGradient(_ scheme: ColorScheme) -> LinearGradient { + LinearGradient( + colors: [ + Color.white.opacity(reflection(scheme)), + Color.clear, + ], + startPoint: .top, + endPoint: .center + ) + } + } +} diff --git a/UI/Settings/SettingsView.swift b/UI/Settings/SettingsView.swift index ad6bc1b..210dd9f 100644 --- a/UI/Settings/SettingsView.swift +++ b/UI/Settings/SettingsView.swift @@ -1,7 +1,7 @@ import ServiceManagement import SwiftUI -// MARK: - Color Theme +// MARK: - Color Theme (Glassmorphism) struct SettingsColors { let background: Material @@ -11,18 +11,20 @@ struct SettingsColors { static func forScheme(_ scheme: ColorScheme) -> SettingsColors { if scheme == .dark { + // Dark mode: translucent white overlays return SettingsColors( background: .thickMaterial, - cardBackground: Color(red: 0x4A / 255.0, green: 0x49 / 255.0, blue: 0x49 / 255.0).opacity(0.5), + cardBackground: Color.white.opacity(0.08), text: .white, - secondaryText: Color(white: 0.5) + secondaryText: Color(white: 0.6) // Better contrast ) } else { + // Light mode: frosted glass with visible depth return SettingsColors( background: .thickMaterial, - cardBackground: Color(red: 0xD0 / 255.0, green: 0xCE / 255.0, blue: 0xCD / 255.0).opacity(0.5), - text: .black, - secondaryText: Color(white: 0.4) + cardBackground: Color.white.opacity(0.7), // More visible glass + text: Color(white: 0.1), + secondaryText: Color(white: 0.35) // Better contrast (4.5:1) ) } } @@ -53,14 +55,29 @@ struct SettingsView: View { VStack(spacing: 0) { // Custom toolbar SettingsToolbar(selectedTab: $selectedTab, colors: colors) - .padding(.top, 8) + .padding(.top, Design.Spacing.sm) // Content TabContent(selectedTab: selectedTab, colors: colors) } .frame(width: 560, height: 420) .background(colors.background) - .preferredColorScheme(effectiveColorScheme) + .onAppear { + updateWindowAppearance(appAppearance) + } + .onChange(of: appAppearance) { newValue in + updateWindowAppearance(newValue) + } + } + + private func updateWindowAppearance(_ value: String) { + guard let window = NSApp.keyWindow else { return } + let appearance: NSAppearance? = switch value { + case "light": NSAppearance(named: .aqua) + case "dark": NSAppearance(named: .darkAqua) + default: nil + } + window.appearance = appearance } } @@ -78,7 +95,7 @@ struct SettingsToolbar: View { ] var body: some View { - HStack(spacing: 4) { + HStack(spacing: Design.Spacing.xs) { ForEach(0 ..< tabs.count, id: \.self) { index in ToolbarTab( icon: tabs[index].icon, @@ -87,14 +104,14 @@ struct SettingsToolbar: View { colors: colors ) .onTapGesture { - withAnimation(.easeInOut(duration: 0.15)) { + withAnimation(Design.Animation.quick) { selectedTab = index } } } } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.horizontal, Design.Spacing.lg) + .padding(.vertical, Design.Spacing.md) } } @@ -103,33 +120,88 @@ struct ToolbarTab: View { let title: String let isSelected: Bool let colors: SettingsColors + @Environment(\.colorScheme) private var colorScheme + @State private var isHovering = false var body: some View { - VStack(spacing: 4) { + VStack(spacing: Design.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 20)) + .font(.system(size: Design.IconSize.md)) .frame(width: 28, height: 28) Text(title) - .font(.system(size: 11)) + .font(Design.Font.caption) } - .foregroundColor(isSelected ? .white : colors.secondaryText) + .foregroundColor(isSelected ? .white : (isHovering ? colors.text : colors.secondaryText)) .frame(width: 80, height: 56) .background( - Group { - if isSelected { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.6)) - ) - } else { - Color.clear + ZStack { + // Glassmorphism: frosted glass background + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(glassBackground) + + // Light reflection (top highlight) for glass effect - not for selected + if !isSelected && isHovering { + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.reflectionGradient(colorScheme)) } } ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke(borderColor, lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: shadowColor, + radius: isSelected ? 6 : (isHovering ? 4 : 0), + x: 0, + y: isSelected ? 3 : 2 + ) .contentShape(Rectangle()) + .onHover { hovering in + isHovering = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .animation(Design.Animation.quick, value: isHovering) + .animation(Design.Animation.quick, value: isSelected) + } + + private var glassBackground: Color { + if isSelected { + return Design.Colors.accent + } + if isHovering { + return Design.Glass.background(colorScheme, isHovering: true) + } + return .clear + } + + private var borderColor: Color { + if isSelected { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderActive) + : Design.Colors.accent.opacity(Design.Glass.Light.borderActive) + } + if isHovering { + return Design.Glass.border(colorScheme, isHovering: true) + } + return .clear + } + + private var shadowColor: Color { + if isSelected { + return Design.Colors.accent.opacity(0.35) + } + if isHovering { + return Design.Glass.shadowColor(colorScheme) + } + return .clear } } @@ -175,8 +247,8 @@ struct SettingsRow: View { content .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.vertical, 8) - .padding(.horizontal, 24) + .padding(.vertical, Design.Spacing.sm) + .padding(.horizontal, Design.Spacing.xxl) } } @@ -184,6 +256,7 @@ struct SettingsRow: View { struct GeneralSettingsView: View { let colors: SettingsColors + @Environment(\.colorScheme) private var colorScheme @AppStorage("label-characters") private var labelCharacters = "ASDFGHJKLQWERTYUIOPZXCVBNM" @AppStorage("is-auto-click-enabled") private var autoClickEnabled = false @State private var accessibilityEnabled = AXEnablerService.shared.isAccessibilityEnabled @@ -193,7 +266,7 @@ struct GeneralSettingsView: View { VStack(spacing: 0) { SettingsRow("Launch at Login", colors: colors) { Toggle("Start Hinto when you log in", isOn: $launchAtLogin) - .toggleStyle(.checkbox) + .toggleStyle(.hintoCheckbox) .onChange(of: launchAtLogin) { newValue in do { if newValue { @@ -209,39 +282,90 @@ struct GeneralSettingsView: View { SettingsRow("Auto Click", colors: colors) { Toggle("Click when label matches exactly", isOn: $autoClickEnabled) - .toggleStyle(.checkbox) + .toggleStyle(.hintoCheckbox) } SettingsRow("Label Characters", colors: colors) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Design.Spacing.xs) { Text(labelCharacters) .font(.system(.body, design: .monospaced)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(colors.cardBackground) - .cornerRadius(6) + .foregroundColor(colors.text) + .padding(.horizontal, Design.Spacing.md) + .padding(.vertical, Design.Spacing.sm) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .fill(Design.Glass.background(colorScheme)) + ) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .stroke(Design.Glass.border(colorScheme), lineWidth: 1) + ) Text("Characters used for hint labels") - .font(.caption) + .font(Design.Font.caption) .foregroundColor(colors.secondaryText) } } SettingsRow("Accessibility", colors: colors) { - HStack(spacing: 12) { + HStack(spacing: Design.Spacing.md) { if accessibilityEnabled { - HStack(spacing: 6) { + HStack(spacing: Design.Spacing.sm) { Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) + .font(.system(size: 16)) + .foregroundColor(colorScheme == .dark ? .green : Color(red: 0.2, green: 0.6, blue: 0.3)) Text("Enabled") - .foregroundColor(colors.text) + .font(Design.Font.body) + .foregroundColor(colorScheme == .dark + ? .green + : Color( + red: 0.15, + green: 0.5, + blue: 0.25 + )) } + .padding(.horizontal, Design.Spacing.md) + .padding(.vertical, Design.Spacing.sm) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(colorScheme == .dark + ? Color.green.opacity(0.15) + : Color(red: 0.85, green: 0.95, blue: 0.88)) + ) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke( + colorScheme == .dark + ? Color.green.opacity(0.3) + : Color(red: 0.3, green: 0.7, blue: 0.4).opacity(0.5), + lineWidth: 1 + ) + ) } else { - HStack(spacing: 6) { + HStack(spacing: Design.Spacing.sm) { Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) + .font(.system(size: 16)) + .foregroundColor(colorScheme == .dark ? .red : Color(red: 0.8, green: 0.2, blue: 0.2)) Text("Not Enabled") - .foregroundColor(colors.text) + .font(Design.Font.body) + .foregroundColor(colorScheme == .dark ? .red : Color(red: 0.7, green: 0.15, blue: 0.15)) } + .padding(.horizontal, Design.Spacing.md) + .padding(.vertical, Design.Spacing.sm) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(colorScheme == .dark + ? Color.red.opacity(0.15) + : Color(red: 1.0, green: 0.9, blue: 0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke( + colorScheme == .dark + ? Color.red.opacity(0.3) + : Color.red.opacity(0.3), + lineWidth: 1 + ) + ) Button("Grant Access") { AXEnablerService.shared.promptForAccessibility() @@ -249,15 +373,14 @@ struct GeneralSettingsView: View { accessibilityEnabled = AXEnablerService.shared.isAccessibilityEnabled } } - .buttonStyle(.borderedProminent) - .controlSize(.small) + .buttonStyle(.hintoCard) } } } Spacer() } - .padding(.top, 16) + .padding(.top, Design.Spacing.lg) } } @@ -278,7 +401,7 @@ struct ShortcutsSettingsView: View { } SettingsRow("Right Click", colors: colors) { - HStack(spacing: 4) { + HStack(spacing: Design.Spacing.xs) { KeyBadge("Shift", colors: colors) Text("+") .foregroundColor(colors.secondaryText) @@ -288,7 +411,7 @@ struct ShortcutsSettingsView: View { } SettingsRow("Switch Mode", colors: colors) { - HStack(spacing: 8) { + HStack(spacing: Design.Spacing.sm) { KeyBadge("Tab", colors: colors) Text("Toggle click/scroll mode") .foregroundColor(colors.secondaryText) @@ -301,7 +424,7 @@ struct ShortcutsSettingsView: View { Spacer() } - .padding(.top, 16) + .padding(.top, Design.Spacing.lg) } } @@ -310,17 +433,17 @@ struct HotkeyBadge: View { let colors: SettingsColors var body: some View { - HStack(spacing: 4) { + HStack(spacing: Design.Spacing.xs) { ForEach(keys, id: \.self) { key in Text(keySymbol(key)) - .font(.system(size: 13, weight: .medium)) + .font(Design.Font.body) .foregroundColor(colors.text) } } - .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.horizontal, Design.Spacing.lg) + .padding(.vertical, Design.Spacing.sm) .background(colors.cardBackground) - .cornerRadius(6) + .cornerRadius(Design.CornerRadius.sm) } func keySymbol(_ key: String) -> String { @@ -336,6 +459,7 @@ struct HotkeyBadge: View { struct KeyBadge: View { let key: String let colors: SettingsColors + @Environment(\.colorScheme) private var colorScheme init(_ key: String, colors: SettingsColors) { self.key = key @@ -346,10 +470,30 @@ struct KeyBadge: View { Text(key) .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundColor(colors.text) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(colors.cardBackground) - .cornerRadius(4) + .padding(.horizontal, Design.Spacing.sm) + .padding(.vertical, 4) + .background( + ZStack { + // Glassmorphism: frosted glass + RoundedRectangle(cornerRadius: Design.CornerRadius.xs) + .fill(Design.Glass.background(colorScheme)) + // Light reflection + RoundedRectangle(cornerRadius: Design.CornerRadius.xs) + .fill(Design.Glass.reflectionGradient(colorScheme)) + } + ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.xs) + .stroke(Design.Glass.border(colorScheme), lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: Design.Glass.shadowColor(colorScheme), + radius: 2, + x: 0, + y: 1 + ) } } @@ -357,7 +501,7 @@ struct ScrollKeyGuide: View { let colors: SettingsColors var body: some View { - HStack(spacing: 20) { + HStack(spacing: Design.Spacing.xl) { KeyGuideItem(key: "H", arrow: "\u{2190}", colors: colors) KeyGuideItem(key: "J", arrow: "\u{2193}", colors: colors) KeyGuideItem(key: "K", arrow: "\u{2191}", colors: colors) @@ -372,6 +516,7 @@ struct KeyGuideItem: View { let key: String let arrow: String let colors: SettingsColors + @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(spacing: 2) { @@ -379,12 +524,32 @@ struct KeyGuideItem: View { .font(.system(size: 13, weight: .bold, design: .monospaced)) .foregroundColor(colors.text) Text(arrow) - .font(.system(size: 10)) + .font(Design.Font.small) .foregroundColor(colors.secondaryText) } .frame(width: 32, height: 36) - .background(colors.cardBackground) - .cornerRadius(6) + .background( + ZStack { + // Glassmorphism: frosted glass + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .fill(Design.Glass.background(colorScheme)) + // Light reflection + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .fill(Design.Glass.reflectionGradient(colorScheme)) + } + ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .stroke(Design.Glass.border(colorScheme), lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: Design.Glass.shadowColor(colorScheme), + radius: 2, + x: 0, + y: 1 + ) } } @@ -392,7 +557,9 @@ struct KeyGuideItem: View { struct HotkeyRecorderView: View { let colors: SettingsColors + @Environment(\.colorScheme) private var colorScheme @State private var isRecording = false + @State private var isHovering = false @State private var currentKeyCode: UInt16 = Preferences.shared.hotkeyKeyCode @State private var currentModifiers: UInt = Preferences.shared.hotkeyModifiers @State private var eventMonitor: Any? @@ -401,27 +568,50 @@ struct HotkeyRecorderView: View { Button(action: { startRecording() }) { - HStack(spacing: 4) { + HStack(spacing: Design.Spacing.xs) { if isRecording { Text("Press keys...") - .font(.system(size: 13, weight: .medium)) + .font(Design.Font.body) .foregroundColor(colors.secondaryText) } else { Text(hotkeyDisplayString) - .font(.system(size: 13, weight: .medium)) + .font(Design.Font.body) .foregroundColor(colors.text) } } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(isRecording ? Color.accentColor.opacity(0.3) : colors.cardBackground) - .cornerRadius(6) + .padding(.horizontal, Design.Spacing.lg) + .padding(.vertical, Design.Spacing.sm) + .background( + ZStack { + // Glassmorphism: frosted glass + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .fill(glassBackground) + // Light reflection + if !isRecording { + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .fill(Design.Glass.reflectionGradient(colorScheme)) + } + } + ) + // Glassmorphism: subtle border .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(isRecording ? Color.accentColor : Color.clear, lineWidth: 2) + RoundedRectangle(cornerRadius: Design.CornerRadius.sm) + .stroke(borderColor, lineWidth: isRecording ? 2 : 1) + ) + // Z-depth shadow + .shadow( + color: isRecording + ? Design.Colors.accent.opacity(0.3) + : Design.Glass.shadowColor(colorScheme), + radius: isRecording ? 6 : 3, + x: 0, + y: isRecording ? 2 : 1 ) } .buttonStyle(.plain) + .onHover { hovering in isHovering = hovering } + .animation(Design.Animation.quick, value: isHovering) + .animation(Design.Animation.quick, value: isRecording) .onReceive(NotificationCenter.default.publisher(for: Preferences.hotkeyDidChangeNotification)) { _ in currentKeyCode = Preferences.shared.hotkeyKeyCode currentModifiers = Preferences.shared.hotkeyModifiers @@ -431,6 +621,25 @@ struct HotkeyRecorderView: View { } } + private var glassBackground: Color { + if isRecording { + return Design.Colors.accent.opacity(0.25) + } + return Design.Glass.background(colorScheme, isHovering: isHovering) + } + + private var borderColor: Color { + if isRecording { + return Design.Colors.accent + } + if isHovering { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderActive) + : Design.Colors.accent.opacity(0.4) + } + return Design.Glass.border(colorScheme) + } + private func startRecording() { guard !isRecording else { return } isRecording = true @@ -592,7 +801,7 @@ struct AppearanceSettingsView: View { ScrollView { VStack(spacing: 0) { SettingsRow("Appearance", colors: colors) { - HStack(spacing: 12) { + HStack(spacing: Design.Spacing.md) { AppearanceOption( icon: "sun.max", label: "Light", @@ -623,7 +832,7 @@ struct AppearanceSettingsView: View { } SettingsRow("Label Theme", colors: colors) { - HStack(spacing: 12) { + HStack(spacing: Design.Spacing.md) { ThemeOption( icon: "sun.max", label: "Light", @@ -689,7 +898,7 @@ struct AppearanceSettingsView: View { } SettingsRow("Label Size", colors: colors) { - HStack(spacing: 12) { + HStack(spacing: Design.Spacing.md) { SizeOption( label: "S", isSelected: labelSize == "small", @@ -727,8 +936,8 @@ struct AppearanceSettingsView: View { ) } } - .padding(.top, 16) - .padding(.bottom, 16) + .padding(.top, Design.Spacing.lg) + .padding(.bottom, Design.Spacing.lg) } } } @@ -739,36 +948,72 @@ struct AppearanceOption: View { let isSelected: Bool let colors: SettingsColors let action: () -> Void + @Environment(\.colorScheme) private var colorScheme + @State private var isHovering = false var body: some View { Button(action: action) { - VStack(spacing: 4) { + VStack(spacing: Design.Spacing.xs) { Image(systemName: icon) .font(.system(size: 18)) - .foregroundColor(isSelected ? .white : colors.secondaryText) + .foregroundColor(isSelected ? .white : (isHovering ? colors.text : colors.secondaryText)) .frame(width: 44, height: 44) .background( - Group { - if isSelected { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.6)) - ) - } else { - RoundedRectangle(cornerRadius: 8) - .fill(colors.cardBackground) + ZStack { + // Glassmorphism: frosted glass background + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(optionBackground) + // Light reflection + if !isSelected { + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.reflectionGradient(colorScheme)) } } ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke(optionBorder, lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: isSelected + ? Design.Colors.accent.opacity(0.35) + : Design.Glass.shadowColor(colorScheme), + radius: isSelected ? 5 : 3, + x: 0, + y: isSelected ? 2 : 1 + ) Text(label) - .font(.system(size: 11)) + .font(Design.Font.caption) .foregroundColor(isSelected ? colors.text : colors.secondaryText) } } .buttonStyle(.plain) + .onHover { hovering in isHovering = hovering } + .animation(Design.Animation.quick, value: isHovering) + } + + private var optionBackground: Color { + if isSelected { + return Design.Colors.accent + } + return Design.Glass.background(colorScheme, isHovering: isHovering) + } + + private var optionBorder: Color { + if isSelected { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderActive) + : Design.Colors.accent.opacity(Design.Glass.Light.borderActive) + } + if isHovering { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderHover) + : Design.Colors.accent.opacity(0.4) + } + return Design.Glass.border(colorScheme) } } @@ -778,36 +1023,72 @@ struct ThemeOption: View { let isSelected: Bool let colors: SettingsColors let action: () -> Void + @Environment(\.colorScheme) private var colorScheme + @State private var isHovering = false var body: some View { Button(action: action) { - VStack(spacing: 4) { + VStack(spacing: Design.Spacing.xs) { Image(systemName: icon) .font(.system(size: 18)) - .foregroundColor(isSelected ? .white : colors.secondaryText) + .foregroundColor(isSelected ? .white : (isHovering ? colors.text : colors.secondaryText)) .frame(width: 44, height: 44) .background( - Group { - if isSelected { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.6)) - ) - } else { - RoundedRectangle(cornerRadius: 8) - .fill(colors.cardBackground) + ZStack { + // Glassmorphism: frosted glass background + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(optionBackground) + // Light reflection + if !isSelected { + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.reflectionGradient(colorScheme)) } } ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke(optionBorder, lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: isSelected + ? Design.Colors.accent.opacity(0.35) + : Design.Glass.shadowColor(colorScheme), + radius: isSelected ? 5 : 3, + x: 0, + y: isSelected ? 2 : 1 + ) Text(label) - .font(.system(size: 11)) + .font(Design.Font.caption) .foregroundColor(isSelected ? colors.text : colors.secondaryText) } } .buttonStyle(.plain) + .onHover { hovering in isHovering = hovering } + .animation(Design.Animation.quick, value: isHovering) + } + + private var optionBackground: Color { + if isSelected { + return Design.Colors.accent + } + return Design.Glass.background(colorScheme, isHovering: isHovering) + } + + private var optionBorder: Color { + if isSelected { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderActive) + : Design.Colors.accent.opacity(Design.Glass.Light.borderActive) + } + if isHovering { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderHover) + : Design.Colors.accent.opacity(0.4) + } + return Design.Glass.border(colorScheme) } } @@ -816,30 +1097,66 @@ struct SizeOption: View { let isSelected: Bool let colors: SettingsColors let action: () -> Void + @Environment(\.colorScheme) private var colorScheme + @State private var isHovering = false var body: some View { Button(action: action) { Text(label) .font(.system(size: 14, weight: .bold)) - .foregroundColor(isSelected ? .white : colors.secondaryText) + .foregroundColor(isSelected ? .white : (isHovering ? colors.text : colors.secondaryText)) .frame(width: 36, height: 36) .background( - Group { - if isSelected { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 8) - .fill(Color.accentColor.opacity(0.6)) - ) - } else { - RoundedRectangle(cornerRadius: 8) - .fill(colors.cardBackground) + ZStack { + // Glassmorphism: frosted glass background + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(optionBackground) + // Light reflection + if !isSelected { + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.reflectionGradient(colorScheme)) } } ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke(optionBorder, lineWidth: 1) + ) + // Z-depth shadow + .shadow( + color: isSelected + ? Design.Colors.accent.opacity(0.35) + : Design.Glass.shadowColor(colorScheme), + radius: isSelected ? 5 : 3, + x: 0, + y: isSelected ? 2 : 1 + ) } .buttonStyle(.plain) + .onHover { hovering in isHovering = hovering } + .animation(Design.Animation.quick, value: isHovering) + } + + private var optionBackground: Color { + if isSelected { + return Design.Colors.accent + } + return Design.Glass.background(colorScheme, isHovering: isHovering) + } + + private var optionBorder: Color { + if isSelected { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderActive) + : Design.Colors.accent.opacity(Design.Glass.Light.borderActive) + } + if isHovering { + return colorScheme == .dark + ? Color.white.opacity(Design.Glass.Dark.borderHover) + : Design.Colors.accent.opacity(0.4) + } + return Design.Glass.border(colorScheme) } } @@ -847,6 +1164,7 @@ struct LabelPreview: View { let theme: String let size: String let colors: SettingsColors + @Environment(\.colorScheme) private var colorScheme var customBackground: Color = .init(white: 0.2) var customText: Color = .white var customBorder: Color = .init(white: 0.4) @@ -877,34 +1195,72 @@ struct LabelPreview: View { } } + // Match LabelSize values from LabelLayer.swift var fontSize: CGFloat { switch size { - case "small": return 10 - case "large": return 16 - default: return 13 + case "small": return 8 + case "large": return 12 + default: return 10 + } + } + + var xPadding: CGFloat { + switch size { + case "small": return 2 + case "large": return 5 + default: return 4 } } + var yPadding: CGFloat { + switch size { + case "small": return 1 + case "large": return 3 + default: return 2 + } + } + + // Match LabelLayer cornerRadius = 3 + private let labelCornerRadius: CGFloat = 3 + var body: some View { - HStack(spacing: 8) { + HStack(spacing: Design.Spacing.sm) { ForEach(["A", "S", "D", "F"], id: \.self) { label in Text(label) .font(.system(size: fontSize, weight: .bold, design: .monospaced)) .foregroundColor(textColor) - .padding(.horizontal, size == "small" ? 4 : (size == "large" ? 10 : 8)) - .padding(.vertical, size == "small" ? 2 : (size == "large" ? 6 : 4)) - .background(backgroundColor) - .cornerRadius(4) + .padding(.horizontal, xPadding) + .padding(.vertical, yPadding) + .background(backgroundColor.opacity(0.95)) + .cornerRadius(labelCornerRadius) .overlay( - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: labelCornerRadius) .stroke(borderColor, lineWidth: 1) ) + // Match LabelLayer shadow + .shadow( + color: Color.black.opacity(0.4), + radius: 2, + x: 0, + y: -1 + ) } } - .padding(12) + .padding(Design.Spacing.md) .background( - RoundedRectangle(cornerRadius: 8) - .fill(colors.cardBackground) + ZStack { + // Glassmorphism: frosted glass container + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.background(colorScheme)) + // Light reflection + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .fill(Design.Glass.reflectionGradient(colorScheme)) + } + ) + // Glassmorphism: subtle border + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.md) + .stroke(Design.Glass.border(colorScheme), lineWidth: 1) ) } } @@ -913,68 +1269,101 @@ struct LabelPreview: View { struct AboutView: View { let colors: SettingsColors + @State private var showingWhatsNew = false + @Environment(\.colorScheme) private var colorScheme private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" } var body: some View { - VStack(spacing: 16) { + VStack(spacing: 0) { Spacer() - Image("AboutIcon") + // App Icon + Image(nsImage: NSApp.applicationIconImage) .resizable() + .interpolation(.high) .aspectRatio(contentMode: .fit) - .frame(width: 80, height: 80) + .frame(width: 72, height: 72) + .padding(.bottom, Design.Spacing.lg) - VStack(spacing: 4) { + // App Name & Version + VStack(spacing: Design.Spacing.xs) { Text("Hinto") - .font(.system(size: 24, weight: .bold)) + .font(.system(size: 22, weight: .bold)) .foregroundColor(colors.text) Text("Version \(appVersion)") - .font(.system(size: 12)) + .font(Design.Font.button) .foregroundColor(colors.secondaryText) } + .padding(.bottom, Design.Spacing.md) + // Description Text("Navigate your Mac without a mouse\nusing keyboard-driven labels.") - .font(.system(size: 13)) + .font(Design.Font.body) .multilineTextAlignment(.center) .foregroundColor(colors.secondaryText) .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) Spacer() - HStack(spacing: 12) { - Link(destination: URL(string: "https://github.com/yhao3/hinto")!) { - HStack(spacing: 6) { - Image(systemName: "star") - Text("Star on GitHub") + // Action Buttons + VStack(spacing: Design.Spacing.md) { + // What's New & Check for Updates + HStack(spacing: Design.Spacing.md) { + Button(action: { showingWhatsNew = true }) { + HStack(spacing: Design.Spacing.sm) { + Image(systemName: "doc.text") + Text("What's New") + } } - .font(.system(size: 12)) - .foregroundColor(colors.text) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(colors.cardBackground) - .cornerRadius(6) + .buttonStyle(.hintoCard) + + Button(action: checkForUpdates) { + HStack(spacing: Design.Spacing.sm) { + Image(systemName: "arrow.triangle.2.circlepath") + Text("Check for Updates") + } + } + .buttonStyle(.hintoCard) } - Link(destination: URL(string: "https://ko-fi.com/yhao3")!) { - HStack(spacing: 6) { - Image(systemName: "heart") - Text("Donate") + // GitHub & Donate links + HStack(spacing: Design.Spacing.md) { + Link(destination: URL(string: "https://github.com/yhao3/hinto")!) { + HStack(spacing: Design.Spacing.sm) { + Image(systemName: "star") + Text("Star on GitHub") + } } - .font(.system(size: 12)) - .foregroundColor(colors.text) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(colors.cardBackground) - .cornerRadius(6) + .buttonStyle(.hintoCard) + + Link(destination: URL(string: "https://ko-fi.com/yhao3")!) { + HStack(spacing: Design.Spacing.sm) { + Image(systemName: "heart") + Text("Donate") + } + } + .buttonStyle(.hintoCard) } } - .padding(.bottom, 20) + .padding(.bottom, Design.Spacing.huge) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheet(isPresented: $showingWhatsNew) { + if let entry = ChangelogParser.shared.currentVersionEntry() { + WhatsNewView(entry: entry, isPostUpdate: false) { + showingWhatsNew = false + } + } + } + } + + private func checkForUpdates() { + (NSApp.delegate as? AppDelegate)?.updater.checkForUpdates() } } diff --git a/UI/WhatsNew/WhatsNewView.swift b/UI/WhatsNew/WhatsNewView.swift new file mode 100644 index 0000000..d890054 --- /dev/null +++ b/UI/WhatsNew/WhatsNewView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +/// View displaying changelog for "What's New" popup +struct WhatsNewView: View { + let entry: ChangelogEntry + let isPostUpdate: Bool + let onDismiss: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 0) { + // Header + header + .padding(.top, Design.Spacing.xxxl) + .padding(.bottom, Design.Spacing.xl) + + // Content + ScrollView { + Text(.init(entry.content)) + .font(Design.Font.body) + .lineSpacing(4) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Design.Spacing.xxxl) + .padding(.vertical, Design.Spacing.xl) + } + .frame(maxHeight: .infinity) + + // Footer + footer + .padding(.top, Design.Spacing.lg) + .padding(.bottom, Design.Spacing.huge) + } + .frame(width: 480, height: 500) + .background { + ZStack { + // Base material (same as Settings) + RoundedRectangle(cornerRadius: Design.CornerRadius.xl) + .fill(.thickMaterial) + + // Subtle gradient overlay + LinearGradient( + colors: gradientColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .opacity(0.15) + } + } + .clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.xl)) + } + + private var gradientColors: [Color] { + colorScheme == .dark + ? [Design.Colors.accent.opacity(0.3), Design.Colors.accent.opacity(0.15)] + : [Design.Colors.accent.opacity(0.2), Design.Colors.accent.opacity(0.1)] + } + + private var header: some View { + VStack(spacing: Design.Spacing.md) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: Design.IconSize.lg, height: Design.IconSize.lg) + + Text("What's New") + .font(Design.Font.title) + + Text("Version \(entry.version)") + .font(Design.Font.button) + .foregroundColor(.secondary) + } + } + + private var footer: some View { + Button(action: { + if isPostUpdate { + // Mark as seen only for post-update popup + Preferences.shared.lastSeenVersion = Preferences.shared.currentVersion + } + onDismiss() + }) { + Text(isPostUpdate ? "Continue" : "Close") + } + .buttonStyle(.hintoCard) + } +} + +// MARK: - Window Controller + +final class WhatsNewWindowController: NSWindowController { + private var externalOnDismiss: (() -> Void)? + + convenience init(entry: ChangelogEntry, onDismiss: (() -> Void)? = nil) { + let whatsNewView = WhatsNewView(entry: entry, isPostUpdate: true) { + // Will be set after window is created + } + + let hostingController = NSHostingController(rootView: whatsNewView) + hostingController.sizingOptions = [.preferredContentSize] + + let window = NSWindow(contentViewController: hostingController) + window.title = "What's New" + window.styleMask = [.titled, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.isOpaque = false + window.backgroundColor = .clear + window.setContentSize(NSSize(width: 480, height: 500)) + window.center() + + self.init(window: window) + externalOnDismiss = onDismiss + + // Update the view with proper dismiss handler (isPostUpdate: true for window controller) + let updatedView = WhatsNewView(entry: entry, isPostUpdate: true) { [weak self] in + self?.dismissWindow() + } + hostingController.rootView = updatedView + } + + private func dismissWindow() { + close() + externalOnDismiss?() + } + + override func showWindow(_ sender: Any?) { + super.showWindow(sender) + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +#Preview("Post Update") { + WhatsNewView( + entry: ChangelogEntry( + version: "0.1.0", + date: "2026-01-01", + content: """ + **Added** + + - Initial release + - Keyboard-driven UI navigation with hint labels + - Global hotkey activation (Cmd+Shift+Space) + + **Fixed** + + - Some bug fixes + """ + ), + isPostUpdate: true, + onDismiss: {} + ) +} + +#Preview("Manual") { + WhatsNewView( + entry: ChangelogEntry( + version: "0.1.0", + date: "2026-01-01", + content: """ + **Added** + + - Initial release + """ + ), + isPostUpdate: false, + onDismiss: {} + ) +}