Skip to content

Latest commit

 

History

History
197 lines (156 loc) · 34.3 KB

Description.md

File metadata and controls

197 lines (156 loc) · 34.3 KB

We are building a macOS utility application.

The purpose of this application is window management.

The user should be able to grab windows, not only in the title bar, but anywhere in the window, while holding configurable modifier keys.

When dragging near a border, the window should be resized. When dragging near the middle, the window should be moved.

The user should be able to define a personalized grid.

When the windows are resized or moved, they should snap to this grid, or to the edges of other windows - whichever is nearer.

You are provided with the basic application template from Xcode.

Current Status

Completed: Stages 1, 2, 3, 4, 5, 6, menu bar conversion, deployment/distribution, post-1.0 refinements, post-1.0.1 feature additions, post-1.0.2 improvements, post-1.0.3 refinements, first-launch onboarding, configurable resize border width, minimum window size filter, per-app window snapping, settings UI refresh, release metadata hardening, percentage + minimum-pixel resize border, custom menu bar icon, mouse button trigger, mouse button app exceptions, Sparkle panel focus fix, grid preview width fix, AX resize degradation fix, exact modifier combinations, and edge shrink / bottom minimize. Current release: v1.1.0.

Architecture

  • GridwellApp.swift — MenuBarExtra + Settings scenes only. App runs as a menu bar agent (LSUIElement = YES): no Dock icon, no App Switcher entry. Menu bar icon uses a custom MenuExtraIcon image asset (Assets.xcassets/MenuExtraIcon.imageset/). Menu contains: Settings… (⌘,), Update Available… (conditional), Check for Updates…, About Gridwell, Quit Gridwell (⌘Q). Settings window is opened via the private SettingsButton view that captures @Environment(\.openSettings) — the correct SwiftUI API (macOS 14+); using the deprecated NSApp.sendAction(Selector(("showSettingsWindow:")), ...) selector produces a runtime warning and must be avoided. SparkleManager owns the SPUStandardUpdaterController and implements SPUStandardUserDriverDelegate for gentle background-update reminders. Settings scene injects GridConfigStore.shared and SparkleManager as environment objects.
  • AppDelegate.swift — Requests Accessibility permission on launch (shows alert + opens System Settings if not granted). Starts monitors once trusted. Observes NSWindow.willCloseNotification to revert the app to .accessory activation policy when the last key window closes.
  • ModifierKeyMonitor.swift — Pure state store for trigger detection. Tracks current modifier flags and non-modifier key state. Exposes handleFlagsChanged(_:CGEvent), handleKeyDown(keyCode:), handleKeyUp(keyCode:) — called directly by the CGEventTap in MouseInteractionHandler. Computes isTriggerActive from both modifier and key state against the configured TriggerShortcut.
  • MouseInteractionHandler.swift — Single CGEventTap at .cgSessionEventTap / .headInsertEventTap covering all intercepted event types: leftMouseDown/Dragged/Up (drag/resize), flagsChanged (modifier tracking, always passed through), keyDown/keyUp (key tracking; suppressed if they match the trigger shortcut). This single session-level tap covers both app-active and app-inactive cases. Identifies the target window via WindowInfoProvider. Computes candidate frames via GridSnapper and forwards them to WindowManipulator.
  • WindowInfoProvider.swift — Uses CGWindowListCopyWindowInfo to enumerate on-screen windows. Exposes window(at: CGPoint) for hit-testing in CoreGraphics coordinates. WindowInfo includes pid for use by the Accessibility API. When multiple windows overlap the click point, window(at:) re-queries the window server for a fresh front-to-back ordering (since CGWindowListCopyWindowInfo does not guarantee order within the same layer) and returns the genuinely topmost candidate. Phantom/ghost windows are excluded via four combined guards: layer == 0 (drops menu bar items and system overlays), alpha > 0 (drops transparent utility surfaces), a system-owner blocklist (Window Server, Dock, SystemUIServer, Control Center, etc.), and non-zero frame size.
  • WindowManipulator.swift — Uses AXUIElementCreateApplication(pid) + kAXPositionAttribute/kAXSizeAttribute to move and resize windows via the Accessibility API. When the target window belongs to Gridwell's own process, uses NSWindow.setFrame (AppKit) instead — the AX API cannot manipulate the calling app's own windows. A DispatchSourceTimer (10 Hz) on a background serial queue applies the latest pending frame. Each frame is applied via a setFrame helper that sets size before position (to prevent the system from repositioning the window after a size change) and uses a bounded read-back retry loop (up to 3 attempts, 80 ms time budget, stale-frame abort) that verifies both position and size before declaring success. AXUIElementSetMessagingTimeout is called with 0.5 s at drag start so a momentarily unresponsive app can't block the serial queue for the system default (several seconds). AXEnhancedUserInterface is temporarily disabled for the drag session (restored in endDrag) to prevent animated window moves in Chromium/Electron apps that would cause read-back mismatches and excessive retries — the documented workaround used by Rectangle and Nudge.
  • GridConfigStore.swift — ObservableObject singleton. Stores a [String: ScreenGridConfig] (columns + rows per screen), raiseWindowOnDrag: Bool, minWindowWidth/Height: Int, resizeBorderPercent/resizeBorderMinPixels, edge shrink settings (edgeZonePercent default 5 %, bottomZoneHeight default 40 pt, edgeShrinkMinWidth default 400 pt), a TriggerShortcut (JSON-encoded), and one JSON-encoded ModifierCombination per ModifierFeature (keys <feature>Modifiers) in UserDefaults, accessed via modifiers(for:) / setModifiers(_:for:) / conflictingFeature(for:excluding:). Screen keys use point-space dimensions only (e.g. "2560x1440"), falling back to appending origin when two screens share the same size. Uses a versioned migration system: a settingsVersion integer in UserDefaults tracks the current data structure version; on init, runMigrations() runs all pending migrations in sequence. Current version: 4. Migration v0→v1 converts the legacy single-ModifierKey trigger to TriggerShortcut; migration v1→v2 renames all com.gridwell.* keys to plain names; v2→v3 converts the resize border to percent + min pixels; v3→v4 converts the single snap keys to one-key ModifierCombinations (trigger not added). reset_prefs_to_v3.sh resets local preferences to the v3 format (with backup) to re-test the v3→v4 migration. delete_prefs.sh deletes all preferences (with backup) for a fresh start; --accessibility also resets the Accessibility permission.
  • GridSnapper.swift — Stateless helpers for drag-zone detection, candidate frame computation, and snapping. Contains SnapMode enum (.none, .windows, .grid). When moving with window-snap active, snaps whichever of the window's four edges (left, right, top, bottom) is nearest to a candidate edge — not just left/top. Screen detection for snapping uses the cursor position (not the candidate window frame) via containingScreen(for: CGPoint). The cgFrame helper converts NSScreen → CG coordinates using NSScreen.screens.first (the stable primary display) for the Y-flip height.
  • ModifierKey.swift — ModifierCombination struct (set of fn/⌃/⌥/⇧/⌘; empty = disabled; isActive(held:) is an exact match) and ModifierFeature enum (.windowSnap, .appWindowSnap, .gridSnap, .edgeShrink) with storage keys and defaults (⇧, ⌥, ⌃, disabled). The legacy ModifierKey enum remains only for settings migrations. Also contains TriggerShortcut struct: Codable/Equatable, holds modifierFlagsRaw: UInt + optional keyCode: UInt16 + optional keyDisplayString: String. Includes displayString (e.g. "fn", "⌃⌥F"), relevantModifiers constant, and defaultFN singleton.
  • EdgeZoneOverlay.swift — EdgeZoneOverlay: click-through, non-activating NSPanel strip (.statusBar level, all Spaces, accent-tinted, per-edge symbol) used to highlight the bottom minimize zone during a drag. EdgeZonePreview.shared: shows the side and bottom zones of all screens while a zone slider in the Behaviour tab is dragged (lingers 1 s).
  • PreferencesView.swift — Tabbed preferences window (Grid / Behaviour / Keys / Updates). Grid, Behaviour, and Keys tabs use a shared card-style SwiftUI layout (PreferenceCard, PreferenceSectionTitle, shared helper text styling) rather than default Form/GroupBox presentation. Grid tab uses per-screen cards with higher-contrast grid previews and discrete sliders for column/row counts. Behaviour tab uses card-based switch/slider controls for raise-on-drag, minimum window size, and resize-border width. Behaviour tab also has a "Screen Edges" card (side zone %, bottom zone height, minimum shrink width) whose zone sliders drive EdgeZonePreview. Keys tab uses cards for drag trigger and modifier combinations; the "Drag Trigger" card uses a click-to-record ShortcutRecorderRow (backed by RecorderState: ObservableObject), and the "Modifier Combinations" card uses a modifier-only click-to-record ModifierCombinationRow per feature (backed by ModifierRecorderState) with a bordered Disable button and inline conflict message. Recorder tracks the last state active before the first key release (snapshots only on press events) so releasing keys in any order commits the correct combination. Escape cancels recording.
  • bump_version.sh / release.sh / Info.plist — Version source of truth is the Xcode project build settings: MARKETING_VERSION and CURRENT_PROJECT_VERSION. Info.plist uses $(MARKETING_VERSION) and $(CURRENT_PROJECT_VERSION) placeholders so Xcode's General tab and the built app bundle cannot drift apart. bump_version.sh updates the Xcode project directly, validates X.Y.Z versions, supports safe --help, and sets build numbers using the YYYYMMDDss scheme. release.sh validates --version, auto-bumps when no .app path is supplied, and commits the appcast together with version metadata when it performs the bump.

Key implementation notes

  • App Sandbox is disabled (ENABLE_APP_SANDBOX = NO) — required for global event monitoring and window manipulation.
  • Trigger shortcut (default FN/Globe) is user-configurable as a modifier-only combination or modifier+key combination (e.g. ⌃⌥F), recorded via a click-to-record UI. Persisted as JSON. Feature modifier combinations (snap to windows ⇧, same-app ⌥, grid ⌃, edge shrink disabled) are matched exactly against the full set of held modifiers, including the trigger's — the trigger is meant to be released after starting the drag. All keys are read live at event time; flagsChanged during a drag re-runs the drag update so modifier changes apply without mouse movement.
  • Event interception architecture: one CGEventTap at .cgSessionEventTap/.headInsertEventTap handles everything. flagsChanged events update modifier state and are passed through. keyDown/keyUp events update key state; if they match the trigger shortcut (key code + required modifiers), they are suppressed (return nil) so no other app receives them. Mouse events are suppressed when the trigger is active.
  • Drag interaction: trigger shortcut + left mouse starts a drag. Releasing the trigger does not end the drag — only mouse-up does. Snap mode is determined per-drag-event: snap-to-windows combination → snap to other window edges (always nearest, no threshold), snap-to-grid combination → snap to grid (always nearest, no threshold), none → no snapping.
  • Edge shrink / bottom minimize (move drags only, while the edge shrink combination is held exactly): the screen under the cursor is locked and the cursor clamped to it (edges between monitors act as outer edges). In the left/right zones (edgeZonePercent of the locked screen's width) the window scales uniformly around the grab point from full size down to edgeShrinkMinWidth (never below the grab filter minWindowWidth/Height), and is kept horizontally inside the screen. In the bottom zone between the side zones, the overlay strip is shown and mouse-up restores the drag-start frame, then minimizes (kAXMinimizedAttribute, or NSWindow.miniaturize for own windows) on the AX queue. Releasing the combination returns the window to full size at the cursor. Full sizes of windows left shrunk are remembered per window ID (in memory) and used when picked up with the combination held. Full concept: Edge Shrink Feature.md.
  • Window-snap move: snaps the nearest of the moved window's four edges to the nearest candidate edge. When two edges are equidistant the left/top edge wins.
  • Move + grid snap: snaps window to a grid position determined by cursor location. Width = one cell width. Height is cursor-position-aware: cursor in top half of its row → 1 cell height; cursor in bottom half and a row exists below → 2 cells; cursor within 25 % of a cell height from the screen bottom (3+ row grids only) → full screen height.
  • Move + no snap / window snap: window keeps its original size. Releasing the snap modifier mid-drag restores original size automatically.
  • Resize zones: any of the four edges when the click is within resizeBorderWidth points of that edge (configurable, default 150 pt); clamped to 40 % of the relevant window dimension on small windows. Corners activate both adjacent edges simultaneously. Centre clicks are move.
  • Own-window drag: when the target window belongs to Gridwell's own PID (e.g. the Settings window), WindowManipulator uses NSWindow.setFrame on the main thread instead of the AX API, which would crash.
  • Raise on drag: when enabled (default), the interacted window and its owning app are brought to the front at drag start. Configurable in Behaviour tab.
  • Opening the Settings window: use the SettingsButton view (defined in GridwellApp.swift) which captures @Environment(\.openSettings) — this is the correct SwiftUI API on macOS 14+. Do not use NSApp.sendAction(Selector(("showSettingsWindow:")), ...): that selector is deprecated and produces the runtime warning "Please use SettingsLink for opening the Settings scene."
  • Bringing any window to front: because the app normally runs as a .accessory (no Dock icon), windows opened from the menu bar will not receive focus unless the app is first promoted to .regular policy and activated. Always call await activateAppForUI() (defined in GridwellApp.swift) before showing any new window. The AppDelegate's NSWindow.willCloseNotification observer automatically reverts the policy back to .accessory once all key windows are closed.
  • Sparkle gentle reminders: background update checks show an "Update Available…" menu item rather than a hidden alert.
  • CGEvent.location, CGWindowListCopyWindowInfo, and AXUIElement position/size all use CoreGraphics coordinates (origin top-left of primary screen, Y down) — no conversion needed between them.
  • NSScreen identity in ForEach uses displayID (CGDirectDisplayID from deviceDescription) — stable across layout passes. Screen list is captured in @State on onAppear rather than queried directly in body.

Stages

  1. ✅ Create the basic skeleton framework of the application with these items:
  • An empty preference Window and a menu item for opening it (shortcut CMD ;)
  • The means to intercept modifier key changes
  • The means of determining the positions and sizes of all windows on screen.
  1. ✅ Implementing the mouse interaction
  • Recognize a mouse left button down if the configured modifier keys are held down (hardcoded to FN key)
  • Recognize which window is being interacted with
  • Intercept/suppress mouse events (via CGEventTap) so underlying windows do not react
  • Track mouse dragging until mouse button is released (logs coordinates to console)
  1. ✅ Implement the means to modify the position and sizes of the windows on screen. For testing, simply move the window the user is interacting with (by holding down the modifier keys and pressing the mouse button) to a hard-coded, predefined location and size.

  2. ✅ Implement the user interface in the preferences window to define the custom grid.

  • Make the preferences window a tabbed window so we can later add other preferences.
  • Take into account that there might be more than one screen. Let the user define different grids for different screens.
  • Define a reasonable default for the grid, for example, columns of full height of a number depending on the width of the screen. For widescreens, more columns.
  1. ✅ Implement the final user interaction for dragging/resizing windows on screen while taking into account the defined grid and the other windows on screen, snapping the currently interacted with window to the grid or to the other windows, whichever is nearer.

  2. ✅ Add user interface in the preferences window to define the modifier keys.

  3. ✅ Integrate Sparkle auto-update framework and set up distribution pipeline.

  • Sparkle 2.x added via Swift Package Manager
  • EdDSA key pair generated; public key embedded in Info.plist
  • "Check for Updates…" menu item wired up via SPUStandardUpdaterController
  • Gentle background-update reminders via SPUStandardUserDriverDelegate — shows "Update Available…" menu item instead of a hidden alert
  • "Automatically check for updates" toggle in Updates preferences tab
  • "Check for Updates…" button in Updates preferences tab (reuses CheckForUpdatesView, disabled while a check is already in progress)
  • Sparkle update windows now reliably open in front: activateAppForUI() shared helper promotes to .regular policy and activates before any UI is shown; used by both the settings window path and Sparkle's activateAndCheckForUpdates(). AppDelegate's existing NSWindow.willCloseNotification observer reverts to .accessory when all windows close.
  • Appcast hosted at https://raw.githubusercontent.com/jdeepwell/gridwell/main/appcast.xml
  • DMG releases published as GitHub Release assets at https://github.com/jdeepwell/gridwell
  • release.sh automates: archive + export via xcodebuild (app path optional), Sparkle component re-signing, DMG creation (via create-dmg with background image and Applications symlink), notarization, stapling, appcast generation, GitHub Release creation (DMG + all Gridwell${BUILD}-*.delta files), and appcast/version-metadata commit + push. Supports --clobber flag to overwrite an existing GitHub Release (delete-then-recreate). Delta files must be uploaded alongside the DMG or Sparkle raises "improperly signed" errors on update.
  • make_dmg.sh creates a styled test DMG without signing/notarization/GitHub upload, and opens it automatically for inspection.
  • dmg-background.png provides the drag-to-install background (1152×928 px, displayed at 576×464 pt on Retina).
  • Versioning uses three-level scheme (major.minor.patch). bump_version.sh automates version and build number updates by editing Xcode build settings directly; called automatically by release.sh before archiving. Info.plist references these build settings instead of hardcoding release values.
  1. ✅ Post-1.0 refinements (shipped in v1.0.1)
  • Resize from all four window edges (left/top edges added; corners activate both adjacent edges)
  • Smarter grid snap height: cursor position within a row determines 1-cell vs 2-cell span; bottom-edge shortcut for full screen height on 3+ row grids
  • Settings window now reliably opens in front using activation policy switching and NSApp.sendAction to trigger the settings window directly
  1. ✅ Post-1.0.2 improvements (shipped in v1.0.3)
  • Check for Updates button in preferences: "Check for Updates…" button added to the Updates tab in the settings window, alongside the existing automatic-check toggle. Reuses CheckForUpdatesView (disabled while a check is already in progress).
  • Fully automated release pipeline: release.sh now accepts an optional app path. When omitted, it archives and exports the project automatically via xcodebuild archive + xcodebuild -exportArchive, using ExportOptions.plist (Developer ID, team PZ44T4KUAK). xcodebuild stdout is suppressed; only errors (stderr) are shown.
  • Automated version/build number bumping: bump_version.sh reads the current MARKETING_VERSION and CURRENT_PROJECT_VERSION from the Xcode project, increments the patch number (or accepts an explicit version), and sets the build number to YYYYMMDDss (today's date + two-digit serial, incremented if already used today). release.sh calls it automatically before archiving and accepts a --version X.Y.Z flag. A safety check aborts if the requested version is ≤ the current version without --clobber.
  • Settings window resize fix: all preference tabs now use .fixedSize(horizontal: false, vertical: true) so the window correctly resizes to each tab's content height when switching tabs.
  1. ✅ Screen edges as snap targets in window-snap mode
  • In window-snap mode (FN + Shift), all four edges of every connected screen are now treated as snap candidates alongside other window edges. Works correctly with multi-monitor setups.
  1. ✅ Post-1.0.1 feature additions (shipped in v1.0.2)
  • Four-edge window snapping: when snap-to-windows is active during a move, all four edges of the moved window (left, right, top, bottom) now snap to other window edges — not just left/top. The nearest edge wins.
  • Drag/resize works when Gridwell is active: the CGEventTap now covers flagsChanged, keyDown, and keyUp in addition to mouse events, replacing separate NSEvent global/local monitors. This single session-level tap works regardless of which app is frontmost. Own-process windows (e.g. Settings) are moved via NSWindow.setFrame since the AX API cannot manipulate the calling app's own windows.
  • Configurable trigger shortcut: the trigger is no longer limited to a single modifier key. Users can record any combination of modifiers and optionally a regular key (e.g. ⌃⌥F) via a click-to-record UI in the Keys preferences tab. Non-modifier trigger keys are fully suppressed by the CGEventTap so they do not reach any other application. Snap modifier keys remain single-modifier pickers. Old single-modifier setting is migrated automatically.
  1. ✅ First-launch onboarding (shipped in v1.0.5)
  • After accessibility permission is granted, the waiting window transitions in place to a "You're All Set!" screen showing where-is-gridwell.png (annotated screenshot pointing to the menu bar icon) with a "Got it" button. Auto-dismisses after 8 seconds. Teaches new users that the app lives in the menu bar without any extra permission prompts.
  1. ✅ Post-1.0.3 refinements (shipped in v1.0.5)
  • Accessibility permission flow: on first launch without accessibility permission, the app shows a modal alert offering "Open Settings" or "Quit". If the user clicks "Open Settings", System Preferences opens to the Accessibility pane and a new waiting window appears (showing waiting-for-permissions.png at its native 1x size plus a Quit button). The app polls AXIsProcessTrusted() every 0.5 s; as soon as the user grants permission the waiting window closes and event monitoring starts — no relaunch required.
  • Settings window fix: replaced deprecated NSApp.sendAction(Selector(("showSettingsWindow:")), ...) with a SettingsButton SwiftUI view that uses @Environment(\.openSettings), eliminating the "Please use SettingsLink" runtime warning.
  • Minimum window size filter: accessory windows (palette views, attached panels) that are below a configurable minimum width or height are now excluded from window grabbing. Defaults to 100 × 100 pt. Configurable in the Behaviour preferences tab. Setting either value to 0 disables filtering for that dimension. Implemented in WindowInfoProvider.refresh() using values from GridConfigStore.
  • Per-modifier app-window snapping: replaced the "snap to same app only" checkbox with a dedicated appWindowSnapKey modifier (default Option). Holding it snaps only to windows of the same application; the existing windowSnapKey (Shift) still snaps to all windows. Both are configurable in the Keys tab under "Snap Modifiers". The window list is filtered dynamically per drag event in MouseInteractionHandler rather than at drag start.
  1. ✅ Unreleased refinements for next release
  • Settings UI refresh: Grid, Behaviour, and Keys preferences now share a cleaner card-based layout. Behaviour uses switch/slider controls with large numeric readouts for minimum window size and resize-border width. Keys uses clearer drag-trigger and snap-modifier cards. Grid uses discrete sliders instead of plus/minus buttons for column/row counts and a darker, higher-contrast grid preview with clearer cell boundaries.
  • Version metadata hardening: Xcode project MARKETING_VERSION is synced to the published v1.0.5 release. Info.plist now uses $(MARKETING_VERSION) and $(CURRENT_PROJECT_VERSION) placeholders. bump_version.sh validates version input, supports safe --help, updates Xcode build settings directly, and preserves the Info.plist placeholders. release.sh validates --version and commits version metadata with the appcast when it auto-bumps.
  • Percentage + minimum-pixel resize border: the single resize-border-width setting is replaced by two independent controls — a percentage of the window dimension (1–50 %, default 25 %) and a minimum pixel floor (0–300 pt, default 40 pt). The effective border is max(dimension × %, minPx), still clamped to 40 % on small windows. Settings migration v2→v3 preserves any existing pixel value as the new minimum. Both controls appear side-by-side in the Behaviour tab.
  • Custom menu bar icon: replaced the SF Symbol rectangle.3.group with a custom MenuExtraIcon PNG image asset. The icon is stored in Assets.xcassets/MenuExtraIcon.imageset/ and referenced via image: "MenuExtraIcon" in the MenuBarExtra scene.
  • Mouse button trigger: windows can now be dragged and resized by pressing a configurable extra mouse button (middle, back, forward, etc.) without any modifier key. The button is chosen via a click-to-record row in the Keys preferences tab (same card pattern as the keyboard shortcut recorder). Setting stored as mouseButtonTrigger: Int? in GridConfigStore (nil = disabled). The CGEventTap intercepts otherMouseDown/Dragged/Up; a DragSource enum tracks whether the active drag was started by the modifier key or the mouse button so both triggers coexist independently.
  • Mouse button app exceptions: a per-app exclusion list for the mouse button trigger. In listed apps the configured mouse button is passed through untouched so native behaviour (e.g. middle-click auto-scroll in browsers) is preserved. Configured via a new "Mouse Button Exceptions" card in the Keys tab — click "+" to pick any .app from an NSOpenPanel, "−" to remove. Stored as a JSON array of ExcludedApp structs (bundle ID + display name) in UserDefaults. The exclusion check in MouseInteractionHandler does a lightweight CGWindowListCopyWindowInfo query on each otherMouseDown to identify the topmost window at the cursor and resolve its pid to a bundle identifier via NSRunningApplication.
  • Grid preview fills card width: the clipShape+strokeBorder overlay is now applied directly to the aspectRatio view (before the frame modifier), so the border wraps the actual grid area instead of a full-width transparent container. The frame is changed from maxWidth: 250, maxHeight: 108 (two frames) to a single frame(maxWidth: .infinity, maxHeight: 200). For ultra-wide screens (e.g. 5120 × 2160, ratio 2.37:1) the grid fills the full card width with equal 16 pt padding on all sides; for standard monitors the grid is taller and wider than before and remains centred.
  • AX resize degradation fix: window dragging/resizing no longer degrades over time or with fast mouse movements. Three changes to WindowManipulator: (1) AXUIElementSetMessagingTimeout (0.5 s) bounds each AX IPC call so an unresponsive target app can't block the serial timer queue for the system default (several seconds); (2) AXEnhancedUserInterface is temporarily disabled for the drag session (restored in endDrag) to prevent animated window moves in Chromium/Electron apps that cause read-back mismatches and excessive retries — the documented workaround used by Rectangle and Nudge; (3) the setFrame retry loop is bounded to 3 attempts with an 80 ms time budget and a stale-frame abort (between retries, if a newer pendingFrame arrived from the drag handler, the current frame is abandoned instead of burning IPC calls on an obsolete frame). Worst-case IPC calls per tick drop from 20 to 12, and fast mouse movements no longer cause the queue to back up.
  1. ✅ Modifier combinations, edge shrink & bottom minimize (shipped in v1.1.0) — see Edge Shrink Feature.md
  • Exact modifier combinations for all drag features, recorded via click-to-record, with conflict checking and per-feature disable. Settings migration v3→v4.
  • Edge shrink: side zones (percentage of screen width) shrink the dragged window towards a minimum width, anchored at the grab point, kept inside the locked screen; original size remembered.
  • Bottom minimize: releasing in the bottom zone minimizes the window, with an overlay strip as visual indication.
  • Zone preview on all screens while adjusting the zone sliders.

Releasing

The project is on GitHub and releases are also published on GitHub.

Here's how to use release.sh:

release.sh — Release Script

Builds a signed, notarized DMG, updates the Sparkle appcast, and publishes a GitHub Release.

Prerequisites (one-time setup)

  1. Install the GitHub CLI: brew install gh && gh auth login
  2. Store Apple notarization credentials: xcrun notarytool store-credentials "notarytool-profile"
    --apple-id "your@apple-id.com"
    --team-id "PZ44T4KUAK"
    --password "app-specific-password"

Usage

./release.sh [--clobber] [--version X.Y.Z] [<path-to-exported-Gridwell.app>]

Examples: ./release.sh # auto-bump patch, archive, and release ./release.sh --version 1.1.0 # release with explicit version ./release.sh --clobber --version 1.0.3 # re-release same version (overwrites GitHub Release) ./release.sh ~/Desktop/Gridwell.app # use pre-built .app (skips archive + version bump)

When no .app is supplied, the script bumps the version/build number automatically (via bump_version.sh) and archives the project. Requesting a version ≤ the current version without --clobber aborts with an error. When the script performs the auto-bump, it commits the Xcode project version metadata and Info.plist placeholders together with the updated appcast.

What it does

┌──────┬────────────────────────────────────────────────────────────────────┐ │ Step │ Action │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 1 │ Re-signs Sparkle's XPC services and helpers with your Developer ID │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 2 │ Creates a compressed DMG in releases/ │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 3 │ Code-signs the DMG │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 4 │ Submits the DMG to Apple for notarization (takes a few minutes) │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 5 │ Staples the notarization ticket to the DMG │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 6 │ Runs generate_appcast to produce appcast.xml │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 7 │ Commits appcast.xml and version metadata to the repo │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 8 │ Creates a GitHub Release tagged vX.Y and uploads the DMG │ ├──────┼────────────────────────────────────────────────────────────────────┤ │ 9 │ Pushes the release metadata commit so the live Sparkle feed updates│ └──────┴────────────────────────────────────────────────────────────────────┘

Output

  • releases/Gridwell-X.Y.dmg — the distributable installer
  • appcast.xml — updated Sparkle feed at raw.githubusercontent.com/.../main/appcast.xml

IMPORTANT: Do not forget to also update the changelog before releasing the new version!