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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
178 changes: 178 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF" >> $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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ buildServer.json

# Build tools
create-dmg/
*.dmg
52 changes: 52 additions & 0 deletions App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import Carbon
import Cocoa
import Sparkle
import SwiftUI

final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
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...")
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions App/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Config/debug.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading