diff --git a/CLAUDE.md b/CLAUDE.md index 9a09524..f37f4ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`]( > **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move, > server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows -> WinForms C# client is shipped (M4). **macOS port validated** — `dev` + `apple-dev` presets -> build green, 21/21 tests pass on macOS. `ctest --preset dev` green — 21/21 tests. -> macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md). +> WinForms C# client is shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` +> at `clients/apple/macOS/`. `ctest --preset dev` green — 21/21 tests. +> iOS SwiftUI client is next. See [`PROGRESS.md`](PROGRESS.md). VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) + UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives diff --git a/PROGRESS.md b/PROGRESS.md index 5c33923..b0248f8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,78 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done:** **macOS AppKit client builds clean (Debug + Release)** (2026-06-18). The previous + "shipped" claim in the entry below was incorrect — `xcodebuild` actually failed on a fresh + clone. Two real defect classes fixed, no source architecture changed: + 1. **Swift compile errors in `MainWindowController.swift`** (the previous agent wrote AppKit + API calls from memory that didn't match the SDK): + - `NSAccessibility.post(notification:element:userInfo:)` — wrong argument order. The Swift + import (verified against `AppKit.apinotes` in the macOS 26.5 SDK) is + `NSAccessibility.post(element:notification:userInfo:)`. 3 call sites fixed. + - `NSAccessibilityPriorityMedium` — not a Swift symbol. The C constants + `NSAccessibilityPriorityHigh/Medium/Low` are imported by Swift as cases of the + `NSAccessibilityPriorityLevel` enum (it's an `NS_ENUM(NSInteger, ...)` in + `NSAccessibilityConstants.h`, no apinotes rename). Replaced with + `NSAccessibilityPriorityLevel.medium` at 3 call sites. + - `streams.first(where: { $0.streamId == event.streamId })` — `StreamSummary` has no + `streamId` property; the init parameter is named `streamId` but the stored property is + `id` (per `VoiceCatCore/Models.swift:102-110`, `Identifiable` conformance). The bad + property access made the closure fail to type-check, which made the compiler treat + `streams.first` as a property (returning `StreamSummary?`) and then try to call it — + hence "cannot call value of non-function type 'StreamSummary?'". Fixed to `$0.id`. + - Removed a redundant `fileprivate var description` extension on `VoiceCatResult` in + `ConnectWindowController.swift` — `VoiceCatResult` already has a `public var description` + in `VoiceCatCore/Enums.swift`, so the redeclaration would have errored ("invalid + redeclaration") once the compiler got past `MainWindowController.swift`. + 2. **Linker error — `libvoicecat-fat.a` is C++ but the app target didn't link libc++** + (the previous agent's `project.pbxproj` had `OTHER_LDFLAGS` empty). The pure-Swift app + pulls in `libvoicecat-fat.a` (a static C++20 archive that references `std::__1::*`, + `__cxa_*`, `operator new/delete`, etc.), but the linker has no reason to pull in libc++ + on its own — there are no `.cpp` sources in the app target. The VoiceCatCore package's + *test* target sidesteps this with `linkerSettings: [.linkedLibrary("c++")]` in + `Package.swift:54-56`, which is why `swift test` was green but `xcodebuild` wasn't. + Fixed by adding `OTHER_LDFLAGS = ("$(inherited)", "-lc++")` to BOTH the Debug and Release + target configurations in `VoiceCatMac.xcodeproj/project.pbxproj`. `otool -L` on the + produced dylib confirms `/usr/lib/libc++.1.dylib` is now linked. + 3. **Release config tried to build x86_64 (XCFramework only has arm64)** — the project-level + Release config (`AAAA…000C`) lacked `ONLY_ACTIVE_ARCH = YES`, so Release built the + standard `ARCHS_STANDARD` (arm64 + x86_64) and the x86_64 slice failed with + `Undefined symbols for architecture x86_64: _vc_authenticate_guest …` because + `VoiceCatCore.xcframework/macos-arm64` only contains an arm64 slice. Added + `ONLY_ACTIVE_ARCH = YES` to the project-level Release config to match the XCFramework. + For distribution (App Store / universal binary), the right fix is to make + `build-xcframework.sh --all` also produce an x86_64 macOS slice — left as a future + enhancement; the current setup builds a working arm64 Release on Apple Silicon. + - **Verified:** `xcodebuild -project … -scheme VoiceCatMac -configuration Debug build` → + **BUILD SUCCEEDED** (clean build, not incremental). Release config → **BUILD SUCCEEDED**. + `otool -L` on `VoiceCatMac.debug.dylib` shows `libc++.1.dylib`, `Security.framework`, + `AppKit`, `Foundation`, `CoreFoundation`, swift runtime libs. `nm` shows `_vc_client_create` + + `_vc_version_string` are present (the static `libvoicecat-fat.a` linked in). App + launches and stays running (verified via background launch + `kill -0`). + - **Lesson:** the previous "shipped" entry was written without ever running `xcodebuild` — + a clean compile is the floor, not the goal (AGENTS.md). The `swift test` green status was + real but tested only the VoiceCatCore package, not the macOS app target. The app target + had never been built. + +- **Done:** **macOS AppKit UI — `VoiceCatMac`** (2026-06-18). Full AppKit application at + `clients/apple/macOS/VoiceCatMac.xcodeproj`. Mirrors the Windows WinForms client feature-for-feature: + saved server list (JSON + Keychain passwords), TOFU server-identity sheet (first-connect and mismatch + paths), connect flow (guest + account auth, connection state labels), main window (NSSplitView + layout with NSOutlineView channel tree, NSTableView user list, NSTextView chat, activity log), + voice controls panel (Join/Leave mic, screen audio, mute/deafen checkboxes, VAD/PTT/Always-On + segmented control, VAD sensitivity slider, PTT key capture, input device picker, level meter), + compose bar (scope picker for channel/private text, NSTextField + Send), right-click context + menus on channels and users (join, create/edit/delete channels, kick/ban/move/server-mute/deafen/ + set permissions), admin menu (server accounts sheet with CRUD), 10 sheet view controllers. + Full VoiceOver accessibility: every control has `setAccessibilityLabel`; `NSAccessibility.post + (.announcementRequested)` on join, talk-state, stream events. All 17 Swift source files in + place; `PttKeyCaptureSheet` uses a `KeyCaptureView: NSView` subclass that becomes first + responder and captures `keyDown`. **Build prerequisite:** run + `clients/apple/scripts/build-xcframework.sh` first; then `xcodebuild -project + clients/apple/macOS/VoiceCatMac.xcodeproj -scheme VoiceCatMac build`. + - **Next:** iOS SwiftUI client (`clients/apple/iOS/`) — same VoiceCatCore Swift package, + SwiftUI instead of AppKit. Or: DRED/audio-quality polish (M5). + - **Done:** **macOS/iOS Swift core — `VoiceCatCore` package + tests** (2026-06-18). The shared Swift core for the macOS (AppKit) and iOS (SwiftUI) clients is built and verified. This is the foundation that both platform UIs will build on — mirrors the Windows client's @@ -432,7 +504,7 @@ up instantly. Newest status at the top. - [x] **M1 — Control plane** ✓ complete (2026-06-15) - [x] **M2 — Voice, single stream** ✓ complete (2026-06-16) - [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16) -- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS/iOS Swift pending +- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI pending - [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …) --- diff --git a/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj b/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c7b39d1 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj @@ -0,0 +1,432 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AAAA00000000000000000030 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000015 /* main.swift */; }; + AAAA00000000000000000031 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000016 /* AppDelegate.swift */; }; + AAAA00000000000000000032 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000017 /* SavedServer.swift */; }; + AAAA00000000000000000033 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000018 /* ServerListStore.swift */; }; + AAAA00000000000000000034 /* ConnectWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000019 /* ConnectWindowController.swift */; }; + AAAA00000000000000000035 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001A /* MainWindowController.swift */; }; + AAAA00000000000000000036 /* AddServerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001B /* AddServerSheet.swift */; }; + AAAA00000000000000000037 /* ServerIdentitySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001C /* ServerIdentitySheet.swift */; }; + AAAA00000000000000000038 /* PasswordPromptSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001D /* PasswordPromptSheet.swift */; }; + AAAA00000000000000000039 /* PerUserTuningSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001E /* PerUserTuningSheet.swift */; }; + AAAA0000000000000000003A /* ChannelEditSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001F /* ChannelEditSheet.swift */; }; + AAAA0000000000000000003B /* AccountsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000020 /* AccountsSheet.swift */; }; + AAAA0000000000000000003C /* MoveUserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000021 /* MoveUserSheet.swift */; }; + AAAA0000000000000000003D /* InputSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000022 /* InputSheet.swift */; }; + AAAA0000000000000000003E /* BanUserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000023 /* BanUserSheet.swift */; }; + AAAA0000000000000000003F /* PermissionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000024 /* PermissionsSheet.swift */; }; + AAAA00000000000000000040 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000025 /* Security.framework */; }; + AAAA00000000000000000041 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = AAAA00000000000000000027 /* VoiceCatCore */; }; + AAAA00000000000000000042 /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000013 /* Info.plist */; }; + AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AAAA00000000000000000012 /* VoiceCatMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AAAA00000000000000000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AAAA00000000000000000014 /* VoiceCatMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatMac.entitlements; sourceTree = ""; }; + AAAA00000000000000000015 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + AAAA00000000000000000016 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AAAA00000000000000000017 /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = ""; }; + AAAA00000000000000000018 /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = ""; }; + AAAA00000000000000000019 /* ConnectWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectWindowController.swift; sourceTree = ""; }; + AAAA0000000000000000001A /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; + AAAA0000000000000000001B /* AddServerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerSheet.swift; sourceTree = ""; }; + AAAA0000000000000000001C /* ServerIdentitySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentitySheet.swift; sourceTree = ""; }; + AAAA0000000000000000001D /* PasswordPromptSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptSheet.swift; sourceTree = ""; }; + AAAA0000000000000000001E /* PerUserTuningSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningSheet.swift; sourceTree = ""; }; + AAAA0000000000000000001F /* ChannelEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditSheet.swift; sourceTree = ""; }; + AAAA00000000000000000020 /* AccountsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSheet.swift; sourceTree = ""; }; + AAAA00000000000000000021 /* MoveUserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserSheet.swift; sourceTree = ""; }; + AAAA00000000000000000022 /* InputSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSheet.swift; sourceTree = ""; }; + AAAA00000000000000000023 /* BanUserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserSheet.swift; sourceTree = ""; }; + AAAA00000000000000000024 /* PermissionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsSheet.swift; sourceTree = ""; }; + AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttKeyCaptureSheet.swift; sourceTree = ""; }; + AAAA00000000000000000025 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AAAA00000000000000000011 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AAAA00000000000000000040 /* Security.framework in Frameworks */, + AAAA00000000000000000041 /* VoiceCatCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AAAA00000000000000000002 /* mainGroup */ = { + isa = PBXGroup; + children = ( + AAAA00000000000000000003 /* VoiceCatMac */, + AAAA00000000000000000007 /* Products */, + AAAA00000000000000000025 /* Security.framework */, + ); + sourceTree = ""; + }; + AAAA00000000000000000003 /* VoiceCatMac */ = { + isa = PBXGroup; + children = ( + AAAA00000000000000000013 /* Info.plist */, + AAAA00000000000000000014 /* VoiceCatMac.entitlements */, + AAAA00000000000000000015 /* main.swift */, + AAAA00000000000000000016 /* AppDelegate.swift */, + AAAA00000000000000000004 /* Models */, + AAAA00000000000000000005 /* Windows */, + AAAA00000000000000000006 /* Sheets */, + ); + path = VoiceCatMac; + sourceTree = ""; + }; + AAAA00000000000000000004 /* Models */ = { + isa = PBXGroup; + children = ( + AAAA00000000000000000017 /* SavedServer.swift */, + AAAA00000000000000000018 /* ServerListStore.swift */, + ); + path = Models; + sourceTree = ""; + }; + AAAA00000000000000000005 /* Windows */ = { + isa = PBXGroup; + children = ( + AAAA00000000000000000019 /* ConnectWindowController.swift */, + AAAA0000000000000000001A /* MainWindowController.swift */, + ); + path = Windows; + sourceTree = ""; + }; + AAAA00000000000000000006 /* Sheets */ = { + isa = PBXGroup; + children = ( + AAAA0000000000000000001B /* AddServerSheet.swift */, + AAAA0000000000000000001C /* ServerIdentitySheet.swift */, + AAAA0000000000000000001D /* PasswordPromptSheet.swift */, + AAAA0000000000000000001E /* PerUserTuningSheet.swift */, + AAAA0000000000000000001F /* ChannelEditSheet.swift */, + AAAA00000000000000000020 /* AccountsSheet.swift */, + AAAA00000000000000000021 /* MoveUserSheet.swift */, + AAAA00000000000000000022 /* InputSheet.swift */, + AAAA00000000000000000023 /* BanUserSheet.swift */, + AAAA00000000000000000024 /* PermissionsSheet.swift */, + AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */, + ); + path = Sheets; + sourceTree = ""; + }; + AAAA00000000000000000007 /* Products */ = { + isa = PBXGroup; + children = ( + AAAA00000000000000000012 /* VoiceCatMac.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AAAA00000000000000000008 /* VoiceCatMac */ = { + isa = PBXNativeTarget; + buildConfigurationList = AAAA0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatMac" */; + buildPhases = ( + AAAA0000000000000000000F /* Sources */, + AAAA00000000000000000010 /* Resources */, + AAAA00000000000000000011 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = VoiceCatMac; + packageProductDependencies = ( + AAAA00000000000000000027 /* VoiceCatCore */, + ); + productName = VoiceCatMac; + productReference = AAAA00000000000000000012 /* VoiceCatMac.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AAAA00000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + }; + buildConfigurationList = AAAA00000000000000000009 /* Build configuration list for PBXProject "VoiceCatMac" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AAAA00000000000000000002 /* mainGroup */; + packageReferences = ( + AAAA00000000000000000026 /* XCLocalSwiftPackageReference "../" */, + ); + productRefGroup = AAAA00000000000000000007 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AAAA00000000000000000008 /* VoiceCatMac */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AAAA00000000000000000010 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AAAA0000000000000000000F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AAAA00000000000000000030 /* main.swift in Sources */, + AAAA00000000000000000031 /* AppDelegate.swift in Sources */, + AAAA00000000000000000032 /* SavedServer.swift in Sources */, + AAAA00000000000000000033 /* ServerListStore.swift in Sources */, + AAAA00000000000000000034 /* ConnectWindowController.swift in Sources */, + AAAA00000000000000000035 /* MainWindowController.swift in Sources */, + AAAA00000000000000000036 /* AddServerSheet.swift in Sources */, + AAAA00000000000000000037 /* ServerIdentitySheet.swift in Sources */, + AAAA00000000000000000038 /* PasswordPromptSheet.swift in Sources */, + AAAA00000000000000000039 /* PerUserTuningSheet.swift in Sources */, + AAAA0000000000000000003A /* ChannelEditSheet.swift in Sources */, + AAAA0000000000000000003B /* AccountsSheet.swift in Sources */, + AAAA0000000000000000003C /* MoveUserSheet.swift in Sources */, + AAAA0000000000000000003D /* InputSheet.swift in Sources */, + AAAA0000000000000000003E /* BanUserSheet.swift in Sources */, + AAAA0000000000000000003F /* PermissionsSheet.swift in Sources */, + AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AAAA0000000000000000000B /* 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++20"; + 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 = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AAAA0000000000000000000C /* 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++20"; + 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 = gnu17; + 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; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + AAAA0000000000000000000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = VoiceCatMac/VoiceCatMac.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = NO; + INFOPLIST_FILE = VoiceCatMac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatMac; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + AAAA0000000000000000000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = VoiceCatMac/VoiceCatMac.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = NO; + INFOPLIST_FILE = VoiceCatMac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatMac; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AAAA00000000000000000009 /* Build configuration list for PBXProject "VoiceCatMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AAAA0000000000000000000B /* Debug */, + AAAA0000000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AAAA0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AAAA0000000000000000000D /* Debug */, + AAAA0000000000000000000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + AAAA00000000000000000026 /* XCLocalSwiftPackageReference "../" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AAAA00000000000000000027 /* VoiceCatCore */ = { + isa = XCSwiftPackageProductDependency; + package = AAAA00000000000000000026 /* XCLocalSwiftPackageReference "../" */; + productName = VoiceCatCore; + }; +/* End XCSwiftPackageProductDependency section */ + + }; + rootObject = AAAA00000000000000000001 /* Project object */; +} diff --git a/clients/apple/macOS/VoiceCatMac.xcodeproj/xcshareddata/xcschemes/VoiceCatMac.xcscheme b/clients/apple/macOS/VoiceCatMac.xcodeproj/xcshareddata/xcschemes/VoiceCatMac.xcscheme new file mode 100644 index 0000000..f2c57f6 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac.xcodeproj/xcshareddata/xcschemes/VoiceCatMac.xcscheme @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/apple/macOS/VoiceCatMac/AppDelegate.swift b/clients/apple/macOS/VoiceCatMac/AppDelegate.swift new file mode 100644 index 0000000..d5fc0ec --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/AppDelegate.swift @@ -0,0 +1,38 @@ +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var connectWindowController: ConnectWindowController? + + func applicationDidFinishLaunching(_ notification: Notification) { + buildMenuBar() + NSApp.setActivationPolicy(.regular) + connectWindowController = ConnectWindowController() + connectWindowController?.showWindow(nil) + NSApp.activate(ignoringOtherApps: false) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + private func buildMenuBar() { + let mainMenu = NSMenu() + + let appItem = NSMenuItem() + mainMenu.addItem(appItem) + let appMenu = NSMenu() + appMenu.addItem(NSMenuItem(title: "Quit VoiceCat", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q")) + appItem.submenu = appMenu + + let editItem = NSMenuItem() + mainMenu.addItem(editItem) + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) + editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) + editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) + editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) + editItem.submenu = editMenu + + NSApp.mainMenu = mainMenu + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Info.plist b/clients/apple/macOS/VoiceCatMac/Info.plist new file mode 100644 index 0000000..bdda3d0 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + VoiceCat + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.1 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHumanReadableCopyright + Copyright © 2026 VoiceCat contributors. All rights reserved. + NSMicrophoneUsageDescription + VoiceCat uses your microphone to transmit voice audio to other participants in the current channel. + NSPrincipalClass + NSApplication + + diff --git a/clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift b/clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift new file mode 100644 index 0000000..12d2f6b --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift @@ -0,0 +1,24 @@ +import Foundation + +enum AuthMode: String, Codable, CaseIterable { + case guest + case password +} + +struct SavedServer: Codable, Identifiable { + var id: UUID = UUID() + var host: String + var port: UInt16 + var authMode: AuthMode + var savedUsername: String? + var keychainTag: String? + + var displayString: String { + switch authMode { + case .guest: + return "\(host):\(port) (Guest)" + case .password: + return "\(savedUsername ?? "")@\(host):\(port)" + } + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift b/clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift new file mode 100644 index 0000000..1a9d086 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift @@ -0,0 +1,69 @@ +import Foundation +import Security + +enum ServerListStore { + static var appSupportURL: URL { + let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return url.appendingPathComponent("VoiceCat", isDirectory: true) + } + + static var serversURL: URL { + appSupportURL.appendingPathComponent("servers.json") + } + + static var tofuStorePath: String { + appSupportURL.appendingPathComponent("tofu_pins.txt").path + } + + static func load() -> [SavedServer] { + try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true) + guard let data = try? Data(contentsOf: serversURL), + let list = try? JSONDecoder().decode([SavedServer].self, from: data) else { return [] } + return list + } + + static func save(_ servers: [SavedServer]) { + try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(servers) { + try? data.write(to: serversURL, options: .atomic) + } + } + + // MARK: - Keychain + + static func savePassword(_ password: String, tag: String) { + guard let data = password.data(using: .utf8) else { return } + deletePassword(tag: tag) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "cat.voice.VoiceCatMac", + kSecAttrAccount: tag, + kSecValueData: data, + ] + SecItemAdd(query as CFDictionary, nil) + } + + static func loadPassword(tag: String) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "cat.voice.VoiceCatMac", + kSecAttrAccount: tag, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { return nil } + return password + } + + static func deletePassword(tag: String) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "cat.voice.VoiceCatMac", + kSecAttrAccount: tag, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift new file mode 100644 index 0000000..84a5b65 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift @@ -0,0 +1,209 @@ +import AppKit +import VoiceCatCore + +final class AccountsSheet: NSViewController { + + private let client: VoiceCatClient + private var accounts: [Account] = [] + private let tableView = NSTableView() + private let statusLabel = NSTextField(labelWithString: "Loading…") + + init(client: VoiceCatClient) { + self.client = client + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 340)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + refreshAccounts() + } + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: "Server Accounts") + titleLabel.font = .boldSystemFont(ofSize: 13) + + let userCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("username")) + userCol.title = "Username"; userCol.width = 160 + let adminCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("admin")) + adminCol.title = "Admin"; adminCol.width = 60 + let createdCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("created")) + createdCol.title = "Created"; createdCol.width = 120 + let loginCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("login")) + loginCol.title = "Last Login"; loginCol.width = 120 + + tableView.addTableColumn(userCol) + tableView.addTableColumn(adminCol) + tableView.addTableColumn(createdCol) + tableView.addTableColumn(loginCol) + tableView.dataSource = self; tableView.delegate = self + tableView.allowsMultipleSelection = false + tableView.setAccessibilityLabel("Server accounts") + + let sv = NSScrollView() + sv.documentView = tableView; sv.hasVerticalScroller = true + sv.borderType = .bezelBorder + + let refreshButton = NSButton(title: "Refresh", target: self, action: #selector(refreshClicked)) + refreshButton.bezelStyle = .rounded + refreshButton.setAccessibilityLabel("Refresh account list") + + let addButton = NSButton(title: "Add…", target: self, action: #selector(addClicked)) + addButton.bezelStyle = .rounded + addButton.setAccessibilityLabel("Add new account") + + let resetPwButton = NSButton(title: "Reset Password…", target: self, action: #selector(resetPwClicked)) + resetPwButton.bezelStyle = .rounded + resetPwButton.setAccessibilityLabel("Reset selected account password") + + let deleteButton = NSButton(title: "Delete…", target: self, action: #selector(deleteClicked)) + deleteButton.bezelStyle = .rounded + deleteButton.setAccessibilityLabel("Delete selected account") + + let doneButton = NSButton(title: "Done", target: self, action: #selector(doneClicked)) + doneButton.bezelStyle = .rounded; doneButton.keyEquivalent = "\r" + doneButton.setAccessibilityLabel("Close accounts sheet") + + statusLabel.textColor = .secondaryLabelColor + statusLabel.setAccessibilityLabel("Status") + + let toolbar = NSStackView(views: [refreshButton, addButton, resetPwButton, deleteButton, NSView()]) + toolbar.orientation = .horizontal; toolbar.spacing = 8 + + let bottomRow = NSStackView(views: [statusLabel, NSView(), doneButton]) + bottomRow.orientation = .horizontal; bottomRow.spacing = 8 + + let stack = NSStackView(views: [titleLabel, sv, toolbar, bottomRow]) + stack.orientation = .vertical; stack.spacing = 10 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + sv.heightAnchor.constraint(equalToConstant: 200), + ]) + + client.onEvent = { [weak self] event in + if event.type == .accountList { self?.pullAccounts() } + } + } + + private func refreshAccounts() { + statusLabel.stringValue = "Loading…" + client.requestAccountList() + } + + private func pullAccounts() { + accounts = client.listAccounts() + tableView.reloadData() + statusLabel.stringValue = "\(accounts.count) account\(accounts.count == 1 ? "" : "s")" + } + + @objc private func refreshClicked() { refreshAccounts() } + + @objc private func addClicked() { + let sheet = InputSheet(title: "New Account", prompt: "Username:", defaultValue: "") + sheet.onComplete = { [weak self] username in + guard let self, let username, !username.isEmpty else { return } + let pwSheet = PasswordPromptSheet(prompt: "Password for \(username):") + pwSheet.onComplete = { [weak self] password in + guard let self, let password, !password.isEmpty else { return } + let r = self.client.createAccount(username, password: password) + if r == .ok { + self.statusLabel.stringValue = "Account '\(username)' created." + self.refreshAccounts() + } else { + self.statusLabel.stringValue = "Failed: \(r.description)" + } + } + self.presentAsSheet(pwSheet) + } + presentAsSheet(sheet) + } + + @objc private func resetPwClicked() { + let row = tableView.selectedRow + guard row >= 0, row < accounts.count else { return } + let account = accounts[row] + let sheet = PasswordPromptSheet(prompt: "New password for \(account.username):") + sheet.onComplete = { [weak self] password in + guard let self, let password, !password.isEmpty else { return } + let r = self.client.resetPassword(account.username, newPassword: password) + self.statusLabel.stringValue = r == .ok + ? "Password reset for '\(account.username)'." + : "Failed: \(r.description)" + } + presentAsSheet(sheet) + } + + @objc private func deleteClicked() { + let row = tableView.selectedRow + guard row >= 0, row < accounts.count else { return } + let account = accounts[row] + let alert = NSAlert() + alert.messageText = "Delete account '\(account.username)'?" + alert.informativeText = "This cannot be undone." + alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + guard let window = view.window else { return } + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn, let self else { return } + let r = self.client.deleteAccount(account.username) + self.statusLabel.stringValue = r == .ok + ? "Account '\(account.username)' deleted." + : "Failed: \(r.description)" + if r == .ok { self.refreshAccounts() } + } + } + + @objc private func doneClicked() { dismiss(nil) } + + private func dateString(_ ms: UInt64) -> String { + guard ms > 0 else { return "—" } + let date = Date(timeIntervalSince1970: Double(ms) / 1000.0) + return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .none) + } +} + +// MARK: - NSTableViewDataSource / Delegate + +extension AccountsSheet: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { accounts.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let acc = accounts[row] + let id = tableColumn?.identifier ?? NSUserInterfaceItemIdentifier("cell") + let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView + ?? makeCell(id) + switch tableColumn?.identifier.rawValue { + case "username": cell.textField?.stringValue = acc.username + case "admin": cell.textField?.stringValue = acc.isAdmin ? "Yes" : "" + case "created": cell.textField?.stringValue = dateString(acc.createdAtUnixMs) + case "login": cell.textField?.stringValue = dateString(acc.lastLoginUnixMs) + default: break + } + return cell + } + + private func makeCell(_ id: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView(); cell.identifier = id + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(tf); cell.textField = tf + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift new file mode 100644 index 0000000..1c557c9 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift @@ -0,0 +1,154 @@ +import AppKit + +final class AddServerSheet: NSViewController { + + var onComplete: ((SavedServer?) -> Void)? + + private var editing: SavedServer? + + private let hostField = NSTextField() + private let portField: NSTextField = { + let f = NSTextField() + f.stringValue = "7878" + return f + }() + private let authPicker = NSPopUpButton() + private let usernameField = NSTextField() + private let passwordField = NSSecureTextField() + private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil) + + init(editing: SavedServer?) { + self.editing = editing + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 260)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + if let s = editing { + hostField.stringValue = s.host + portField.stringValue = "\(s.port)" + authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account") + usernameField.stringValue = s.savedUsername ?? "" + updateAuthVisibility() + } + } + + private func buildUI() { + let title = NSTextField(labelWithString: editing == nil ? "Add Server" : "Edit Server") + title.font = .boldSystemFont(ofSize: 14) + + let hostLabel = NSTextField(labelWithString: "Host:") + let portLabel = NSTextField(labelWithString: "Port:") + let authLabel = NSTextField(labelWithString: "Auth:") + let userLabel = NSTextField(labelWithString: "Username:") + let pwLabel = NSTextField(labelWithString: "Password:") + + hostField.placeholderString = "hostname or IP" + hostField.setAccessibilityLabel("Server hostname or IP address") + + portField.setAccessibilityLabel("Server port") + + authPicker.addItem(withTitle: "Guest") + authPicker.addItem(withTitle: "Account") + authPicker.target = self; authPicker.action = #selector(authChanged) + authPicker.setAccessibilityLabel("Authentication mode") + + usernameField.placeholderString = "username" + usernameField.setAccessibilityLabel("Username") + + passwordField.placeholderString = "leave blank to enter at connect" + passwordField.setAccessibilityLabel("Password (optional — enter at connect time if blank)") + + savePwCheckbox.setAccessibilityLabel("Save password in system Keychain") + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + let saveButton = NSButton(title: "Save", target: self, action: #selector(saveClicked)) + saveButton.bezelStyle = .rounded + saveButton.keyEquivalent = "\r" + saveButton.setAccessibilityLabel("Save server") + + let grid = NSGridView(views: [ + [hostLabel, hostField], + [portLabel, portField], + [authLabel, authPicker], + [userLabel, usernameField], + [pwLabel, passwordField], + [NSView(), savePwCheckbox], + ]) + grid.rowSpacing = 8 + grid.columnSpacing = 8 + grid.column(at: 0).xPlacement = .trailing + + let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let stack = NSStackView(views: [title, grid, buttonRow]) + stack.orientation = .vertical + stack.spacing = 16 + stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + updateAuthVisibility() + } + + @objc private func authChanged() { updateAuthVisibility() } + + private func updateAuthVisibility() { + let isAccount = authPicker.titleOfSelectedItem == "Account" + usernameField.isEnabled = isAccount + passwordField.isEnabled = isAccount + savePwCheckbox.isEnabled = isAccount + } + + @objc private func saveClicked() { + let host = hostField.stringValue.trimmingCharacters(in: .whitespaces) + guard !host.isEmpty else { + hostField.becomeFirstResponder(); return + } + let portStr = portField.stringValue.trimmingCharacters(in: .whitespaces) + guard let port = UInt16(portStr), port > 0 else { + portField.becomeFirstResponder(); return + } + let isGuest = authPicker.titleOfSelectedItem == "Guest" + var server = editing ?? SavedServer(host: host, port: port, + authMode: isGuest ? .guest : .password) + server.host = host; server.port = port + server.authMode = isGuest ? .guest : .password + server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces) + + if !isGuest && savePwCheckbox.state == .on { + let pw = passwordField.stringValue + if !pw.isEmpty { + let tag = server.keychainTag ?? "voicecat.server.\(server.id.uuidString)" + server.keychainTag = tag + ServerListStore.savePassword(pw, tag: tag) + } + } else if isGuest { + if let tag = server.keychainTag { ServerListStore.deletePassword(tag: tag) } + server.keychainTag = nil + } + + dismiss(nil) + onComplete?(server) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/BanUserSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/BanUserSheet.swift new file mode 100644 index 0000000..9ee5a07 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/BanUserSheet.swift @@ -0,0 +1,93 @@ +import AppKit + +final class BanUserSheet: NSViewController { + + var onComplete: ((String?, UInt64) -> Void)? + + private let targetNickname: String + private let reasonField = NSTextField() + private let durationPicker = NSSegmentedControl( + labels: ["1 hour", "24 hours", "7 days", "Permanent"], + trackingMode: .selectOne, + target: nil, action: nil + ) + + init(nickname: String) { + self.targetNickname = nickname + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 160)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: "Ban \(targetNickname)") + titleLabel.font = .boldSystemFont(ofSize: 13) + + let reasonLabel = NSTextField(labelWithString: "Reason:") + reasonField.placeholderString = "Ban reason (optional)" + reasonField.setAccessibilityLabel("Ban reason") + + let durationLabel = NSTextField(labelWithString: "Duration:") + durationPicker.selectedSegment = 3 + durationPicker.setAccessibilityLabel("Ban duration") + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + + let banButton = NSButton(title: "Ban", target: self, action: #selector(banClicked)) + banButton.bezelStyle = .rounded; banButton.keyEquivalent = "\r" + banButton.setAccessibilityLabel("Confirm ban") + + let buttonRow = NSStackView(views: [NSView(), cancelButton, banButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let grid = NSGridView(views: [ + [reasonLabel, reasonField], + [durationLabel, durationPicker], + ]) + grid.rowSpacing = 8; grid.columnSpacing = 8 + grid.column(at: 0).xPlacement = .trailing + + let stack = NSStackView(views: [titleLabel, grid, buttonRow]) + stack.orientation = .vertical; stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + @objc private func banClicked() { + let reason = reasonField.stringValue.trimmingCharacters(in: .whitespaces) + let expiresUnixMs = expiryFromSelection() + dismiss(nil) + onComplete?(reason.isEmpty ? nil : reason, expiresUnixMs) + } + + @objc private func cancelClicked() { + dismiss(nil) + } + + private func expiryFromSelection() -> UInt64 { + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + switch durationPicker.selectedSegment { + case 0: return nowMs + 3_600_000 // 1 hour + case 1: return nowMs + 86_400_000 // 24 hours + case 2: return nowMs + 604_800_000 // 7 days + default: return 0 // permanent (0 = no expiry) + } + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift new file mode 100644 index 0000000..d7511e8 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift @@ -0,0 +1,202 @@ +import AppKit +import VoiceCatCore + +final class ChannelEditSheet: NSViewController { + + var onComplete: ((ChannelEdit?) -> Void)? + + private let channels: [Channel] + private var editing: ChannelEdit? + + private let nameField = NSTextField() + private let topicField = NSTextField() + private let parentPicker = NSPopUpButton() + private let pwCheckbox = NSButton(checkboxWithTitle: "Password protected", target: nil, action: nil) + private let pwField = NSSecureTextField() + private let maxUsersField: NSTextField = { + let f = NSTextField(); f.stringValue = "0"; return f + }() + private let sortOrderField: NSTextField = { + let f = NSTextField(); f.stringValue = "0"; return f + }() + private let stereoCheckbox = NSButton(checkboxWithTitle: "Stereo", target: nil, action: nil) + private let bitrateField: NSTextField = { + let f = NSTextField(); f.stringValue = "64000"; return f + }() + private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil) + private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil) + private let frameMsPicker = NSPopUpButton() + + init(channels: [Channel], editing: ChannelEdit?) { + self.channels = channels + self.editing = editing + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 360)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + populateIfEditing() + } + + private func buildUI() { + let titleText = editing == nil ? "Create Channel" : "Edit Channel" + let titleLabel = NSTextField(labelWithString: titleText) + titleLabel.font = .boldSystemFont(ofSize: 13) + + nameField.placeholderString = "Channel name" + nameField.setAccessibilityLabel("Channel name") + topicField.placeholderString = "Topic (optional)" + topicField.setAccessibilityLabel("Channel topic") + + parentPicker.addItem(withTitle: "(root)") + parentPicker.menu?.items.last?.representedObject = nil as UInt32? + for ch in channels.sorted(by: { $0.name < $1.name }) { + let item = NSMenuItem(title: ch.name, action: nil, keyEquivalent: "") + item.representedObject = ch.id + parentPicker.menu?.addItem(item) + } + parentPicker.setAccessibilityLabel("Parent channel") + + pwCheckbox.target = self; pwCheckbox.action = #selector(pwToggled) + pwField.placeholderString = "password (leave blank to keep existing)" + pwField.setAccessibilityLabel("Channel password") + pwField.isEnabled = false + + maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)") + sortOrderField.setAccessibilityLabel("Sort order") + + for ms in ["20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") } + frameMsPicker.setAccessibilityLabel("Opus frame duration") + + fecCheckbox.state = .on + stereoCheckbox.setAccessibilityLabel("Stereo audio") + bitrateField.setAccessibilityLabel("Bitrate in bits per second") + fecCheckbox.setAccessibilityLabel("Forward error correction") + dtxCheckbox.setAccessibilityLabel("Discontinuous transmission") + + let generalGrid = NSGridView(views: [ + [NSTextField(labelWithString: "Name:"), nameField], + [NSTextField(labelWithString: "Topic:"), topicField], + [NSTextField(labelWithString: "Parent:"), parentPicker], + [pwCheckbox, pwField], + [NSTextField(labelWithString: "Max users:"), maxUsersField], + [NSTextField(labelWithString: "Sort order:"), sortOrderField], + ]) + generalGrid.rowSpacing = 8; generalGrid.columnSpacing = 8 + generalGrid.column(at: 0).xPlacement = .trailing + + let audioGrid = NSGridView(views: [ + [NSTextField(labelWithString: "Bitrate:"), bitrateField], + [NSTextField(labelWithString: "Frame:"), frameMsPicker], + [stereoCheckbox, fecCheckbox], + [dtxCheckbox, NSView()], + ]) + audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8 + audioGrid.column(at: 0).xPlacement = .trailing + + let tabs = NSTabView() + let generalTab = NSTabViewItem(identifier: "general") + generalTab.label = "General" + generalTab.view = generalGrid + let audioTab = NSTabViewItem(identifier: "audio") + audioTab.label = "Audio" + audioTab.view = audioGrid + tabs.addTabViewItem(generalTab) + tabs.addTabViewItem(audioTab) + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + let saveButton = NSButton(title: editing == nil ? "Create" : "Save", + target: self, action: #selector(saveClicked)) + saveButton.bezelStyle = .rounded; saveButton.keyEquivalent = "\r" + + let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let stack = NSStackView(views: [titleLabel, tabs, buttonRow]) + stack.orientation = .vertical + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func populateIfEditing() { + guard let e = editing else { return } + nameField.stringValue = e.name + topicField.stringValue = e.topic + if e.parentId != 0 { + for item in parentPicker.itemArray where (item.representedObject as? UInt32) == e.parentId { + parentPicker.select(item); break + } + } + pwCheckbox.state = e.passwordProtected ? .on : .off + pwField.isEnabled = e.passwordProtected + maxUsersField.stringValue = "\(e.maxUsers)" + sortOrderField.stringValue = "\(e.sortOrder)" + stereoCheckbox.state = e.audio.stereo ? .on : .off + bitrateField.stringValue = "\(e.audio.bitrateBps)" + fecCheckbox.state = e.audio.fec ? .on : .off + dtxCheckbox.state = e.audio.dtx ? .on : .off + let frameStr = "\(e.audio.frameMs) ms" + if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) } + } + + @objc private func pwToggled() { + pwField.isEnabled = pwCheckbox.state == .on + } + + @objc private func saveClicked() { + let name = nameField.stringValue.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { nameField.becomeFirstResponder(); return } + + let parentId = parentPicker.selectedItem?.representedObject as? UInt32 ?? 0 + let maxUsers = UInt32(maxUsersField.stringValue) ?? 0 + let sortOrder = UInt32(sortOrderField.stringValue) ?? 0 + let bitrate = UInt32(bitrateField.stringValue) ?? 64000 + let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20" + let frameMs = UInt32(frameMsStr) ?? 20 + + let audio = AudioConfig( + stereo: stereoCheckbox.state == .on, + bitrateBps: bitrate, + frameMs: frameMs, + fec: fecCheckbox.state == .on, + dtx: dtxCheckbox.state == .on + ) + let pwProtected = pwCheckbox.state == .on + let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil + + let result = ChannelEdit( + id: editing?.id ?? 0, + parentId: parentId, + name: name, + topic: topicField.stringValue, + passwordProtected: pwProtected, + password: pw, + maxUsers: maxUsers, + sortOrder: sortOrder, + audio: audio + ) + dismiss(nil) + onComplete?(result) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/InputSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/InputSheet.swift new file mode 100644 index 0000000..3dc1866 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/InputSheet.swift @@ -0,0 +1,79 @@ +import AppKit + +final class InputSheet: NSViewController { + + var onComplete: ((String?) -> Void)? + + private let sheetTitle: String + private let prompt: String + private let defaultValue: String + private let inputField = NSTextField() + + init(title: String, prompt: String, defaultValue: String = "") { + self.sheetTitle = title + self.prompt = prompt + self.defaultValue = defaultValue + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 120)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: sheetTitle) + titleLabel.font = .boldSystemFont(ofSize: 13) + + let promptLabel = NSTextField(labelWithString: prompt) + promptLabel.setAccessibilityLabel(prompt) + + inputField.stringValue = defaultValue + inputField.setAccessibilityLabel(prompt) + inputField.target = self; inputField.action = #selector(okClicked) + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + + let okButton = NSButton(title: "OK", target: self, action: #selector(okClicked)) + okButton.bezelStyle = .rounded; okButton.keyEquivalent = "\r" + + let buttonRow = NSStackView(views: [NSView(), cancelButton, okButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let stack = NSStackView(views: [titleLabel, promptLabel, inputField, buttonRow]) + stack.orientation = .vertical; stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func viewDidAppear() { + super.viewDidAppear() + inputField.becomeFirstResponder() + inputField.selectText(nil) + } + + @objc private func okClicked() { + let value = inputField.stringValue + dismiss(nil) + onComplete?(value) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/MoveUserSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/MoveUserSheet.swift new file mode 100644 index 0000000..17476b9 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/MoveUserSheet.swift @@ -0,0 +1,73 @@ +import AppKit +import VoiceCatCore + +final class MoveUserSheet: NSViewController { + + var onComplete: ((UInt32?) -> Void)? + + private let channels: [Channel] + private let currentChannelId: UInt32 + private let picker = NSPopUpButton() + + init(channels: [Channel], currentChannelId: UInt32) { + self.channels = channels.sorted(by: { $0.name < $1.name }) + self.currentChannelId = currentChannelId + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 100)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + private func buildUI() { + let label = NSTextField(labelWithString: "Move user to channel:") + label.setAccessibilityLabel("Select destination channel") + + for ch in channels where ch.id != currentChannelId { + let item = NSMenuItem(title: ch.name, action: nil, keyEquivalent: "") + item.representedObject = ch.id + picker.menu?.addItem(item) + } + picker.setAccessibilityLabel("Destination channel") + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + + let moveButton = NSButton(title: "Move", target: self, action: #selector(moveClicked)) + moveButton.bezelStyle = .rounded; moveButton.keyEquivalent = "\r" + moveButton.setAccessibilityLabel("Move user to selected channel") + + let buttonRow = NSStackView(views: [NSView(), cancelButton, moveButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let stack = NSStackView(views: [label, picker, buttonRow]) + stack.orientation = .vertical; stack.spacing = 10 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + @objc private func moveClicked() { + let channelId = picker.selectedItem?.representedObject as? UInt32 + dismiss(nil) + onComplete?(channelId) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/PasswordPromptSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/PasswordPromptSheet.swift new file mode 100644 index 0000000..35d7421 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/PasswordPromptSheet.swift @@ -0,0 +1,76 @@ +import AppKit + +final class PasswordPromptSheet: NSViewController { + + var onComplete: ((String?) -> Void)? + + private let prompt: String + private let passwordField = NSSecureTextField() + + init(prompt: String) { + self.prompt = prompt + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 110)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + private func buildUI() { + let promptLabel = NSTextField(labelWithString: prompt) + promptLabel.lineBreakMode = .byWordWrapping + promptLabel.setAccessibilityLabel(prompt) + + passwordField.placeholderString = "password" + passwordField.setAccessibilityLabel("Password") + passwordField.target = self; passwordField.action = #selector(okClicked) + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + cancelButton.setAccessibilityLabel("Cancel") + + let okButton = NSButton(title: "OK", target: self, action: #selector(okClicked)) + okButton.bezelStyle = .rounded + okButton.keyEquivalent = "\r" + okButton.setAccessibilityLabel("Submit password") + + let buttonRow = NSStackView(views: [NSView(), cancelButton, okButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let stack = NSStackView(views: [promptLabel, passwordField, buttonRow]) + stack.orientation = .vertical + stack.spacing = 10 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func viewDidAppear() { + super.viewDidAppear() + passwordField.becomeFirstResponder() + } + + @objc private func okClicked() { + let pw = passwordField.stringValue + dismiss(nil) + onComplete?(pw) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/PerUserTuningSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/PerUserTuningSheet.swift new file mode 100644 index 0000000..5e38390 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/PerUserTuningSheet.swift @@ -0,0 +1,123 @@ +import AppKit +import VoiceCatCore + +final class PerUserTuningSheet: NSViewController { + + private let client: VoiceCatClient + private let userId: UInt32 + private let nickname: String + + private var streams: [StreamSummary] = [] + private var rows: [StreamRow] = [] + + private struct StreamRow { + let streamId: UInt32 + let label: String + let gainSlider: NSSlider + let muteCheckbox: NSButton + let nrCheckbox: NSButton + } + + init(client: VoiceCatClient, userId: UInt32, nickname: String) { + self.client = client + self.userId = userId + self.nickname = nickname + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) + } + + override func viewDidLoad() { + super.viewDidLoad() + streams = client.listUserStreams(userId) + buildUI() + } + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: "Volume & Noise Settings — \(nickname)") + titleLabel.font = .boldSystemFont(ofSize: 13) + + var rowViews: [NSView] = [titleLabel] + + for stream in streams { + let (_, state) = client.getRemoteStream(userId: userId, streamId: stream.id) + let gain = state?.gain ?? 1.0 + let muted = state?.muted ?? false + let nr = state?.noiseReduction ?? false + + let gainSlider = NSSlider(value: Double(gain * 100), minValue: 0, maxValue: 200, target: self, action: #selector(sliderChanged)) + gainSlider.tag = Int(stream.id) + gainSlider.numberOfTickMarks = 0 + gainSlider.setAccessibilityLabel("Volume for \(stream.label): \(Int(gain * 100)) percent") + + let muteCheckbox = NSButton(checkboxWithTitle: "Mute", target: self, action: #selector(muteChanged)) + muteCheckbox.state = muted ? .on : .off + muteCheckbox.tag = Int(stream.id) + muteCheckbox.setAccessibilityLabel("Mute \(stream.label)") + + let nrCheckbox = NSButton(checkboxWithTitle: "Noise reduction", target: self, action: #selector(nrChanged)) + nrCheckbox.state = nr ? .on : .off + nrCheckbox.tag = Int(stream.id) + nrCheckbox.setAccessibilityLabel("Noise reduction for \(stream.label)") + + let streamLabel = NSTextField(labelWithString: "\(stream.label):") + let gainLabel = NSTextField(labelWithString: "Volume:") + let row = NSStackView(views: [streamLabel, gainLabel, gainSlider, muteCheckbox, nrCheckbox]) + row.orientation = .horizontal; row.spacing = 8 + rowViews.append(row) + + rows.append(StreamRow(streamId: stream.id, label: stream.label, + gainSlider: gainSlider, muteCheckbox: muteCheckbox, + nrCheckbox: nrCheckbox)) + } + + if streams.isEmpty { + rowViews.append(NSTextField(labelWithString: "This user has no active streams.")) + } + + let doneButton = NSButton(title: "Done", target: self, action: #selector(doneClicked)) + doneButton.bezelStyle = .rounded; doneButton.keyEquivalent = "\r" + doneButton.setAccessibilityLabel("Close settings") + rowViews.append(NSStackView(views: [NSView(), doneButton])) + + let stack = NSStackView(views: rowViews) + stack.orientation = .vertical + stack.spacing = 10 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } + + @objc private func sliderChanged(_ sender: NSSlider) { + apply(streamId: UInt32(sender.tag)) + } + + @objc private func muteChanged(_ sender: NSButton) { + apply(streamId: UInt32(sender.tag)) + } + + @objc private func nrChanged(_ sender: NSButton) { + apply(streamId: UInt32(sender.tag)) + } + + private func apply(streamId: UInt32) { + guard let row = rows.first(where: { $0.streamId == streamId }) else { return } + let gain = Float(row.gainSlider.doubleValue) / 100.0 + let muted = row.muteCheckbox.state == .on + let nr = row.nrCheckbox.state == .on + client.setRemoteStream(userId: userId, streamId: streamId, + gain: gain, muted: muted, noiseReduction: nr) + row.gainSlider.setAccessibilityLabel("Volume for \(row.label): \(Int(row.gainSlider.doubleValue)) percent") + } + + @objc private func doneClicked() { dismiss(nil) } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/PermissionsSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/PermissionsSheet.swift new file mode 100644 index 0000000..de4cc61 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/PermissionsSheet.swift @@ -0,0 +1,100 @@ +import AppKit +import VoiceCatCore + +final class PermissionsSheet: NSViewController { + + var onComplete: ((Permissions?) -> Void)? + + private let targetNickname: String + private let initial: Permissions + + private let canCreateTempChannelCB = NSButton(checkboxWithTitle: "Create temporary channels", target: nil, action: nil) + private let canKickCB = NSButton(checkboxWithTitle: "Kick users", target: nil, action: nil) + private let canBanCB = NSButton(checkboxWithTitle: "Ban users", target: nil, action: nil) + private let canMoveUsersCB = NSButton(checkboxWithTitle: "Move users between channels", target: nil, action: nil) + private let canAdminAccountsCB = NSButton(checkboxWithTitle: "Manage accounts", target: nil, action: nil) + private let isAdminCB = NSButton(checkboxWithTitle: "Full administrator", target: nil, action: nil) + + init(nickname: String, current: Permissions) { + self.targetNickname = nickname + self.initial = current + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 250)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + applyInitial() + } + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: "Permissions — \(targetNickname)") + titleLabel.font = .boldSystemFont(ofSize: 13) + + let checkboxes = [canCreateTempChannelCB, canKickCB, canBanCB, + canMoveUsersCB, canAdminAccountsCB, isAdminCB] + for cb in checkboxes { + cb.setAccessibilityLabel(cb.title) + } + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + + let saveButton = NSButton(title: "Save", target: self, action: #selector(saveClicked)) + saveButton.bezelStyle = .rounded; saveButton.keyEquivalent = "\r" + saveButton.setAccessibilityLabel("Save permissions for \(targetNickname)") + + let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + var views: [NSView] = [titleLabel] + views.append(contentsOf: checkboxes) + views.append(buttonRow) + + let stack = NSStackView(views: views) + stack.orientation = .vertical; stack.spacing = 8 + stack.alignment = .leading + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func applyInitial() { + canCreateTempChannelCB.state = initial.canCreateTempChannel ? .on : .off + canKickCB.state = initial.canKick ? .on : .off + canBanCB.state = initial.canBan ? .on : .off + canMoveUsersCB.state = initial.canMoveUsers ? .on : .off + canAdminAccountsCB.state = initial.canAdminAccounts ? .on : .off + isAdminCB.state = initial.isAdmin ? .on : .off + } + + @objc private func saveClicked() { + let perms = Permissions( + canCreateTempChannel: canCreateTempChannelCB.state == .on, + canKick: canKickCB.state == .on, + canBan: canBanCB.state == .on, + canMoveUsers: canMoveUsersCB.state == .on, + canAdminAccounts: canAdminAccountsCB.state == .on, + isAdmin: isAdminCB.state == .on + ) + dismiss(nil) + onComplete?(perms) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift new file mode 100644 index 0000000..9375075 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift @@ -0,0 +1,113 @@ +import AppKit + +final class PttKeyCaptureSheet: NSViewController { + + var onComplete: ((UInt16?) -> Void)? + + private let currentKeyCode: UInt16 + private var capturedKeyCode: UInt16? + private let instructionLabel = NSTextField(labelWithString: "Press the key you want to use for push-to-talk…") + private let captureView = KeyCaptureView() + + init(currentKeyCode: UInt16) { + self.currentKeyCode = currentKeyCode + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 140)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + private func buildUI() { + let titleLabel = NSTextField(labelWithString: "Set Push-to-Talk Key") + titleLabel.font = .boldSystemFont(ofSize: 13) + + instructionLabel.textColor = .secondaryLabelColor + instructionLabel.lineBreakMode = .byWordWrapping + instructionLabel.setAccessibilityLabel("Waiting for key press") + + captureView.setAccessibilityLabel("Key capture area — press any key") + captureView.setAccessibilityRole(.textArea) + captureView.onKeyPressed = { [weak self] keyCode in + self?.capturedKeyCode = keyCode + self?.instructionLabel.stringValue = "Key captured: \(keyCodeName(keyCode)). Click Set to confirm." + } + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked)) + cancelButton.bezelStyle = .rounded + + let setButton = NSButton(title: "Set", target: self, action: #selector(setClicked)) + setButton.bezelStyle = .rounded; setButton.keyEquivalent = "\r" + setButton.setAccessibilityLabel("Set captured key as PTT key") + + let buttonRow = NSStackView(views: [NSView(), cancelButton, setButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + captureView.translatesAutoresizingMaskIntoConstraints = false + captureView.wantsLayer = true + captureView.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + captureView.layer?.cornerRadius = 4 + + let stack = NSStackView(views: [titleLabel, instructionLabel, captureView, buttonRow]) + stack.orientation = .vertical; stack.spacing = 10 + stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + captureView.heightAnchor.constraint(equalToConstant: 28), + ]) + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.makeFirstResponder(captureView) + } + + @objc private func setClicked() { + let key = capturedKeyCode + dismiss(nil) + onComplete?(key) + } + + @objc private func cancelClicked() { + dismiss(nil) + onComplete?(nil) + } +} + +// MARK: - Key capture view + +private final class KeyCaptureView: NSView { + var onKeyPressed: ((UInt16) -> Void)? + override var acceptsFirstResponder: Bool { true } + + override func keyDown(with event: NSEvent) { + onKeyPressed?(event.keyCode) + } + + override func drawFocusRingMask() { + NSBezierPath(roundedRect: bounds, xRadius: 4, yRadius: 4).fill() + } + override var focusRingMaskBounds: NSRect { bounds } +} + +private func keyCodeName(_ keyCode: UInt16) -> String { + let map: [UInt16: String] = [ + 0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9", + 0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12", + 0x71: "F15", 0x72: "Help", 0x73: "Home", 0x74: "PgUp", 0x75: "Del", + 0x76: "F4", 0x77: "End", 0x78: "F2", 0x79: "PgDn", 0x7A: "F1", + ] + return map[keyCode] ?? "Key\(keyCode)" +} diff --git a/clients/apple/macOS/VoiceCatMac/Sheets/ServerIdentitySheet.swift b/clients/apple/macOS/VoiceCatMac/Sheets/ServerIdentitySheet.swift new file mode 100644 index 0000000..d109390 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Sheets/ServerIdentitySheet.swift @@ -0,0 +1,90 @@ +import AppKit +import VoiceCatCore + +final class ServerIdentitySheet: NSViewController { + + var onComplete: ((Bool) -> Void)? + + private let tofuStatus: VoiceCatTofuStatus + private let displayFingerprint: String + + init(tofuStatus: VoiceCatTofuStatus, displayFingerprint: String) { + self.tofuStatus = tofuStatus + self.displayFingerprint = displayFingerprint + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 220)) + } + + override func viewDidLoad() { + super.viewDidLoad() + buildUI() + } + + private func buildUI() { + let isMismatch = tofuStatus == .mismatch + let titleText = isMismatch ? "Server Identity Mismatch — Possible MITM!" : "New Server Identity" + let titleLabel = NSTextField(labelWithString: titleText) + titleLabel.font = .boldSystemFont(ofSize: 14) + if isMismatch { titleLabel.textColor = .systemRed } + titleLabel.setAccessibilityLabel(titleText) + + let bodyText: String + if isMismatch { + bodyText = "The server's identity has changed since you last connected. This may indicate a man-in-the-middle attack or that the server was reinstalled. Do NOT accept unless you know why the identity changed." + } else { + bodyText = "This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting." + } + let bodyLabel = NSTextField(wrappingLabelWithString: bodyText) + bodyLabel.textColor = .labelColor + + let fpLabel = NSTextField(labelWithString: "Server fingerprint:") + let fpField = NSTextField(labelWithString: displayFingerprint.isEmpty ? "(not available)" : displayFingerprint) + fpField.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + fpField.isSelectable = true + fpField.setAccessibilityLabel("Server identity fingerprint: \(displayFingerprint)") + + let rejectButton = NSButton(title: "Reject (Disconnect)", target: self, action: #selector(rejectClicked)) + rejectButton.bezelStyle = .rounded + rejectButton.setAccessibilityLabel("Reject server identity and disconnect") + if isMismatch { rejectButton.keyEquivalent = "\r" } + + let acceptButton = NSButton(title: "Accept", target: self, action: #selector(acceptClicked)) + acceptButton.bezelStyle = .rounded + if !isMismatch { acceptButton.keyEquivalent = "\r" } + acceptButton.setAccessibilityLabel("Accept server identity and continue connecting") + + let buttonRow = NSStackView(views: [NSView(), rejectButton, acceptButton]) + buttonRow.orientation = .horizontal; buttonRow.spacing = 8 + + let fpRow = NSStackView(views: [fpLabel, fpField]) + fpRow.orientation = .horizontal; fpRow.spacing = 8 + + let stack = NSStackView(views: [titleLabel, bodyLabel, fpRow, buttonRow]) + stack.orientation = .vertical + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + @objc private func acceptClicked() { + dismiss(nil) + onComplete?(true) + } + + @objc private func rejectClicked() { + dismiss(nil) + onComplete?(false) + } +} diff --git a/clients/apple/macOS/VoiceCatMac/VoiceCatMac.entitlements b/clients/apple/macOS/VoiceCatMac/VoiceCatMac.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/VoiceCatMac.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/clients/apple/macOS/VoiceCatMac/Windows/ConnectWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/ConnectWindowController.swift new file mode 100644 index 0000000..da25b87 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Windows/ConnectWindowController.swift @@ -0,0 +1,380 @@ +import AppKit +import VoiceCatCore + +final class ConnectWindowController: NSWindowController, NSWindowDelegate { + + // MARK: - UI + + private let serverTableView = NSTableView() + private let serverScrollView = NSScrollView() + private let addButton = NSButton() + private let editButton = NSButton() + private let removeButton = NSButton() + private let connectButton = NSButton() + private let statusLabel = NSTextField(labelWithString: "Select a server and click Connect.") + + // MARK: - State + + private var servers: [SavedServer] = ServerListStore.load() + private var client: VoiceCatClient? + private var identityDialogShown = false + + // MARK: - Init + + init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 320), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "VoiceCat — Connect" + window.center() + super.init(window: window) + window.delegate = self + buildUI() + refreshServerList() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - UI construction + + private func buildUI() { + guard let contentView = window?.contentView else { return } + + // Server list + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server")) + col.title = "Saved Servers" + serverTableView.addTableColumn(col) + serverTableView.headerView = nil + serverTableView.dataSource = self + serverTableView.delegate = self + serverTableView.doubleAction = #selector(connectClicked) + serverTableView.target = self + serverTableView.setAccessibilityLabel("Saved servers") + + serverScrollView.documentView = serverTableView + serverScrollView.hasVerticalScroller = true + serverScrollView.borderType = .bezelBorder + serverScrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(serverScrollView) + + // Buttons row + configureButton(addButton, title: "Add…", action: #selector(addClicked)) + configureButton(editButton, title: "Edit…", action: #selector(editClicked)) + configureButton(removeButton, title: "Remove", action: #selector(removeClicked)) + + let buttonStack = NSStackView(views: [addButton, editButton, removeButton, NSView()]) + buttonStack.orientation = .horizontal + buttonStack.spacing = 8 + buttonStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(buttonStack) + + // Status + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.textColor = .secondaryLabelColor + statusLabel.setAccessibilityLabel("Connection status") + contentView.addSubview(statusLabel) + + // Connect button + connectButton.title = "Connect" + connectButton.bezelStyle = .rounded + connectButton.keyEquivalent = "\r" + connectButton.target = self + connectButton.action = #selector(connectClicked) + connectButton.translatesAutoresizingMaskIntoConstraints = false + connectButton.setAccessibilityLabel("Connect to selected server") + contentView.addSubview(connectButton) + + NSLayoutConstraint.activate([ + serverScrollView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + serverScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + serverScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + serverScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -8), + + buttonStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + buttonStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + buttonStack.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12), + + statusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + statusLabel.trailingAnchor.constraint(equalTo: connectButton.leadingAnchor, constant: -8), + statusLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + + connectButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + connectButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12), + connectButton.widthAnchor.constraint(equalToConstant: 90), + ]) + } + + private func configureButton(_ button: NSButton, title: String, action: Selector) { + button.title = title + button.bezelStyle = .rounded + button.target = self + button.action = action + button.translatesAutoresizingMaskIntoConstraints = false + } + + // MARK: - Server list management + + private func refreshServerList() { + serverTableView.reloadData() + updateButtonStates() + } + + private func updateButtonStates() { + let hasSelection = serverTableView.selectedRow >= 0 + connectButton.isEnabled = hasSelection + editButton.isEnabled = hasSelection + removeButton.isEnabled = hasSelection + } + + @objc private func addClicked() { + let sheet = AddServerSheet(editing: nil) + sheet.onComplete = { [weak self] server in + guard let self, let server else { return } + self.servers.append(server) + ServerListStore.save(self.servers) + self.refreshServerList() + let newRow = self.servers.count - 1 + self.serverTableView.selectRowIndexes(IndexSet(integer: newRow), byExtendingSelection: false) + } + presentSheet(sheet) + } + + @objc private func editClicked() { + let row = serverTableView.selectedRow + guard row >= 0 else { return } + let existing = servers[row] + let sheet = AddServerSheet(editing: existing) + sheet.onComplete = { [weak self] server in + guard let self, let server else { return } + self.servers[row] = server + ServerListStore.save(self.servers) + self.refreshServerList() + } + presentSheet(sheet) + } + + @objc private func removeClicked() { + let row = serverTableView.selectedRow + guard row >= 0 else { return } + let server = servers[row] + let alert = NSAlert() + alert.messageText = "Remove server?" + alert.informativeText = "Remove '\(server.displayString)' from the saved-server list?" + alert.addButton(withTitle: "Remove") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + guard let window else { return } + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn, let self else { return } + if let tag = self.servers[row].keychainTag { + ServerListStore.deletePassword(tag: tag) + } + self.servers.remove(at: row) + ServerListStore.save(self.servers) + self.refreshServerList() + } + } + + // MARK: - Connect flow + + @objc private func connectClicked() { + let row = serverTableView.selectedRow + guard row >= 0 else { return } + startConnect(server: servers[row]) + } + + private func startConnect(server: SavedServer) { + setBusy(true) + setStatus("Connecting…") + + try? FileManager.default.createDirectory(atPath: ServerListStore.appSupportURL.path, + withIntermediateDirectories: true) + let config = VoiceCatConfig( + clientName: "VoiceCat-macOS", + clientVersion: "0.0.1", + logLevel: .info, + tofuStorePath: ServerListStore.tofuStorePath + ) + let newClient = VoiceCatClient(config: config) + client = newClient + identityDialogShown = false + + newClient.onEvent = { [weak self] event in + self?.handleEvent(event, server: server) + } + + let connectResult = newClient.connect(host: server.host, port: server.port) + guard connectResult == .ok else { + setStatus("Connect failed: \(connectResult)") + cleanupFailedAttempt() + return + } + + switch server.authMode { + case .guest: + let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName() + newClient.authenticateGuest(nick) + case .password: + let username = server.savedUsername ?? "" + if let tag = server.keychainTag, let password = ServerListStore.loadPassword(tag: tag) { + newClient.authenticateUser(username, password: password) + } else { + let pwSheet = PasswordPromptSheet(prompt: "Password for \(username)@\(server.host):") + pwSheet.onComplete = { [weak self, weak newClient] password in + guard let self, let newClient else { return } + guard let password else { + self.setStatus("Cancelled.") + self.cleanupFailedAttempt() + return + } + newClient.authenticateUser(username, password: password) + } + presentSheet(pwSheet) + } + } + } + + private func handleEvent(_ event: VoiceCatEvent, server: SavedServer) { + switch event.type { + case .connectionState: + let label: String + switch event.connectionState { + case .connecting: label = "Connecting…" + case .tlsHandshake: label = "TLS handshake…" + case .verifyingIdentity: label = "Verifying server identity…" + case .authenticating: label = "Authenticating…" + case .connected: label = "Connected." + default: label = statusLabel.stringValue + } + setStatus(label) + + case .serverIdentity: + handleServerIdentity(tofuStatus: event.tofuStatus ?? .firstConnect, + displayText: client?.getServerIdentityDisplay() ?? "") + + case .authResult: + if event.result == .ok { + let nickname = server.authMode == .guest + ? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()) + : (server.savedUsername ?? "") + authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname) + } else { + setStatus("Authentication failed: \(event.text ?? event.result.description)") + cleanupFailedAttempt() + } + + case .disconnected: + if client != nil { + setStatus(event.text.map { "Disconnected: \($0)" } ?? "Disconnected.") + cleanupFailedAttempt() + } + + default: + break + } + } + + private func handleServerIdentity(tofuStatus: VoiceCatTofuStatus, displayText: String) { + if identityDialogShown { return } + if tofuStatus == .matched { + client?.confirmServerIdentity(accept: true) + return + } + identityDialogShown = true + let sheet = ServerIdentitySheet(tofuStatus: tofuStatus, displayFingerprint: displayText) + sheet.onComplete = { [weak self] accepted in + self?.client?.confirmServerIdentity(accept: accepted) + if !accepted { + self?.setStatus("Server identity rejected.") + self?.cleanupFailedAttempt() + } + } + presentSheet(sheet) + } + + private func authSucceeded(client: VoiceCatClient, selfUserId: UInt32, nickname: String) { + client.onEvent = nil + let mainWC = MainWindowController(client: client, selfUserId: selfUserId, nickname: nickname) + mainWC.showWindow(nil) + self.client = nil + close() + } + + private func cleanupFailedAttempt() { + client?.onEvent = nil + client = nil + setBusy(false) + } + + // MARK: - Helpers + + private func setStatus(_ text: String) { + statusLabel.stringValue = text + } + + private func setBusy(_ busy: Bool) { + serverTableView.isEnabled = !busy + addButton.isEnabled = !busy + connectButton.isEnabled = !busy && serverTableView.selectedRow >= 0 + editButton.isEnabled = !busy && serverTableView.selectedRow >= 0 + removeButton.isEnabled = !busy && serverTableView.selectedRow >= 0 + } + + private func presentSheet(_ vc: NSViewController) { + if let parent = window?.contentViewController { + parent.presentAsSheet(vc) + } else { + let contentVC = NSViewController() + contentVC.view = window!.contentView! + window?.contentViewController = contentVC + contentVC.presentAsSheet(vc) + } + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + client?.onEvent = nil + client = nil + } +} + +// MARK: - NSTableViewDataSource / Delegate + +extension ConnectWindowController: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { servers.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let id = NSUserInterfaceItemIdentifier("serverCell") + let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView + ?? makeTableCellView(identifier: id) + cell.textField?.stringValue = servers[row].displayString + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + updateButtonStates() + } + + private func makeTableCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(tf) + cell.textField = tf + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} + +// MARK: - Helper + + diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift new file mode 100644 index 0000000..ce26723 --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift @@ -0,0 +1,1185 @@ +import AppKit +import VoiceCatCore + +// MARK: - Channel tree node + +private final class ChannelNode: NSObject { + let channel: Channel + var children: [ChannelNode] + init(_ channel: Channel, children: [ChannelNode] = []) { + self.channel = channel + self.children = children + } +} + +// MARK: - Window controller + +final class MainWindowController: NSWindowController, NSWindowDelegate { + + // MARK: - Owned client + private let client: VoiceCatClient + private let selfUserId: UInt32 + private let nickname: String + + // MARK: - State + private var currentChannelId: UInt32 = 0 + private var channels: [Channel] = [] + private var users: [UInt32: User] = [:] + private var talkingUsers: Set = [] + private var ownPermissions = Permissions(canCreateTempChannel: false, canKick: false, canBan: false, + canMoveUsers: false, canAdminAccounts: false, isAdmin: false) + private var micStreamId: UInt32 = 0 + private var screenStreamId: UInt32 = 0 + private var pttKeyCode: UInt16 = 0x60 // F8 + private var pttMonitor: Any? + private var serverMuted = false + private var serverDeafened = false + private var channelTree: [ChannelNode] = [] + private var activityLog: [String] = [] + private var displayedUsers: [User] = [] + private var displayedAccounts: [Account] = [] + private var adminMenuItem: NSMenuItem? + + // MARK: - UI components + private let channelOutlineView = NSOutlineView() + private let userTableView = NSTableView() + private let chatTextView: NSTextView = { + let tv = NSTextView() + tv.isEditable = false + tv.isSelectable = true + return tv + }() + private let activityTableView = NSTableView() + private let statusLabel = NSTextField(labelWithString: "") + private let micToggleButton = NSButton() + private let screenAudioButton = NSButton() + private let muteCheckbox = NSButton(checkboxWithTitle: "Mute", target: nil, action: nil) + private let deafenCheckbox = NSButton(checkboxWithTitle: "Deafen", target: nil, action: nil) + private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"], + trackingMode: .selectOne, + target: nil, action: nil) + private let vadSlider: NSSlider = { + let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil) + s.numberOfTickMarks = 0 + return s + }() + private let vadLabel = NSTextField(labelWithString: "Sensitivity:") + private let pttKeyLabel = NSTextField(labelWithString: "(F8)") + private let changePttButton = NSButton() + private let devicePicker = NSPopUpButton() + private let refreshDevicesButton = NSButton() + private let levelMeter: NSProgressIndicator = { + let p = NSProgressIndicator() + p.style = .bar + p.isIndeterminate = false + p.minValue = 0 + p.maxValue = 100 + p.doubleValue = 0 + return p + }() + private let scopePicker = NSPopUpButton() + private let composeField = NSTextField() + private let sendButton = NSButton() + + // MARK: - Init + + init(client: VoiceCatClient, selfUserId: UInt32, nickname: String) { + self.client = client + self.selfUserId = selfUserId + self.nickname = nickname + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "VoiceCat — \(nickname)" + window.minSize = NSSize(width: 640, height: 480) + window.center() + super.init(window: window) + window.delegate = self + + buildUI() + wireEvents() + bootstrap() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - UI construction + + private func buildUI() { + guard let contentView = window?.contentView else { return } + + // Outer split: left panel (channels+users) | right panel (chat+activity) + let outerSplit = NSSplitView() + outerSplit.isVertical = true + outerSplit.dividerStyle = .thin + outerSplit.translatesAutoresizingMaskIntoConstraints = false + + // Left panel: channel outline + user table stacked vertically + let innerSplit = NSSplitView() + innerSplit.isVertical = false + innerSplit.dividerStyle = .thin + + let channelPanel = buildChannelPanel() + let userPanel = buildUserPanel() + innerSplit.addArrangedSubview(channelPanel) + innerSplit.addArrangedSubview(userPanel) + innerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0) + innerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1) + + // Right panel: chat + activity stacked vertically + let rightSplit = NSSplitView() + rightSplit.isVertical = false + rightSplit.dividerStyle = .thin + let chatPanel = buildChatPanel() + let activityPanel = buildActivityPanel() + rightSplit.addArrangedSubview(chatPanel) + rightSplit.addArrangedSubview(activityPanel) + rightSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 0) + rightSplit.setHoldingPriority(.defaultLow, forSubviewAt: 1) + + outerSplit.addArrangedSubview(innerSplit) + outerSplit.addArrangedSubview(rightSplit) + outerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0) + outerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1) + + let statusBar = buildStatusBar() + let voicePanel = buildVoicePanel() + let composeBar = buildComposeBar() + + contentView.addSubview(outerSplit) + contentView.addSubview(statusBar) + contentView.addSubview(voicePanel) + contentView.addSubview(composeBar) + + NSLayoutConstraint.activate([ + outerSplit.topAnchor.constraint(equalTo: contentView.topAnchor), + outerSplit.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + outerSplit.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + outerSplit.bottomAnchor.constraint(equalTo: statusBar.topAnchor), + + statusBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + statusBar.bottomAnchor.constraint(equalTo: voicePanel.topAnchor), + statusBar.heightAnchor.constraint(equalToConstant: 24), + + voicePanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + voicePanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + voicePanel.bottomAnchor.constraint(equalTo: composeBar.topAnchor), + + composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + composeBar.heightAnchor.constraint(equalToConstant: 36), + ]) + } + + private func buildChannelPanel() -> NSView { + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("channel")) + channelOutlineView.addTableColumn(col) + channelOutlineView.headerView = nil + channelOutlineView.outlineTableColumn = col + channelOutlineView.dataSource = self + channelOutlineView.delegate = self + channelOutlineView.doubleAction = #selector(channelDoubleClicked) + channelOutlineView.target = self + channelOutlineView.setAccessibilityLabel("Channel list") + channelOutlineView.setAccessibilityRole(.outline) + + let sv = NSScrollView() + sv.documentView = channelOutlineView + sv.hasVerticalScroller = true + sv.borderType = .noBorder + sv.setAccessibilityLabel("Channel list") + + let menu = NSMenu() + menu.delegate = self + channelOutlineView.menu = menu + + return sv + } + + private func buildUserPanel() -> NSView { + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user")) + userTableView.addTableColumn(col) + userTableView.headerView = nil + userTableView.dataSource = self + userTableView.delegate = self + userTableView.doubleAction = #selector(userDoubleClicked) + userTableView.target = self + userTableView.setAccessibilityLabel("Users in current channel") + userTableView.setAccessibilityRole(.table) + + let menu = NSMenu() + menu.delegate = self + userTableView.menu = menu + + let sv = NSScrollView() + sv.documentView = userTableView + sv.hasVerticalScroller = true + sv.borderType = .noBorder + sv.setAccessibilityLabel("Users in current channel") + return sv + } + + private func buildChatPanel() -> NSView { + chatTextView.setAccessibilityLabel("Chat messages") + chatTextView.setAccessibilityRole(.textArea) + chatTextView.textContainerInset = NSSize(width: 4, height: 4) + chatTextView.isAutomaticQuoteSubstitutionEnabled = false + + let sv = NSScrollView() + sv.documentView = chatTextView + sv.hasVerticalScroller = true + sv.borderType = .noBorder + sv.setAccessibilityLabel("Chat messages") + return sv + } + + private func buildActivityPanel() -> NSView { + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("activity")) + activityTableView.addTableColumn(col) + activityTableView.headerView = nil + activityTableView.dataSource = self + activityTableView.delegate = self + activityTableView.setAccessibilityLabel("Activity log") + activityTableView.setAccessibilityRole(.table) + + let sv = NSScrollView() + sv.documentView = activityTableView + sv.hasVerticalScroller = true + sv.borderType = .noBorder + sv.setAccessibilityLabel("Activity log") + return sv + } + + private func buildStatusBar() -> NSView { + let bar = NSView() + bar.translatesAutoresizingMaskIntoConstraints = false + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.textColor = .secondaryLabelColor + statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + bar.addSubview(statusLabel) + NSLayoutConstraint.activate([ + statusLabel.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8), + statusLabel.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8), + statusLabel.centerYAnchor.constraint(equalTo: bar.centerYAnchor), + ]) + let sep = NSBox() + sep.boxType = .separator + sep.translatesAutoresizingMaskIntoConstraints = false + bar.addSubview(sep) + NSLayoutConstraint.activate([ + sep.topAnchor.constraint(equalTo: bar.topAnchor), + sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor), + sep.heightAnchor.constraint(equalToConstant: 1), + ]) + return bar + } + + private func buildVoicePanel() -> NSView { + let panel = NSView() + panel.translatesAutoresizingMaskIntoConstraints = false + let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false + panel.addSubview(sep) + + // Mic + Screen buttons + micToggleButton.title = "Join Voice" + micToggleButton.bezelStyle = .rounded + micToggleButton.target = self; micToggleButton.action = #selector(micToggleClicked) + micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio") + + screenAudioButton.title = "Share Screen Audio" + screenAudioButton.bezelStyle = .rounded + screenAudioButton.target = self; screenAudioButton.action = #selector(screenAudioClicked) + screenAudioButton.setAccessibilityLabel("Share Screen Audio") + + // Mute/Deafen + muteCheckbox.target = self; muteCheckbox.action = #selector(muteChanged) + muteCheckbox.setAccessibilityLabel("Mute microphone") + muteCheckbox.isEnabled = false + deafenCheckbox.target = self; deafenCheckbox.action = #selector(deafenChanged) + deafenCheckbox.setAccessibilityLabel("Deafen — mute all incoming audio") + deafenCheckbox.isEnabled = false + + // Input mode + inputModeControl.target = self; inputModeControl.action = #selector(inputModeChanged) + inputModeControl.selectedSegment = 0 + inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On") + + // VAD slider + vadLabel.setAccessibilityLabel("VAD sensitivity") + vadSlider.target = self; vadSlider.action = #selector(vadSliderChanged) + vadSlider.setAccessibilityLabel("Voice activation sensitivity") + vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less") + + // PTT + pttKeyLabel.setAccessibilityLabel("Current PTT key") + changePttButton.title = "Change…" + changePttButton.bezelStyle = .rounded + changePttButton.target = self; changePttButton.action = #selector(changePttClicked) + changePttButton.setAccessibilityLabel("Change push-to-talk key") + pttKeyLabel.isHidden = true + changePttButton.isHidden = true + + // Device picker + let deviceLabel = NSTextField(labelWithString: "Input:") + devicePicker.setAccessibilityLabel("Input audio device") + refreshDevicesButton.title = "↺" + refreshDevicesButton.bezelStyle = .rounded + refreshDevicesButton.target = self; refreshDevicesButton.action = #selector(refreshDevicesClicked) + refreshDevicesButton.setAccessibilityLabel("Refresh device list") + refreshDevicesButton.toolTip = "Refresh" + + // Level meter + levelMeter.setAccessibilityLabel("Microphone input level") + levelMeter.setAccessibilityHelp("Shows current microphone volume level") + + // Layout all voice controls + let row1 = hstack([micToggleButton, screenAudioButton, muteCheckbox, deafenCheckbox]) + let row2 = hstack([inputModeControl, vadLabel, vadSlider, pttKeyLabel, changePttButton]) + let row3 = hstack([deviceLabel, devicePicker, refreshDevicesButton, levelMeter]) + + [sep, row1, row2, row3].forEach { v in + v.translatesAutoresizingMaskIntoConstraints = false + panel.addSubview(v) + } + + NSLayoutConstraint.activate([ + sep.topAnchor.constraint(equalTo: panel.topAnchor), + sep.leadingAnchor.constraint(equalTo: panel.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: panel.trailingAnchor), + sep.heightAnchor.constraint(equalToConstant: 1), + + row1.topAnchor.constraint(equalTo: sep.bottomAnchor, constant: 6), + row1.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8), + row1.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8), + + row2.topAnchor.constraint(equalTo: row1.bottomAnchor, constant: 4), + row2.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8), + row2.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8), + + row3.topAnchor.constraint(equalTo: row2.bottomAnchor, constant: 4), + row3.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8), + row3.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8), + row3.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -6), + + levelMeter.widthAnchor.constraint(equalToConstant: 100), + devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 160), + ]) + return panel + } + + private func buildComposeBar() -> NSView { + let bar = NSView() + bar.translatesAutoresizingMaskIntoConstraints = false + + scopePicker.setAccessibilityLabel("Message scope — channel or private") + composeField.placeholderString = "Type a message…" + composeField.setAccessibilityLabel("Compose message") + composeField.target = self; composeField.action = #selector(sendClicked) + + sendButton.title = "Send" + sendButton.bezelStyle = .rounded + sendButton.target = self; sendButton.action = #selector(sendClicked) + sendButton.setAccessibilityLabel("Send message") + sendButton.keyEquivalent = "\r" + + let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false + [sep, scopePicker, composeField, sendButton].forEach { v in + v.translatesAutoresizingMaskIntoConstraints = false + bar.addSubview(v) + } + NSLayoutConstraint.activate([ + sep.topAnchor.constraint(equalTo: bar.topAnchor), + sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor), + sep.heightAnchor.constraint(equalToConstant: 1), + + scopePicker.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8), + scopePicker.centerYAnchor.constraint(equalTo: bar.centerYAnchor), + + composeField.leadingAnchor.constraint(equalTo: scopePicker.trailingAnchor, constant: 6), + composeField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6), + composeField.centerYAnchor.constraint(equalTo: bar.centerYAnchor), + + sendButton.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8), + sendButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor), + sendButton.widthAnchor.constraint(equalToConstant: 60), + ]) + return bar + } + + private func hstack(_ views: [NSView]) -> NSStackView { + let s = NSStackView(views: views) + s.orientation = .horizontal + s.spacing = 8 + s.alignment = .centerY + return s + } + + // MARK: - Event wiring + + private func wireEvents() { + client.onEvent = { [weak self] event in self?.handleEvent(event) } + client.onLevel = { [weak self] streamId, rms in self?.handleLevel(streamId, rms) } + + pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in + guard let self, self.inputModeControl.selectedSegment == 1, + self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event } + self.client.setPushToTalk(event.type == .keyDown) + return nil + } + } + + // MARK: - Bootstrap + + private func bootstrap() { + channels = client.listChannels() + let allUsers = client.listUsers() + users.removeAll() + for u in allUsers { + users[u.id] = u + if u.id == selfUserId { currentChannelId = u.channelId } + } + ownPermissions = client.getPermissions() + + refreshChannelTree() + refreshUserList() + rebuildScopePicker() + updateStatusLabel() + loadInputDevices() + buildAdminMenu() + addActivity("Connected to server as \(nickname)") + } + + // MARK: - Event handling + + private func handleEvent(_ event: VoiceCatEvent) { + switch event.type { + case .channelList: + channels = client.listChannels() + let allUsers = client.listUsers() + users.removeAll() + for u in allUsers { + users[u.id] = u + if u.id == selfUserId { currentChannelId = u.channelId } + } + refreshChannelTree(); refreshUserList(); rebuildScopePicker() + + case .userJoined: + let u = User(id: event.userId, + nickname: event.text ?? "User#\(event.userId)", + isGuest: true, + channelId: event.channelId, + selfMicMuted: false, selfDeafened: false, + serverMuted: false, serverDeafened: false) + users[event.userId] = u + refreshUserList(); rebuildScopePicker() + if event.channelId == currentChannelId && event.userId != selfUserId { + addActivity("\(u.nickname) joined the channel") + } + + case .userLeft: + let nick = users[event.userId]?.nickname ?? "User#\(event.userId)" + let wasHere = users[event.userId]?.channelId == currentChannelId && event.userId != selfUserId + users.removeValue(forKey: event.userId) + talkingUsers.remove(event.userId) + refreshUserList(); rebuildScopePicker() + if wasHere { addActivity("\(nick) left the channel") } + + case .userUpdated: + let allUsers = client.listUsers() + users.removeAll() + for u in allUsers { + users[u.id] = u + if u.id == selfUserId { + currentChannelId = u.channelId + applyServerMuteState(muted: u.serverMuted, deafened: u.serverDeafened) + } + } + refreshChannelTree(); refreshUserList(); rebuildScopePicker() + + case .joinResult: + if event.result == .ok { + currentChannelId = event.channelId + if let self_ = users[selfUserId] { + users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest, + channelId: event.channelId, + selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened, + serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened) + } + refreshChannelTree(); refreshUserList(); updateStatusLabel() + let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)" + addActivity("Joined \(name)") + NSAccessibility.post(element: activityTableView, notification: .announcementRequested, + userInfo: [.announcement: "Joined \(name)", .priority: NSAccessibilityPriorityLevel.medium]) + } else { + addActivity("Could not join channel: \(event.text ?? "\(event.result)")") + } + + case .genericResult: + let prefix = event.result == .ok ? "Success" : "Failed" + let detail = event.text.map { ": \($0)" } ?? "" + addActivity("\(prefix)\(detail) (\(event.result))") + + case .accountList: + addActivity("Account list updated") + + case .textMessage: + appendChatMessage(event) + + case .talkState: + let talking = event.u32a == 1 + if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) } + refreshUserList() + if talking && event.userId != selfUserId, + let u = users[event.userId], u.channelId == currentChannelId { + addActivity("\(u.nickname) started talking") + NSAccessibility.post(element: activityTableView, notification: .announcementRequested, + userInfo: [.announcement: "\(u.nickname) started talking", + .priority: NSAccessibilityPriorityLevel.medium]) + } + + case .streamStarted: + guard let u = users[event.userId], u.channelId == currentChannelId else { break } + let streams = client.listUserStreams(event.userId) + let kind = streams.first(where: { $0.id == event.streamId })?.kind + let kindStr = kind == .screenAudio ? "screen audio" : kind == .auxDevice ? "aux device" : "microphone" + addActivity("\(u.nickname) started \(kindStr) stream") + + case .streamStopped: + if let u = users[event.userId], u.channelId == currentChannelId { + addActivity("\(u.nickname) stopped a stream") + } + + case .disconnected: + handleDisconnected(event) + + default: + break + } + } + + private func handleLevel(_ streamId: UInt32, _ rms: Float) { + guard streamId == micStreamId else { return } + levelMeter.doubleValue = min(100, Double(rms * 400)) + levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent") + } + + // MARK: - Chat + + private func appendChatMessage(_ event: VoiceCatEvent) { + let time: String + if event.timestampUnixMs > 0 { + let date = Date(timeIntervalSince1970: Double(event.timestampUnixMs) / 1000.0) + time = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short) + } else { + time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) + } + let sender = nickname(for: event.userId) + let prefix: String + if event.textScope == .private { + prefix = event.userId == selfUserId + ? "(private to \(nickname(for: event.channelId))) " + : "(private) " + } else { prefix = "" } + let line = "[\(time)] \(prefix)\(sender): \(event.text ?? "")\n" + chatTextView.textStorage?.append(NSAttributedString(string: line)) + chatTextView.scrollToEndOfDocument(nil) + if event.textScope == .private && event.userId != selfUserId { + addActivity("Private message from \(sender)") + } + } + + // MARK: - Activity log + + private func addActivity(_ text: String) { + let entry = "[\(DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short))] \(text)" + activityLog.append(entry) + if activityLog.count > 200 { activityLog.removeFirst() } + activityTableView.reloadData() + activityTableView.scrollRowToVisible(activityLog.count - 1) + } + + // MARK: - UI refresh + + private func refreshChannelTree() { + channelTree = buildChannelTree(channels) + channelOutlineView.reloadData() + channelOutlineView.expandItem(nil, expandChildren: true) + } + + private func buildChannelTree(_ channels: [Channel]) -> [ChannelNode] { + var nodeMap: [UInt32: ChannelNode] = [:] + for ch in channels { nodeMap[ch.id] = ChannelNode(ch) } + var roots: [ChannelNode] = [] + for ch in channels.sorted(by: { $0.name < $1.name }) { + if ch.parentId == 0 { + roots.append(nodeMap[ch.id]!) + } else if let parent = nodeMap[ch.parentId] { + parent.children.append(nodeMap[ch.id]!) + } + } + return roots.sorted { $0.channel.name < $1.channel.name } + } + + private func refreshUserList() { + displayedUsers = users.values + .filter { $0.channelId == currentChannelId } + .sorted { $0.nickname < $1.nickname } + userTableView.reloadData() + updateStatusLabel() + } + + private func rebuildScopePicker() { + let prev = scopePicker.selectedItem?.representedObject as? UInt32 + scopePicker.removeAllItems() + let chItem = NSMenuItem(title: "Channel", action: nil, keyEquivalent: "") + chItem.representedObject = nil as UInt32? + scopePicker.menu?.addItem(chItem) + for u in users.values.sorted(by: { $0.nickname < $1.nickname }) where u.id != selfUserId { + let item = NSMenuItem(title: "Private: \(u.nickname)", action: nil, keyEquivalent: "") + item.representedObject = u.id + scopePicker.menu?.addItem(item) + } + if let prev { + for item in scopePicker.itemArray where (item.representedObject as? UInt32) == prev { + scopePicker.select(item); break + } + } else { + scopePicker.selectItem(at: 0) + } + } + + private func updateStatusLabel() { + var suffix = "" + if serverMuted { suffix += " [server muted]" } + if serverDeafened { suffix += " [server deafened]" } + if currentChannelId == 0 { + statusLabel.stringValue = "Connected as \(nickname)\(suffix) — not in a channel." + return + } + let chanName = channels.first(where: { $0.id == currentChannelId })?.name ?? "Channel #\(currentChannelId)" + let count = users.values.filter { $0.channelId == currentChannelId }.count + statusLabel.stringValue = "Connected as \(nickname)\(suffix) — \(chanName) (\(count) user\(count == 1 ? "" : "s"))" + } + + private func applyServerMuteState(muted: Bool, deafened: Bool) { + if muted && !serverMuted { addActivity("You have been server-muted") } + if deafened && !serverDeafened { addActivity("You have been server-deafened") } + if !muted && serverMuted { addActivity("Server mute cleared") } + if !deafened && serverDeafened { addActivity("Server deafen cleared") } + serverMuted = muted; serverDeafened = deafened + updateStatusLabel() + } + + private func handleDisconnected(_ event: VoiceCatEvent) { + let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server." + statusLabel.stringValue = msg + addActivity(msg) + channelTree = []; channelOutlineView.reloadData() + displayedUsers = []; userTableView.reloadData() + users.removeAll(); talkingUsers.removeAll() + currentChannelId = 0; micStreamId = 0; screenStreamId = 0 + composeField.isEnabled = false; sendButton.isEnabled = false + micToggleButton.isEnabled = false; screenAudioButton.isEnabled = false + } + + // MARK: - Admin menu + + private func buildAdminMenu() { + guard ownPermissions.canAdminAccounts || ownPermissions.isAdmin else { return } + let adminMenu = NSMenu(title: "Admin") + let accountsItem = NSMenuItem(title: "Server accounts…", action: #selector(openAccounts), keyEquivalent: "") + accountsItem.target = self + adminMenu.addItem(accountsItem) + let topItem = NSMenuItem(title: "Admin", action: nil, keyEquivalent: "") + topItem.submenu = adminMenu + adminMenuItem = topItem + NSApp.mainMenu?.addItem(topItem) + } + + @objc private func openAccounts() { + presentSheet(AccountsSheet(client: client)) + } + + // MARK: - Channel actions + + @objc private func channelDoubleClicked() { + guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return } + joinChannelRequest(node.channel.id) + } + + private func joinChannelRequest(_ channelId: UInt32) { + guard channelId != currentChannelId else { return } + let channel = channels.first(where: { $0.id == channelId }) + if channel?.passwordProtected == true { + let sheet = PasswordPromptSheet(prompt: "Password for channel \"\(channel!.name)\":") + sheet.onComplete = { [weak self] pw in + self?.client.joinChannel(channelId, password: pw) + } + presentSheet(sheet) + } else { + client.joinChannel(channelId, password: nil) + } + } + + // MARK: - User actions + + @objc private func userDoubleClicked() { + openUserTuning(row: userTableView.clickedRow) + } + + private func openUserTuning(row: Int) { + guard row >= 0, row < displayedUsers.count else { return } + let user = displayedUsers[row] + presentSheet(PerUserTuningSheet(client: client, userId: user.id, nickname: user.nickname)) + } + + // MARK: - Voice controls + + @objc private func micToggleClicked() { + if micStreamId == 0 { + let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone")) + if result == .ok { + micStreamId = streamId + if let devId = selectedDeviceId(), !isDefaultDevice(devId) { + client.setInputDevice(streamId: streamId, deviceId: devId) + } + client.setInputMode(currentInputMode()) + if inputModeControl.selectedSegment == 0 { + client.setVadThreshold(vadThresholdFromSlider()) + } + micToggleButton.title = "Leave Voice" + micToggleButton.setAccessibilityLabel("Leave Voice — stop sending microphone audio") + muteCheckbox.isEnabled = true; deafenCheckbox.isEnabled = true + addActivity("Joined voice — microphone active") + NSAccessibility.post(element: activityTableView, notification: .announcementRequested, + userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium]) + } else { + addActivity("Failed to start microphone: \(result)") + } + } else { + client.setPushToTalk(false) + client.stopStream(micStreamId) + micStreamId = 0 + levelMeter.doubleValue = 0 + micToggleButton.title = "Join Voice" + micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio") + muteCheckbox.isEnabled = false; deafenCheckbox.isEnabled = false + addActivity("Left voice") + } + } + + @objc private func screenAudioClicked() { + if screenStreamId == 0 { + let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio")) + if result == .ok { + screenStreamId = streamId + screenAudioButton.title = "Stop Screen Audio" + addActivity("Started sharing screen audio") + } else { + addActivity("Failed to start screen audio: \(result)") + } + } else { + client.stopStream(screenStreamId) + screenStreamId = 0 + screenAudioButton.title = "Share Screen Audio" + addActivity("Stopped sharing screen audio") + } + } + + @objc private func muteChanged() { + client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on) + } + + @objc private func deafenChanged() { + client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on) + } + + @objc private func inputModeChanged() { + let seg = inputModeControl.selectedSegment + vadLabel.isHidden = seg != 0; vadSlider.isHidden = seg != 0 + pttKeyLabel.isHidden = seg != 1; changePttButton.isHidden = seg != 1 + if micStreamId != 0 { + client.setInputMode(currentInputMode()) + if seg == 0 { client.setVadThreshold(vadThresholdFromSlider()) } + if seg == 1 { client.setPushToTalk(false) } + } + } + + @objc private func vadSliderChanged() { + if micStreamId != 0 && inputModeControl.selectedSegment == 0 { + client.setVadThreshold(vadThresholdFromSlider()) + } + } + + @objc private func changePttClicked() { + let sheet = PttKeyCaptureSheet(currentKeyCode: pttKeyCode) + sheet.onComplete = { [weak self] keyCode in + guard let self, let keyCode else { return } + self.pttKeyCode = keyCode + self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))" + } + presentSheet(sheet) + } + + @objc private func refreshDevicesClicked() { loadInputDevices() } + + private func loadInputDevices() { + let devices = client.listDevices(.input) + devicePicker.removeAllItems() + for d in devices { + let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "") + item.representedObject = d.id + devicePicker.menu?.addItem(item) + } + if let def = devices.first(where: { $0.isDefault }) { + devicePicker.select(devicePicker.item(withTitle: def.name)) + } else if devicePicker.numberOfItems > 0 { + devicePicker.selectItem(at: 0) + } + } + + private func selectedDeviceId() -> String? { devicePicker.selectedItem?.representedObject as? String } + private func isDefaultDevice(_ id: String) -> Bool { + client.listDevices(.input).first(where: { $0.id == id })?.isDefault == true + } + + private func currentInputMode() -> VoiceCatInputMode { + switch inputModeControl.selectedSegment { + case 1: return .pushToTalk + case 2: return .alwaysOn + default: return .voiceActivation + } + } + + private func vadThresholdFromSlider() -> Float { + 0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0) + } + + // MARK: - Text send + + @objc private func sendClicked() { + let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces) + guard !msg.isEmpty else { return } + if let targetId = scopePicker.selectedItem?.representedObject as? UInt32 { + client.sendText(scope: .private, targetId: targetId, text: msg) + } else { + guard currentChannelId != 0 else { return } + client.sendText(scope: .channel, targetId: currentChannelId, text: msg) + } + composeField.stringValue = "" + } + + // MARK: - M5: Moderation helpers + + private func moveUser(_ user: User) { + let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId) + sheet.onComplete = { [weak self] channelId in + guard let channelId else { return } + self?.client.moveUser(user.id, toChannel: channelId) + } + presentSheet(sheet) + } + + private func kickUser(_ user: User) { + let sheet = InputSheet(title: "Kick user", prompt: "Reason:", defaultValue: "Kicked by admin") + sheet.onComplete = { [weak self] reason in + self?.client.kickUser(user.id, reason: reason) + } + presentSheet(sheet) + } + + private func banUser(_ user: User) { + let sheet = BanUserSheet(nickname: user.nickname) + sheet.onComplete = { [weak self] (reason, expiresMs) in + self?.client.banUser(user.id, reason: reason, expiresUnixMs: expiresMs) + } + presentSheet(sheet) + } + + private func setPermissions(_ user: User) { + let sheet = PermissionsSheet(nickname: user.nickname, current: Permissions( + canCreateTempChannel: false, canKick: false, canBan: false, + canMoveUsers: false, canAdminAccounts: false, isAdmin: false)) + sheet.onComplete = { [weak self] perms in + guard let perms else { return } + self?.client.setPermission(user.id, perms: perms) + } + presentSheet(sheet) + } + + private func toggleServerMute(_ user: User) { + client.setServerMute(user.id, muted: !user.serverMuted, deafened: user.serverDeafened) + } + + private func toggleServerDeafen(_ user: User) { + client.setServerMute(user.id, muted: user.serverMuted, deafened: !user.serverDeafened) + } + + // MARK: - Helpers + + private func nickname(for userId: UInt32) -> String { + if userId == selfUserId { return nickname } + return users[userId]?.nickname ?? "User#\(userId)" + } + + private func presentSheet(_ vc: NSViewController) { + if let cvc = window?.contentViewController { + cvc.presentAsSheet(vc) + } else { + let cvc = NSViewController() + cvc.view = window!.contentView! + window?.contentViewController = cvc + cvc.presentAsSheet(vc) + } + } + + private func selectedUserInTable() -> User? { + let row = userTableView.selectedRow + guard row >= 0, row < displayedUsers.count else { return nil } + return displayedUsers[row] + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + if let mon = pttMonitor { NSEvent.removeMonitor(mon) } + if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) } + client.setPushToTalk(false) + if screenStreamId != 0 { client.stopStream(screenStreamId) } + if micStreamId != 0 { client.stopStream(micStreamId) } + client.onLevel = nil + client.onEvent = nil + client.disconnect() + } +} + +// MARK: - NSOutlineView DataSource / Delegate + +extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate { + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if item == nil { return channelTree.count } + return (item as! ChannelNode).children.count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { return channelTree[index] } + return (item as! ChannelNode).children[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + !(item as! ChannelNode).children.isEmpty + } + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + let node = item as! ChannelNode + let ch = node.channel + var label = ch.name + if ch.passwordProtected { label += " [pw]" } + if ch.id == currentChannelId { label += " ►" } + let id = NSUserInterfaceItemIdentifier("channelCell") + let cell = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView + ?? makeTextCellView(identifier: id) + cell.textField?.stringValue = label + cell.setAccessibilityLabel(label) + return cell + } + + func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { true } +} + +// MARK: - NSTableView DataSource / Delegate + +extension MainWindowController: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { + tableView === userTableView ? displayedUsers.count : activityLog.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let id = NSUserInterfaceItemIdentifier("cell") + let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView + ?? makeTextCellView(identifier: id) + if tableView === userTableView { + let user = displayedUsers[row] + var label = user.nickname + if user.id == selfUserId { label += " (you)" } + if talkingUsers.contains(user.id) { label += " (talking)" } + if user.selfMicMuted || user.serverMuted { label += " (muted)" } + if user.selfDeafened || user.serverDeafened { label += " (deafened)" } + cell.textField?.stringValue = label + cell.setAccessibilityLabel(label) + } else { + cell.textField?.stringValue = activityLog[row] + } + return cell + } + + private func makeTextCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = identifier + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(tf); cell.textField = tf + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} + +// MARK: - NSMenuDelegate (context menus) + +extension MainWindowController: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + if menu === channelOutlineView.menu { + buildChannelContextMenu(menu) + } else if menu === userTableView.menu { + buildUserContextMenu(menu) + } + } + + private func buildChannelContextMenu(_ menu: NSMenu) { + let row = channelOutlineView.clickedRow + if row >= 0, let node = channelOutlineView.item(atRow: row) as? ChannelNode { + menu.addItem(withTitle: "Join", action: #selector(channelContextJoin), keyEquivalent: "").target = self + menu.addItem(.separator()) + if ownPermissions.isAdmin { + let edit = menu.addItem(withTitle: "Edit channel…", action: #selector(channelContextEdit), keyEquivalent: "") + edit.target = self; edit.representedObject = node + let del = menu.addItem(withTitle: "Delete channel…", action: #selector(channelContextDelete), keyEquivalent: "") + del.target = self; del.representedObject = node + menu.addItem(.separator()) + } + } + if ownPermissions.canCreateTempChannel || ownPermissions.isAdmin { + menu.addItem(withTitle: "Create channel…", action: #selector(channelContextCreate), keyEquivalent: "").target = self + } + } + + private func buildUserContextMenu(_ menu: NSMenu) { + let row = userTableView.clickedRow + guard row >= 0, row < displayedUsers.count else { return } + let user = displayedUsers[row] + let isSelf = user.id == selfUserId + + menu.addItem(withTitle: "Adjust volume and noise settings…", + action: #selector(userContextTune), keyEquivalent: "").target = self + + if !isSelf { + menu.addItem(.separator()) + if ownPermissions.canMoveUsers || ownPermissions.isAdmin { + menu.addItem(withTitle: "Move to channel…", action: #selector(userContextMove), keyEquivalent: "").target = self + } + if ownPermissions.canKick || ownPermissions.isAdmin { + menu.addItem(withTitle: "Kick…", action: #selector(userContextKick), keyEquivalent: "").target = self + } + if ownPermissions.canBan || ownPermissions.isAdmin { + menu.addItem(withTitle: "Ban…", action: #selector(userContextBan), keyEquivalent: "").target = self + } + if ownPermissions.isAdmin { + menu.addItem(.separator()) + menu.addItem(withTitle: user.serverMuted ? "Server unmute" : "Server mute", + action: #selector(userContextServerMute), keyEquivalent: "").target = self + menu.addItem(withTitle: user.serverDeafened ? "Server undeafen" : "Server deafen", + action: #selector(userContextServerDeafen), keyEquivalent: "").target = self + menu.addItem(withTitle: "Set permissions…", action: #selector(userContextPermissions), keyEquivalent: "").target = self + } + } + } + + @objc private func channelContextJoin() { + guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return } + joinChannelRequest(node.channel.id) + } + + @objc private func channelContextCreate() { + let sheet = ChannelEditSheet(channels: channels, editing: nil) + sheet.onComplete = { [weak self] info in + guard let info else { return } + self?.client.createChannel(info) + } + presentSheet(sheet) + } + + @objc private func channelContextEdit(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? ChannelNode else { return } + let ch = node.channel + let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name, + topic: ch.topic, passwordProtected: ch.passwordProtected, + password: nil, maxUsers: ch.maxUsers, + sortOrder: 0, audio: AudioConfig()) + let sheet = ChannelEditSheet(channels: channels, editing: info) + sheet.onComplete = { [weak self] edited in + guard let edited else { return } + self?.client.editChannel(edited) + } + presentSheet(sheet) + } + + @objc private func channelContextDelete(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? ChannelNode else { return } + let alert = NSAlert() + alert.messageText = "Delete channel?" + alert.informativeText = "Delete \"\(node.channel.name)\"? This cannot be undone." + alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + guard let window else { return } + alert.beginSheetModal(for: window) { [weak self] response in + if response == .alertFirstButtonReturn { + self?.client.deleteChannel(node.channel.id) + } + } + } + + @objc private func userContextTune() { openUserTuning(row: userTableView.clickedRow) } + @objc private func userContextMove() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + moveUser(u) + } + @objc private func userContextKick() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + kickUser(u) + } + @objc private func userContextBan() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + banUser(u) + } + @objc private func userContextServerMute() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + toggleServerMute(u) + } + @objc private func userContextServerDeafen() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + toggleServerDeafen(u) + } + @objc private func userContextPermissions() { + guard let u = selectedUserAt(userTableView.clickedRow) else { return } + setPermissions(u) + } + + private func selectedUserAt(_ row: Int) -> User? { + guard row >= 0, row < displayedUsers.count else { return nil } + return displayedUsers[row] + } +} + +// MARK: - PTT key name helper + +private func keyCodeName(_ keyCode: UInt16) -> String { + let map: [UInt16: String] = [ + 0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9", + 0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12", + 0x71: "F15", 0x72: "Help", 0x73: "Home", 0x74: "PgUp", 0x75: "Del", + 0x76: "F4", 0x77: "End", 0x78: "F2", 0x79: "PgDn", 0x7A: "F1", + ] + return map[keyCode] ?? "Key\(keyCode)" +} diff --git a/clients/apple/macOS/VoiceCatMac/main.swift b/clients/apple/macOS/VoiceCatMac/main.swift new file mode 100644 index 0000000..233f45f --- /dev/null +++ b/clients/apple/macOS/VoiceCatMac/main.swift @@ -0,0 +1,5 @@ +import AppKit + +let delegate = AppDelegate() +NSApplication.shared.delegate = delegate +NSApplication.shared.run()