From 9e81e9107da2a74f4a75b312f651a12cd761dd7e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 21 Oct 2026 22:00:54 -0700 Subject: [PATCH] Add an iPhone client sharing the macOS app's SwiftUI code --- AGENTS.md | 2 +- TODO.md | 3 - docs/iphone.md | 17 ++ docs/macos.md | 2 +- mac/Package.swift | 9 +- mac/Sources/Byre/API.swift | 8 +- mac/Sources/Byre/Attention.swift | 1 - mac/Sources/Byre/BoxDiagram.swift | 67 ++++- mac/Sources/Byre/ByreApp.swift | 3 +- mac/Sources/Byre/Chat.swift | 61 +++- mac/Sources/Byre/Composer.swift | 199 +++++++++---- mac/Sources/Byre/Diagram.swift | 3 +- mac/Sources/Byre/Dialogs.swift | 63 ++++- mac/Sources/Byre/EventStream.swift | 8 +- mac/Sources/Byre/Files.swift | 6 +- mac/Sources/Byre/Fonts.swift | 4 +- mac/Sources/Byre/Format.swift | 63 ++++- mac/Sources/Byre/Images.swift | 15 +- mac/Sources/Byre/MarkdownView.swift | 202 ++++++++++--- mac/Sources/Byre/Notifications.swift | 4 +- mac/Sources/Byre/Pages.swift | 98 +++++-- mac/Sources/Byre/Palette.swift | 13 +- mac/Sources/Byre/PaneFocus.swift | 61 +++- mac/Sources/Byre/Panes.swift | 18 +- mac/Sources/Byre/Phone.swift | 343 +++++++++++++++++++++++ mac/Sources/Byre/Platform.swift | 110 ++++++++ mac/Sources/Byre/Project.swift | 2 +- mac/Sources/Byre/Remote.swift | 2 + mac/Sources/Byre/Render.swift | 3 +- mac/Sources/Byre/Server.swift | 17 +- mac/Sources/Byre/Settings.swift | 22 +- mac/Sources/Byre/Sheet.swift | 18 +- mac/Sources/Byre/Startup.swift | 70 ++++- mac/Sources/Byre/Store.swift | 13 + mac/Sources/Byre/Switcher.swift | 13 +- mac/Sources/Byre/Tailnet.swift | 54 ++-- mac/Sources/Byre/Terminal.swift | 74 +++-- mac/Sources/Byre/TranscriptBuilder.swift | 1 - mac/Sources/Byre/Updater.swift | 3 +- mac/bundle.sh | 17 +- mac/deploy.sh | 3 + mac/phone.sh | 101 +++++++ mac/phone/Info.plist | 57 ++++ mac/phone/byre.entitlements | 26 ++ mac/phone/export.plist | 18 ++ mac/tailscalekit.sh | 15 + 46 files changed, 1625 insertions(+), 287 deletions(-) create mode 100644 docs/iphone.md create mode 100644 mac/Sources/Byre/Phone.swift create mode 100644 mac/Sources/Byre/Platform.swift create mode 100755 mac/phone.sh create mode 100644 mac/phone/Info.plist create mode 100644 mac/phone/byre.entitlements create mode 100644 mac/phone/export.plist create mode 100755 mac/tailscalekit.sh diff --git a/AGENTS.md b/AGENTS.md index 8a34ecf..3481aa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are requirements, not preferences. Changing one needs the owner's agreemen A deploy is a release that isn't uploaded: the same notarized disk image, installed on the owner's Macs instead of published. When asked to deploy, do exactly this, from the worktree root: -1. Run `mac/deploy.sh`, always as the last action of the turn. It runs `mac/package.sh` (the build, both notarizations and the disk image, as for a release, into `mac/.build/release//`), and installs the image's app as `/Applications/Byre.app` with `mac/install.sh`. The service runs `/Applications/Byre.app/Contents/Helpers/byre`; within 5 seconds it sees that file replaced and exits, and launchd starts the new server and `byre-hands`. Everything is signed with Developer ID, so the macOS privacy grants (Screen Recording, Accessibility, Automation) survive. +1. Run `mac/deploy.sh`, always as the last action of the turn. It runs `mac/package.sh` (the build, both notarizations and the disk image, as for a release, into `mac/.build/release//`), builds the iPhone app with `mac/phone.sh` and uploads it to TestFlight (`docs/iphone.md`), and installs the image's app as `/Applications/Byre.app` with `mac/install.sh`. The service runs `/Applications/Byre.app/Contents/Helpers/byre`; within 5 seconds it sees that file replaced and exits, and launchd starts the new server and `byre-hands`. Everything is signed with Developer ID, so the macOS privacy grants (Screen Recording, Accessibility, Automation) survive. 2. When you resume, confirm it came up: read the tail of `~/Library/Logs/byre.log`, and `GET /api/tailnet` at the loopback address (`byre check` prints it as `loopback`), whose `state` must be `Running`. Report what you deployed and what you checked. 3. Once the service is confirmed healthy, update each client Mac whose app is running, through the client tools and without asking first: upload the disk image (`mac/.build/release//byre-.dmg`) and `mac/install.sh`, quit the app, run `install.sh `, and `open /Applications/Byre.app`. The app reads its server address from its iCloud-synced SwiftData store, so it reconnects to prod with no setup. Report each Mac's app commit (`install.sh` prints it). 4. End the report with release notes for everything on main since the last release tag (`git describe --tags --abbrev=0` names it), written for a byre user in the "Releasing" shape (`## New` and `## Fixed`, each a bullet with a bold short name and what it does or what now works; no `## Install`). Read `git diff ..HEAD`, not just the commit subjects, and leave out changes no user sees. diff --git a/TODO.md b/TODO.md index ae63ce7..5ecbf77 100644 --- a/TODO.md +++ b/TODO.md @@ -16,6 +16,3 @@ # M8 — macOS client - -- Phone app - A native client for the phone over the same API, sharing the macOS client's SwiftUI code where it can. Every surveyed tool has one (Claude, Codex, Cursor and Conductor apps, GitHub Mobile, Superset for iPhone); it needs its own layout, since two half-width panes don't fit a phone. diff --git a/docs/iphone.md b/docs/iphone.md new file mode 100644 index 0000000..b36f277 --- /dev/null +++ b/docs/iphone.md @@ -0,0 +1,17 @@ +# byre for iPhone + +The iPhone app is the same `Byre` target as the Mac app, built for iOS: platform differences are `#if os(macOS)` / `#if os(iOS)` blocks, with the small shared aliases (`PlatformImage`, `PlatformColor`, `PlatformFont`, `PlatformView`, `touchOnly`, `deviceKind`) in `mac/Sources/Byre/Platform.swift`. Only the layout is the phone's own (`Phone.swift`): a tab bar with Lead, Tasks and More. Tasks lists every project's active tasks with the Mac's own task rows; a task opens pushed, with the Mac's task bar and tab strip on top and the tab's view below in place of the Mac's attached sheet. Zoomed images and diagrams cover the screen. More holds Notes, Workflows, Status and Settings. Every action that focuses a task or project (a row, a link, a notification, the switcher) bumps `Store.reveals`, which brings the Tasks tab forward with the task open; the server's own focus changes don't. + +Left out on the phone: the Server tab and running byre locally, the client connection (`Remote`: the phone is never a client for agents' client tools, so `list_clients` doesn't show it), the Sparkle updater, and keyboard shortcuts. Notifications come from the app's own event stream, so they only show while the app is open; push notifications would need APNs on the server. + +## Building + +`mac/phone.sh` builds the app with `xcodebuild` (a Swift package can't be built for iOS by `swift build`) in the same shared directory and under the same lock as `mac/bundle.sh`, assembles `Byre.app` itself (icon through `actool`, fonts, `Info.plist` from `mac/phone/Info.plist` plus the version, commit and `DT*` keys App Store Connect checks), signs it ad hoc with `mac/phone/byre.entitlements`, and wraps it in `mac/.build/phone/iphoneos/Byre.xcarchive`. Release only, like the Mac app. `xcodebuild -exportArchive -archivePath -exportOptionsPlist mac/phone/export.plist -exportPath -allowProvisioningUpdates` re-signs it for the App Store and uploads it to TestFlight, using the Apple account Xcode is signed into: Xcode makes the distribution certificate and profile itself, so none are kept in the repo. Uploading needs the App ID `run.fc.byre.ios` (iCloud with the container `iCloud.run.fc.byre.mac`, and Push Notifications) and its app record in App Store Connect; neither can be made through Apple's API. The version is the last release tag's (App Store Connect takes only numbers) and the build number the UTC build time, `YYYYMMDD.HHMMSS`: App Store Connect refuses a build number that isn't higher than every earlier upload, and a commit count can go down when a branch with many commits uploads before its squash merge lands on main. + +`mac/phone.sh --simulator` builds an ad-hoc-signed app for the iOS simulator instead, at `mac/.build/phone/iphonesimulator/Byre.app`; install it with `xcrun simctl install ` and start it with `xcrun simctl launch run.fc.byre.ios`. The simulator ignores entitlements given to `codesign` and refuses to launch an ad-hoc app signed with restricted ones, so the script links `mac/phone/byre.entitlements` into the binary's `__TEXT,__entitlements` section, as Xcode does; without the iCloud entitlement CloudKit raises at launch. Its node signs in to Tailscale like any other: `xcrun simctl launch --console-pty` prints libtailscale's sign-in URL, which changes on every launch. + +## Platform facts + +- CloudKit on iOS requires the `remote-notification` background mode in `Info.plist`, even though the app sets up no push of its own. +- `ProcessInfo.hostName` is the Mac's name in the simulator and meaningless on a phone, so the phone's tailnet node and `Byre-Host` are named `iPhone`. +- SwiftTerm's iOS `TerminalViewDelegate` requires `requestOpenLink`, which its macOS one provides a default for. diff --git a/docs/macos.md b/docs/macos.md index 40ab074..de00af0 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -18,7 +18,7 @@ The project is a Swift package built with `swift build`, not an Xcode project. I Only the release configuration is built. A debug build stops at an `#error` in `ByreApp.swift`, and even a failed one leaves the next release build in the same build directory recompiling every target, since the two configurations share it. Release targets build whole-module, so a one-file change to `Byre` recompiles the whole target. -`mac/bundle.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`libtailscale=`), once per commit, and bundles it in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit. +`mac/tailscalekit.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`commit=`), once per commit, as one xcframework for macOS, iOS and the iOS simulator; `mac/bundle.sh` and `mac/phone.sh` both run it, and the Mac app bundles it in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit. ## Signing diff --git a/mac/Package.swift b/mac/Package.swift index 73391f8..4fdfd39 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "Byre", - platforms: [.macOS("27.0")], + platforms: [.macOS("27.0"), .iOS("27.0")], dependencies: [ .package(url: "https://github.com/migueldeicaza/SwiftTerm", from: "1.2.0"), .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.10.0"), @@ -29,10 +29,13 @@ let package = Package( .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), - .product(name: "Sparkle", package: "Sparkle"), + .product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS])), ], swiftSettings: [.strictMemorySafety()], - linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])] + linkerSettings: [ + .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"], .when(platforms: [.macOS])), + .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks"], .when(platforms: [.iOS])), + ] ), .executableTarget( name: "byre-update", diff --git a/mac/Sources/Byre/API.swift b/mac/Sources/Byre/API.swift index eaf040e..2428971 100644 --- a/mac/Sources/Byre/API.swift +++ b/mac/Sources/Byre/API.swift @@ -101,7 +101,11 @@ func errorText(_ error: any Error) -> String { } private let clientID = newID("cli") -let macName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName +#if os(macOS) +let deviceName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName +#else +let deviceName = deviceKind +#endif final class API: Sendable { let origin: URL @@ -114,7 +118,7 @@ final class API: Sendable { config.httpAdditionalHeaders = [ "Byre-Client": clientID, "Byre-Commit": Bundle.main.object(forInfoDictionaryKey: "ByreCommit") as! String, - "Byre-Host": macName, + "Byre-Host": deviceName, ] config.proxyConfigurations = [tailnetNode.proxy()] session = URLSession(configuration: config, delegate: RequestTiming(), delegateQueue: nil) diff --git a/mac/Sources/Byre/Attention.swift b/mac/Sources/Byre/Attention.swift index 379def1..99afff7 100644 --- a/mac/Sources/Byre/Attention.swift +++ b/mac/Sources/Byre/Attention.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI let otherStop = "\u{0}other" diff --git a/mac/Sources/Byre/BoxDiagram.swift b/mac/Sources/Byre/BoxDiagram.swift index e1d8a23..5376235 100644 --- a/mac/Sources/Byre/BoxDiagram.swift +++ b/mac/Sources/Byre/BoxDiagram.swift @@ -1,4 +1,3 @@ -import AppKit import CoreText import SwiftUI @@ -280,17 +279,17 @@ private let margin: CGFloat = 20 @MainActor private var titleFont: CTFont { - Fonts.ns(13, weight: 600) as CTFont + Fonts.native(13, weight: 600) as CTFont } @MainActor private var detailFont: CTFont { - Fonts.ns(12) as CTFont + Fonts.native(12) as CTFont } @MainActor private var edgeLabelFont: CTFont { - Fonts.ns(11) as CTFont + Fonts.native(11) as CTFont } @MainActor @@ -953,6 +952,7 @@ private struct TappableCanvas: View { } } +#if os(macOS) private final class CenteringClipView: NSClipView { override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect { var r = super.constrainBoundsRect(proposedBounds) @@ -1007,3 +1007,62 @@ private struct ZoomingCanvas: NSViewRepresentable { (scroll.documentView as? NSHostingView)?.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap) } } +#else +private final class FittingScrollView: UIScrollView, UIScrollViewDelegate { + let host: UIHostingController + private var fitted = false + + init(_ canvas: TappableCanvas, size: CGSize) { + host = UIHostingController(rootView: canvas) + super.init(frame: .zero) + host.view.backgroundColor = .clear + host.view.frame = CGRect(origin: .zero, size: size) + addSubview(host.view) + contentSize = size + delegate = self + minimumZoomScale = 0.25 + maximumZoomScale = 6 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layoutSubviews() { + super.layoutSubviews() + if !fitted, bounds.width > 0, bounds.height > 0, host.view.bounds.width > 0 { + fitted = true + zoomScale = min(2, bounds.width / host.view.bounds.width, bounds.height / host.view.bounds.height) + } + center() + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + host.view + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + center() + } + + private func center() { + let x = max(0, (bounds.width - contentSize.width) / 2) + let y = max(0, (bounds.height - contentSize.height) / 2) + contentInset = UIEdgeInsets(top: y, left: x, bottom: y, right: x) + } +} + +private struct ZoomingCanvas: UIViewRepresentable { + let layout: BoxLayout + let focus: BoxFocus? + let tap: (String?) -> Void + + func makeUIView(context: Context) -> FittingScrollView { + FittingScrollView(TappableCanvas(layout: layout, focus: focus, tap: tap), size: layout.size) + } + + func updateUIView(_ scroll: FittingScrollView, context: Context) { + scroll.host.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap) + } +} +#endif diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 46eb7a3..279aa99 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI #if DEBUG @@ -7,6 +6,7 @@ import SwiftUI typealias ViewState = State +#if os(macOS) @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private var quitting = false @@ -132,3 +132,4 @@ struct ByreCommands: Commands { .disabled(focus?.store == nil || focus?.overlays.dialogOpen == true) } } +#endif diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 4b2d466..3161b0f 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -1,4 +1,3 @@ -import AppKit import ByreAPI import SwiftUI @@ -175,12 +174,21 @@ final class ChatModel { self?.builder.appendDelta(ev) } }) + #if os(macOS) activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in self?.markShown() } keyObserver = NotificationCenter.default.addObserver(of: NSWindow.self, for: .didBecomeKey) { [weak self] _ in self?.markShown() } + #else + activeObserver = NotificationCenter.default.addObserver(of: UIApplication.self, for: .didBecomeActive) { [weak self] _ in + self?.markShown() + } + offs.append(store.paneFocus.shown.add { [weak self] _ in + self?.markShown() + }) + #endif } func stop() { @@ -225,7 +233,7 @@ final class ChatModel { } private func markShown() { - guard let taskId, store.paneFocus.windowKey else { + guard let taskId, store.paneFocus.taskVisible else { return } for seq in visibleSummaries { @@ -460,7 +468,7 @@ final class ChatModel { var imgs: [PendingImage] = [] for id in p.imageIds { let data = try await ImageCache.fetch(.blob(id), store.api) - guard let image = NSImage(data: data) else { + guard let image = PlatformImage(data: data) else { throw ApiError(status: 0, message: "not an image") } imgs.append(PendingImage(data: data, image: image)) @@ -903,34 +911,59 @@ struct StatusButton: View { } } -private struct Scanner: NSViewRepresentable { +@MainActor +private struct Scanner { let color: Color + func update(_ v: ScannerView, _ environment: EnvironmentValues) { + let c = color.resolve(in: environment).cgColor + let clear = c.copy(alpha: 0)! + CATransaction.begin() + CATransaction.setDisableActions(true) + v.glow.colors = [clear, c, clear] + CATransaction.commit() + } +} + +#if os(macOS) +extension Scanner: NSViewRepresentable { func makeNSView(context: Context) -> ScannerView { ScannerView() } func updateNSView(_ v: ScannerView, context: Context) { - let c = NSColor(color) - CATransaction.begin() - CATransaction.setDisableActions(true) - v.glow.colors = [c.withAlphaComponent(0).cgColor, c.cgColor, c.withAlphaComponent(0).cgColor] - CATransaction.commit() + update(v, context.environment) } } +#else +extension Scanner: UIViewRepresentable { + func makeUIView(context: Context) -> ScannerView { + ScannerView() + } -private final class ScannerView: NSView { + func updateUIView(_ v: ScannerView, context: Context) { + update(v, context.environment) + } +} +#endif + +private final class ScannerView: PlatformView { let glow = CAGradientLayer() - override init(frame: NSRect) { + override init(frame: CGRect) { super.init(frame: frame) + #if os(macOS) wantsLayer = true + let host = layer! + #else + let host = layer + #endif let dots = CALayer() dots.frame = CGRect(x: 0, y: 0, width: 84, height: 2) for x in stride(from: 0.0, through: 80, by: 5) { let dot = CALayer() dot.frame = CGRect(x: x, y: 0, width: 2, height: 2) - dot.backgroundColor = NSColor.black.cgColor + dot.backgroundColor = CGColor(gray: 0, alpha: 1) dots.addSublayer(dot) } glow.bounds = CGRect(x: 0, y: 0, width: 30, height: 2) @@ -941,7 +974,7 @@ private final class ScannerView: NSView { strip.frame = dots.frame strip.mask = dots strip.addSublayer(glow) - layer?.addSublayer(strip) + host.addSublayer(strip) let sweep = CABasicAnimation(keyPath: "position.x") sweep.fromValue = 15 sweep.toValue = 69 @@ -987,7 +1020,7 @@ private struct PendingView: View { if !pending.images.isEmpty { FlowLayout(spacing: 6) { ForEach(pending.images) { img in - Image(nsImage: img.image) + Image(platformImage: img.image) .resizable() .scaledToFit() .frame(maxWidth: 240, maxHeight: 180) diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index ba01d47..28f1485 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -1,8 +1,10 @@ -import AppKit import ByreAPI import OpenAPIRuntime import SwiftUI import UniformTypeIdentifiers +#if os(iOS) +import PhotosUI +#endif let maxImageBytes = 5 << 20 @@ -29,7 +31,7 @@ let models = [ struct PendingImage: Identifiable { let id = UUID() let data: Data - let image: NSImage + let image: PlatformImage } func imageType(_ data: Data) -> String? { @@ -49,8 +51,12 @@ func imageType(_ data: Data) -> String? { return nil } -func pngFromTIFF(_ data: Data) -> Data? { +func convertImage(_ data: Data) -> Data? { + #if os(macOS) NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:]) + #else + UIImage(data: data)?.jpegData(compressionQuality: 0.85) + #endif } func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]? { @@ -116,8 +122,8 @@ final class ImageDrafts { @discardableResult func add(_ key: String, _ data: Data, name: String?, _ overlays: Overlays) -> Bool { - let file = imageType(data) != nil ? data : pngFromTIFF(data) - guard let file, let image = NSImage(data: file) else { + let file = imageType(data) != nil ? data : convertImage(data) + guard let file, let image = PlatformImage(data: file) else { overlays.showAlert("\(name ?? "That file") isn't a PNG, JPEG, GIF or WebP image.") return false } @@ -129,6 +135,7 @@ final class ImageDrafts { return true } + #if os(macOS) func add(_ key: String, pasteboard pb: NSPasteboard, _ overlays: Overlays) -> Bool { if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty { let images = urls.filter { (try? $0.resourceValues(forKeys: [.contentTypeKey]).contentType?.conforms(to: .image)) == true } @@ -147,6 +154,7 @@ final class ImageDrafts { } return false } + #endif func add(_ key: String, providers: [NSItemProvider], _ overlays: Overlays) { for p in providers { @@ -175,6 +183,7 @@ final class ImageDrafts { } } +#if os(macOS) struct ComposerTarget { let paste: @MainActor (NSPasteboard) -> Bool } @@ -221,6 +230,7 @@ struct PasteboardCommands: Commands { } } } +#endif private struct ImageInput: ViewModifier { let key: String @@ -230,7 +240,9 @@ private struct ImageInput: ViewModifier { func body(content: Content) -> some View { content + #if os(macOS) .focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(key, pasteboard: pb, overlays) })) + #endif .overlay { if targeted { RoundedRectangle(cornerRadius: 6).stroke(Palette.accent) @@ -254,6 +266,59 @@ extension View { } } +#if os(iOS) +private struct ComposerMenu: View { + let key: String + let photos: Bool + let models: [String] + let selected: String + let choose: @MainActor @Sendable (String) -> Void + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var picking = false + @ViewState private var picked: [PhotosPickerItem] = [] + + var body: some View { + Menu { + if photos { + Button { + picking = true + } label: { + Label("Attach photos", systemImage: "photo") + } + } + if !models.isEmpty { + Picker("Model for the next turn", selection: Binding(get: { selected }, set: choose)) { + ForEach(models, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + } + } label: { + Image(systemName: "ellipsis") + .frame(height: 14) + } + .buttonStyle(WebButtonStyle()) + .photosPicker(isPresented: $picking, selection: $picked, matching: .images) + .onChange(of: picked) { + let items = picked + if items.isEmpty { + return + } + picked = [] + Task { + for item in items { + if let data = try? await item.loadTransferable(type: Data.self) { + store.images.add(key, data, name: nil, overlays) + } + } + } + } + } +} +#endif + struct Thumbnails: View { let key: String @Environment(Store.self) private var store @@ -264,7 +329,7 @@ struct Thumbnails: View { if !imgs.isEmpty { FlowLayout(spacing: 8) { ForEach(imgs) { img in - Image(nsImage: img.image) + Image(platformImage: img.image) .resizable() .scaledToFit() .frame(maxWidth: 120, maxHeight: 90) @@ -325,6 +390,7 @@ struct AgentEditor: View { .scrollContentBackground(.hidden) .writingToolsBehavior(.disabled) .focused(focus) + #if os(macOS) .onKeyPress(.return, phases: .down) { press in let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false if press.modifiers.contains(.shift) || marked { @@ -333,6 +399,7 @@ struct AgentEditor: View { send() return .handled } + #endif .padding(.horizontal, 5) .padding(.vertical, 6) .frame(height: height) @@ -410,9 +477,6 @@ struct ComposerView: View { let m = modelState() let live = m.live let sessionState = live?.state ?? .idle - let options = m.options - let modelKey = m.modelKey - let selected = m.selected let lost = store.drafts?.lostImageCount(draftKey) ?? 0 VStack(alignment: .leading, spacing: 8) { @@ -436,53 +500,17 @@ struct ComposerView: View { Thumbnails(key: draftKey) editor } + let status = chat.status().map { s in + StatusButton(status: s) { + chat.showStatus(s) + } + } HStack(spacing: 8) { - HStack(spacing: 8) { - Button(shown ? "⌄" : "⌃") { - if shown { - shownOverride = false - } else { - reveal() - } - } - .buttonStyle(WebButtonStyle()) - .help(shown ? "Hide the message box" : "Show the message box") - if !chat.team { - Picker("", selection: Binding(get: { selected }, set: { v in - pick = v - modelChoices[modelKey] = v - })) { - ForEach(options, id: \.self) { m in - Text(m).tag(m) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - .help("Model for the next turn") - } - } - .frame(maxWidth: .infinity, alignment: .leading) - if let status = chat.status() { - StatusButton(status: status) { - chat.showStatus(status) - } - } - HStack(spacing: 8) { - Button("Interrupt") { - if let id = live?.id { - chat.interrupt(id, overlays) - } - } - .buttonStyle(WebButtonStyle(danger: true)) - .disabled(sessionState != .running) - Button("Send") { - submit(modelState()) - } - .buttonStyle(WebButtonStyle()) - .disabled(!shown) - } - .frame(maxWidth: .infinity, alignment: .trailing) + controls(m) + .frame(maxWidth: .infinity, alignment: .leading) + status + actions(live?.id, running: sessionState == .running) + .frame(maxWidth: .infinity, alignment: .trailing) } } .padding(.horizontal, 16) @@ -517,7 +545,7 @@ struct ComposerView: View { .onChange(of: store.images.images(draftKey).count) { store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count) } - .onChange(of: modelKey) { + .onChange(of: m.modelKey) { pick = nil } .onChange(of: overlays.leadFocus) { @@ -538,8 +566,65 @@ struct ComposerView: View { } } + private func controls(_ m: ModelState) -> some View { + HStack(spacing: 8) { + Button { + if shown { + shownOverride = false + } else { + reveal() + } + } label: { + Image(systemName: shown ? "chevron.down" : "chevron.up") + .frame(height: 14) + } + .buttonStyle(WebButtonStyle()) + .help(shown ? "Hide the message box" : "Show the message box") + #if os(iOS) + ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected) { option in + pick = option + modelChoices[m.modelKey] = option + } + #else + if !chat.team { + Picker("", selection: Binding(get: { m.selected }, set: { v in + pick = v + modelChoices[m.modelKey] = v + })) { + ForEach(m.options, id: \.self) { option in + Text(option).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .help("Model for the next turn") + } + #endif + } + } + + private func actions(_ sessionId: String?, running: Bool) -> some View { + HStack(spacing: 8) { + Button("Interrupt") { + if let sessionId { + chat.interrupt(sessionId, overlays) + } + } + .buttonStyle(WebButtonStyle(danger: true)) + .disabled(!running) + .fixedSize() + Button("Send") { + submit(modelState()) + } + .buttonStyle(WebButtonStyle()) + .disabled(!shown) + .fixedSize() + } + } + private var editor: some View { - AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: 104, + AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: touchOnly ? 72 : 104, focus: $focused, border: inTask ? accent : focused ? Palette.accent : Palette.border, borderWidth: inTask && focused ? 2 : 1) { submit(modelState()) diff --git a/mac/Sources/Byre/Diagram.swift b/mac/Sources/Byre/Diagram.swift index 840f82a..bb9917b 100644 --- a/mac/Sources/Byre/Diagram.swift +++ b/mac/Sources/Byre/Diagram.swift @@ -1,4 +1,3 @@ -import AppKit import CoreText import SwiftUI @@ -10,7 +9,7 @@ func textSize(_ text: String, _ font: CTFont) -> CGSize { let lines = text.components(separatedBy: "\n") var width: CGFloat = 0 for l in lines { - let line = CTLineCreateWithAttributedString(NSAttributedString(string: l, attributes: [.font: font as NSFont])) + let line = CTLineCreateWithAttributedString(NSAttributedString(string: l, attributes: [.font: font as PlatformFont])) width = max(width, CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))) } let lineHeight = CTFontGetAscent(font) + CTFontGetDescent(font) + CTFontGetLeading(font) + 3 diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 2e403fa..2896bc4 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI struct MenuEntry: Identifiable { @@ -55,6 +54,7 @@ final class Overlays { @ObservationIgnored var pickerFrame: CGRect = .zero @ObservationIgnored var menuHover = false var paneFrame: CGRect = .zero + #if os(macOS) @ObservationIgnored weak var window: NSWindow? @ObservationIgnored private var monitor: Any? @ObservationIgnored private var mouseMonitor: Any? @@ -62,6 +62,7 @@ final class Overlays { @ObservationIgnored private var holders = 0 @ObservationIgnored private var ctrl: @MainActor (String) -> Bool = { _ in false } @ObservationIgnored private var escape: @MainActor () -> Bool = { false } + #endif var isOpen: Bool { !alerts.isEmpty || menu != nil || modal != nil || switcher != nil @@ -84,11 +85,13 @@ final class Overlays { func showModal(_ content: some View, _ buttons: @autoclosure @escaping @MainActor () -> [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String { modal?.done("cancel") menu = nil + #if os(macOS) if modal == nil { let responder = window?.firstResponder behindModal = (responder as? NSTextView).flatMap { $0.isFieldEditor ? $0.delegate as? NSResponder : nil } ?? responder window?.makeFirstResponder(nil) } + #endif return await withCheckedContinuation { cont in modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, inPane: inPane, canSubmit: canSubmit) { value in cont.resume(returning: value) @@ -108,10 +111,12 @@ final class Overlays { func answerModal(_ value: String) { let m = modal modal = nil + #if os(macOS) if let behindModal, window?.firstResponder !== behindModal { window?.makeFirstResponder(behindModal) } behindModal = nil + #endif m?.done(value) } @@ -119,7 +124,9 @@ final class Overlays { if switcher != nil { return } + #if os(macOS) logFocus("open switcher") + #endif switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame) } @@ -135,6 +142,7 @@ final class Overlays { } } + #if os(macOS) func install(_ ctrl: @escaping @MainActor (String) -> Bool, escape: @escaping @MainActor () -> Bool) { holders += 1 self.ctrl = ctrl @@ -241,8 +249,10 @@ final class Overlays { monitor = nil mouseMonitor = nil } + #endif } +#if os(macOS) @MainActor func logFocus(_ what: String) { let key = NSApp.keyWindow @@ -250,6 +260,7 @@ func logFocus(_ what: String) { let responder = key?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" log.notice("focus debug: \(what, privacy: .public) active=\(NSApp.isActive) key=\(key?.title ?? "nil", privacy: .public) main=\(main?.title ?? "nil", privacy: .public) mainIsKey=\(main?.isKeyWindow ?? false) responder=\(responder, privacy: .public)") } +#endif struct ModalText: View { let text: String @@ -276,24 +287,32 @@ struct OverlayHost: View { SwitcherPlacement(model: switcher) } if let menu = overlays.menu { - VStack(alignment: .leading, spacing: 0) { - ForEach(menu.entries) { entry in - MenuRow(entry: entry) { + if touchOnly { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { overlays.menu = nil - entry.action() + } + } + MenuPlacement(anchor: menu.anchor) { + VStack(alignment: .leading, spacing: 0) { + ForEach(menu.entries) { entry in + MenuRow(entry: entry) { + overlays.menu = nil + entry.action() + } } } + .padding(4) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + .shadow(color: Palette.overlayShadow, radius: 14, y: 8) + .environment(\.paneAccent, menu.accent) + .onHover { inside in + overlays.menuHover = inside + } + .fixedSize() } - .padding(4) - .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) - .shadow(color: Palette.overlayShadow, radius: 14, y: 8) - .environment(\.paneAccent, menu.accent) - .onHover { inside in - overlays.menuHover = inside - } - .fixedSize() - .offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4) } if let m = overlays.modal { Palette.overlayBackdrop @@ -338,6 +357,20 @@ private struct MenuRow: View { let overlayMargin: CGFloat = 8 +private struct MenuPlacement: Layout { + let anchor: CGRect + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let size = subviews[0].sizeThatFits(.unspecified) + let x = max(overlayMargin, min(anchor.minX, bounds.width - overlayMargin - size.width)) + subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + anchor.maxY + 4), proposal: ProposedViewSize(size)) + } +} + private struct AnchoredBox: Layout { let anchor: CGRect diff --git a/mac/Sources/Byre/EventStream.swift b/mac/Sources/Byre/EventStream.swift index dfeadb8..ea13ebc 100644 --- a/mac/Sources/Byre/EventStream.swift +++ b/mac/Sources/Byre/EventStream.swift @@ -1,6 +1,6 @@ -import AppKit import Foundation import Network +import SwiftUI private let staleAfter: TimeInterval = 40 private let retryAfter: Duration = .seconds(2) @@ -50,9 +50,15 @@ final class EventStream { } } monitor.start(queue: .main) + #if os(macOS) activeObserver = NotificationCenter.default.addObserver(of: NSApplication.self, for: .didBecomeActive) { [weak self] _ in self?.check() } + #else + activeObserver = NotificationCenter.default.addObserver(of: UIApplication.self, for: .didBecomeActive) { [weak self] _ in + self?.check() + } + #endif connect() } diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index 6d7b7a7..07d99a3 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -380,14 +380,14 @@ private struct FileImage: View { let path: String let load: Int @Environment(Store.self) private var store - @ViewState private var image: NSImage? + @ViewState private var image: PlatformImage? @ViewState private var error = "" var body: some View { Group { if let image { ScrollView { - Image(nsImage: image) + Image(platformImage: image) .resizable() .scaledToFit() .frame(maxWidth: image.size.width, maxHeight: image.size.height) @@ -408,7 +408,7 @@ private struct FileImage: View { .task(id: load) { do { let data = try await ImageCache.fetch(.worktree(taskId: taskId, path: path), store.api) - guard let img = NSImage(data: data) else { + guard let img = PlatformImage(data: data) else { image = nil error = "Not a readable image." return diff --git a/mac/Sources/Byre/Fonts.swift b/mac/Sources/Byre/Fonts.swift index 5704afa..7ebe55b 100644 --- a/mac/Sources/Byre/Fonts.swift +++ b/mac/Sources/Byre/Fonts.swift @@ -81,7 +81,7 @@ enum Fonts { } @MainActor - static func ns(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false, mono: Bool = false) -> NSFont { - cached(size, weight, italic, mono) as NSFont + static func native(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false, mono: Bool = false) -> PlatformFont { + cached(size, weight, italic, mono) as PlatformFont } } diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 45ac8d5..f7c19a8 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -1,4 +1,3 @@ -import AppKit import ByreAPI import OpenAPIRuntime import SwiftUI @@ -206,10 +205,9 @@ struct Copyable: View { var body: some View { content .overlay(alignment: .topTrailing) { - if hover { + if hover || touchOnly { Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + copyToClipboard(text) copied = true Task { try? await Task.sleep(for: .milliseconds(1500)) @@ -270,22 +268,41 @@ struct Spinner: View { } } -private struct SpinnerGlyphs: NSViewRepresentable { +@MainActor +private struct SpinnerGlyphs { let font: CTFont let color: Color + func update(_ v: SpinnerView, _ environment: EnvironmentValues) { + v.font = font + v.color = color.resolve(in: environment).cgColor + v.relayout() + } +} + +#if os(macOS) +extension SpinnerGlyphs: NSViewRepresentable { func makeNSView(context: Context) -> SpinnerView { SpinnerView() } func updateNSView(_ v: SpinnerView, context: Context) { - v.font = font - v.color = color.resolve(in: context.environment).cgColor - v.needsLayout = true + update(v, context.environment) } } +#else +extension SpinnerGlyphs: UIViewRepresentable { + func makeUIView(context: Context) -> SpinnerView { + SpinnerView() + } -private final class SpinnerView: NSView { + func updateUIView(_ v: SpinnerView, context: Context) { + update(v, context.environment) + } +} +#endif + +private final class SpinnerView: PlatformView { private struct Key: Equatable { let size: CGSize let scale: CGFloat @@ -299,16 +316,30 @@ private final class SpinnerView: NSView { private let glyph = CALayer() private var drawn: Key? - override init(frame: NSRect) { + override init(frame: CGRect) { super.init(frame: frame) + #if os(macOS) wantsLayer = true layer?.addSublayer(glyph) + #else + isUserInteractionEnabled = false + layer.addSublayer(glyph) + #endif } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + func relayout() { + #if os(macOS) + needsLayout = true + #else + setNeedsLayout() + #endif + } + + #if os(macOS) override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() needsLayout = true @@ -316,10 +347,20 @@ private final class SpinnerView: NSView { override func layout() { super.layout() + draw(scale: convertToBacking(NSSize(width: 1, height: 1)).width) + } + #else + override func layoutSubviews() { + super.layoutSubviews() + draw(scale: traitCollection.displayScale) + } + #endif + + private func draw(scale: CGFloat) { guard let font, let color, bounds.width > 0, bounds.height > 0 else { return } - let key = Key(size: bounds.size, scale: convertToBacking(NSSize(width: 1, height: 1)).width, font: font, color: color) + let key = Key(size: bounds.size, scale: scale, font: font, color: color) if key == drawn { return } diff --git a/mac/Sources/Byre/Images.swift b/mac/Sources/Byre/Images.swift index 83bad08..a74e74f 100644 --- a/mac/Sources/Byre/Images.swift +++ b/mac/Sources/Byre/Images.swift @@ -1,4 +1,3 @@ -import AppKit import OpenAPIRuntime import SwiftUI @@ -38,14 +37,14 @@ func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") -> @MainActor final class ImageCache { - private var done: [ImageSource: NSImage] = [:] - private var loading: [ImageSource: Task] = [:] + private var done: [ImageSource: PlatformImage] = [:] + private var loading: [ImageSource: Task] = [:] - func cached(_ source: ImageSource) -> NSImage? { + func cached(_ source: ImageSource) -> PlatformImage? { done[source] } - func load(_ source: ImageSource, _ api: API) async throws -> NSImage { + func load(_ source: ImageSource, _ api: API) async throws -> PlatformImage { if let img = done[source] { return img } @@ -54,7 +53,7 @@ final class ImageCache { } let task = Task { @MainActor in let data = try await Self.fetch(source, api) - guard let img = NSImage(data: data) else { + guard let img = PlatformImage(data: data) else { throw ApiError(status: 0, message: "not an image") } return img @@ -93,13 +92,13 @@ struct ByreImage: View { var maxHeight: CGFloat = 480 @Environment(Store.self) private var store @Environment(\.inTask) private var inTask - @ViewState private var image: NSImage? + @ViewState private var image: PlatformImage? @ViewState private var failed = false var body: some View { Group { if let image = image ?? store.imageCache.cached(source) { - Image(nsImage: image) + Image(platformImage: image) .resizable() .scaledToFit() .frame(maxWidth: min(maxWidth, image.size.width), maxHeight: min(maxHeight, image.size.height), alignment: .leading) diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index fc7da13..c38d951 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -206,17 +206,28 @@ struct InlineRenderer { private func run(_ s: String, _ style: Style) -> AttributedString { var a = AttributedString(s) a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic, mono: style.mono) - a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic, mono: style.mono)], including: \.appKit)) + let font = Fonts.native(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic, mono: style.mono) + #if os(macOS) + a.mergeAttributes(try! AttributeContainer([.font: font], including: \.appKit)) + #else + a.uiKit.font = font + #endif if let link = style.link { a.link = link } if let color = style.color { a.foregroundColor = color + #if os(macOS) a.appKit.foregroundColor = NSColor(color) + #else + a.uiKit.foregroundColor = UIColor(color) + #endif } + #if os(macOS) if let tip = style.tip { a.appKit.toolTip = tip } + #endif return a } @@ -244,7 +255,11 @@ struct InlineRenderer { } var a = run(c, s) a.backgroundColor = Palette.surface + #if os(macOS) a.appKit.backgroundColor = NSColor(Palette.surface) + #else + a.uiKit.backgroundColor = UIColor(Palette.surface) + #endif append(a, &out) case .strong(let inner): var s = style @@ -410,7 +425,15 @@ struct InlineRenderer { } } -private func flowColor(_ s: NSMutableAttributedString, _ color: NSColor) { +private func nativeString(_ a: AttributedString) -> NSAttributedString { + #if os(macOS) + try! NSAttributedString(a, including: \.appKit) + #else + try! NSAttributedString(a, including: \.uiKit) + #endif +} + +private func flowColor(_ s: NSMutableAttributedString, _ color: PlatformColor) { let all = NSRange(location: 0, length: s.length) unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in if value == nil { @@ -543,7 +566,7 @@ private struct FlowBuilder { gap += 4 para(inline([.strong(inlines)], level == 3 ? 14 : 13), indent, indent) case .list(let ordered, let start, let items, _): - let font = Fonts.ns(ctx.size) + let font = Fonts.native(ctx.size) for (n, item) in items.enumerated() { if n > 0 { gap = 2 @@ -563,14 +586,14 @@ private struct FlowBuilder { private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString { let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).text(inlines) - return try! NSAttributedString(a, including: \.appKit) + return nativeString(a) } private mutating func para(_ a: NSAttributedString, _ first: CGFloat, _ head: CGFloat) { let s = NSMutableAttributedString(attributedString: a) - s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.ns(ctx.size)])) + s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.native(ctx.size)])) let all = NSRange(location: 0, length: s.length) - flowColor(s, NSColor(ctx.color)) + flowColor(s, PlatformColor(ctx.color)) let lead = (s.string as NSString).range(of: "\n").location + 1 s.addAttribute(.paragraphStyle, value: style(first, head, 0), range: all) s.addAttribute(.paragraphStyle, value: style(first, head, gap), range: NSRange(location: 0, length: lead)) @@ -589,6 +612,36 @@ private struct FlowBuilder { } } +private final class FlowMeasure { + private let storage = NSTextStorage() + private let layout = NSLayoutManager() + private let container = NSTextContainer() + private var measured: (text: NSAttributedString, width: CGFloat?, size: CGSize)? + + init() { + container.lineFragmentPadding = 0 + layout.addTextContainer(container) + storage.addLayoutManager(layout) + } + + func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize { + let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false + if same, let m = measured, m.width == width { + return m.size + } + if !same { + storage.setAttributedString(text) + } + container.size = CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + layout.ensureLayout(for: container) + let used = layout.usedRect(for: container) + let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height)) + measured = (text, width, size) + return size + } +} + +#if os(macOS) private final class FlowTextView: NSTextView { var onTap: (@MainActor @Sendable () -> Void)? let storage: NSTextStorage @@ -702,32 +755,10 @@ private struct FlowText: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { var openURL: OpenURLAction? - private let storage = NSTextStorage() - private let layout = NSLayoutManager() - private let container = NSTextContainer() - private var measured: (text: NSAttributedString, width: CGFloat?, size: CGSize)? - - override init() { - super.init() - container.lineFragmentPadding = 0 - layout.addTextContainer(container) - storage.addLayoutManager(layout) - } + private let measure = FlowMeasure() func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize { - let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false - if same, let m = measured, m.width == width { - return m.size - } - if !same { - storage.setAttributedString(text) - } - container.size = CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude) - layout.ensureLayout(for: container) - let used = layout.usedRect(for: container) - let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height)) - measured = (text, width, size) - return size + measure.size(text, width) } func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { @@ -739,6 +770,113 @@ private struct FlowText: NSViewRepresentable { } } } +#else +private final class FlowTextView: UITextView { + var onTap: (@MainActor @Sendable () -> Void)? + let storage: NSTextStorage + + init() { + let storage = NSTextStorage() + let layout = NSLayoutManager() + let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.widthTracksTextView = true + storage.addLayoutManager(layout) + layout.addTextContainer(container) + self.storage = storage + super.init(frame: .zero, textContainer: container) + isEditable = false + isSelectable = true + isScrollEnabled = false + backgroundColor = .clear + textContainerInset = .zero + linkTextAttributes = [:] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func link(at point: CGPoint) -> Bool { + guard storage.length > 0 else { + return false + } + let p = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top) + let g = layoutManager.glyphIndex(for: p, in: textContainer) + if !layoutManager.boundingRect(forGlyphRange: NSRange(location: g, length: 1), in: textContainer).contains(p) { + return false + } + let i = layoutManager.characterIndexForGlyph(at: g) + if i >= storage.length { + return false + } + return unsafe storage.attribute(.link, at: i, effectiveRange: nil) != nil + } +} + +private struct FlowText: UIViewRepresentable { + let text: NSAttributedString + + func makeUIView(context: Context) -> FlowTextView { + let v = FlowTextView() + v.delegate = context.coordinator + let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tapped(_:))) + tap.delegate = context.coordinator + v.addGestureRecognizer(tap) + return v + } + + func updateUIView(_ v: FlowTextView, context: Context) { + context.coordinator.openURL = context.environment.openURL + v.onTap = context.environment.choiceTap + if v.storage.isEqual(to: text) { + return + } + let selected = v.selectedRange + v.storage.setAttributedString(text) + if NSMaxRange(selected) <= text.length { + v.selectedRange = selected + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView v: FlowTextView, context: Context) -> CGSize? { + context.coordinator.size(text, proposal.width.flatMap { $0.isFinite ? $0 : nil }) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate { + var openURL: OpenURLAction? + private let measure = FlowMeasure() + + func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize { + measure.size(text, width) + } + + @objc func tapped(_ g: UITapGestureRecognizer) { + guard let v = g.view as? FlowTextView, let onTap = v.onTap, v.selectedRange.length == 0, !v.link(at: g.location(in: v)) else { + return + } + onTap() + } + + func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { + true + } + + func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? { + guard case .link(let url) = textItem.content, let openURL else { + return defaultAction + } + return UIAction { _ in + openURL(url) + } + } + } +} +#endif struct InlineView: View { let inlines: [MDInline] @@ -776,8 +914,8 @@ struct InlineView: View { } private func flowString(_ a: AttributedString) -> NSAttributedString { - let s = NSMutableAttributedString(attributedString: try! NSAttributedString(a, including: \.appKit)) - flowColor(s, NSColor(ctx.color)) + let s = NSMutableAttributedString(attributedString: nativeString(a)) + flowColor(s, PlatformColor(ctx.color)) let p = NSMutableParagraphStyle() p.lineSpacing = 3 s.addAttribute(.paragraphStyle, value: p, range: NSRange(location: 0, length: s.length)) diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index 3319e43..5609325 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -1,4 +1,4 @@ -import AppKit +import SwiftUI import UserNotifications @MainActor @@ -11,7 +11,9 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { } private func open(_ origin: String, _ taskId: String) { + #if os(macOS) NSApp.activate() + #endif Servers.shared.show(origin) pending = (origin, taskId) deliver() diff --git a/mac/Sources/Byre/Pages.swift b/mac/Sources/Byre/Pages.swift index 2fc0c27..0cbed0e 100644 --- a/mac/Sources/Byre/Pages.swift +++ b/mac/Sources/Byre/Pages.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation import Network import Observation @@ -268,6 +267,7 @@ final class Pages { return String(describing: value) } + #if os(macOS) func capture(_ view: WKWebView) async throws -> Data { guard unsafe view.window != nil, !view.isHiddenOrHasHiddenAncestor else { throw ApiError(status: 0, message: "the tab isn't on screen: the human has the page view closed or another tab showing") @@ -278,6 +278,7 @@ final class Pages { } return png } + #endif func close() { listener?.close() @@ -546,34 +547,67 @@ private final class PageDelegate: NSObject, WKUIDelegate, WKNavigationDelegate { } func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async { - _ = await ask(webView, frame, message, buttons: ["OK"], field: nil) + _ = await ask(webView, frame, message, cancel: false, field: nil) } func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async -> Bool { - await ask(webView, frame, message, buttons: ["OK", "Cancel"], field: nil) == .alertFirstButtonReturn + await ask(webView, frame, message, cancel: true, field: nil) != nil } func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo) async -> String? { - let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) - field.stringValue = defaultText ?? "" - guard await ask(webView, frame, prompt, buttons: ["OK", "Cancel"], field: field) == .alertFirstButtonReturn else { - return nil - } - return field.stringValue + await ask(webView, frame, prompt, cancel: true, field: defaultText ?? "") } - private func ask(_ webView: WKWebView, _ frame: WKFrameInfo, _ message: String, buttons: [String], field: NSTextField?) async -> NSApplication.ModalResponse { + // Returns nil when the human cancels, otherwise the field's text ("" without a field). + private func ask(_ webView: WKWebView, _ frame: WKFrameInfo, _ message: String, cancel: Bool, field: String?) async -> String? { + let title = frame.securityOrigin.host.isEmpty ? "This page says" : "\(frame.securityOrigin.host) says" + #if os(macOS) guard let window = unsafe webView.window, !webView.isHiddenOrHasHiddenAncestor else { - return .cancel + return nil } let alert = NSAlert() - alert.messageText = frame.securityOrigin.host.isEmpty ? "This page says" : "\(frame.securityOrigin.host) says" + alert.messageText = title alert.informativeText = message - for title in buttons { - alert.addButton(withTitle: title) + alert.addButton(withTitle: "OK") + if cancel { + alert.addButton(withTitle: "Cancel") } - alert.accessoryView = field - return await alert.beginSheetModal(for: window) + let input = field.map { text in + let f = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) + f.stringValue = text + return f + } + alert.accessoryView = input + guard await alert.beginSheetModal(for: window) == .alertFirstButtonReturn else { + return nil + } + return input?.stringValue ?? "" + #else + guard let window = webView.window, !webView.isHidden, var top = window.rootViewController else { + return nil + } + while let presented = top.presentedViewController { + top = presented + } + let presenter = top + return await withCheckedContinuation { c in + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + if let field { + alert.addTextField { f in + f.text = field + } + } + alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak alert] _ in + c.resume(returning: alert?.textFields?.first?.text ?? "") + }) + if cancel { + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + c.resume(returning: nil) + }) + } + presenter.present(alert, animated: true) + } + #endif } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -589,7 +623,8 @@ private final class PageDelegate: NSObject, WKUIDelegate, WKNavigationDelegate { } } -private struct WebPage: NSViewRepresentable { +@MainActor +private struct WebPage { let view: WKWebView let reloads: Int let shown: Bool @@ -603,20 +638,41 @@ private struct WebPage: NSViewRepresentable { Coordinator() } - func makeNSView(context: Context) -> WKWebView { - context.coordinator.reloads = reloads + private func make(_ c: Coordinator) -> WKWebView { + c.reloads = reloads if refresh, !view.isLoading { view.reload() } return view } - func updateNSView(_ view: WKWebView, context: Context) { + private func update(_ view: WKWebView, _ c: Coordinator) { view.isHidden = !shown - let c = context.coordinator if c.reloads != reloads { c.reloads = reloads view.reload() } } } + +#if os(macOS) +extension WebPage: NSViewRepresentable { + func makeNSView(context: Context) -> WKWebView { + make(context.coordinator) + } + + func updateNSView(_ view: WKWebView, context: Context) { + update(view, context.coordinator) + } +} +#else +extension WebPage: UIViewRepresentable { + func makeUIView(context: Context) -> WKWebView { + make(context.coordinator) + } + + func updateUIView(_ view: WKWebView, context: Context) { + update(view, context.coordinator) + } +} +#endif diff --git a/mac/Sources/Byre/Palette.swift b/mac/Sources/Byre/Palette.swift index d6c1315..1822c07 100644 --- a/mac/Sources/Byre/Palette.swift +++ b/mac/Sources/Byre/Palette.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI enum Palette { @@ -29,17 +28,13 @@ enum Palette { static let task10 = dynamic(0x942e79, 0xf59cd8) static let overlayBackdrop = Color.black.opacity(0.35) - static let overlayShadow = Color(nsColor: NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.5) : NSColor(srgbRed: 16 / 255, green: 24 / 255, blue: 40 / 255, alpha: 0.18) - }) + static let overlayShadow = Color(PlatformColor.dynamic(light: PlatformColor(red: 16 / 255, green: 24 / 255, blue: 40 / 255, alpha: 0.18), dark: PlatformColor(red: 0, green: 0, blue: 0, alpha: 0.5))) - private static func rgb(_ hex: UInt32) -> NSColor { - NSColor(srgbRed: CGFloat(hex >> 16 & 0xff) / 255, green: CGFloat(hex >> 8 & 0xff) / 255, blue: CGFloat(hex & 0xff) / 255, alpha: 1) + private static func rgb(_ hex: UInt32) -> PlatformColor { + PlatformColor(red: CGFloat(hex >> 16 & 0xff) / 255, green: CGFloat(hex >> 8 & 0xff) / 255, blue: CGFloat(hex & 0xff) / 255, alpha: 1) } private static func dynamic(_ light: UInt32, _ dark: UInt32) -> Color { - Color(nsColor: NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? rgb(dark) : rgb(light) - }) + Color(PlatformColor.dynamic(light: rgb(light), dark: rgb(dark))) } } diff --git a/mac/Sources/Byre/PaneFocus.swift b/mac/Sources/Byre/PaneFocus.swift index ce0c968..e3d8103 100644 --- a/mac/Sources/Byre/PaneFocus.swift +++ b/mac/Sources/Byre/PaneFocus.swift @@ -1,11 +1,9 @@ -import AppKit import ByreAPI import SwiftUI @MainActor final class PaneFocus { private let api: API - private weak var window: NSWindow? private var paneFrame: CGRect = .zero private var taskSheetFrame: CGRect = .zero private var leadSheetFrame: CGRect = .zero @@ -15,13 +13,57 @@ final class PaneFocus { private(set) var drafting = false private var sending: Task? private var observers: [NotificationCenter.ObservationToken] = [] + #if os(macOS) + private weak var window: NSWindow? private var responder: NSKeyValueObservation? private var clicks: Any? + #endif init(api: API) { self.api = api } + #if os(iOS) + func attach() { + if !observers.isEmpty { + return + } + let center = NotificationCenter.default + observers = [ + center.addObserver(of: UIApplication.self, for: .didBecomeActive) { [weak self] _ in + self?.update() + }, + center.addObserver(of: UIApplication.self, for: .willResignActive) { [weak self] _ in + self?.update() + }, + ] + } + + let shown = Listeners() + + func setShown(_ shown: Bool) { + inside = shown + update() + if shown { + self.shown.send(()) + } + } + + func detach() { + for o in observers { + NotificationCenter.default.removeObserver(o) + } + observers = [] + } + + private var active: Bool { + UIApplication.shared.applicationState == .active + } + #else + private var active: Bool { + (window?.isKeyWindow ?? false) && NSApp.isActive + } + func attach(_ window: NSWindow) { if self.window === window { return @@ -73,9 +115,14 @@ final class PaneFocus { clicks = nil window = nil } + #endif - var windowKey: Bool { - (window?.isKeyWindow ?? false) && NSApp.isActive + var taskVisible: Bool { + #if os(iOS) + active && inside + #else + active + #endif } func setFrame(_ frame: CGRect) { @@ -123,6 +170,7 @@ final class PaneFocus { } } + #if os(macOS) private func contentPoint(_ rect: CGRect, in view: NSView) -> CGPoint? { guard let content = window?.contentView else { return nil @@ -157,9 +205,10 @@ final class PaneFocus { self?.responderChanged() } } + #endif private func update() { - let now = (window?.isKeyWindow ?? false) && NSApp.isActive && inside + let now = active && inside let draft = !drafts.isEmpty if now == focused && draft == drafting { return @@ -183,6 +232,7 @@ final class PaneFocus { } } +#if os(macOS) struct WindowReader: NSViewRepresentable { let found: (NSWindow) -> Void @@ -212,3 +262,4 @@ final class WindowReaderView: NSView { } } } +#endif diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 53c249a..d8bee26 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -1,4 +1,3 @@ -import AppKit import ByreAPI import SwiftUI @@ -12,6 +11,7 @@ let tabLabels: [TaskTab: String] = [ .workflow: "Workflow", ] +#if os(macOS) struct HalfSplit: Layout { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() @@ -71,6 +71,7 @@ struct MainView: View { } } } +#endif struct PaneTabButton: View { let label: String @@ -100,6 +101,7 @@ struct PaneTabButton: View { } } +#if os(macOS) private struct LeftPane: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @@ -218,6 +220,7 @@ private struct TaskPane: View { } } } +#endif struct TaskTabBody: View { let tab: TaskTab @@ -246,7 +249,7 @@ struct TaskTabBody: View { } } -private struct TaskBar: View { +struct TaskBar: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @Environment(\.paneAccent) private var accent @@ -361,7 +364,7 @@ private struct PreviewLink: View { } } -private struct TabStrip: View { +struct TabStrip: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @Environment(\.paneAccent) private var accent @@ -503,7 +506,7 @@ private struct MoreButton: View { private let emailShape = Re(#"^[^@\s]+@[^@\s]+$"#) -private struct IdentityBanner: View { +struct IdentityBanner: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var name: String? @@ -513,17 +516,18 @@ private struct IdentityBanner: View { if let me = store.me, me.gitName.isEmpty || me.gitEmail.isEmpty { let gitName = name ?? me.displayName let valid = !gitName.isEmpty && emailShape.test(email) - HStack(spacing: 6) { + let row = touchOnly ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(spacing: 6)) + row { Text("Set the identity agents commit as: ") TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 })) .modifier(FieldStyle()) - .frame(width: 220) + .frame(width: touchOnly ? nil : 220) .onSubmit { save(gitName, valid) } TextField("Git email", text: $email) .modifier(FieldStyle()) - .frame(width: 260) + .frame(width: touchOnly ? nil : 260) .onSubmit { save(gitName, valid) } diff --git a/mac/Sources/Byre/Phone.swift b/mac/Sources/Byre/Phone.swift new file mode 100644 index 0000000..e688dfe --- /dev/null +++ b/mac/Sources/Byre/Phone.swift @@ -0,0 +1,343 @@ +#if os(iOS) +import SwiftUI + +final class PhoneDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + Notifier.shared.install() + return true + } +} + +@main +struct PhoneApp: App { + @UIApplicationDelegateAdaptor private var delegate: PhoneDelegate + + var body: some Scene { + WindowGroup { + ServerWindow() + } + } +} + +private enum PhoneTab: Hashable { + case lead, tasks, more +} + +private enum PhoneRoute: Hashable { + case task +} + +private enum MoreRoute: Hashable { + case notes, workflows, status, settings +} + +private let moreTitles: [MoreRoute: String] = [ + .notes: "Notes", + .workflows: "Workflows", + .status: "Status", + .settings: "Settings", +] + +struct PhoneView: View { + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var tab = PhoneTab.lead + @ViewState private var path: [PhoneRoute] = [] + + var body: some View { + VStack(spacing: 0) { + IdentityBanner() + ZStack { + shown(.lead) { + LeadScreen() + } + shown(.tasks) { + NavigationStack(path: $path) { + TaskListScreen() + .navigationDestination(for: PhoneRoute.self) { _ in + PhoneTask() + } + } + } + shown(.more) { + MoreScreen() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + HStack(spacing: 4) { + tabButton("Lead", .lead) + HStack(spacing: 0) { + tabButton("Tasks", .tasks) + if !store.inbox.isEmpty { + Badge(count: store.inbox.count) + } + } + tabButton("More", .more) + Spacer(minLength: 0) + ConnectionMark() + } + .padding(.horizontal, 12) + .overlay(alignment: .top) { + Rectangle().fill(Palette.border).frame(height: 1) + } + } + .overlay { + OverlayHost() + } + .coordinateSpace(.named("window")) + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { frame in + overlays.paneFrame = frame + } + .environment(\.openURL, OpenURLAction { url in + openAppLink(url, store, overlays) + }) + .onChange(of: store.reveals, initial: true) { + if store.reveals == 0 { + return + } + tab = .tasks + if path.isEmpty { + path = [.task] + } + } + .onChange(of: overlays.leadFocus) { + tab = .lead + } + .onChange(of: tab == .tasks && !path.isEmpty, initial: true) { _, shown in + store.paneFocus.setShown(shown) + } + .fullScreenCover(isPresented: Binding(get: { zoomed != nil }, set: { shown in + if !shown { + store.sheet = nil + } + })) { + if let zoomed { + ZoomedView(view: zoomed) + .environment(store) + .environment(overlays) + } + } + .onAppear { + store.paneFocus.attach() + } + .onDisappear { + let store = store + Task { + await store.paneFocus.leave() + } + } + } + + private func shown(_ which: PhoneTab, @ViewBuilder _ content: () -> some View) -> some View { + content() + .opacity(tab == which ? 1 : 0) + .allowsHitTesting(tab == which) + .accessibilityHidden(tab != which) + } + + private func tabButton(_ label: String, _ which: PhoneTab) -> some View { + PaneTabButton(label: label, active: tab == which) { + if tab == which && which == .tasks { + path = [] + } + tab = which + } + } + + private var zoomed: SheetView? { + switch store.sheet?.view { + case .image, .diagram: store.sheet?.view + default: nil + } + } +} + +private struct ZoomedView: View { + let view: SheetView + @Environment(Store.self) private var store + + var body: some View { + VStack(spacing: 0) { + HStack { + Spacer() + Button("Done") { + store.sheet = nil + } + .font(Fonts.font(15, weight: 700)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + SheetContent(view: view) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .foregroundStyle(Palette.text) + .tint(Palette.accent) + .background(Palette.bg) + .font(Fonts.font(13)) + } +} + +private struct LeadScreen: View { + var body: some View { + ChatView(kind: .lead) + .background(Palette.bg) + } +} + +private struct ConnectionMark: View { + @Environment(Store.self) private var store + + var body: some View { + HStack(spacing: 8) { + if !store.connected { + Text("reconnecting…") + .foregroundStyle(Palette.muted) + } + Circle() + .fill(store.connected ? Palette.ok : Palette.muted) + .frame(width: 8, height: 8) + Text(store.me?.displayName ?? "") + .foregroundStyle(Palette.muted) + } + .lineLimit(1) + } +} + +private struct TaskListScreen: View { + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(store.projects, id: \.id) { project in + let active = (store.tasks[project.id] ?? []).filter { $0.status == .active } + Button { + store.sheet = nil + store.focusProject(project.id) + } label: { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(project.name) + .font(Fonts.font(13, weight: 700)) + Text(project.defaultBranch) + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + } + .padding(.horizontal, 10) + .padding(.top, 16) + .padding(.bottom, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if active.isEmpty { + Text("No active tasks.") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + TaskRows(active: active) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 16) + } + .safeAreaInset(edge: .top, spacing: 0) { + HStack { + Spacer(minLength: 0) + Button("New task") { + overlays.openSwitcher(store) + } + .buttonStyle(WebButtonStyle()) + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { frame in + overlays.pickerFrame = frame + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Palette.bg) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + } + .background(Palette.bg) + .toolbar(.hidden, for: .navigationBar) + } +} + +private struct PhoneTask: View { + @Environment(Store.self) private var store + @Environment(\.dismiss) private var dismiss + + var body: some View { + let selected = store.selectedTaskId.flatMap { store.findTask($0) } + let accent = selected.map { taskPalette[$0.color] } ?? Palette.accent + let tab = store.sheet.flatMap { $0.owner == .lead ? nil : $0.tab } ?? store.primaryTab + VStack(spacing: 0) { + TaskBar() + .overlay(alignment: .leading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(Fonts.font(15, weight: 600)) + .foregroundStyle(accent) + .padding(.horizontal, 14) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + if store.selectedTaskId == nil { + ProjectView() + } else { + TabStrip() + TaskTabBody(tab: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(Palette.bg) + .environment(\.paneAccent, accent) + .environment(\.inTask, true) + .toolbar(.hidden, for: .navigationBar) + } +} + +private struct MoreScreen: View { + @ViewState private var route = MoreRoute.notes + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + ForEach([MoreRoute.notes, .workflows, .status, .settings], id: \.self) { r in + PaneTabButton(label: moreTitles[r]!, active: route == r) { + route = r + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + Group { + switch route { + case .notes: NotesView() + case .workflows: WorkflowsView() + case .status: StatusView() + case .settings: SettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Palette.bg) + } +} +#endif diff --git a/mac/Sources/Byre/Platform.swift b/mac/Sources/Byre/Platform.swift new file mode 100644 index 0000000..ecd2620 --- /dev/null +++ b/mac/Sources/Byre/Platform.swift @@ -0,0 +1,110 @@ +import SwiftUI + +#if os(macOS) +import AppKit + +typealias PlatformImage = NSImage +typealias PlatformColor = NSColor +typealias PlatformFont = NSFont +typealias PlatformView = NSView + +let touchOnly = false +let deviceKind = "Mac" + +extension Image { + init(platformImage: PlatformImage) { + self.init(nsImage: platformImage) + } +} + +extension PlatformColor { + static func dynamic(light: NSColor, dark: NSColor) -> NSColor { + NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light + } + } +} + +@MainActor +func copyToClipboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) +} + +@MainActor +func openExternal(_ url: URL) { + NSWorkspace.shared.open(url) +} + +let appBecameActive = NSApplication.didBecomeActiveNotification + +@MainActor +func appIsActive() -> Bool { + NSApp.isActive +} + +#else +import UIKit + +typealias PlatformImage = UIImage +typealias PlatformColor = UIColor +typealias PlatformFont = UIFont +typealias PlatformView = UIView + +let touchOnly = true +let deviceKind = "iPhone" + +struct CheckToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + HStack(spacing: 8) { + Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square") + .foregroundStyle(configuration.isOn ? Palette.accent : Palette.muted) + configuration.label + .foregroundStyle(Palette.text) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +extension ToggleStyle where Self == CheckToggleStyle { + static var checkbox: CheckToggleStyle { + CheckToggleStyle() + } +} + +extension Image { + init(platformImage: PlatformImage) { + self.init(uiImage: platformImage) + } +} + +extension PlatformColor { + static func dynamic(light: UIColor, dark: UIColor) -> UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + } + } +} + +@MainActor +func copyToClipboard(_ text: String) { + UIPasteboard.general.string = text +} + +@MainActor +func openExternal(_ url: URL) { + UIApplication.shared.open(url) +} + +let appBecameActive = UIApplication.didBecomeActiveNotification + +@MainActor +func appIsActive() -> Bool { + UIApplication.shared.applicationState == .active +} +#endif diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index 7bef269..9aec1a4 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -202,7 +202,7 @@ private func rowIndent(_ task: ByreTask, _ depth: Int) -> CGFloat { task.parentTaskId != nil ? 20 * CGFloat(depth - 1) : 0 } -private struct TaskRows: View { +struct TaskRows: View { let active: [ByreTask] @Environment(Store.self) private var store diff --git a/mac/Sources/Byre/Remote.swift b/mac/Sources/Byre/Remote.swift index c7f612d..d833fb9 100644 --- a/mac/Sources/Byre/Remote.swift +++ b/mac/Sources/Byre/Remote.swift @@ -7,6 +7,7 @@ typealias ClientRequest = Components.Schemas.ClientRequest typealias ClientReply = Components.Schemas.ClientReply typealias ClientSync = Components.Schemas.ClientSync +#if os(macOS) private let retryAfter = Duration.seconds(2) private let checkEvery = Duration.seconds(10) private let staleAfter: TimeInterval = 25 @@ -520,3 +521,4 @@ private actor Hands { } } } +#endif diff --git a/mac/Sources/Byre/Render.swift b/mac/Sources/Byre/Render.swift index 3c4252c..370a5f9 100644 --- a/mac/Sources/Byre/Render.swift +++ b/mac/Sources/Byre/Render.swift @@ -1,4 +1,4 @@ -import AppKit +#if os(macOS) import SwiftUI @MainActor @@ -31,3 +31,4 @@ func renderDiagram() -> Int32 { FileHandle.standardOutput.write(png) return 0 } +#endif diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift index 0f783a9..33c51ea 100644 --- a/mac/Sources/Byre/Server.swift +++ b/mac/Sources/Byre/Server.swift @@ -6,6 +6,11 @@ typealias Permission = Components.Schemas.Permission typealias UsageCommands = Components.Schemas.UsageCommands typealias TailnetInfo = Components.Schemas.Tailnet +struct ProcessFailure: LocalizedError { + let errorDescription: String? +} + +#if os(macOS) let serverHelper = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path private let service = "gui/\(getuid())/run.fc.byre" @@ -121,8 +126,7 @@ private struct CommandBox: View { } private func copy() { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(command, forType: .string) + copyToClipboard(command) copied = true Task { try? await Task.sleep(for: .milliseconds(1500)) @@ -293,10 +297,6 @@ private struct UsageCommandsForm: View { } } -struct ProcessFailure: LocalizedError { - let errorDescription: String? -} - private func execute(_ path: String, _ args: [String], env: [String: String] = [:]) async throws -> (status: Int32, output: String) { try await Task.detached { let p = Process() @@ -386,13 +386,13 @@ struct ServerView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 8) { - Text("Server on \(macName)") + Text("Server on \(deviceName)") .font(Fonts.font(13, weight: 700)) .padding(.top, 16) if let state { details(state) } - Text("Tools on \(macName)") + Text("Tools on \(deviceName)") .font(Fonts.font(13, weight: 700)) .padding(.top, 16) switch checks { @@ -513,3 +513,4 @@ struct ServerView: View { } } } +#endif diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift index 1998431..56bbb77 100644 --- a/mac/Sources/Byre/Settings.swift +++ b/mac/Sources/Byre/Settings.swift @@ -23,12 +23,12 @@ struct SettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { heading("Window color") - Text("Windows for this server have a border in this color, on every Mac you use.") + Text("Windows for this server have a border in this color, on every device you use.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) ServerColorPicker() heading("Font size") - Text("The size of body text and of code, on every Mac you use. Other text grows and shrinks with them.") + Text("The size of body text and of code, on every device you use. Other text grows and shrinks with them.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) FontSizeStepper(label: "Text", size: FontSize.shared.body) { n in @@ -54,11 +54,13 @@ struct SettingsView: View { SavedCommands() heading("iCloud sync") SyncState() + #if os(macOS) heading("This Mac's permissions") Text("The macOS privacy grants byre's app needs for agents to see and control this Mac. Checking asks macOS for any that are missing.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) PermissionsCheck(run: checkLocalPermissions) + #endif } .padding(.horizontal, 16) .padding(.top, 8) @@ -133,10 +135,10 @@ private struct SyncState: View { TimelineView(.periodic(from: .now, by: 30)) { _ in if let sync = Servers.shared.sync { if sync.ok { - Text("This Mac shares its servers, window colors, font sizes, drafts and task visits through iCloud. Last synced \(ago(sync.at)).") + Text("This \(deviceKind) shares its servers, window colors, font sizes, drafts and task visits through iCloud. Last synced \(ago(sync.at)).") .fixedSize(horizontal: false, vertical: true) } else { - Text("iCloud sync is failing on this Mac, so changes made here aren't reaching your other Macs. Failed \(ago(sync.at)): \(sync.error ?? "")") + Text("iCloud sync is failing on this \(deviceKind), so changes made here aren't reaching your other devices. Failed \(ago(sync.at)): \(sync.error ?? "")") .foregroundStyle(Palette.danger) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) @@ -158,13 +160,13 @@ private struct DeviceNotifications: View { case .none: EmptyView() case .denied: - Text("Notifications are blocked for byre in System Settings.").foregroundStyle(Palette.muted) + Text(touchOnly ? "Notifications are blocked for byre in Settings." : "Notifications are blocked for byre in System Settings.").foregroundStyle(Palette.muted) case .authorized, .provisional: - Text("This Mac shows notifications while byre is running.") + Text(touchOnly ? "This iPhone shows notifications while byre is open." : "This Mac shows notifications while byre is running.") default: VStack(alignment: .leading, spacing: 8) { - Text("This Mac doesn't show notifications from byre.") - Button("Turn on for this Mac") { + Text("This \(deviceKind) doesn't show notifications from byre.") + Button("Turn on for this \(deviceKind)") { asking = true Task { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) @@ -181,7 +183,7 @@ private struct DeviceNotifications: View { .task { await read() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: appBecameActive)) { _ in Task { await read() } @@ -298,7 +300,7 @@ private struct SavedCommands: View { return HStack(spacing: 6) { TextField("Name", text: $name) .modifier(FieldStyle()) - .frame(width: 180) + .frame(width: touchOnly ? 100 : 180) .onSubmit { save(id, valid) } diff --git a/mac/Sources/Byre/Sheet.swift b/mac/Sources/Byre/Sheet.swift index deac024..a4ea909 100644 --- a/mac/Sources/Byre/Sheet.swift +++ b/mac/Sources/Byre/Sheet.swift @@ -1,9 +1,8 @@ -import AppKit import SwiftUI enum SheetView { case tab(TaskTab) - case image(NSImage) + case image(PlatformImage) case diagram(BoxLayout) } @@ -83,7 +82,7 @@ private struct SheetPanel: View { Spacer(minLength: 0) if sheet.tab == .page, let url = store.currentPage()?.url(store) { Button { - NSWorkspace.shared.open(url) + openExternal(url) } label: { Text("↗ Browser") .font(Fonts.font(13, weight: 700)) @@ -112,7 +111,7 @@ private struct SheetPanel: View { .overlay(alignment: .bottom) { Rectangle().fill(accent).frame(height: 1) } - content + SheetContent(view: sheet.view) .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(Palette.bg) @@ -138,15 +137,18 @@ private struct SheetPanel: View { } return "\(project) ▸ \(task.name) · \(label)" } +} - @ViewBuilder - private var content: some View { - switch sheet.view { +struct SheetContent: View { + let view: SheetView + + var body: some View { + switch view { case .tab(let t): TaskTabBody(tab: t) case .image(let image): ScrollView([.horizontal, .vertical]) { - Image(nsImage: image) + Image(platformImage: image) .resizable() .scaledToFit() .frame(maxWidth: image.size.width, maxHeight: image.size.height) diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 48735ab..739c6df 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -254,7 +254,13 @@ final class Connection { store.startGlobalStream(after: status.lastEventSeq) { [weak self] in self?.reconnected(status.startedAt) } + #if os(macOS) store.remote.start() + #else + if let sync = Servers.shared.sync { + store.reportSync(sync) + } + #endif forwards.sync(store.api, Set(store.activeTasks().map(\.task.id))) Notifier.shared.deliver() return true @@ -273,7 +279,9 @@ final class Connection { final class ConnectForm { var serverText = "" var problem: String? + #if os(macOS) var missing: [Check] = [] + #endif var connecting = false func connect() async -> URL? { @@ -295,6 +303,7 @@ final class ConnectForm { return origin } + #if os(macOS) func installHere() async -> URL? { connecting = true problem = nil @@ -353,6 +362,7 @@ final class ConnectForm { try await group.next() } } + #endif } private let tlsErrors: Set = [ @@ -411,7 +421,9 @@ struct ConnectView: View { .onSubmit(connect) HStack(spacing: 10) { Button("Connect", action: connect) + #if os(macOS) Button("Run byre on this Mac", action: installHere) + #endif } .disabled(form.connecting) if let problem = form.problem { @@ -420,14 +432,18 @@ struct ConnectView: View { .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } + #if os(macOS) if !form.missing.isEmpty { Text("byre can't run on this Mac yet. Install what's missing, then click Run byre on this Mac again.") .fixedSize(horizontal: false, vertical: true) .padding(.top, 6) ChecksView(checks: form.missing) } + #endif } - .frame(width: 460) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: 460) + .padding(.horizontal, 16) } private func connect() { @@ -438,6 +454,7 @@ struct ConnectView: View { } } + #if os(macOS) private func installHere() { Task { if let origin = await form.installHere() { @@ -445,6 +462,7 @@ struct ConnectView: View { } } } + #endif } struct ConnectingView: View { @@ -465,8 +483,9 @@ struct ConnectingView: View { .fixedSize(horizontal: false, vertical: true) } } - .frame(width: 460, alignment: .leading) - .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: 460) + .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -483,7 +502,9 @@ extension FocusedValues { struct ServerWindow: View { @ViewState private var form = ConnectForm() + #if os(macOS) @Environment(\.openWindow) private var openWindow + #endif private var connection: Connection? { Servers.shared.current.flatMap { Servers.shared.connections[$0] } @@ -511,10 +532,17 @@ struct ServerWindow: View { case .failed(let problem): ConnectingView(step: step, problem: problem) case .running(let store): + #if os(macOS) MainView() .environment(store) .environment(connection.overlays) .id(ObjectIdentifier(store)) + #else + PhoneView() + .environment(store) + .environment(connection.overlays) + .id(ObjectIdentifier(store)) + #endif } } else if !Tailnet.shared.searched { ConnectingView(step: "Looking for byre servers on the tailnet…") @@ -526,6 +554,7 @@ struct ServerWindow: View { } } + #if os(macOS) private var framed: some View { content .foregroundStyle(Palette.text) @@ -536,9 +565,37 @@ struct ServerWindow: View { .font(Fonts.font(13)) .frame(minWidth: 640, minHeight: 400) } + #else + private var framed: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + ServerPicker() + .padding(.horizontal, 8) + } + AwayToggle() + .font(Fonts.font(13)) + .fixedSize() + .padding(.trailing, 12) + } + .padding(.vertical, 4) + .background(Palette.surface) + .overlay(alignment: .bottom) { + Rectangle().fill(frameColor).frame(height: connection == nil ? 0 : 2) + } + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .foregroundStyle(Palette.text) + .tint(Palette.accent) + .background(Palette.bg) + .font(Fonts.font(13)) + } + #endif var body: some View { framed + #if os(macOS) .toolbar { ToolbarItem(placement: .principal) { ServerPicker() @@ -554,15 +611,16 @@ struct ServerWindow: View { .toolbar(removing: .title) .toolbarBackgroundVisibility(.visible, for: .windowToolbar) .focusedSceneValue(\.serverWindow, focus) + #endif .onChange(of: Servers.shared.origins) { old, new in adopt(new.filter { !old.contains($0) }) } .onAppear(perform: appeared) - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: appBecameActive)) { _ in remindAway() } .onChange(of: Set(Servers.shared.origins.filter { Servers.shared.connections[$0]?.store != nil })) { old, new in - if !new.isSubset(of: old), NSApp.isActive { + if !new.isSubset(of: old), appIsActive() { remindAway() } } @@ -586,10 +644,12 @@ struct ServerWindow: View { } private func appeared() { + #if os(macOS) let openWindow = openWindow Servers.shared.openWindow = { openWindow(id: "main") } + #endif Notifier.shared.deliver() } diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index f216b0a..ed12c22 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -77,6 +77,7 @@ final class Store { let leadFeed: Feed var jump: Jump? @ObservationIgnored private var jumps = 0 + private(set) var reveals = 0 var composerFocus: String? var summaryReads: [String: Int64] = [:] private(set) var leadMore = false @@ -88,7 +89,9 @@ final class Store { let imageCache = ImageCache() let events = Listeners() let paneFocus: PaneFocus + #if os(macOS) let remote: Remote + #endif let forwards: Forwards let pages: Pages @@ -112,8 +115,10 @@ final class Store { self.pages = pages leadFeed = Feed(shown: { true }) paneFocus = PaneFocus(api: api) + #if os(macOS) remote = Remote(api: api) remote.store = self + #endif pages.popup = { [weak self] page in self?.openPage(page) } @@ -139,7 +144,9 @@ final class Store { stopped = true globalStream?.stop() leadStream?.stop() + #if os(macOS) remote.stop() + #endif drafts?.flush() await paneFocus.leave() } @@ -516,12 +523,14 @@ final class Store { } func focusTask(_ taskId: String) { + reveals += 1 spawn("set focus") { try await self.setFocus(taskId: taskId) } } func focusProject(_ projectId: String) { + reveals += 1 spawn("set focus") { try await self.setFocus(taskId: .some(nil), projectId: projectId) } @@ -534,6 +543,7 @@ final class Store { let name = m?[3] ?? nil let tab = taskTabs.first { $0.rawValue == name } if kind == "task", let id { + reveals += 1 if let tab, tab != .transcript, tab != .workflow { sheet = Sheet(owner: .task(id), view: .tab(tab)) } @@ -545,6 +555,9 @@ final class Store { return } let projectId = kind == "project" ? id : nil + if projectId != nil { + reveals += 1 + } if focus.taskId != nil || projectId != focus.projectId { spawn("set focus") { try await self.setFocus(taskId: .some(nil), projectId: .some(projectId)) diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index a4e88e8..1304073 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -1,4 +1,3 @@ -import AppKit import ByreAPI import SwiftUI @@ -58,6 +57,7 @@ func createTask(_ projectId: String, _ body: Components.Schemas.TaskCreate, _ st } } +#if os(macOS) @MainActor func ctrlGo(_ key: String, _ store: Store, _ overlays: Overlays) -> Bool { if terminalFocused() { @@ -66,6 +66,7 @@ func ctrlGo(_ key: String, _ store: Store, _ overlays: Overlays) -> Bool { runGo(key, store, overlays) return true } +#endif private let shortcuts: [(String, [(String, String)])] = [ ("Anywhere", [ @@ -157,8 +158,10 @@ final class SwitcherModel { let imageKey = "switcher:\(UUID().uuidString)" var text = "" var selected = 0 - var inList = true + var inList = !touchOnly + #if os(macOS) @ObservationIgnored var pending: NSEvent? + #endif var workflows: [WorkflowSummary] = [] init(store: Store, overlays: Overlays, anchor: CGRect) { @@ -422,6 +425,7 @@ private struct SwitcherBox: View { .shadow(color: Palette.overlayShadow, radius: 14, y: 8) .onAppear { DispatchQueue.main.async { + #if os(macOS) logFocus("switcher before focus") if model.inList { overlays.window?.makeFirstResponder(nil) @@ -431,6 +435,9 @@ private struct SwitcherBox: View { DispatchQueue.main.async { logFocus("switcher after focus focused=\(focused)") } + #else + focused = !model.inList + #endif } } .onChange(of: model.inList) { @@ -439,10 +446,12 @@ private struct SwitcherBox: View { .onChange(of: focused) { if focused { model.inList = false + #if os(macOS) if let event = model.pending { model.pending = nil NSApp.postEvent(event, atStart: true) } + #endif } } } diff --git a/mac/Sources/Byre/Tailnet.swift b/mac/Sources/Byre/Tailnet.swift index 2dd76c7..3e77871 100644 --- a/mac/Sources/Byre/Tailnet.swift +++ b/mac/Sources/Byre/Tailnet.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation import Network import Observation @@ -24,7 +23,7 @@ func slug(_ text: String) -> String { final class TailnetNode: Sendable { let sd: tailscale - let hostname = "byre-app-\(slug(macName))" + let hostname = "byre-app-\(slug(deviceName))" private let proxyHost: String private let proxyPort: UInt16 private let proxyCredential: String @@ -165,14 +164,17 @@ final class Tailnet { try? await Task.sleep(for: .seconds(1)) } } + #if os(macOS) Task { await watchService() } + #endif Task { await discover() } } + #if os(macOS) private func watchService() async { var loopback: URL? var asked: ContinuousClock.Instant? @@ -198,6 +200,7 @@ final class Tailnet { try? await Task.sleep(for: .seconds(2)) } } + #endif private func discover() async { while true { @@ -252,16 +255,24 @@ final class Tailnet { struct TailnetLoginView: View { var body: some View { let tailnet = Tailnet.shared - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { + Text("Sign in to Tailscale") + .font(Fonts.font(17, weight: 700)) Text("byre reaches its servers through Tailscale. Sign each of these nodes in to the tailnet your byre servers are on.") + .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 4) NodeLoginRow(hostname: tailnetNode.hostname, role: "this app", state: tailnet.state, authURL: tailnet.authURL) if let service = tailnet.service { NodeLoginRow(hostname: service.hostname, role: "this Mac's server", state: service.state, authURL: URL(string: service.authUrl)) } } - .frame(width: 460) - .padding(.vertical, 24) + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) + .frame(maxWidth: 460) + .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { Tailnet.shared.serviceWatchers += 1 @@ -279,23 +290,34 @@ private struct NodeLoginRow: View { let authURL: URL? var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(hostname).font(Fonts.font(13, weight: 700)) - Text(role).foregroundStyle(Palette.muted) + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(hostname).font(Fonts.font(13, weight: 700)) + Text(role).foregroundStyle(Palette.muted) + } + if state == "Running" { + Text("Signed in").foregroundStyle(Palette.ok) + } else { + Text(authURL == nil ? "Waiting for Tailscale (\(state))…" : "Waiting for the sign-in (\(state))…") + .font(Fonts.font(12)) + .foregroundStyle(Palette.muted) + } } - if state == "Running" { - Text("Signed in").foregroundStyle(Palette.ok) - } else { - Button("Sign in to Tailscale") { + .frame(maxWidth: .infinity, alignment: .leading) + if state != "Running" { + Button("Sign in") { if let authURL { - NSWorkspace.shared.open(authURL) + openExternal(authURL) } } + .buttonStyle(WebButtonStyle(primary: true)) .disabled(authURL == nil) - Text(authURL == nil ? "Waiting for Tailscale (\(state))…" : "Waiting for the sign-in (\(state))…") - .foregroundStyle(Palette.muted) + .fixedSize() } } + .padding(12) + .background(Palette.bg, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border)) } } diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index fa50dac..67bde67 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -1,4 +1,3 @@ -import AppKit import ByreAPI import SwiftTerm import SwiftUI @@ -10,10 +9,12 @@ typealias TaskTerminal = Components.Schemas.TaskTerminal typealias TerminalList = Components.Schemas.TerminalList typealias SavedCommand = Components.Schemas.SavedCommand +#if os(macOS) @MainActor func terminalFocused() -> Bool { NSApp.keyWindow?.firstResponder is TerminalView } +#endif struct TerminalTab: View { @Environment(Store.self) private var store @@ -160,7 +161,8 @@ struct TerminalTab: View { } } -struct TerminalHost: NSViewRepresentable { +@MainActor +struct TerminalHost { let api: API let taskId: String let terminalId: String @@ -172,41 +174,47 @@ struct TerminalHost: NSViewRepresentable { TerminalSession(api: api, taskId: taskId, terminalId: terminalId, active: active, show: show) } - func makeNSView(context: Context) -> TerminalView { - let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) - t.font = Fonts.ns(13, mono: true) + private func make(_ session: TerminalSession) -> TerminalView { + let t = TerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 400)) + t.font = Fonts.native(13, mono: true) t.changeScrollback(5000) t.setCursorStyle(.steadyBlock) - t.terminalDelegate = context.coordinator - context.coordinator.view = t + t.terminalDelegate = session + session.view = t DispatchQueue.main.async { + #if os(macOS) unsafe t.window?.makeFirstResponder(t) - context.coordinator.connect() + #endif + session.connect() } return t } - func updateNSView(_ nsView: TerminalView, context: Context) { - let font = Fonts.ns(13, mono: true) - if nsView.font !== font { - nsView.font = font + private func update(_ t: TerminalView, _ session: TerminalSession) { + let font = Fonts.native(13, mono: true) + if t.font !== font { + t.font = font } - if context.coordinator.scheme == scheme { + if session.scheme == scheme { return } - context.coordinator.scheme = scheme + session.scheme = scheme DispatchQueue.main.async { - color(nsView) + color(t) } } private func color(_ t: TerminalView) { - func resolved(_ c: SwiftUI.Color) -> NSColor { + func resolved(_ c: SwiftUI.Color) -> PlatformColor { + #if os(macOS) var out = NSColor(c) t.effectiveAppearance.performAsCurrentDrawingAppearance { out = NSColor(c).usingColorSpace(.sRGB) ?? NSColor(c) } return out + #else + UIColor(c).resolvedColor(with: t.traitCollection) + #endif } t.nativeBackgroundColor = resolved(Palette.bg) t.nativeForegroundColor = resolved(Palette.text) @@ -214,11 +222,37 @@ struct TerminalHost: NSViewRepresentable { t.caretTextColor = resolved(Palette.bg) t.selectedTextBackgroundColor = resolved(Palette.accentTint) } +} + +#if os(macOS) +extension TerminalHost: NSViewRepresentable { + func makeNSView(context: Context) -> TerminalView { + make(context.coordinator) + } + + func updateNSView(_ nsView: TerminalView, context: Context) { + update(nsView, context.coordinator) + } static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) { coordinator.close() } } +#else +extension TerminalHost: UIViewRepresentable { + func makeUIView(context: Context) -> TerminalView { + make(context.coordinator) + } + + func updateUIView(_ uiView: TerminalView, context: Context) { + update(uiView, context.coordinator) + } + + static func dismantleUIView(_ uiView: TerminalView, coordinator: TerminalSession) { + coordinator.close() + } +} +#endif @MainActor final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate { @@ -332,4 +366,12 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} + + #if os(iOS) + func requestOpenLink(source: TerminalView, link: String, params: [String: String]) { + if let url = URL(string: link) { + openExternal(url) + } + } + #endif } diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index ddb116d..60bdf80 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation import Observation import OpenAPIRuntime diff --git a/mac/Sources/Byre/Updater.swift b/mac/Sources/Byre/Updater.swift index 9ca7ddf..9c95a2f 100644 --- a/mac/Sources/Byre/Updater.swift +++ b/mac/Sources/Byre/Updater.swift @@ -1,4 +1,4 @@ -import AppKit +#if os(macOS) import Sparkle import SwiftUI @@ -221,3 +221,4 @@ final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate { hideProgress() } } +#endif diff --git a/mac/bundle.sh b/mac/bundle.sh index 3903124..8335b01 100755 --- a/mac/bundle.sh +++ b/mac/bundle.sh @@ -8,25 +8,14 @@ app=$build/Byre.app svg=$mac/icon.svg icns=$build/byre.icns identity="Developer ID Application" -libtailscale=d1dc45d039edb7759ecbee6ba2aea0c4bca884b8 mkdir -p "$shared" "$build" if [ "$(security cms -D -i "$mac/byre.provisionprofile" | plutil -extract ProvisionsAllDevices raw -)" != true ]; then echo "bundle: mac/byre.provisionprofile is not a Developer ID profile" >&2 exit 1 fi lockf -k "$shared.lock" sh -ec ' -kit=$2/libtailscale-$7 -if [ ! -d "$kit/TailscaleKit.xcframework" ]; then - rm -rf "$kit" - git clone --quiet https://github.com/tailscale/libtailscale "$kit" - git -C "$kit" checkout --quiet "$7" - make -C "$kit/swift" macos XCPRETTIFIER=cat - xcodebuild -create-xcframework -framework "$kit/swift/build/Build/Products/Release/TailscaleKit.framework" -output "$kit/TailscaleKit.xcframework" -fi rsync -rlc --delete --exclude /mac/.build --exclude /mac/Frameworks --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/" -rm -rf "$2/mac/Frameworks" -mkdir -p "$2/mac/Frameworks" -ditto "$kit/TailscaleKit.xcframework" "$2/mac/Frameworks/TailscaleKit.xcframework" +sh "$2/mac/tailscalekit.sh" "$2" swift build --package-path "$2/mac" --configuration release --no-color-diagnostics swift build --package-path "$2/hands" --configuration release --no-color-diagnostics cp "$2/mac/.build/release/Byre" "$3" @@ -34,8 +23,8 @@ cp "$2/hands/.build/release/byre-hands" "$4" cp "$2/mac/.build/release/byre-update" "$6" rm -rf "$5" "$8" ditto "$2/mac/.build/release/Sparkle.framework" "$5" -ditto "$2/mac/.build/release/TailscaleKit.framework" "$8" -' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework" "$build/byre-update" "$libtailscale" "$build/TailscaleKit.framework" +ditto "$2/mac/.build/release/TailscaleKit.framework" "$7" +' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework" "$build/byre-update" "$build/TailscaleKit.framework" go -C "$root" build -trimpath -o "$build/server/byre" ./cmd/byre if [ ! "$icns" -nt "$svg" ]; then iconset=$build/byre.iconset diff --git a/mac/deploy.sh b/mac/deploy.sh index c6c998e..6ddb5f6 100755 --- a/mac/deploy.sh +++ b/mac/deploy.sh @@ -3,6 +3,9 @@ set -eu mac=$(cd "$(dirname "$0")" && pwd) "$mac/package.sh" +"$mac/phone.sh" +rm -rf "$mac/.build/phone/iphoneos/export" +xcodebuild -exportArchive -archivePath "$mac/.build/phone/iphoneos/Byre.xcarchive" -exportOptionsPlist "$mac/phone/export.plist" -exportPath "$mac/.build/phone/iphoneos/export" -allowProvisioningUpdates version=$(plutil -extract CFBundleShortVersionString raw "$mac/.build/Byre.app/Contents/Info.plist") dmg=$mac/.build/release/$version/byre-$version.dmg "$mac/install.sh" "$dmg" diff --git a/mac/phone.sh b/mac/phone.sh new file mode 100755 index 0000000..b42559f --- /dev/null +++ b/mac/phone.sh @@ -0,0 +1,101 @@ +#!/bin/sh +set -eu +mac=$(cd "$(dirname "$0")" && pwd) +root=$(dirname "$mac") +shared=$HOME/Library/Caches/byre/build +sdk=iphoneos +destination="generic/platform=iOS" +link= +if [ "${1:-}" = --simulator ]; then + sdk=iphonesimulator + destination="generic/platform=iOS Simulator" + link="-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker $shared/mac/phone/byre.entitlements" +fi +build=$mac/.build/phone/$sdk +app=$build/Byre.app +svg=$mac/icon.svg +mkdir -p "$shared" "$build" +lockf -k "$shared.lock" sh -ec ' +rsync -rlc --delete --exclude /mac/.build --exclude /mac/Frameworks --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/" +sh "$2/mac/tailscalekit.sh" "$2" +cd "$2/mac" +xcodebuild build -quiet -scheme Byre -destination "$4" -configuration Release -derivedDataPath "$2/phone" -skipPackagePluginValidation -skipMacroValidation CODE_SIGNING_ALLOWED=NO "OTHER_LDFLAGS=\$(inherited) $6" +products=$2/phone/Build/Products/Release-$3 +rm -rf "$5" +mkdir -p "$5/Frameworks" +cp "$products/Byre" "$5/Byre" +ditto "$products/TailscaleKit.framework" "$5/Frameworks/TailscaleKit.framework" +' sh "$root" "$shared" "$sdk" "$destination" "$app" "$link" +assets=$build/Assets.xcassets/AppIcon.appiconset +rm -rf "$build/Assets.xcassets" +mkdir -p "$assets" +rsvg-convert --width 1024 --height 1024 --output "$assets/icon.png" "$svg" +cat > "$assets/Contents.json" <$(if [ "$sdk" = iphoneos ]; then echo iPhoneOS; else echo iPhoneSimulator; fi)" "$plist" +plutil -insert DTPlatformName -string "$sdk" "$plist" +plutil -insert DTPlatformVersion -string "$platform" "$plist" +plutil -insert DTSDKName -string "$sdk$platform" "$plist" +plutil -insert DTSDKBuild -string "$sdkbuild" "$plist" +plutil -insert DTPlatformBuild -string "$sdkbuild" "$plist" +plutil -insert DTXcode -string "$(xcodebuild -version | awk 'NR == 1 { split($2, v, "."); printf "%d%d%d", v[1], v[2], v[3] }')" "$plist" +plutil -insert DTXcodeBuild -string "$(xcodebuild -version | awk 'NR == 2 { print $3 }')" "$plist" +plutil -insert DTCompiler -string com.apple.compilers.llvm.clang.1_0 "$plist" +mkdir -p "$app/fonts" +cp "$mac"/fonts/*.woff2 "$app/fonts/" +codesign --force --sign - "$app/Frameworks/TailscaleKit.framework" +if [ "$sdk" = iphonesimulator ]; then + codesign --force --sign - "$app" + echo "$app" + exit 0 +fi +codesign --force --sign - --entitlements "$mac/phone/byre.entitlements" "$app" +archive=$build/Byre.xcarchive +rm -rf "$archive" +mkdir -p "$archive/Products/Applications" +ditto "$app" "$archive/Products/Applications/Byre.app" +cat > "$archive/Info.plist" < + + + + ApplicationProperties + + ApplicationPath + Applications/Byre.app + Architectures + + arm64 + + CFBundleIdentifier + $(plutil -extract CFBundleIdentifier raw "$plist") + CFBundleShortVersionString + $(plutil -extract CFBundleShortVersionString raw "$plist") + CFBundleVersion + $(plutil -extract CFBundleVersion raw "$plist") + Team + XDZ54KDM4W + + ArchiveVersion + 2 + CreationDate + $(date -u +%Y-%m-%dT%H:%M:%SZ) + Name + Byre + SchemeName + Byre + + +EOF +echo "$archive" diff --git a/mac/phone/Info.plist b/mac/phone/Info.plist new file mode 100644 index 0000000..59eadcc --- /dev/null +++ b/mac/phone/Info.plist @@ -0,0 +1,57 @@ + + + + + CFBundleIdentifier + run.fc.byre.ios + CFBundleName + Byre + CFBundleDisplayName + Byre + CFBundleExecutable + Byre + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + en + MinimumOSVersion + 27.0 + UIDeviceFamily + + 1 + + UIRequiredDeviceCapabilities + + arm64 + + UIBackgroundModes + + remote-notification + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + ITSAppUsesNonExemptEncryption + + NSAppTransportSecurity + + NSExceptionDomains + + localhost + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + + + + + diff --git a/mac/phone/byre.entitlements b/mac/phone/byre.entitlements new file mode 100644 index 0000000..7a10575 --- /dev/null +++ b/mac/phone/byre.entitlements @@ -0,0 +1,26 @@ + + + + + application-identifier + XDZ54KDM4W.run.fc.byre.ios + com.apple.developer.team-identifier + XDZ54KDM4W + com.apple.developer.icloud-container-identifiers + + iCloud.run.fc.byre.mac + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-environment + Production + aps-environment + production + beta-reports-active + + get-task-allow + + + diff --git a/mac/phone/export.plist b/mac/phone/export.plist new file mode 100644 index 0000000..3b07663 --- /dev/null +++ b/mac/phone/export.plist @@ -0,0 +1,18 @@ + + + + + method + app-store-connect + destination + upload + signingStyle + automatic + teamID + XDZ54KDM4W + uploadSymbols + + manageAppVersionAndBuildNumber + + + diff --git a/mac/tailscalekit.sh b/mac/tailscalekit.sh new file mode 100755 index 0000000..c58ca00 --- /dev/null +++ b/mac/tailscalekit.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu +commit=d1dc45d039edb7759ecbee6ba2aea0c4bca884b8 +kit=$1/tailscalekit-$commit +if [ ! -d "$kit/TailscaleKit.xcframework" ]; then + rm -rf "$kit" + git clone --quiet https://github.com/tailscale/libtailscale "$kit" + git -C "$kit" checkout --quiet "$commit" + make -C "$kit/swift" macos ios ios-sim XCPRETTIFIER=cat + products=$kit/swift/build/Build/Products + xcodebuild -create-xcframework -framework "$products/Release/TailscaleKit.framework" -framework "$products/Release-iphoneos/TailscaleKit.framework" -framework "$products/Release-iphonesimulator/TailscaleKit.framework" -output "$kit/TailscaleKit.xcframework" +fi +rm -rf "$1/mac/Frameworks" +mkdir -p "$1/mac/Frameworks" +ditto "$kit/TailscaleKit.xcframework" "$1/mac/Frameworks/TailscaleKit.xcframework"