Skip to content

Commit 59fe55f

Browse files
Add Mac screenshots, wrap permission hints, debug screen renderer
1 parent 4010f9b commit 59fe55f

25 files changed

Lines changed: 125 additions & 0 deletions

‎Mac/Sources/App/AppModel.swift‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,29 @@ final class AppModel: ObservableObject {
246246
defaults.set(try? JSONEncoder().encode(defaultOptions), forKey: "defaultOptions")
247247
}
248248

249+
#if DEBUG
250+
func applyDemo() {
251+
let display = DisplayInfo(pixelWidth: 2852, pixelHeight: 2006, scale: 3, pixelsPerInch: 460, maxRefreshRate: 120)
252+
let duo = DeviceInfo(deviceID: "duo", name: "Daniel's iPhone Duo", model: "iPhone19,4", appVersion: "1.0.0", display: display)
253+
let session = HostSession(deviceID: "duo", name: duo.name, options: SessionOptions(highRefreshRate: true), requestedByDevice: false, userInitiated: true)
254+
session.applyDemo(device: duo, transport: .usb)
255+
sessions = ["duo": session]
256+
rows = [
257+
DeviceRow(id: "duo", name: duo.name, model: "iPhone19,4", isAvailable: true, viaUSB: true, viaWiFi: true, isKnown: true),
258+
DeviceRow(id: "ipad", name: "iPad Pro", model: "iPad16,3", isAvailable: true, viaUSB: false, viaWiFi: true, isKnown: false)
259+
]
260+
screenRecordingGranted = true
261+
accessibilityGranted = true
262+
}
263+
264+
func applyEmptyDemo() {
265+
sessions = [:]
266+
rows = []
267+
screenRecordingGranted = true
268+
accessibilityGranted = false
269+
}
270+
#endif
271+
249272
private func checkForUpdate() async {
250273
guard let url = URL(string: "https://api.github.com/repos/codextde/spanscreen/releases/latest"),
251274
let (data, _) = try? await URLSession.shared.data(from: url),

‎Mac/Sources/App/SpanscreenApp.swift‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
2323
private var welcomeWindow: NSWindow?
2424

2525
func applicationDidFinishLaunching(_ notification: Notification) {
26+
#if DEBUG
27+
if let dir = UserDefaults.standard.string(forKey: "renderScreens") {
28+
ScreenRenderer.renderAll(to: URL(fileURLWithPath: dir))
29+
NSApp.terminate(nil)
30+
return
31+
}
32+
#endif
2633
AppModel.shared.start()
2734
if !UserDefaults.standard.bool(forKey: "onboarded") {
2835
showWelcome()
@@ -53,3 +60,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5360
welcomeWindow = window
5461
}
5562
}
63+
64+
#if DEBUG
65+
@MainActor
66+
enum ScreenRenderer {
67+
static func renderAll(to dir: URL) {
68+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
69+
let model = AppModel.shared
70+
model.applyDemo()
71+
render(MenuContentView().environmentObject(model), name: "mac-menu", to: dir)
72+
model.applyEmptyDemo()
73+
render(MenuContentView().environmentObject(model), name: "mac-menu-empty", to: dir)
74+
render(OnboardingView {}.environmentObject(model), name: "mac-welcome", to: dir)
75+
}
76+
77+
private static func render<V: View>(_ view: V, name: String, to dir: URL) {
78+
for (suffix, appearance) in [("dark", NSAppearance.Name.darkAqua), ("light", .aqua)] {
79+
let host = NSHostingView(rootView: view.padding(0))
80+
host.appearance = NSAppearance(named: appearance)
81+
let size = host.fittingSize
82+
let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless], backing: .buffered, defer: false)
83+
window.appearance = NSAppearance(named: appearance)
84+
window.backgroundColor = appearance == .darkAqua ? NSColor(white: 0.13, alpha: 1) : NSColor(white: 0.97, alpha: 1)
85+
window.contentView = host
86+
host.frame = NSRect(origin: .zero, size: size)
87+
host.layoutSubtreeIfNeeded()
88+
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
89+
guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { continue }
90+
host.cacheDisplay(in: host.bounds, to: rep)
91+
if let data = rep.representation(using: .png, properties: [:]) {
92+
try? data.write(to: dir.appendingPathComponent("\(name)-\(suffix).png"))
93+
}
94+
}
95+
}
96+
}
97+
#endif

‎Mac/Sources/Session/HostSession.swift‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,18 @@ final class HostSession: ObservableObject, Identifiable {
495495
}
496496
}
497497

498+
#if DEBUG
499+
extension HostSession {
500+
func applyDemo(device: DeviceInfo, transport: TransportKind) {
501+
self.device = device
502+
transportKind = transport
503+
phase = .streaming
504+
position = .right
505+
stats = StreamStats(latencyMilliseconds: 7, bitrate: 28_000_000, framesPerSecond: 120)
506+
}
507+
}
508+
#endif
509+
498510
final class StatsBox: @unchecked Sendable {
499511
private let lock = NSLock()
500512
private var stats = StreamStats()

‎Mac/Sources/UI/MenuContentView.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ struct PermissionRow: View {
131131
VStack(alignment: .leading, spacing: 1) {
132132
Text(title).font(.system(size: 12, weight: .semibold))
133133
Text(detail).font(.system(size: 11)).foregroundStyle(.secondary)
134+
.fixedSize(horizontal: false, vertical: true)
134135
}
135136
Spacer()
136137
if !granted {

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
<a href="https://codextde.github.io/spanscreen/">Website</a>
1616
</p>
1717

18+
<p align="center">
19+
<img src="design/appstore/framed/en-US-pm-1.png" width="720" alt="Spanscreen on iPhone showing an extended Mac desktop">
20+
</p>
21+
22+
<p align="center">
23+
<img src="design/screens/mac-menu.png" width="340" alt="Spanscreen menu bar app on the Mac">
24+
</p>
25+
1826
---
1927

2028
## Features

‎design/appstore/frame.html‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!doctype html><html><head><meta charset="utf-8"><style>
2+
*{margin:0;padding:0;box-sizing:border-box}
3+
body{width:100vw;height:100vh;overflow:hidden;background:#090c14;font-family:-apple-system,"SF Pro Display",system-ui,sans-serif;color:#fff;-webkit-font-smoothing:antialiased;display:flex;flex-direction:column;align-items:center}
4+
.glow{position:absolute;inset:0;background:radial-gradient(55% 60% at 20% 0%,rgba(46,107,255,.45),transparent 70%),radial-gradient(45% 55% at 95% 100%,rgba(91,230,207,.22),transparent 70%)}
5+
h1{position:relative;font-weight:750;letter-spacing:-.03em;text-align:center;line-height:1.05}
6+
h1 span{background:linear-gradient(90deg,#5b8cff,#3ba0ff 55%,#5be6cf);-webkit-background-clip:text;background-clip:text;color:transparent}
7+
.device{position:relative;background:#1a1d24;box-shadow:0 40px 120px rgba(0,0,0,.6),inset 0 0 0 2px rgba(255,255,255,.12)}
8+
.device img{display:block;width:100%;height:100%;object-fit:cover}
9+
</style></head><body><div class="glow"></div><h1 id="t"></h1><div class="device" id="d"><img id="i"></div>
10+
<script>
11+
const q=new URLSearchParams(location.search);const W=innerWidth,H=innerHeight;const ipad=q.get('kind')==='ipad';
12+
const t=document.getElementById('t');t.innerHTML=q.get('title');
13+
const head=ipad?H*.17:H*.22;t.style.fontSize=(ipad?H*.052:H*.075)+'px';t.style.marginTop=(ipad?H*.055:H*.06)+'px';
14+
const sw=+q.get('sw'),sh=+q.get('sh');const availH=H-head-(ipad?H*.06:H*.07),availW=W*(ipad?.86:.84);
15+
let s=Math.min(availW/sw,availH/sh);const w=sw*s,h=sh*s;const bez=ipad?H*.018:H*.022;
16+
const d=document.getElementById('d');d.style.width=(w+bez*2)+'px';d.style.height=(h+bez*2)+'px';d.style.padding=bez+'px';d.style.marginTop=(ipad?H*.035:H*.04)+'px';
17+
d.style.borderRadius=(ipad?H*.045:H*.11)+'px';const img=document.getElementById('i');img.style.borderRadius=(ipad?H*.03:H*.085)+'px';img.src=q.get('src');
18+
</script></body></html>

‎design/appstore/frame.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { chromium } from '/Users/danielehrhardt/node_modules/playwright-core/index.mjs';
2+
const exe = process.env.HOME + '/Library/Caches/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-mac-arm64/chrome-headless-shell';
3+
const titles = {
4+
'en-US': ['Your iPhone, now a <span>second Mac display</span>', 'Extend or mirror. <span>Wi-Fi or USB.</span>', 'Connects in <span>seconds</span>', 'Only the Macs <span>you allow</span>'],
5+
'de-DE': ['Dein iPhone als <span>zweiter Mac-Bildschirm</span>', 'Erweitern oder spiegeln. <span>WLAN oder USB.</span>', 'In Sekunden <span>verbunden</span>', 'Nur Macs, die <span>du erlaubst</span>'],
6+
};
7+
const sets = { pm: { w: 2868, h: 1320, files: ['pm-1-stream', 'pm-2-panel', 'pm-3-home', 'pm-4-approval'] }, ipad: { w: 2752, h: 2064, files: ['ipad-1-stream', 'ipad-2-panel', 'ipad-3-home', 'ipad-4-approval'] } };
8+
const b = await chromium.launch({ executablePath: exe });
9+
for (const [kind, set] of Object.entries(sets)) {
10+
const p = await b.newPage({ viewport: { width: set.w / 2, height: set.h / 2 }, deviceScaleFactor: 2 });
11+
for (const [locale, list] of Object.entries(titles)) {
12+
for (let i = 0; i < set.files.length; i++) {
13+
const src = `file://${process.cwd()}/${set.files[i]}.png`;
14+
const title = kind === 'ipad' ? list[i].replace('iPhone', 'iPad') : list[i];
15+
const url = `file://${process.cwd()}/frame.html?kind=${kind}&sw=${set.w}&sh=${set.h}&src=${encodeURIComponent(src)}&title=${encodeURIComponent(title)}`;
16+
await p.goto(url); await p.waitForTimeout(250);
17+
await p.screenshot({ path: `framed/${locale}-${kind}-${i + 1}.png` });
18+
}
19+
}
20+
}
21+
await b.close();
1.03 MB
Loading
1.1 MB
Loading
768 KB
Loading

0 commit comments

Comments
 (0)