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
89 changes: 86 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ on:
- "v[0-9]*"

permissions:
contents: write
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
build:
name: Build unsigned release
runs-on: macos-15
timeout-minutes: 25

steps:
- name: Check out source
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Show toolchain
run: |
Expand Down Expand Up @@ -75,6 +75,74 @@ jobs:
dist \
"Startle-${release_version}-macOS-unsigned"

- name: Upload release inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-${{ github.ref_name }}
path: dist
if-no-files-found: error
retention-days: 1

publish:
name: Sign and publish release
needs: build
runs-on: macos-15
timeout-minutes: 10
environment: release
permissions:
contents: write

steps:
- name: Download release inputs
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-${{ github.ref_name }}
path: dist

- name: Install pinned Sparkle signing tool
env:
SPARKLE_ARCHIVE_SHA256: ce89daf967db1e1893ed3ebd67575ed82d3902563e3191ca92aaec9164fbdef9
SPARKLE_VERSION: 2.9.4
run: |
archive="$RUNNER_TEMP/Sparkle-$SPARKLE_VERSION.tar.xz"
tools_dir="$RUNNER_TEMP/sparkle-tools"

curl \
--fail \
--location \
--proto '=https' \
--tlsv1.2 \
--output "$archive" \
"https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz"
printf '%s %s\n' "$SPARKLE_ARCHIVE_SHA256" "$archive" | shasum --algorithm 256 --check
mkdir -p "$tools_dir"
tar -xJf "$archive" -C "$tools_dir" ./bin/generate_appcast
chmod 700 "$tools_dir/bin/generate_appcast"

- name: Generate signed update feed
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
echo "SPARKLE_PRIVATE_KEY is not configured." >&2
exit 78
fi

release_version=${GITHUB_REF_NAME#v}
asset_name="Startle-${release_version}-macOS-unsigned"
update_dir=build/update-feed
mkdir -p "$update_dir"
cp "dist/$asset_name.zip" "$update_dir/"

printf '%s' "$SPARKLE_PRIVATE_KEY" | \
"$RUNNER_TEMP/sparkle-tools/bin/generate_appcast" \
--ed-key-file - \
--download-url-prefix \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/" \
"$update_dir"

test -f "$update_dir/appcast.xml"

- name: Publish prerelease
env:
GH_TOKEN: ${{ github.token }}
Expand Down Expand Up @@ -104,3 +172,18 @@ jobs:
--notes "$release_notes" \
--generate-notes
fi

- name: Publish update feed
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view appcast >/dev/null 2>&1; then
gh release upload appcast build/update-feed/appcast.xml --clobber
else
gh release create \
appcast \
build/update-feed/appcast.xml \
--prerelease \
--title "Startle update feed" \
--notes "Machine-readable update feed used by Startle."
fi
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes will be documented here. The project follows [Semantic Versi

## Unreleased

### Added

- Secure in-app update checks, downloads, installation, and relaunch through Sparkle.
- Check-for-updates actions in the app menu, menu-bar menu, and About screen.

## 1.0.4 - 2026-07-30

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Contributions are welcome. Safety-sensitive behavior needs especially careful re
## Before opening a change

- Search existing issues and pull requests.
- Keep changes focused and preserve the public-framework-only approach.
- Keep changes focused, use public Apple APIs, and preserve the existing Sparkle update path.
- Do not add bundled copyrighted video, telemetry, private APIs, or behavior that bypasses the user's active window or safety settings.
- For a security vulnerability, follow [SECURITY.md](SECURITY.md) instead of opening a public report.

Expand Down
4 changes: 4 additions & 0 deletions Config/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@
<key>NSHighResolutionCapable</key><true/>
<key>NSHumanReadableCopyright</key><string>Copyright © 2026 Startle contributors.</string>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
<key>SUEnableDownloaderService</key><true/>
<key>SUEnableInstallerLauncherService</key><true/>
<key>SUFeedURL</key><string>https://github.com/Charlie284/Startle/releases/download/appcast/appcast.xml</string>
<key>SUPublicEDKey</key><string>zrBtzrc2pGL/vji3HnZBRptI2HLURhTT4tWmO0jMpzk=</string>
</dict>
</plist>
5 changes: 5 additions & 0 deletions Config/Startle.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict>
</plist>
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ let package = Package(
.library(name: "StartleCore", targets: ["StartleCore"]),
.executable(name: "Startle", targets: ["StartleApp"]),
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.4")
],
targets: [
.target(name: "StartleCore"),
.executableTarget(
name: "StartleApp",
dependencies: ["StartleCore"],
dependencies: [
"StartleCore",
.product(name: "Sparkle", package: "Sparkle"),
],
resources: [.process("Resources")]
),
.testTarget(name: "StartleCoreTests", dependencies: ["StartleCore"]),
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Startle

Startle is a native macOS prank/productivity utility that waits in the menu bar and plays a randomly selected local video at carefully constrained times. It is built entirely with SwiftUI, AppKit, AVFoundation, and public Apple frameworks.
Startle is a native macOS prank/productivity utility that waits in the menu bar and plays a randomly selected local video at carefully constrained times. It is built with SwiftUI, AppKit, AVFoundation, public Apple frameworks, and Sparkle; it does not use private APIs.

[![CI](https://github.com/Charlie284/Startle/actions/workflows/ci.yml/badge.svg)](https://github.com/Charlie284/Startle/actions/workflows/ci.yml)

Expand Down Expand Up @@ -48,6 +48,8 @@ Version tags matching `v*` build a universal macOS app and publish it as a GitHu

After verifying the checksum, open the DMG and drag **Startle** to the **Applications** shortcut. macOS will identify the app as coming from an unidentified developer, so use **Control-click → Open** in Applications for the first launch. Do not treat an unsigned preview as equivalent to a Developer ID-signed and notarized release.

Once installed, Startle checks for updates through Sparkle. You can also choose **Check for Updates…** from the app menu, menu-bar menu, or About screen. Update archives are verified with Startle's Ed25519 release key before installation.

## Architecture

- `SettingsStore` persists simple and structured preferences through Codable data in UserDefaults.
Expand All @@ -59,6 +61,7 @@ After verifying the checksum, open the DMG and drag **Startle** to the **Applica
- `ScareWindowController` preloads AVFoundation media, creates borderless high-level AppKit windows, supports every display mode, maintains aspect ratio or crop-to-fill, captures Escape, restores the cursor and previous app, and removes all observers/resources.
- `EmergencyShortcutManager` registers **Command–Option–Shift–Escape** as a system hot key using the public Carbon hot-key API. Scheduled scares fail closed until that registration succeeds.
- `LaunchAtLoginManager` wraps `SMAppService.mainApp` and respects “Never run at login.”
- `SoftwareUpdater` uses Sparkle's sandboxed installer and downloader services to verify, install, and relaunch updates from the signed release feed.
- SwiftUI views provide onboarding, Dashboard, Videos, Schedule, Safety, Appearance, About, drag-and-drop, per-video controls, and a persistent `MenuBarExtra`.

## Scheduling and wake behavior
Expand All @@ -69,7 +72,7 @@ Only one scheduling task exists at a time. Disabling scares cancels it. Preferen

Startle is sandboxed. The file picker grants read-only access only to videos selected by the user; bookmarks preserve that access across launches. Videos are never copied or uploaded. Device-running, Apple screen-capture app, display, battery, output-volume, and window checks use public system APIs. Focus status is unavailable through public macOS APIs, and third-party sharing detection is best-effort.

Startle contains no analytics, advertising SDKs, remote services, or telemetry. Its privacy manifest declares no tracking or collected data. Local file paths and security-scoped bookmark data remain on the Mac.
Startle contains no analytics, advertising SDKs, or telemetry. Its only network use is checking for and downloading signed releases from GitHub through Sparkle. Its privacy manifest declares no tracking or collected data. Local file paths and security-scoped bookmark data remain on the Mac.

Automatic safety checks can be incomplete when macOS or another app does not expose the needed state. Read [SAFETY.md](SAFETY.md) before enabling scheduled scares.

Expand Down
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Pushing a version tag such as `v1.0.1` runs `.github/workflows/release.yml`. The

Unsigned previews are intended for development and evaluation. Gatekeeper will identify them as coming from an unidentified developer, and they do not satisfy the production release process below.

The release workflow builds and tests without signing-key access. A separate job protected by the `release` environment signs the ZIP with Sparkle's Ed25519 key, uploads the versioned assets, and then replaces the fixed `appcast` release asset consumed by installed copies of Startle. The private key must exist only in that environment's `SPARKLE_PRIVATE_KEY` secret and the release operator's Keychain; never commit or print it.

## 1. Prepare the version

1. Start from a clean default branch with passing CI.
Expand Down Expand Up @@ -71,6 +73,7 @@ Exercise the final signed artifact on a clean account on macOS 14 and the curren
- full-screen, centered, current-display, and all-display modes
- cursor and previous-application restoration after completion, failure, and dismissal
- launch-at-login enablement and the “Never run at login” override
- automatic and manual update checks, download verification, installation, and relaunch from the previous release
- recovery from corrupt settings and video-library files

## 5. Publish
Expand Down
10 changes: 10 additions & 0 deletions Scripts/verify-distribution.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ if [ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.files.user-selecte
exit 70
fi

if [ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.temporary-exception.mach-lookup.global-name:0' "$entitlements_path")" != "com.startle.app-spks" ]; then
echo "The app signature does not allow communication with Sparkle's status service." >&2
exit 70
fi

if [ "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.temporary-exception.mach-lookup.global-name:1' "$entitlements_path")" != "com.startle.app-spki" ]; then
echo "The app signature does not allow communication with Sparkle's installer service." >&2
exit 70
fi

echo "Checking Gatekeeper acceptance"
spctl --assess --type execute --verbose=2 "$app_path"

Expand Down
48 changes: 48 additions & 0 deletions Scripts/verify-unsigned-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ executable_name=$(plutil -extract CFBundleExecutable raw -o - "$info_plist")
executable_path="$app_path/Contents/MacOS/$executable_name"
framework_path="$app_path/Contents/Frameworks/StartleCore.framework"
framework_executable="$framework_path/Versions/A/StartleCore"
sparkle_framework_path="$app_path/Contents/Frameworks/Sparkle.framework"
sparkle_executable="$sparkle_framework_path/Versions/B/Sparkle"

if [ ! -x "$executable_path" ]; then
echo "The application executable is missing: $executable_path" >&2
Expand All @@ -29,6 +31,21 @@ if [ ! -x "$framework_executable" ]; then
exit 66
fi

if [ ! -x "$sparkle_executable" ]; then
echo "The embedded Sparkle framework is missing or incomplete." >&2
exit 66
fi

if [ ! -d "$sparkle_framework_path/Versions/B/XPCServices/Installer.xpc" ]; then
echo "Sparkle's installer service is missing from the sandboxed app." >&2
exit 66
fi

if [ ! -d "$sparkle_framework_path/Versions/B/XPCServices/Downloader.xpc" ]; then
echo "Sparkle's downloader service is missing from the sandboxed app." >&2
exit 66
fi

if ! otool -l "$executable_path" | grep -q '@executable_path/../Frameworks'; then
echo "The application does not search its embedded Frameworks directory." >&2
exit 70
Expand All @@ -39,8 +56,35 @@ if ! otool -L "$executable_path" | grep -q '@rpath/StartleCore.framework/Version
exit 70
fi

if ! otool -L "$executable_path" | grep -q '@rpath/Sparkle.framework/Versions/B/Sparkle'; then
echo "The application is not linked to the expected Sparkle framework." >&2
exit 70
fi

if [ "$(plutil -extract SUEnableInstallerLauncherService raw -o - "$info_plist")" != "true" ]; then
echo "Sparkle's installer service is not enabled in Info.plist." >&2
exit 70
fi

if [ "$(plutil -extract SUEnableDownloaderService raw -o - "$info_plist")" != "true" ]; then
echo "Sparkle's downloader service is not enabled in Info.plist." >&2
exit 70
fi

if [ -z "$(plutil -extract SUFeedURL raw -o - "$info_plist")" ]; then
echo "The Sparkle update feed URL is missing from Info.plist." >&2
exit 70
fi

public_key=$(plutil -extract SUPublicEDKey raw -o - "$info_plist")
if [ -z "$public_key" ] || [ "$public_key" = "SPARKLE_PUBLIC_KEY" ]; then
echo "The Sparkle public signing key is missing from Info.plist." >&2
exit 70
fi

app_architectures=$(lipo -archs "$executable_path")
framework_architectures=$(lipo -archs "$framework_executable")
sparkle_architectures=$(lipo -archs "$sparkle_executable")
for architecture in arm64 x86_64; do
case " $app_architectures " in
*" $architecture "*) ;;
Expand All @@ -50,6 +94,10 @@ for architecture in arm64 x86_64; do
*" $architecture "*) ;;
*) echo "StartleCore.framework is missing the $architecture architecture." >&2; exit 70 ;;
esac
case " $sparkle_architectures " in
*" $architecture "*) ;;
*) echo "Sparkle.framework is missing the $architecture architecture." >&2; exit 70 ;;
esac
done

plutil -lint "$info_plist"
Expand Down
2 changes: 2 additions & 0 deletions Sources/StartleApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class AppState {
let scheduler: ScareScheduler
let coordinator: ScareCoordinator
let launchAtLogin: LaunchAtLoginManager
let softwareUpdater: SoftwareUpdater
private var emergencyShortcut: EmergencyShortcutManager!

init() {
Expand All @@ -24,6 +25,7 @@ final class AppState {
scheduler = ScareScheduler(settings: settings, activity: activity)
coordinator = ScareCoordinator(settings: settings, library: library)
launchAtLogin = LaunchAtLoginManager()
softwareUpdater = SoftwareUpdater()
emergencyShortcut = EmergencyShortcutManager { [weak self] in self?.emergencyDisable() }
scheduler.onTrigger = { [weak self] in await self?.coordinator.trigger() }
library.onEnabledVideosChanged = { [weak self] hasVideos in
Expand Down
4 changes: 4 additions & 0 deletions Sources/StartleApp/AppearanceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ extension Color {
}

struct AboutView: View {
@Environment(AppState.self) private var state

private var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
?? "Development"
Expand All @@ -71,6 +73,8 @@ struct AboutView: View {
Text(
"Startle never uploads your videos. Imported files remain where you put them and are reopened through security-scoped bookmarks."
).multilineTextAlignment(.center).frame(maxWidth: 520)
Button("Check for Updates…") { state.softwareUpdater.checkForUpdates() }
.disabled(!state.softwareUpdater.canCheckForUpdates)
}.frame(maxWidth: .infinity).padding(.vertical, 40)
}
}
Expand Down
Loading