Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
The appcast generator reads this file. Each release is a `## <version>` heading, and everything
under it until the next heading becomes that version's release notes in the update feed.

## Unreleased

- Remember Focus Mode globally across launches and apply toggles to all open simulators.
New devices follow the shared preference; normal window frames remain per device.
Previously enabled per-device focus preferences migrate automatically.

- Window → Focus Mode (⇧⌘F) hides the header and fits the device into the desktop,
without entering full screen. Toggle it off to restore the previous window; ⌥⌘-drag
moves the focused window. Menus, device shortcuts and the separate bezel toggle remain available.

## 0.2.0

- The camera control works on every iPhone that has one, not only the iPhone Duo, and Device,
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ options.

</details>

## Focus Mode

Choose **Window → Focus Mode** (⇧⌘F) to hide the header and fill the available desktop
without entering macOS full screen. The phone keeps its proportions, with a small resize
margin. Toggle it again to restore the previous window. Hold ⌥⌘ and drag to move the
window; device controls remain in the menus and their usual keyboard shortcuts work.
Hide device bezels separately with ⌘B for more screen space. Focus Mode is shared by all simulator windows and remembered across launches.
Toggling it updates all open devices; new devices follow the same preference. Each
device keeps its own normal window frame for toggling back. Focus Mode uses fit sizing; leave
it before choosing a fixed scale preset.

## Current limitations
- **The windows are invisible to accessibility**: VoiceOver, window managers and scripting tools see
the app and its menus but not its windows.
Expand Down
4 changes: 4 additions & 0 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ struct ODHubViewer: ParsableCommand {
guard let target = front() else { return }
target.controller.setKeepOnTop(!target.controller.isKeptOnTop)
},
toggleFocusMode: {
manager.toggleFocusMode()
},
pasteToDevice: {
guard let target = front(),
let text = NSPasteboard.general.string(forType: .string) else { return }
Expand Down Expand Up @@ -522,6 +525,7 @@ struct ODHubViewer: ParsableCommand {
case .latencyOverlay: return controller.isLatencyOverlayVisible
case .bezel: return controller.isBezelEnabled
case .keepOnTop: return controller.isKeptOnTop
case .focusMode: return manager.isFocused
case .increasedContrast:
if let known = controller.increasesContrast { return known }
let read = SimctlService().increasesContrast(udid: target.udid)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public final class DevicePresentationView: NSView {
public let model: DuoModelView?

private var isFullScreen = false
public var isFocused = false {
didSet { bar.isHidden = isFocused; needsLayout = true }
}

public init(bar: DeviceControlBar, chrome: DeviceChromeView, model: DuoModelView? = nil) {
self.bar = bar
Expand Down Expand Up @@ -37,7 +40,7 @@ public final class DevicePresentationView: NSView {
}

public var currentLayout: PresentationLayout {
PresentationLayout(contentSize: bounds.size, isFullScreen: isFullScreen)
PresentationLayout(contentSize: bounds.size, isFullScreen: isFullScreen, isFocused: isFocused)
}

/// The content covers the whole window, including the few points AppKit keeps at the edges for
Expand Down
9 changes: 9 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ final class DeviceWindow: NSWindow {

/// A drag that starts on one of the device's corners resizes the window, keeping the device's
/// shape. AppKit's own edge resize still works; this is the handle that is easy to hit.
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }

override func sendEvent(_ event: NSEvent) {
if event.type == .leftMouseDown,
event.modifierFlags.contains([.command, .option]),
(contentView as? DevicePresentationView)?.isFocused == true {
performDrag(with: event)
return
}
if resize != nil {
switch event.type {
case .leftMouseDragged:
Expand Down
43 changes: 41 additions & 2 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,40 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
private var parallelOffset = CGSize(width: 0.12, height: 0)
private var dragEdge: TouchEvent.Edge = .none
private var orientation: DeviceOrientation = .portrait
public private(set) var isFocused = false
private var frameBeforeFocus: CGRect?
private var styleBeforeFocus: NSWindow.StyleMask?
private var toolbarBeforeFocus: NSToolbar?

/// A reversible, ordinary desktop window with no header and only its resize margin.
public func setFocused(_ focused: Bool, activate: Bool = true) {
guard let window, focused != isFocused,
!window.styleMask.contains(.fullScreen) else { return }
isFocused = focused
presentationView.isFocused = focused
if focused {
frameBeforeFocus = window.frame
frameStore.save(uprightFrame(of: window), for: frameKey)
styleBeforeFocus = window.styleMask
toolbarBeforeFocus = window.toolbar
window.toolbar = nil
window.styleMask = [.borderless, .resizable, .miniaturizable]
window.contentResizeIncrements = NSSize(width: 1, height: 1)
if let visible = (window.screen ?? NSScreen.main)?.visibleFrame {
window.setFrame(visible, display: true)
}
} else {
if let styleBeforeFocus { window.styleMask = styleBeforeFocus }
window.toolbar = toolbarBeforeFocus
toolbarBeforeFocus = nil
if let frameBeforeFocus { window.setFrame(frameBeforeFocus, display: true) }
frameBeforeFocus = nil
styleBeforeFocus = nil
}
if activate { window.makeKeyAndOrderFront(nil) }
presentationView.needsLayout = true
}

private var scaleMode: ScaleMode
private let deviceName: String
private var pendingSend: Task<Void, Never>?
Expand Down Expand Up @@ -352,6 +386,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
@discardableResult
public func applyScaleMode(_ mode: ScaleMode) -> ScaleApplication {
scaleMode = mode
if isFocused { presentationView.needsLayout = true; return .noFixedSize }
guard let window else { return .unavailable }
let device = DeviceMetrics(
pixelSize: session.pixelSize,
Expand Down Expand Up @@ -626,7 +661,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
/// Fit mode has no size of its own, so a sideways turn reshapes the window itself, keeping the
/// device at the size it is drawn. A foldable's viewport is never reshaped.
private func reshape(from previous: DeviceOrientation) {
guard scaleMode == .fit, modelView == nil, let window,
guard !isFocused, scaleMode == .fit, modelView == nil, let window,
!window.styleMask.contains(.fullScreen) else { return }
let content = PresentationLayout.contentSizeTurned(
from: window.contentRect(forFrameRect: window.frame).size,
Expand Down Expand Up @@ -690,6 +725,9 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
// A unified toolbar makes the title bar taller, which would otherwise come out of the
// device's own height, so the window is sized again now that it is there.
applyScaleMode(scaleMode)
// Restore after toolbar installation and normal window placement, so leaving Focus Mode
// has a complete toolbar and a normal frame to return to.
if frameStore.isFocused { setFocused(true) }
}

/// Offers a finished recording for dragging out of the window.
Expand All @@ -698,7 +736,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
}

public func rememberFrame() {
guard tracksFrameChanges,
guard !isFocused, tracksFrameChanges,
let window,
!window.styleMask.contains(.fullScreen) else { return }
frameStore.save(uprightFrame(of: window), for: frameKey)
Expand Down Expand Up @@ -736,6 +774,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
(window as? DeviceWindow)?.constrainsToScreen = false
presentationView.setFullScreen(false)
toolbar?.setFoldModes(visible: false)
setFocused(frameStore.isFocused, activate: false)
// The device may have been turned while full screen, where the window is not resized, so
// the shape it comes back to is whatever the current orientation asks for.
applyScaleMode(scaleMode)
Expand Down
12 changes: 12 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ public final class DeviceWindowManager {

public var openCount: Int { controllers.count }

public var isFocused: Bool { frameStore.isFocused }

public func toggleFocusMode() {
let focused = !isFocused
frameStore.setFocused(focused)
let front = NSApp.keyWindow
for controller in controllers.values {
controller.setFocused(focused, activate: false)
}
front?.makeKey()
}

/// Current screenshot preference, read by per-window toolbar actions at invocation time.
public var savesScreenshotsToClipboard: Bool { settings.savesScreenshotsToClipboard }

Expand Down
11 changes: 10 additions & 1 deletion engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@ public struct PresentationLayout: Equatable {

/// Full screen hands the window to AppKit, which puts its own chrome at the top, so the bar
/// squares off and the device takes the rest.
public init(contentSize: CGSize, isFullScreen: Bool = false) {
public init(contentSize: CGSize, isFullScreen: Bool = false, isFocused: Bool = false) {
let width = max(contentSize.width, 0)
let height = max(contentSize.height, 0)
if isFocused {
bar = .zero
cornerRadius = 0
let border = Self.resizeBorder
device = CGRect(x: border, y: border,
width: max(width - border * 2, 0),
height: max(height - border * 2, 0))
return
}
bar = CGRect(
x: 0,
y: max(height - Self.barHeight, 0),
Expand Down
8 changes: 8 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ public enum ViewerMenu {
case latencyOverlay
case bezel
case keepOnTop
case focusMode
}

public struct Actions {
public var setScaleMode: (ScaleMode) -> Void
public var toggleBezel: () -> Void
public var toggleKeepOnTop: () -> Void
public var toggleFocusMode: () -> Void
public var pasteToDevice: () -> Void
public var toggleAppearance: () -> Void
public var saveScreenshot: () -> Void
Expand Down Expand Up @@ -88,6 +90,7 @@ public enum ViewerMenu {
setScaleMode: @escaping (ScaleMode) -> Void,
toggleBezel: @escaping () -> Void,
toggleKeepOnTop: @escaping () -> Void,
toggleFocusMode: @escaping () -> Void = {},
pasteToDevice: @escaping () -> Void,
toggleAppearance: @escaping () -> Void,
saveScreenshot: @escaping () -> Void,
Expand Down Expand Up @@ -134,6 +137,7 @@ public enum ViewerMenu {
self.setScaleMode = setScaleMode
self.toggleBezel = toggleBezel
self.toggleKeepOnTop = toggleKeepOnTop
self.toggleFocusMode = toggleFocusMode
self.pasteToDevice = pasteToDevice
self.toggleAppearance = toggleAppearance
self.saveScreenshot = saveScreenshot
Expand Down Expand Up @@ -457,6 +461,9 @@ public enum ViewerMenu {
windowMenu.addItem(.separator())
windowMenu.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
.keyEquivalentModifierMask = [.control, .command]
let focus = target.item("Focus Mode", #selector(MenuTarget.focusMode), "f", [.command, .shift])
target.track(focus, as: .focusMode)
windowMenu.addItem(focus)
let bezel = target.item("Show Device Bezels", #selector(MenuTarget.bezel), "b", [])
target.track(bezel, as: .bezel)
windowMenu.addItem(bezel)
Expand Down Expand Up @@ -689,6 +696,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation {
])
}

@objc func focusMode() { actions.toggleFocusMode() }
@objc func bezel() { actions.toggleBezel() }
@objc func keepOnTop() { actions.toggleKeepOnTop() }
@objc func saveScreenshot() { actions.saveScreenshot() }
Expand Down
23 changes: 21 additions & 2 deletions engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,36 @@ public struct WindowFrameStore: Sendable {
storage.setText(WindowFrameCodec.encode(frame), forKey: prefix + udid)
}

/// Shared across devices. Migrate an enabled legacy device preference once.
public var isFocused: Bool {
let key = prefix + "focus.enabled"
if let saved = storage.text(forKey: key) { return saved == "true" }
let enabled = storage.keys(withPrefix: prefix + "focus.").contains {
storage.text(forKey: $0) == "true"
}
setFocused(enabled)
return enabled
}

public func setFocused(_ focused: Bool) {
for key in storage.keys(withPrefix: prefix + "focus.") {
storage.removeText(forKey: key)
}
storage.setText(focused ? "true" : "false", forKey: prefix + "focus.enabled")
}

public func forget(_ udid: String) {
storage.removeText(forKey: prefix + "focus." + udid)
storage.removeText(forKey: prefix + udid)
}

/// How many windows would reopen where they were left.
public var rememberedCount: Int {
storage.keys(withPrefix: prefix).count
storage.keys(withPrefix: prefix).filter { !$0.hasPrefix(prefix + "focus.") }.count
}

public func forgetAll() {
for key in storage.keys(withPrefix: prefix) {
for key in storage.keys(withPrefix: prefix) where !key.hasPrefix(prefix + "focus.") {
storage.removeText(forKey: key)
}
}
Expand Down
29 changes: 29 additions & 0 deletions engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ final class FrontDeviceTests: XCTestCase {
XCTAssertEqual(manager.frontmostUDID, "second")
}

func testFocusToggleUpdatesAllWindowsAndNewDevicesFollowIt() throws {
func installToolbar(_ controller: DeviceWindowController) {
controller.setToolbarActions(DeviceToolbarActions(
goHome: {}, saveScreenshot: {}, stopRecording: {}, rotate: { _ in }
))
}
let first = try open("first")
let second = try open("second")
installToolbar(first)
installToolbar(second)
let firstFrame = try XCTUnwrap(first.window).frame
let secondFrame = try XCTUnwrap(second.window).frame
manager.toggleFocusMode()
XCTAssertTrue(manager.isFocused)
XCTAssertTrue(first.isFocused)
XCTAssertTrue(second.isFocused)
let third = try open("third")
installToolbar(third)
XCTAssertTrue(third.isFocused, "new devices must follow the shared preference")
manager.toggleFocusMode()
XCTAssertFalse(first.isFocused)
XCTAssertFalse(second.isFocused)
XCTAssertFalse(third.isFocused)
XCTAssertEqual(first.window?.frame, firstFrame)
XCTAssertEqual(second.window?.frame, secondFrame)
XCTAssertNotNil(first.window?.toolbar)
XCTAssertNotNil(second.window?.toolbar)
}

private func open(_ udid: String) throws -> DeviceWindowController {
try manager.open(
device: DeviceInfo(
Expand Down
17 changes: 17 additions & 0 deletions engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ import OpenDeviceHubEngine
final class MenuSettingsTests: XCTestCase {
private var previousMenu: NSMenu?

func testFocusModeCanBeFoundAndItsCheckmarkFollowsTheFrontDevice() throws {
var focused = false
var harness = MenuHarness()
harness.isOn = { $0 == .focusMode && focused }
let target = harness.install()
let (item, _) = try XCTUnwrap(MenuHarness.item(titled: "Focus Mode"))
XCTAssertTrue(target.validateMenuItem(item))
XCTAssertEqual(item.state, .off)
XCTAssertEqual(item.keyEquivalent, "f")
XCTAssertEqual(item.keyEquivalentModifierMask, [.command, .shift])
focused = true
XCTAssertTrue(target.validateMenuItem(item))
XCTAssertEqual(item.state, .on)
}


override func setUp() async throws {
previousMenu = NSApplication.shared.mainMenu
}
Expand Down Expand Up @@ -78,6 +94,7 @@ final class MenuSettingsTests: XCTestCase {
.latencyOverlay: "Show Click to Frame Latency",
.bezel: "Show Device Bezels",
.keepOnTop: "Stay On Top",
.focusMode: "Focus Mode",
]
XCTAssertEqual(Set(titles.keys), Set(ViewerMenu.DeviceSetting.allCases))
var inFront: Set<ViewerMenu.DeviceSetting> = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import XCTest
@testable import OpenDeviceHubViewer

final class PresentationLayoutTests: XCTestCase {
func testFocusModeReclaimsHeaderAndMarginsWhileKeepingResizeEdges() {
let size = CGSize(width: 1024, height: 644)
let normal = PresentationLayout(contentSize: size)
let focused = PresentationLayout(contentSize: size, isFocused: true)
XCTAssertEqual(focused.bar, .zero)
XCTAssertEqual(focused.device, CGRect(x: 5, y: 5, width: 1014, height: 634))
XCTAssertGreaterThan(focused.device.height, normal.device.height)
let tiny = PresentationLayout(contentSize: CGSize(width: 2, height: 2), isFocused: true)
XCTAssertEqual(tiny.device.size, .zero)
}

private let device = CGSize(width: 402, height: 874)

func testTheBarFloatsInsetFromEveryEdgeItTouches() {
Expand Down
Loading