From 6c2dcdd603f4408feddbd35f8f168a42fd11a212 Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Sat, 20 Jun 2026 14:27:21 -0500 Subject: [PATCH] Add macOS menu bar app (MacTetherApp) MenuBarExtra SwiftUI app sharing TetherKit wire layer with iOS. Auto-sends clipboard changes via NSPasteboard polling (500ms), auto-writes received messages to NSPasteboard. XcodeGen project.yml. Both apps build clean; server tested end-to-end with curl. Co-Authored-By: Claude Sonnet 4.6 --- .../MacTetherApp.xcodeproj/project.pbxproj | 346 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + ios/MacTetherApp/Sources/Info.plist | 31 ++ ios/MacTetherApp/Sources/MacTetherApp.swift | 13 + ios/MacTetherApp/Sources/MacTetherStore.swift | 88 +++++ ios/MacTetherApp/Sources/MenuView.swift | 103 ++++++ ios/MacTetherApp/project.yml | 39 ++ 7 files changed, 627 insertions(+) create mode 100644 ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj create mode 100644 ios/MacTetherApp/MacTetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/MacTetherApp/Sources/Info.plist create mode 100644 ios/MacTetherApp/Sources/MacTetherApp.swift create mode 100644 ios/MacTetherApp/Sources/MacTetherStore.swift create mode 100644 ios/MacTetherApp/Sources/MenuView.swift create mode 100644 ios/MacTetherApp/project.yml diff --git a/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj b/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..38fd5be --- /dev/null +++ b/ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj @@ -0,0 +1,346 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 5AE0D6380E8D23C337DD0EB0 /* TetherKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7A65056F38679ECD9498103E /* TetherKit */; }; + 9386BB2E2BBBCF245A7E605D /* MacTetherStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010C4E8B3DC255487AE12A75 /* MacTetherStore.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 */ + +/* Begin PBXFileReference section */ + 010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTetherStore.swift; sourceTree = ""; }; + 2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTetherApp.swift; sourceTree = ""; }; + 6C53AB32EB02089B09AD31D6 /* MacTetherApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTetherApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + BDF5D8809D1EC415AB35059B /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2E5F624BEFA01FFDE63654A3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5AE0D6380E8D23C337DD0EB0 /* TetherKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B17B25DB8865282691E0B024 /* Products */ = { + isa = PBXGroup; + children = ( + 6C53AB32EB02089B09AD31D6 /* MacTetherApp.app */, + ); + name = Products; + sourceTree = ""; + }; + D08DCB1AE8C0173418D178ED /* Sources */ = { + isa = PBXGroup; + children = ( + 7D193BC76AB5D363A4CB1F6C /* Info.plist */, + 2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */, + 010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */, + BDF5D8809D1EC415AB35059B /* MenuView.swift */, + ); + path = Sources; + sourceTree = ""; + }; + E0AC15A5A587EEB6DB5D715F /* Packages */ = { + isa = PBXGroup; + children = ( + 6F7F88910D568FDE283249B4 /* TetherKit */, + ); + name = Packages; + sourceTree = ""; + }; + E91265DF5258B5F642EBDFEC = { + isa = PBXGroup; + children = ( + E0AC15A5A587EEB6DB5D715F /* Packages */, + D08DCB1AE8C0173418D178ED /* Sources */, + B17B25DB8865282691E0B024 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DF53DF6A402A0F4985D125E6 /* MacTetherApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 04A5EC9A9899CC4040F690F3 /* Build configuration list for PBXNativeTarget "MacTetherApp" */; + buildPhases = ( + 45D151270EEF801923C101BE /* Sources */, + 2E5F624BEFA01FFDE63654A3 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MacTetherApp; + packageProductDependencies = ( + 7A65056F38679ECD9498103E /* TetherKit */, + ); + productName = MacTetherApp; + productReference = 6C53AB32EB02089B09AD31D6 /* MacTetherApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 75142B138CD13CB28DFE8614 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + DF53DF6A402A0F4985D125E6 = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 0C5288A62A776D2D55345DF0 /* Build configuration list for PBXProject "MacTetherApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = E91265DF5258B5F642EBDFEC; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AECCD2CEB9003CD2BFF2B541 /* XCLocalSwiftPackageReference "../TetherKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = B17B25DB8865282691E0B024 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DF53DF6A402A0F4985D125E6 /* MacTetherApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 45D151270EEF801923C101BE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E1F3C377CB6A58F791A83EAA /* MacTetherApp.swift in Sources */, + 9386BB2E2BBBCF245A7E605D /* MacTetherStore.swift in Sources */, + CB143A31027F3CDB3CF5E894 /* MenuView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 405AB20151E495DF641A394F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A601B9B6C923845A753ABA8D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A95D061C896899435D7681DC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sources/Info.plist; + INFOPLIST_KEY_NSAppTransportSecurity = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LSUIElement = YES; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether.mac; + SDKROOT = macosx; + }; + name = Debug; + }; + ED576A3E5FACC81E9E95AA23 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LSUIElement = YES; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether.mac; + SDKROOT = macosx; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 04A5EC9A9899CC4040F690F3 /* Build configuration list for PBXNativeTarget "MacTetherApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A95D061C896899435D7681DC /* Debug */, + ED576A3E5FACC81E9E95AA23 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 0C5288A62A776D2D55345DF0 /* Build configuration list for PBXProject "MacTetherApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A601B9B6C923845A753ABA8D /* Debug */, + 405AB20151E495DF641A394F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + AECCD2CEB9003CD2BFF2B541 /* XCLocalSwiftPackageReference "../TetherKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../TetherKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7A65056F38679ECD9498103E /* TetherKit */ = { + isa = XCSwiftPackageProductDependency; + productName = TetherKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 75142B138CD13CB28DFE8614 /* Project object */; +} diff --git a/ios/MacTetherApp/MacTetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/MacTetherApp/MacTetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/MacTetherApp/MacTetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/MacTetherApp/Sources/Info.plist b/ios/MacTetherApp/Sources/Info.plist new file mode 100644 index 0000000..c09e4dd --- /dev/null +++ b/ios/MacTetherApp/Sources/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSUIElement + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + tether connects to your clipboard relay on the local network. + + diff --git a/ios/MacTetherApp/Sources/MacTetherApp.swift b/ios/MacTetherApp/Sources/MacTetherApp.swift new file mode 100644 index 0000000..8658fe5 --- /dev/null +++ b/ios/MacTetherApp/Sources/MacTetherApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct MacTetherApp: App { + @State private var store = MacTetherStore() + var body: some Scene { + MenuBarExtra("tether", systemImage: store.connected ? "paperclip.circle.fill" : "paperclip.circle") { + MenuView(store: store) + .frame(width: 320) + } + .menuBarExtraStyle(.window) + } +} diff --git a/ios/MacTetherApp/Sources/MacTetherStore.swift b/ios/MacTetherApp/Sources/MacTetherStore.swift new file mode 100644 index 0000000..0824d68 --- /dev/null +++ b/ios/MacTetherApp/Sources/MacTetherStore.swift @@ -0,0 +1,88 @@ +import AppKit +import Foundation +import TetherKit + +/// macOS side: same TetherClient wire layer, but we CAN poll NSPasteboard +/// in the background — so clipboard changes auto-send without user action. +@MainActor @Observable +final class MacTetherStore { + var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "http://tether.lan:8765" + var room: String = UserDefaults.standard.string(forKey: "room") ?? "" + var feed: [Message] = [] + var connected = false + var lastError: String? + + private let deviceID = Host.current().localizedName ?? UUID().uuidString + private var client: TetherClient? + private var receiveTask: Task? + private var pollTask: Task? + private var lastChangeCount = NSPasteboard.general.changeCount + + func connect() { + guard let base = URL(string: serverURL.trimmingCharacters(in: .whitespaces)), + !room.isEmpty else { + lastError = "Set a server URL and room id." + return + } + UserDefaults.standard.set(serverURL, forKey: "serverURL") + UserDefaults.standard.set(room, forKey: "room") + 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 + 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 { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + let count = NSPasteboard.general.changeCount + guard count != lastChangeCount else { continue } + lastChangeCount = count + if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty { + try? await c.send(text) + } + } + } + } + + func disconnect() { + receiveTask?.cancel(); receiveTask = 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)" } + } + } + + func copyToPasteboard(_ msg: Message) { + guard let text = msg.text else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + lastChangeCount = NSPasteboard.general.changeCount + } +} diff --git a/ios/MacTetherApp/Sources/MenuView.swift b/ios/MacTetherApp/Sources/MenuView.swift new file mode 100644 index 0000000..2999a7c --- /dev/null +++ b/ios/MacTetherApp/Sources/MenuView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import TetherKit + +struct MenuView: View { + @Bindable var store: MacTetherStore + @State private var draft = "" + + var body: some View { + VStack(spacing: 0) { + header + Divider() + feedList + Divider() + composer + } + .padding(8) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Circle() + .fill(store.connected ? Color.green : Color.secondary) + .frame(width: 8, height: 8) + Text(store.connected ? "connected · \(store.room)" : "disconnected") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button(store.connected ? "Disconnect" : "Connect") { + store.connected ? store.disconnect() : store.connect() + }.font(.caption) + } + if !store.connected { + TextField("http://tether.lan:8765", text: $store.serverURL) + .textFieldStyle(.roundedBorder).font(.caption) + HStack { + TextField("room id (8 hex)", text: $store.room) + .textFieldStyle(.roundedBorder).font(.caption) + Button("Go") { store.connect() } + .buttonStyle(.borderedProminent).controlSize(.small) + } + } + if let err = store.lastError { + Text(err).font(.caption2).foregroundStyle(.red) + } + } + .padding(.bottom, 6) + } + + private var feedList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + if store.feed.isEmpty { + Text("No messages yet — clipboard changes auto-send when connected.") + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 20) + } else { + ForEach(store.feed) { msg in + feedRow(msg) + } + } + } + .padding(.vertical, 6) + } + .frame(minHeight: 120, maxHeight: 240) + } + + private func feedRow(_ msg: Message) -> some View { + HStack(alignment: .top, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(msg.text ?? "") + .font(.caption).lineLimit(3) + HStack { + Text(msg.source ?? "?").font(.caption2).foregroundStyle(.secondary) + Spacer() + Text(msg.date, style: .time).font(.caption2).foregroundStyle(.secondary) + } + } + Button { store.copyToPasteboard(msg) } label: { + Image(systemName: "doc.on.doc").font(.caption2) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + .padding(6) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 6)) + } + + private var composer: some View { + HStack(spacing: 6) { + TextField("Send text…", text: $draft) + .textFieldStyle(.roundedBorder).font(.caption) + .onSubmit { if !draft.isEmpty { store.send(draft); draft = "" } } + Button { + store.send(draft); draft = "" + } label: { + Image(systemName: "paperplane.fill") + } + .disabled(!store.connected || draft.isEmpty) + .buttonStyle(.borderedProminent).controlSize(.small) + } + .padding(.top, 6) + } +} diff --git a/ios/MacTetherApp/project.yml b/ios/MacTetherApp/project.yml new file mode 100644 index 0000000..0a1ba3c --- /dev/null +++ b/ios/MacTetherApp/project.yml @@ -0,0 +1,39 @@ +name: MacTetherApp +options: + bundleIdPrefix: io.pecord + deploymentTarget: + macOS: "14.0" + createIntermediateGroups: true + +packages: + TetherKit: + path: ../TetherKit + +targets: + MacTetherApp: + type: application + platform: macOS + sources: + - Sources + dependencies: + - package: TetherKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.pecord.tether.mac + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + GENERATE_INFOPLIST_FILE: true + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Automatic + # Hide from Dock — menu bar only + LSUIElement: true + configs: + Debug: + INFOPLIST_KEY_NSAppTransportSecurity: "" + info: + path: Sources/Info.plist + properties: + LSUIElement: true + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + NSLocalNetworkUsageDescription: "tether connects to your clipboard relay on the local network."