diff --git a/core/build-apple.sh b/core/build-apple.sh new file mode 100755 index 0000000..3f5d6d6 --- /dev/null +++ b/core/build-apple.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Builds the tethercore Rust engine for Apple targets and refreshes the +# artifacts both Xcode apps consume: the Swift bindings + a single +# TetherCore.xcframework (iOS device + iOS sim + macOS arm64). +# Outputs are git-ignored / regenerated — run after any change to core/src, +# then build the apps in Xcode. +set -euo pipefail +cd "$(dirname "$0")" + +IOS_APP="../ios/TetherApp" +MAC_APP="../ios/MacTetherApp" +IOS=aarch64-apple-ios +SIM=aarch64-apple-ios-sim +MAC=aarch64-apple-darwin + +echo "▸ building static libs ($IOS, $SIM, $MAC)…" +rustup target add "$IOS" "$SIM" "$MAC" >/dev/null 2>&1 || true +cargo build --release --target "$IOS" --lib +cargo build --release --target "$SIM" --lib +cargo build --release --target "$MAC" --lib + +echo "▸ generating Swift bindings…" +cargo run --release --bin uniffi-bindgen -- \ + generate --library "target/$MAC/release/libtethercore.dylib" \ + --language swift --out-dir bindings/swift + +echo "▸ assembling TetherCore.xcframework (ios + ios-sim + macos)…" +rm -rf build-xcframework && mkdir -p build-xcframework/headers +cp bindings/swift/tethercoreFFI.h build-xcframework/headers/ +cp bindings/swift/tethercoreFFI.modulemap build-xcframework/headers/module.modulemap +rm -rf TetherCore.xcframework +xcodebuild -create-xcframework \ + -library "target/$IOS/release/libtethercore.a" -headers build-xcframework/headers \ + -library "target/$SIM/release/libtethercore.a" -headers build-xcframework/headers \ + -library "target/$MAC/release/libtethercore.a" -headers build-xcframework/headers \ + -output TetherCore.xcframework + +echo "▸ vendoring into both apps…" +for APP in "$IOS_APP" "$MAC_APP"; do + rm -rf "$APP/Vendor/TetherCore.xcframework" + mkdir -p "$APP/Vendor" + cp -R TetherCore.xcframework "$APP/Vendor/" + cp bindings/swift/tethercore.swift "$APP/Sources/tethercore.swift" +done + +echo "✅ done — build TetherApp (iOS) and MacTetherApp (macOS) in Xcode." diff --git a/core/build-ios.sh b/core/build-ios.sh deleted file mode 100755 index bba2e65..0000000 --- a/core/build-ios.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# Builds the tethercore Rust engine for iOS and refreshes the artifacts the -# Xcode app consumes: the Swift bindings + the TetherCore.xcframework. -# These outputs are git-ignored (binaries) / regenerated — run this after any -# change to core/src, then build the app in Xcode. -set -euo pipefail -cd "$(dirname "$0")" - -APP_DIR="../ios/TetherApp" -DEVICE=aarch64-apple-ios -SIM=aarch64-apple-ios-sim - -echo "▸ building static libs ($DEVICE, $SIM)…" -rustup target add "$DEVICE" "$SIM" >/dev/null 2>&1 || true -cargo build --release --target "$DEVICE" --lib -cargo build --release --target "$SIM" --lib - -echo "▸ generating Swift bindings…" -cargo build --release --lib # host dylib for library-mode bindgen -cargo run --release --bin uniffi-bindgen -- \ - generate --library "target/release/libtethercore.dylib" \ - --language swift --out-dir bindings/swift - -echo "▸ assembling TetherCore.xcframework…" -rm -rf build-xcframework && mkdir -p build-xcframework/headers -cp bindings/swift/tethercoreFFI.h build-xcframework/headers/ -cp bindings/swift/tethercoreFFI.modulemap build-xcframework/headers/module.modulemap -rm -rf "$APP_DIR/Vendor/TetherCore.xcframework" -mkdir -p "$APP_DIR/Vendor" -xcodebuild -create-xcframework \ - -library "target/$DEVICE/release/libtethercore.a" -headers build-xcframework/headers \ - -library "target/$SIM/release/libtethercore.a" -headers build-xcframework/headers \ - -output "$APP_DIR/Vendor/TetherCore.xcframework" - -echo "▸ refreshing Swift wrapper in app…" -cp bindings/swift/tethercore.swift "$APP_DIR/Sources/tethercore.swift" - -echo "✅ done — open $APP_DIR and build." diff --git a/ios/.gitignore b/ios/.gitignore index 0337aac..c341b6f 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -18,3 +18,7 @@ TetherApp/Sources/tethercore.swift *.xcuserstate xcuserdata/ .swiftpm/ + +# macOS app generated artifacts (core/build-apple.sh) +MacTetherApp/Vendor/ +MacTetherApp/Sources/tethercore.swift diff --git a/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj b/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj index f807540..05d1f67 100644 --- a/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj +++ b/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 5AE0D6380E8D23C337DD0EB0 /* TetherKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7A65056F38679ECD9498103E /* TetherKit */; }; 9386BB2E2BBBCF245A7E605D /* MacTetherStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */; }; 9617ED2468FED9FC38A37FD8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826E98E1B2A0C7A9AC4ED879 /* ContentView.swift */; }; + B41AF236C08FA6BD15F00807 /* TetherCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF6782E994649B6E18C307E8 /* TetherCore.xcframework */; }; + C18BF415C7B42C5FF3235F5E /* tethercore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A8D10256F25095F3FF5047E /* tethercore.swift */; }; CB143A31027F3CDB3CF5E894 /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF5D8809D1EC415AB35059B /* MenuView.swift */; }; E1F3C377CB6A58F791A83EAA /* MacTetherApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */; }; /* End PBXBuildFile section */ @@ -21,7 +23,9 @@ 6F7F88910D568FDE283249B4 /* TetherKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TetherKit; path = ../TetherKit; sourceTree = SOURCE_ROOT; }; 7D193BC76AB5D363A4CB1F6C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 826E98E1B2A0C7A9AC4ED879 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8A8D10256F25095F3FF5047E /* tethercore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tethercore.swift; sourceTree = ""; }; BDF5D8809D1EC415AB35059B /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; + EF6782E994649B6E18C307E8 /* TetherCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TetherCore.xcframework; path = Vendor/TetherCore.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -30,6 +34,7 @@ buildActionMask = 2147483647; files = ( 5AE0D6380E8D23C337DD0EB0 /* TetherKit in Frameworks */, + B41AF236C08FA6BD15F00807 /* TetherCore.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -44,6 +49,14 @@ name = Products; sourceTree = ""; }; + C74C31B92D5DD6D8CCD0F873 /* Frameworks */ = { + isa = PBXGroup; + children = ( + EF6782E994649B6E18C307E8 /* TetherCore.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; D08DCB1AE8C0173418D178ED /* Sources */ = { isa = PBXGroup; children = ( @@ -52,6 +65,7 @@ 2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */, 010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */, BDF5D8809D1EC415AB35059B /* MenuView.swift */, + 8A8D10256F25095F3FF5047E /* tethercore.swift */, ); path = Sources; sourceTree = ""; @@ -69,6 +83,7 @@ children = ( E0AC15A5A587EEB6DB5D715F /* Packages */, D08DCB1AE8C0173418D178ED /* Sources */, + C74C31B92D5DD6D8CCD0F873 /* Frameworks */, B17B25DB8865282691E0B024 /* Products */, ); sourceTree = ""; @@ -141,6 +156,7 @@ E1F3C377CB6A58F791A83EAA /* MacTetherApp.swift in Sources */, 9386BB2E2BBBCF245A7E605D /* MacTetherStore.swift in Sources */, CB143A31027F3CDB3CF5E894 /* MenuView.swift in Sources */, + C18BF415C7B42C5FF3235F5E /* tethercore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -274,6 +290,10 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"Vendor\"", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/Info.plist; INFOPLIST_KEY_NSAppTransportSecurity = ""; @@ -282,6 +302,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 0.1.0; + OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -lresolv"; PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether.mac; SDKROOT = macosx; }; @@ -295,6 +316,10 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"Vendor\"", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -302,6 +327,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 0.1.0; + OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -lresolv"; PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether.mac; SDKROOT = macosx; }; diff --git a/ios/MacTetherApp/Sources/ContentView.swift b/ios/MacTetherApp/Sources/ContentView.swift index 16cec6c..c491054 100644 --- a/ios/MacTetherApp/Sources/ContentView.swift +++ b/ios/MacTetherApp/Sources/ContentView.swift @@ -1,5 +1,4 @@ import SwiftUI -import TetherKit struct ContentView: View { @Bindable var store: MacTetherStore @@ -72,23 +71,23 @@ struct ContentView: View { } } - private func feedRow(_ msg: Message) -> some View { + private func feedRow(_ item: FeedItem) -> some View { HStack(alignment: .top, spacing: 10) { VStack(alignment: .leading, spacing: 4) { - Text(msg.text ?? "") + Text(item.text) .font(.body) .lineLimit(6) .textSelection(.enabled) HStack { - Label(msg.source ?? "unknown", systemImage: sourceIcon(msg.source)) + Label(item.source, systemImage: sourceIcon(item.source)) .font(.caption).foregroundStyle(.secondary) Spacer() - Text(msg.date, style: .time) + Text(item.date, style: .time) .font(.caption).foregroundStyle(.secondary) } } Button { - store.copyToPasteboard(msg) + store.copyToPasteboard(item) } label: { Image(systemName: "doc.on.doc") .foregroundStyle(.secondary) @@ -100,8 +99,8 @@ struct ContentView: View { .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) } - private func sourceIcon(_ source: String?) -> String { - let s = source ?? "" + private func sourceIcon(_ source: String) -> String { + let s = source if s.contains("iphone") || s.contains("ios") { return "iphone" } if s.contains("ipad") { return "ipad" } if s.contains("macos") || s.contains("mac") { return "laptopcomputer" } diff --git a/ios/MacTetherApp/Sources/MacTetherStore.swift b/ios/MacTetherApp/Sources/MacTetherStore.swift index fb207b8..4712807 100644 --- a/ios/MacTetherApp/Sources/MacTetherStore.swift +++ b/ios/MacTetherApp/Sources/MacTetherStore.swift @@ -1,21 +1,31 @@ import AppKit import Foundation -import TetherKit +import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clashing + // with the engine's Message (compiled into this target) +// Engine + Message + MessageHandler come from the generated tethercore.swift. -/// macOS side: same TetherClient wire layer, but we CAN poll NSPasteboard -/// in the background — so clipboard changes auto-send without user action. +/// UI-facing clipboard item. Decouples the view from the FFI `Message` record. +struct FeedItem: Identifiable, Equatable { + let id = UUID() + let text: String + let source: String + let date: Date +} + +/// macOS side: drives the shared `tethercore.Engine` (SSE + RTC). Unlike iOS we +/// CAN poll NSPasteboard in the background, so clipboard changes auto-send. @MainActor @Observable final class MacTetherStore { var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765" var room: String = RoomIdentity.derive() - var feed: [Message] = [] + var feed: [FeedItem] = [] var connected = false var lastError: String? var hasAttemptedConnect = false private let deviceID = Host.current().localizedName ?? UUID().uuidString - private var client: TetherClient? - private var receiveTask: Task? + private var engine: Engine? + private var bridge: EngineBridge? private var pollTask: Task? private var lastChangeCount = NSPasteboard.general.changeCount @@ -25,7 +35,8 @@ final class MacTetherStore { } func connect() { - guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)) else { + let url = serverURL.trimmingCharacters(in: .whitespaces) + guard !url.isEmpty else { hasAttemptedConnect = true lastError = "Invalid server URL." return @@ -34,61 +45,79 @@ final class MacTetherStore { UserDefaults.standard.set(serverURL, forKey: "serverURL") disconnect() - let source = "macos-\(ProcessInfo.processInfo.hostName)" - let cfg = TetherClient.Config(baseURL: base, room: room, - from: deviceID, source: "macos-app") - let c = TetherClient(config: cfg) - client = c + let b = EngineBridge(store: self) + let e = Engine(server: url, room: room, from: deviceID, source: "macos") + e.start(handler: b) + engine = e + bridge = b connected = true lastError = nil - // Receive: clipboard messages from peers → write to NSPasteboard. - receiveTask = Task { - for await msg in await c.clipboardStream() { - feed.insert(msg, at: 0) - if feed.count > 100 { feed.removeLast() } - if let text = msg.text { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - lastChangeCount = NSPasteboard.general.changeCount // don't echo back - } - } - connected = false - } - // Send: poll NSPasteboard for local changes → push to room. lastChangeCount = NSPasteboard.general.changeCount - pollTask = Task { + pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(500)) + guard let self else { return } let count = NSPasteboard.general.changeCount - guard count != lastChangeCount else { continue } - lastChangeCount = count + guard count != self.lastChangeCount else { continue } + self.lastChangeCount = count if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty { - try? await c.send(text) + self.engine?.send(text: text) } } } } func disconnect() { - receiveTask?.cancel(); receiveTask = nil - pollTask?.cancel(); pollTask = nil + engine?.stop() + engine = nil + bridge = nil + pollTask?.cancel() + pollTask = nil connected = false } func send(_ text: String) { - guard let c = client else { return } - Task { - do { try await c.send(text) } - catch { lastError = "Send failed: \(error.localizedDescription)" } - } + engine?.send(text: text) } - func copyToPasteboard(_ msg: Message) { - guard let text = msg.text else { return } + func copyToPasteboard(_ item: FeedItem) { + writePasteboard(item.text) + } + + private func writePasteboard(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) - lastChangeCount = NSPasteboard.general.changeCount + lastChangeCount = NSPasteboard.general.changeCount // don't echo our own write back + } + + // Called from the engine bridge, already on the main actor. + fileprivate func ingest(_ item: FeedItem) { + feed.insert(item, at: 0) + if feed.count > 100 { feed.removeLast() } + writePasteboard(item.text) // mirror received clipboard onto this Mac + } + + fileprivate func setConnected(_ value: Bool) { + connected = value + } +} + +/// Adapts the engine's callback interface (fired on the Rust runtime thread) +/// onto the main actor for SwiftUI. +private final class EngineBridge: MessageHandler { + weak var store: MacTetherStore? + init(store: MacTetherStore) { self.store = store } + + func onMessage(msg: Message) { + // RTC-delivered messages carry ts=0 (no server stamp) — use now. + let date = msg.ts > 0 ? Date(timeIntervalSince1970: Double(msg.ts) / 1000.0) : Date() + let item = FeedItem(text: msg.text, source: msg.source.isEmpty ? "unknown" : msg.source, date: date) + Task { @MainActor in self.store?.ingest(item) } + } + + func onStatus(connected: Bool) { + Task { @MainActor in self.store?.setConnected(connected) } } } diff --git a/ios/MacTetherApp/Sources/MenuView.swift b/ios/MacTetherApp/Sources/MenuView.swift index e178d5d..d8a26b6 100644 --- a/ios/MacTetherApp/Sources/MenuView.swift +++ b/ios/MacTetherApp/Sources/MenuView.swift @@ -1,5 +1,4 @@ import SwiftUI -import TetherKit struct MenuView: View { @Bindable var store: MacTetherStore @@ -63,18 +62,18 @@ struct MenuView: View { .frame(minHeight: 120, maxHeight: 240) } - private func feedRow(_ msg: Message) -> some View { + private func feedRow(_ item: FeedItem) -> some View { HStack(alignment: .top, spacing: 6) { VStack(alignment: .leading, spacing: 2) { - Text(msg.text ?? "") + Text(item.text) .font(.caption).lineLimit(3) HStack { - Text(msg.source ?? "?").font(.caption2).foregroundStyle(.secondary) + Text(item.source).font(.caption2).foregroundStyle(.secondary) Spacer() - Text(msg.date, style: .time).font(.caption2).foregroundStyle(.secondary) + Text(item.date, style: .time).font(.caption2).foregroundStyle(.secondary) } } - Button { store.copyToPasteboard(msg) } label: { + Button { store.copyToPasteboard(item) } label: { Image(systemName: "doc.on.doc").font(.caption2) } .buttonStyle(.plain).foregroundStyle(.secondary) diff --git a/ios/MacTetherApp/project.yml b/ios/MacTetherApp/project.yml index a6562d5..1ad13e4 100644 --- a/ios/MacTetherApp/project.yml +++ b/ios/MacTetherApp/project.yml @@ -16,10 +16,14 @@ targets: sources: - Sources dependencies: - - package: TetherKit + - package: TetherKit # RoomIdentity only — networking now lives in TetherCore + - framework: Vendor/TetherCore.xcframework + embed: false # static lib, nothing to embed settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.pecord.tether.mac + # Rust staticlib pulls in SecRandomCopyBytes (ring) + system config (reqwest). + OTHER_LDFLAGS: "-framework Security -framework SystemConfiguration -lresolv" MARKETING_VERSION: "0.1.0" CURRENT_PROJECT_VERSION: "1" GENERATE_INFOPLIST_FILE: true