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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ jobs:
exit 1
fi

- name: Sparkle embedding verifier
run: |
./scripts/build-app.sh --version 0.0.0-ci
./scripts/verify-sparkle-embedding.sh dist/WinMice.app

- name: Packaged zip verifier
run: |
./scripts/package-release.sh 0.0.0-ci
./scripts/verify-packaged-zip.sh dist/WinMice-0.0.0-ci.zip

- name: Appcast generator test
run: ./scripts/test-generate-appcast.sh

- name: Pages home verifier
run: ./scripts/verify-pages-home.sh

Expand Down
54 changes: 46 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,32 @@ jobs:
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT"

- name: Require Apple signing secrets
- name: Require a changelog for this version
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
# Both the release notes and the Sparkle appcast are generated from this
# file. Checking it here fails in seconds instead of after notarization.
if [ ! -f "docs/changelog/${VERSION}.md" ]; then
echo "Missing docs/changelog/${VERSION}.md" >&2
echo "Write the changelog first; the release notes and the appcast both come from it." >&2
exit 1
fi

- name: Require signing secrets
env:
APPLE_DEVELOPER_ID_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }}
APPLE_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
missing=0
for v in APPLE_DEVELOPER_ID_P12_BASE64 APPLE_DEVELOPER_ID_P12_PASSWORD \
APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8; do
APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8 \
SPARKLE_ED_PRIVATE_KEY; do
eval "val=\${$v}"
if [ -z "$val" ]; then
echo "Missing secret: $v" >&2
Expand Down Expand Up @@ -95,6 +109,9 @@ jobs:
VERSION: ${{ steps.ver.outputs.version }}
run: ./scripts/verify-bundle-metadata.sh dist/WinMice.app "$VERSION"

- name: Verify Sparkle embedding
run: ./scripts/verify-sparkle-embedding.sh dist/WinMice.app

- name: Notarize app
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
Expand All @@ -107,9 +124,17 @@ jobs:
run: ./scripts/verify-notarized-app.sh dist/WinMice.app

- name: Package zip
id: pkg
env:
VERSION: ${{ steps.ver.outputs.version }}
run: ./scripts/package-release.sh "$VERSION"
run: |
./scripts/package-release.sh "$VERSION"
echo "zip=dist/WinMice-${VERSION}.zip" >> "$GITHUB_OUTPUT"

- name: Verify packaged zip
env:
ZIP: ${{ steps.pkg.outputs.zip }}
run: ./scripts/verify-packaged-zip.sh "$ZIP"

- name: Install create-dmg
run: |
Expand Down Expand Up @@ -159,6 +184,19 @@ jobs:
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"

- name: Generate appcast
env:
VERSION: ${{ steps.ver.outputs.version }}
ZIP: ${{ steps.sum.outputs.zip }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
KEY_FILE="${RUNNER_TEMP}/sparkle-ed-private-key"
umask 077
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
SPARKLE_ED_KEY_FILE="$KEY_FILE" \
./scripts/generate-appcast.sh "$VERSION" "$ZIP" "docs/changelog/${VERSION}.md"
rm -f "$KEY_FILE"

- name: Upload workflow artifacts (dry run / debugging)
if: steps.ver.outputs.publish != 'true'
uses: actions/upload-artifact@v4
Expand All @@ -167,15 +205,14 @@ jobs:
path: |
${{ steps.sum.outputs.zip }}
${{ steps.sum.outputs.dmg }}
dist/appcast.xml
if-no-files-found: error

- name: Render release notes
if: steps.ver.outputs.publish == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
sed "s/{{VERSION}}/${VERSION}/g" \
docs/release-notes-template.md > /tmp/release-notes.md
run: ./scripts/render-release-notes.sh "$VERSION" > /tmp/release-notes.md

- name: Publish GitHub Release
if: steps.ver.outputs.publish == 'true'
Expand All @@ -187,9 +224,9 @@ jobs:
DMG: ${{ steps.sum.outputs.dmg }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "$ZIP" "$DMG" --clobber
gh release upload "$TAG" "$ZIP" "$DMG" dist/appcast.xml --clobber
else
gh release create "$TAG" "$ZIP" "$DMG" \
gh release create "$TAG" "$ZIP" "$DMG" dist/appcast.xml \
--title "WinMice $VERSION" \
--notes-file /tmp/release-notes.md
fi
Expand Down Expand Up @@ -235,6 +272,7 @@ jobs:
security delete-keychain "$KEYCHAIN" || true
rm -f "${RUNNER_TEMP}/winmice-keychain-path" \
"${RUNNER_TEMP}/winmice-signing.p12" \
"${RUNNER_TEMP}/sparkle-ed-private-key" \
"${RUNNER_TEMP}"/AuthKey_*.p8 \
"${RUNNER_TEMP}"/*.notarize.zip \
"${RUNNER_TEMP}"/*.notary.json || true
Expand Down
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.

19 changes: 17 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ let package = Package(
products: [
.executable(name: "WinMice", targets: ["WinMice"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0")
],
targets: [
.target(
name: "SwipeGesturePoster",
Expand Down Expand Up @@ -37,7 +40,12 @@ let package = Package(
),
.executableTarget(
name: "WinMice",
dependencies: ["SwipeGesturePoster", "ScrollEngine", "SideButtons"],
dependencies: [
"SwipeGesturePoster",
"ScrollEngine",
"SideButtons",
.product(name: "Sparkle", package: "Sparkle")
],
path: "Sources/WinMice",
swiftSettings: [
.unsafeFlags(["-warnings-as-errors"])
Expand All @@ -46,7 +54,14 @@ let package = Package(
.linkedFramework("AppKit"),
.linkedFramework("SwiftUI"),
.linkedFramework("ApplicationServices"),
.linkedFramework("ServiceManagement")
.linkedFramework("ServiceManagement"),
// SwiftPM has no equivalent of Xcode's "Embed & Sign", and it does not add
// a Frameworks rpath to executables. Without this the app links fine and
// then dies at launch with a dyld "Library not loaded: @rpath/Sparkle.framework".
// NOTE: the combined "-Wl,-rpath,PATH" form is rejected by this toolchain's
// swiftc driver ("unknown argument"); -Xlinker pairs produce the identical
// LC_RPATH and are accepted. See task-1-report.md for verification.
.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@loader_path/../Frameworks"])
]
),
.testTarget(
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<img src="docs/icon.png" alt="WinMice icon" width="128" height="128">

A tiny native macOS menu-bar utility that brings Windows-style mouse behavior to Mac: vector scrolling on middle-click and configurable back/forward side buttons. Built on Swift and AppKit with no Electron or bundled runtimes. Ships as a ~600 KB app, runs as a single lightweight process, and uses negligible CPU and memory while idle.
A tiny native macOS menu-bar utility that brings Windows-style mouse behavior to Mac: vector scrolling on middle-click and configurable back/forward side buttons. Built on Swift and AppKit with no Electron and no interpreted runtime. Ships as a ~1.7 MB download, about 4 MB installed — the app's own binary is under 1 MB and the rest is the Sparkle framework that delivers updates — runs as a single lightweight process, and uses negligible CPU and memory while idle.

**[Product site](https://anibalribeiro.cz/Winmice/)** — screenshots and a quick demo loop.

Expand Down Expand Up @@ -33,6 +33,20 @@ row under **System Settings → Privacy & Security → Accessibility**, then
re-enable `/Applications/WinMice.app` once. Later notarized updates keep that
Developer ID identity.

### Updates

WinMice asks, shortly after you first run it, whether it may check for updates.
If you agree it checks once a day, shows you what changed, and installs the
update only when you say so. You can change your mind any time in
**Settings → General → Updates**, or check on demand with **Check for
Updates…** in the menu bar menu.

Homebrew users can keep using `brew upgrade --cask winmice` instead.

Coming from 1.0.0, this one upgrade has to be done by hand — 1.0.0 has no
updater to tell it about anything newer. Every release after this one can
update itself.

## Use

Open **Settings…** from the menu bar to configure:
Expand Down
17 changes: 16 additions & 1 deletion Sources/WinMice/Settings/Panes/GeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

struct GeneralPane: View {
@ObservedObject var settings: AppSettings
@ObservedObject var updater: UpdateController

@State private var loginItemError: String?
@State private var isConfirmingReset = false
Expand All @@ -29,6 +30,13 @@ struct GeneralPane: View {
}
}

Section("Updates") {
Toggle(isOn: automaticallyChecksForUpdates) {
Text("Check for updates automatically")
Text("WinMice checks once a day and asks before installing anything.")
}
}

Section {
LabeledContent {
Button("Restore Defaults…") {
Expand Down Expand Up @@ -58,7 +66,7 @@ struct GeneralPane: View {
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Open at login is a system setting and stays as it is.")
Text("Open at login and the automatic update check are handled outside WinMice's own settings and stay as they are.")
}
}

Expand All @@ -75,4 +83,11 @@ struct GeneralPane: View {
}
)
}

private var automaticallyChecksForUpdates: Binding<Bool> {
Binding(
get: { updater.automaticallyChecksForUpdates },
set: { updater.automaticallyChecksForUpdates = $0 }
)
}
}
3 changes: 2 additions & 1 deletion Sources/WinMice/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct SettingsView: View {
@ObservedObject var settings: AppSettings
@ObservedObject var permissions: PermissionsMonitor
@ObservedObject var recorder: ButtonRecorder
@ObservedObject var updater: UpdateController

@State private var pane: SettingsPane = .scrolling

Expand Down Expand Up @@ -88,7 +89,7 @@ struct SettingsView: View {
case .buttons:
ButtonsPane(settings: settings, recorder: recorder)
case .general:
GeneralPane(settings: settings)
GeneralPane(settings: settings, updater: updater)
case .permissions:
PermissionsPane(permissions: permissions)
case .about:
Expand Down
10 changes: 8 additions & 2 deletions Sources/WinMice/Settings/SettingsWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {

private let recorder: ButtonRecorder

init(settings: AppSettings, permissions: PermissionsMonitor, recorder: ButtonRecorder) {
init(
settings: AppSettings,
permissions: PermissionsMonitor,
recorder: ButtonRecorder,
updater: UpdateController
) {
self.recorder = recorder
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
Expand All @@ -32,7 +37,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
rootView: SettingsView(
settings: settings,
permissions: permissions,
recorder: recorder
recorder: recorder,
updater: updater
)
)
window.appearance = NSAppearance(named: .aqua)
Expand Down
47 changes: 47 additions & 0 deletions Sources/WinMice/UpdateController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Combine
import Sparkle

/// Thin wrapper over Sparkle's standard updater.
///
/// Sparkle owns all update state: `automaticallyChecksForUpdates` reads and writes straight
/// through to `SPUUpdater`, which persists it in `UserDefaults` under `SUEnableAutomaticChecks`.
/// It is deliberately not mirrored into `AppSettings` — Sparkle's own first-launch permission
/// prompt writes the same value, so a second copy would drift.
@MainActor
final class UpdateController: ObservableObject {
private let controller: SPUStandardUpdaterController
/// Sparkle's own first-launch/second-launch permission prompt writes
/// `automaticallyChecksForUpdates` directly on `SPUUpdater`
/// (`updatePermissionRequestFinishedWithResponse:`), bypassing the setter below entirely.
/// KVO is the only way to notice that write and republish it to SwiftUI — Sparkle documents
/// the property as KVO-compliant and main-thread-only, which is what makes the
/// `MainActor.assumeIsolated` in the handler below sound.
private var automaticChecksObservation: NSKeyValueObservation?

init() {
// startingUpdater: true schedules the first check itself. Because Info.plist omits
// SUEnableAutomaticChecks, Sparkle asks the user for permission before checking.
controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
automaticChecksObservation = controller.updater.observe(\.automaticallyChecksForUpdates) { [weak self] _, _ in
MainActor.assumeIsolated {
self?.objectWillChange.send()
}
}
}

/// User-initiated check. Always reports an outcome, even when already up to date.
func checkForUpdates() {
controller.updater.checkForUpdates()
}

/// Pass-through. Notification is driven by the KVO observation set up in `init`,
/// not from here, so writes made by Sparkle itself are published too.
var automaticallyChecksForUpdates: Bool {
get { controller.updater.automaticallyChecksForUpdates }
set { controller.updater.automaticallyChecksForUpdates = newValue }
}
}
Loading
Loading