diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 29ebae0..56bc881 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -145,6 +145,13 @@ jobs:
- name: Build, sign, notarize, staple, package, checksum, appcast
run: scripts/sign-and-notarize.sh
+ - name: Compose the release notes
+ run: |
+ set -euo pipefail
+ python3 scripts/release-notes.py "${{ steps.version.outputs.version }}" \
+ > build/release-notes.md
+ wc -l build/release-notes.md
+
- name: Release candidate artifacts
if: ${{ inputs.publish != true }}
uses: actions/upload-artifact@v4
@@ -154,6 +161,7 @@ jobs:
build/*.dmg
build/SHA256SUMS
build/appcast.xml
+ build/release-notes.md
build/notarization/*.json
retention-days: 3
@@ -164,7 +172,7 @@ jobs:
version="${{ steps.version.outputs.version }}"
gh release create "v${version}" \
--title "${version}" \
- --notes-file <(python3 scripts/changelog-section.py "${version}") \
+ --notes-file build/release-notes.md \
--prerelease \
build/OpenDeviceHub-${version}.dmg build/SHA256SUMS
env:
@@ -179,6 +187,7 @@ jobs:
build/*.dmg
build/SHA256SUMS
build/appcast.xml
+ build/release-notes.md
retention-days: 90
- name: Clean up
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6b2a3c..9325310 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,16 @@ First release.
you switch to another app.
- A window follows its device: it shows when the device has shut down, offers a reboot, and
reattaches on its own when the device comes back.
+- A capture shows itself beside the window before it is filed, so you can open, copy, save or discard
+ it. Left alone it saves itself, to a folder you choose.
+- Create a simulator without leaving the app: name, device type and OS version, where the two lists
+ follow each other so a device no installed runtime can run is never offered.
+- Device controls beyond the buttons: restart, erase, text size, increased contrast, an iCloud sync,
+ and a location from a scenario or your own coordinates.
+- Keyboard choices: send keys to the device or keep them, connect the Mac keyboard as the device's
+ hardware keyboard, and match the device's keyboard language to the Mac's.
+- Settings for what closing a window does, what launching opens, where captures go, whether device
+ links open here, and updates.
- The menu bar follows the simulator it replaces: the same menus in the same order, the same
shortcuts, and the standard macOS ones that were missing. The zoom shortcuts are Apple's now,
⌘1 Physical Size, ⌘2 Point Accurate, ⌘3 Pixel Accurate, ⌘4 Fit Screen, which changes three of the
@@ -28,5 +38,3 @@ First release.
One menu item puts it on your PATH, without a password on most machines.
- If Xcode is missing or its simulator frameworks will not load, the app says what is missing and
what to do about it instead of failing silently.
-
-Known gaps are listed in the README.
diff --git a/README.md b/README.md
index 8946451..45515ac 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,34 @@
-# OpenDeviceHub
-
-**A macOS app that shows your iOS simulators in windows, one window per device, the way
-Simulator.app did.**
-
-Xcode 27 replaced Simulator.app with Device Hub, and a decade of habits did not come across with it.
-OpenDeviceHub connects to the same simulators Xcode installs and brings back the familiar window,
-scaling and shortcuts, with a command line tool for driving a device from a script.
-
-> **Screenshot goes here.** Placeholder: replace this block with a screenshot or a short GIF of a
-> device window before publishing.
+
+
+
+
+OpenDeviceHub
+
+
+ Your iOS simulators in windows again, one per device.
+
+
+
+ Xcode 27 replaced Simulator.app with Device Hub, and a decade of habits did not come across with
+ it. OpenDeviceHub connects to the same simulators Xcode installs and brings back the familiar
+ window, scaling and shortcuts, with a command line tool for driving a device from a script.
+
+
+
+
+ A screenshot goes here.
+
+
+
+
+
+
+
+
+
+ Signed, notarized, and it updates itself.
+
## Install
@@ -25,24 +45,36 @@ simulators.
## Features
-- **A window per device** — several simulators side by side, resizable from any edge or corner, full
+- **A window per device**: several simulators side by side, resizable from any edge or corner, full
screen, keep on top, and each window remembers where you put it.
-- **Precise scaling** — Point Accurate, Pixel Accurate, Physical Size or Fit, with the device body
+- **Precise scaling**: Point Accurate, Pixel Accurate, Physical Size or Fit, with the device body
drawn around the screen or hidden.
-- **Opens like an app** — click the icon for whatever is running, or pick a simulator from the Dock
+- **Opens like an app**: click the icon for whatever is running, or pick a simulator from the Dock
icon or File → Open Simulator. A device booted anywhere else gets a window here too.
-- **Familiar shortcuts** — the Simulator keys you already know.
-- **Input** — click to tap and drag, pinch and rotate with the trackpad, type with your Mac
- keyboard, and swipe up from the bottom edge for home or the app switcher.
-- **Device controls** — Home, Lock, volume, rotation, and light or dark appearance, from the menu or
- by clicking the buttons on the device body.
-- **Screenshots and recordings** — PNGs and MP4s to the Desktop, or a screenshot to the clipboard.
-- **Drag and drop** — drop a file or a link onto a device to open it there.
-- **Follows the device** — shut a simulator down from anywhere and its window says so and offers to
+- **Familiar shortcuts**: the Simulator keys you already know.
+- **Input**: click to tap and drag, pinch and rotate with the trackpad, type with your Mac
+ keyboard, and swipe up from the bottom edge for home or the app switcher. Send keys to the device or
+ keep them, connect the Mac keyboard as the device's hardware keyboard, and match its language to
+ yours.
+- **Device controls**: Home, Lock, volume, rotation, Siri, the Action button and light or dark
+ appearance, from the menu or by clicking the buttons on the device body. Restart or erase a device,
+ step its text size, turn on increased contrast, trigger an iCloud sync, and set a location from a
+ scenario or your own coordinates.
+- **Create a simulator**: name it, pick a device type and an OS version, and it boots and opens. The
+ two lists follow each other, so a device an installed runtime cannot run is never offered.
+- **Screenshots and recordings**: a capture appears beside the window first, where you can open,
+ copy, save it elsewhere, reveal it in the Finder or throw it away. Left alone it files itself. The
+ folder is yours to choose.
+- **The clipboard stays in step**: copy on the Mac and it is on the device; copy on the device and it
+ is on the Mac when you switch to another app. Or do it by hand from Edit.
+- **Settings**: whether closing a window shuts the device down, whether launching opens the
+ simulator you had last, where captures go, and which app opens device links.
+- **Drag and drop**: drop a file or a link onto a device to open it there.
+- **Follows the device**: shut a simulator down from anywhere and its window says so and offers to
start it again; boot it and the window reattaches on its own.
-- **Debug helpers** — slow animations, shake, simulated memory warning, the system log and app data
+- **Debug helpers**: slow animations, shake, simulated memory warning, the system log and app data
in the Finder, and a click to frame latency overlay.
-- **A command line tool** — `odhub` drives a simulator from a script: tap, swipe, pinch, type,
+- **A command line tool**: `odhub` drives a simulator from a script: tap, swipe, pinch, type,
buttons, rotation. One menu item puts it on your `PATH`.
@@ -94,28 +126,44 @@ options.
## Current limitations
-
-- **Input can stop on a running simulator** — a booted device sometimes stops accepting taps and
- keystrokes while it still renders and still reports itself booted, with no error anywhere. Not the
- app window: a command line tap fails on the same device at the same moment, while a device booted
- fresh right then is fine. Shut the simulator down and boot it again. Under investigation.
-- **Copying from a device does nothing** on at least one machine, which traces to `simctl pbpaste`
- returning nothing. Pasting to a device works.
-- **The windows are invisible to accessibility** — VoiceOver, window managers and scripting tools see
+- **The windows are invisible to accessibility**: VoiceOver, window managers and scripting tools see
the app and its menus but not its windows.
- **A device taller than your display** keeps its bottom edge off screen, where the pointer cannot
reach it. Use Fit.
-- **Simulators shut themselves down when idle** on at least one machine. Not reproduced on demand,
- and CoreSimulator has no setting for it.
- **The compact layout below 340 points wide** is written and unit tested but has never been used by
hand.
-- **No Flutter tooling yet** — it is the reason this project exists, and none of it is built.
-- **Other Apple platforms** — tvOS, watchOS and visionOS simulators are not supported.
+- **Other Apple platforms**: tvOS, watchOS and visionOS simulators are not supported.
Want to help close one of these? Pull requests are welcome. [ROADMAP.md](ROADMAP.md) is where that
list is going: location, push, biometrics, proxy certificates, device management, and the project
aware tooling this exists for.
+## Working with Device Hub
+
+Xcode 27 opens Device Hub itself when you run an app on a simulator, and other tools may launch it
+too. OpenDeviceHub shows the same devices, so both can be on screen at once. Two of Apple's own
+settings make them share better.
+
+Quitting Device Hub can shut down the simulators it started, which closes their windows here. To stop
+that, quit Device Hub and run:
+
+```sh
+defaults write com.apple.dt.Devices shutdownStartedDevicesOnQuit -bool false
+```
+
+To stop Xcode opening Device Hub at all, quit Xcode and run:
+
+```sh
+defaults write com.apple.dt.Xcode DVTiPhoneSimulatorAlwaysLaunchInCoreSimulatorSession -bool true
+```
+
+Xcode then boots the simulator without a window of its own and yours is the one you use. With this
+on, boot the device here before pressing Run, or Xcode may shut it down when you press Stop.
+
+**OpenDeviceHub → Settings → Links** offers to open `devices://` links here instead. That covers
+links only. Nothing can take over from a tool that launches Device Hub directly, because it is
+launched by name rather than through the link.
+
## Requirements
macOS 14 or later, Apple silicon, and Xcode 26 or 27 with at least one iOS simulator runtime.
diff --git a/ROADMAP.md b/ROADMAP.md
index 79df955..4eec055 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,33 +11,32 @@ mentioned.
Things the classic Simulator had, or that a simulator should simply be able to do.
-- **Location** — presets, a search, GPX routes, and per-project saved places.
-- **Push notifications** — send a payload to an app, with a saved library of them and templates for
+- **Location**: a search, GPX routes, and per-project saved places. Scenarios, your own coordinates
+ and clearing the location already work.
+- **Push notifications**: send a payload to an app, with a saved library of them and templates for
the fiddly parts.
-- **Biometrics** — enrol, match and fail a match for Face ID and Touch ID.
-- **Device and runtime management** — create, rename, erase and delete simulators, and install
- runtimes, without going back to Xcode.
-- **Proxy certificates** — drop a `.cer` onto a device, or install a debugging proxy's root
+- **Biometrics**: enrol, match and fail a match for Face ID and Touch ID.
+- **Device and runtime management**: rename and delete simulators, and install runtimes, without
+ going back to Xcode. Creating and erasing already work.
+- **Proxy certificates**: drop a `.cer` onto a device, or install a debugging proxy's root
certificate on every booted device at once. Removed from Device Hub, and badly missed by anyone
who inspects traffic for a living.
-- **Status bar presets** — 9:41, full battery, full signal, for clean App Store screenshots.
-- **Locale, region and Dynamic Type** — switch them without digging through Settings.
-- **Privacy permissions** — grant, revoke and reset per app, so the first-run flow can be tested more
+- **Status bar presets**: 9:41, full battery, full signal, for clean App Store screenshots.
+- **Locale and region**: switch them without digging through Settings. Text size and increased
+ contrast already work.
+- **Privacy permissions**: grant, revoke and reset per app, so the first-run flow can be tested more
than once.
## Tooling for the app you are actually building
-The reason this project exists, and none of it is built yet. The theme is everything a mobile
-developer scripts by hand today, one click away, remembered per project.
-
-- **Project awareness** — point it at a project and it learns the bundle identifiers, flavours and
+- **Project awareness**: point it at a project and it learns the bundle identifiers, flavours and
entry points, then remembers your deep links, payloads, proxy settings and favourite devices
alongside them.
-- **Run, hot reload and hot restart** — on any device, flavour aware, from the device window.
-- **DevTools** — a link on the window once the app is attached, and attaching to an app that is
+- **Run, hot reload and hot restart**: on any device, flavour aware, from the device window.
+- **DevTools**: a link on the window once the app is attached, and attaching to an app that is
already running.
-- **Deep links** — saved per app, custom schemes and universal links, one click to fire.
-- **Screenshot sets** — the same screen captured across several devices in one action, named and
+- **Deep links**: saved per app, custom schemes and universal links, one click to fire.
+- **Screenshot sets**: the same screen captured across several devices in one action, named and
organised for the App Store.
## Harder, or not yet certain
@@ -45,12 +44,12 @@ developer scripts by hand today, one click away, remembered per project.
Listed separately because the honest answer is that these might not work, or might not be worth what
they cost.
-- **iPad pointer and mouse input** — the hardest item here, and the one most likely to slip.
-- **Network conditioning presets** — 3G, lossy, offline. Apple's own conditioner is system wide on
+- **iPad pointer and mouse input**: the hardest item here, and the one most likely to slip.
+- **Network conditioning presets**: 3G, lossy, offline. Apple's own conditioner is system wide on
macOS rather than per simulator, so either a per-device approach turns up or this gets documented
as the compromise it is.
-- **Audio routing and external displays** — wanted, not investigated.
-- **Android emulators in the same window** — cross platform developers juggle both all day. Genuinely
+- **Audio routing and external displays**: wanted, not investigated.
+- **Android emulators in the same window**: cross platform developers juggle both all day. Genuinely
useful, a long way out, and not started.
## Not planned
diff --git a/icons/README.md b/icons/README.md
index 2ac0e8a..21517a6 100644
--- a/icons/README.md
+++ b/icons/README.md
@@ -6,6 +6,7 @@ Every file here is generated, not hand drawn. `packaging/make-icon.swift` is the
xcrun swiftc -O -o /tmp/make-icon packaging/make-icon.swift
/tmp/make-icon icons --marketing # the PNGs below
/tmp/make-icon icons --social # social-preview.png
+/tmp/make-icon icons --button # download-button.png
```
The app's own `.icns` is built from the same file by `scripts/build-app.sh`, so the icon on disk and
@@ -18,6 +19,7 @@ the icon in the app can never disagree.
| `icon-512.png`, `icon-1024.png` | store listings, anything print or retina |
| `icon-square-400.png`, `-512.png`, `-1024.png` | avatars: GitHub, X, Discord, anywhere the picture is cropped |
| `social-preview.png` | GitHub's repository social preview, 1280x640 |
+| `download-button.png` | the download button on the README, shown at half its pixel width |
| `icon.svg` | vector, for any size that is not here |
The square files fill the frame instead of rounding their own corners, because an avatar is cropped
diff --git a/icons/download-button.png b/icons/download-button.png
new file mode 100644
index 0000000..d4a704e
Binary files /dev/null and b/icons/download-button.png differ
diff --git a/packaging/make-icon.swift b/packaging/make-icon.swift
index ff272b2..42fb3f6 100644
--- a/packaging/make-icon.swift
+++ b/packaging/make-icon.swift
@@ -205,8 +205,86 @@ func socialPreview(width: Int = 1280, height: Int = 640) -> Data {
return data
}
+// The download button on the README. Drawn rather than written as SVG text, because a web font is
+// not guaranteed where the README is rendered and clipped lettering is worse than a picture.
+func downloadButton(scale: CGFloat = 2) -> Data {
+ let label = "Download for macOS"
+ let font = NSFont.systemFont(ofSize: 25 * scale, weight: .semibold)
+ let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: NSColor.white]
+ let text = label.size(withAttributes: attributes)
+
+ let arrow = 22 * scale
+ let padding = 30 * scale
+ let gap = 14 * scale
+ // Sized to the text it was measured with, so a longer label cannot run off the end.
+ let width = Int((padding * 2 + arrow + gap + text.width).rounded())
+ let height = Int((text.height + 26 * scale).rounded())
+
+ guard let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
+ ) else {
+ print("could not make the button bitmap")
+ exit(1)
+ }
+ bitmap.size = NSSize(width: width, height: height)
+
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
+
+ let frame = NSRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))
+ let pill = NSBezierPath(roundedRect: frame, xRadius: frame.height / 2, yRadius: frame.height / 2)
+ plateGradient.draw(in: pill, angle: -90)
+
+ let centre = frame.height / 2
+ let arrowX = padding + arrow / 2
+ let stem = NSBezierPath()
+ stem.move(to: NSPoint(x: arrowX, y: centre + arrow * 0.55))
+ stem.line(to: NSPoint(x: arrowX, y: centre - arrow * 0.15))
+ stem.lineWidth = 3 * scale
+ stem.lineCapStyle = .round
+ NSColor.white.setStroke()
+ stem.stroke()
+
+ let head = NSBezierPath()
+ head.move(to: NSPoint(x: arrowX - arrow * 0.42, y: centre - arrow * 0.02))
+ head.line(to: NSPoint(x: arrowX, y: centre - arrow * 0.5))
+ head.line(to: NSPoint(x: arrowX + arrow * 0.42, y: centre - arrow * 0.02))
+ head.lineWidth = 3 * scale
+ head.lineCapStyle = .round
+ head.lineJoinStyle = .round
+ head.stroke()
+
+ let tray = NSBezierPath()
+ tray.move(to: NSPoint(x: arrowX - arrow * 0.5, y: centre - arrow * 0.62))
+ tray.line(to: NSPoint(x: arrowX + arrow * 0.5, y: centre - arrow * 0.62))
+ tray.lineWidth = 3 * scale
+ tray.lineCapStyle = .round
+ tray.stroke()
+
+ label.draw(
+ at: NSPoint(x: padding + arrow + gap, y: (frame.height - text.height) / 2),
+ withAttributes: attributes
+ )
+
+ NSGraphicsContext.restoreGraphicsState()
+
+ guard let data = bitmap.representation(using: .png, properties: [:]) else {
+ print("could not encode the button")
+ exit(1)
+ }
+ return data
+}
+
let outputDirectory = CommandLine.arguments[1]
+if CommandLine.arguments.contains("--button") {
+ try downloadButton().write(to: URL(fileURLWithPath: "\(outputDirectory)/download-button.png"))
+ print("wrote the download button to \(outputDirectory)")
+ exit(0)
+}
+
if CommandLine.arguments.contains("--social") {
try socialPreview().write(to: URL(fileURLWithPath: "\(outputDirectory)/social-preview.png"))
print("wrote the social preview to \(outputDirectory)")
diff --git a/scripts/__pycache__/changelog-section.cpython-314.pyc b/scripts/__pycache__/changelog-section.cpython-314.pyc
new file mode 100644
index 0000000..e32d3f8
Binary files /dev/null and b/scripts/__pycache__/changelog-section.cpython-314.pyc differ
diff --git a/scripts/release-notes.py b/scripts/release-notes.py
new file mode 100755
index 0000000..740bc93
--- /dev/null
+++ b/scripts/release-notes.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Prints the body of a GitHub Release.
+
+The changes come from CHANGELOG.md, so the release page and the notes inside the update feed cannot
+drift apart. What follows them is the same for every release: what you need, where the gaps are
+written down, how to check the download, who to credit and where to say something.
+
+The limitations come from the README rather than being repeated here, so a gap that gets fixed
+disappears from both at once.
+
+Usage: scripts/release-notes.py
+"""
+import pathlib
+import sys
+
+import importlib.util
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+REPOSITORY = "https://github.com/Mastersam07/OpenDeviceHub"
+
+
+def changelog_section(version: str) -> str:
+ spec = importlib.util.spec_from_file_location(
+ "changelog_section", ROOT / "scripts" / "changelog-section.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module.section((ROOT / "CHANGELOG.md").read_text(), version)
+
+
+def readme_section(heading: str) -> str:
+ collecting = False
+ body: list[str] = []
+ for line in (ROOT / "README.md").read_text().splitlines():
+ if line.startswith("## "):
+ if collecting:
+ break
+ collecting = line[3:].strip() == heading
+ continue
+ if collecting:
+ body.append(line)
+ return "\n".join(body).strip()
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print(__doc__, file=sys.stderr)
+ return 2
+ version = sys.argv[1]
+
+ changes = changelog_section(version)
+ if not changes:
+ print(f"No '## {version}' section in CHANGELOG.md", file=sys.stderr)
+ return 1
+
+ requirements = readme_section("Requirements")
+ limitations = readme_section("Current limitations")
+ if not requirements or not limitations:
+ print("README.md is missing Requirements or Current limitations", file=sys.stderr)
+ return 1
+ # The README's closing invitation belongs on its own page, not in release notes.
+ limitations = limitations.split("Want to help close one of these?")[0].strip()
+
+ print(f"""{changes}
+
+## Requirements
+
+{requirements}
+
+## Known gaps
+
+This is a pre-release. These are the ones worth knowing before you install it:
+
+{limitations}
+
+## Verifying the download
+
+The app is signed with a Developer ID certificate, notarized by Apple and stapled, so it launches
+without a Gatekeeper warning. `SHA256SUMS` is attached; to check the DMG against it:
+
+```sh
+shasum -a 256 -c SHA256SUMS
+```
+
+## Credits
+
+Built after studying two MIT licensed projects, and better for both:
+[Siniulator](https://github.com/kmagiera/Siniulator) by Krzysztof Magiera, and
+[idb / FBSimulatorControl](https://github.com/facebook/idb) by Meta. What was adapted from each, down
+to the individual constants, is listed in
+[THIRD_PARTY_NOTICES.md]({REPOSITORY}/blob/dev/THIRD_PARTY_NOTICES.md).
+
+## Tell me what breaks
+
+This is the first release and it has been used by one person on one Mac. If something does not work,
+please [open an issue]({REPOSITORY}/issues/new/choose): `odhub doctor` prints almost
+everything the bug form asks for.""")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())