feat(ios): ship iOS SwiftUI client (VoiceCatiOS)
Full SwiftUI app at clients/apple/iOS/VoiceCatiOS.xcodeproj:
- 24 Swift source files: AppState + SessionState (@Observable @MainActor),
AudioSessionManager (AVAudioSession owner + interruption/route handling),
ServerListStore/SavedServer (App Group container + Keychain sharing),
and 14 SwiftUI views covering the full feature set
- NavigationSplitView on iPad, TabView on iPhone (horizontalSizeClass)
- Channel tree via OutlineGroup, user list with context menu admin actions
- PTT via DragGesture(minimumDistance: 0) + @GestureState
- onEvent closures hop to MainActor via Task { @MainActor in ... }
- App Group: group.cat.voice.VoiceCat (shared with future ReplayKit extension)
C ABI: add vc_audio_suspend / vc_audio_resume (AudioEngine::suspend/resume)
called by AudioSessionManager on AVAudioSession interruption events.
XCFramework: add ios-arm64 and ios-arm64-simulator slices to build-xcframework.sh;
Package.swift gains .iOS(.v17) platform; CMakePresets.json adds apple-ios /
apple-ios-sim presets with arm64-ios / arm64-ios-simulator vcpkg triplets.
Verified: xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 BUILD SUCCEEDED.
This commit is contained in:
@@ -16,11 +16,12 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VoiceCatCore",
|
||||
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS is deferred; when the
|
||||
// iOS app lands, add `.iOS(.v17)` here and the apple-ios / apple-ios-sim slices to the
|
||||
// XCFramework (scripts/build-xcframework.sh --all).
|
||||
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 17 is the SwiftUI client
|
||||
// target (clients/apple/iOS/). Run `scripts/build-xcframework.sh --all` to produce all
|
||||
// three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
|
||||
|
||||
@@ -322,6 +322,20 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||||
}
|
||||
|
||||
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||
|
||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||
@discardableResult
|
||||
public func audioSuspend() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_suspend(handle))
|
||||
}
|
||||
|
||||
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||||
@discardableResult
|
||||
public func audioResume() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_resume(handle))
|
||||
}
|
||||
|
||||
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
||||
|
||||
@discardableResult
|
||||
|
||||
443
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
443
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,443 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001A /* AudioSessionManager.swift */; };
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001B /* ServerListStore.swift */; };
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001C /* SavedServer.swift */; };
|
||||
BBBB00000000000000000036 /* BroadcastCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001D /* BroadcastCredentials.swift */; };
|
||||
BBBB00000000000000000037 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001E /* ServerListView.swift */; };
|
||||
BBBB00000000000000000038 /* AddServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001F /* AddServerView.swift */; };
|
||||
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000020 /* ServerIdentityView.swift */; };
|
||||
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000021 /* PasswordPromptView.swift */; };
|
||||
BBBB0000000000000000003B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000022 /* MainView.swift */; };
|
||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
|
||||
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
||||
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
|
||||
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000026 /* ActivityLogView.swift */; };
|
||||
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
|
||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
|
||||
BBBB00000000000000000043 /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002A /* BanUserView.swift */; };
|
||||
BBBB00000000000000000044 /* MoveUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002B /* MoveUserView.swift */; };
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001D /* BroadcastCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastCredentials.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
BBBB00000000000000000011 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
BBBB00000000000000000002 /* mainGroup */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||
BBBB00000000000000000007 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000015 /* Info.plist */,
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||
BBBB00000000000000000018 /* AppState.swift */,
|
||||
BBBB00000000000000000019 /* SessionState.swift */,
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||
BBBB0000000000000000001D /* BroadcastCredentials.swift */,
|
||||
BBBB00000000000000000006 /* Views */,
|
||||
);
|
||||
path = VoiceCatiOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000006 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB0000000000000000001E /* ServerListView.swift */,
|
||||
BBBB0000000000000000001F /* AddServerView.swift */,
|
||||
BBBB00000000000000000020 /* ServerIdentityView.swift */,
|
||||
BBBB00000000000000000021 /* PasswordPromptView.swift */,
|
||||
BBBB00000000000000000022 /* MainView.swift */,
|
||||
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||
BBBB00000000000000000024 /* UserListView.swift */,
|
||||
BBBB00000000000000000025 /* ChatView.swift */,
|
||||
BBBB00000000000000000026 /* ActivityLogView.swift */,
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||
BBBB0000000000000000002A /* BanUserView.swift */,
|
||||
BBBB0000000000000000002B /* MoveUserView.swift */,
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */,
|
||||
BBBB0000000000000000002D /* AccountsView.swift */,
|
||||
BBBB0000000000000000002E /* SettingsView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000007 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
BBBB00000000000000000008 /* VoiceCatiOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
|
||||
buildPhases = (
|
||||
BBBB0000000000000000000F /* Sources */,
|
||||
BBBB00000000000000000010 /* Resources */,
|
||||
BBBB00000000000000000011 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = VoiceCatiOS;
|
||||
packageProductDependencies = (
|
||||
BBBB0000000000000000004A /* VoiceCatCore */,
|
||||
);
|
||||
productName = VoiceCatiOS;
|
||||
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
BBBB00000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
};
|
||||
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = BBBB00000000000000000002 /* mainGroup */;
|
||||
packageReferences = (
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||
);
|
||||
productRefGroup = BBBB00000000000000000007 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
BBBB00000000000000000008 /* VoiceCatiOS */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
BBBB00000000000000000010 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
BBBB0000000000000000000F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||
BBBB00000000000000000036 /* BroadcastCredentials.swift in Sources */,
|
||||
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
|
||||
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
|
||||
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
|
||||
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
|
||||
BBBB0000000000000000003B /* MainView.swift in Sources */,
|
||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||
BBBB0000000000000000003E /* ChatView.swift in Sources */,
|
||||
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */,
|
||||
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
|
||||
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
|
||||
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
BBBB0000000000000000000B /* 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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BBBB0000000000000000000C /* 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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BBBB0000000000000000000D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BBBB0000000000000000000E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBB0000000000000000000B /* Debug */,
|
||||
BBBB0000000000000000000C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBB0000000000000000000D /* Debug */,
|
||||
BBBB0000000000000000000E /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "../";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
BBBB0000000000000000004A /* VoiceCatCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */;
|
||||
productName = VoiceCatCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
};
|
||||
rootObject = BBBB00000000000000000001 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
174
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
174
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import VoiceCatCore
|
||||
|
||||
struct PendingIdentity: Identifiable {
|
||||
let id = UUID()
|
||||
let displayText: String
|
||||
let tofuStatus: VoiceCatTofuStatus
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
var servers: [SavedServer] = ServerListStore.shared.load()
|
||||
var session: SessionState?
|
||||
|
||||
// Connect-flow state
|
||||
var isConnecting = false
|
||||
var connectStatus = ""
|
||||
var showAddServer = false
|
||||
var editingServer: SavedServer?
|
||||
var showPasswordPrompt = false
|
||||
var pendingIdentity: PendingIdentity?
|
||||
|
||||
private var connectingClient: VoiceCatClient?
|
||||
private(set) var connectingServer: SavedServer?
|
||||
private var identityHandled = false
|
||||
|
||||
// MARK: - Server list management
|
||||
|
||||
func addServer(_ server: SavedServer, password: String?) {
|
||||
if let pw = password, !pw.isEmpty {
|
||||
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||
}
|
||||
servers.append(server)
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
func updateServer(_ server: SavedServer, password: String?) {
|
||||
if let pw = password, !pw.isEmpty {
|
||||
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||
}
|
||||
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
||||
servers[idx] = server
|
||||
}
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
func removeServer(_ server: SavedServer) {
|
||||
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
||||
servers.removeAll(where: { $0.id == server.id })
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
// MARK: - Connect flow
|
||||
|
||||
func connectTo(_ server: SavedServer) {
|
||||
guard !isConnecting else { return }
|
||||
isConnecting = true
|
||||
connectStatus = "Connecting…"
|
||||
connectingServer = server
|
||||
identityHandled = false
|
||||
|
||||
let config = VoiceCatConfig(
|
||||
clientName: "VoiceCat-iOS",
|
||||
clientVersion: "0.0.1",
|
||||
logLevel: .info,
|
||||
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
||||
let client = VoiceCatClient(config: config)
|
||||
connectingClient = client
|
||||
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
||||
}
|
||||
client.connect(host: server.host, port: server.port)
|
||||
|
||||
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
||||
switch server.authMode {
|
||||
case .guest:
|
||||
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
|
||||
client.authenticateGuest(nick)
|
||||
case .password:
|
||||
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
||||
if let pw = savedPw, !pw.isEmpty {
|
||||
client.authenticateUser(server.savedUsername, password: pw)
|
||||
} else {
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
session?.client.disconnect()
|
||||
session = nil
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Auth actions (called from prompt sheets)
|
||||
|
||||
func authenticateUser(username: String, password: String) {
|
||||
connectingClient?.authenticateUser(username, password: password)
|
||||
showPasswordPrompt = false
|
||||
}
|
||||
|
||||
func confirmServerIdentity(accept: Bool) {
|
||||
connectingClient?.confirmServerIdentity(accept: accept)
|
||||
pendingIdentity = nil
|
||||
if !accept { cancelConnect() }
|
||||
}
|
||||
|
||||
func cancelConnect() {
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Connect event handler
|
||||
|
||||
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
||||
switch ev.type {
|
||||
case .connectionState:
|
||||
switch ev.connectionState {
|
||||
case .connecting: connectStatus = "Connecting…"
|
||||
case .tlsHandshake: connectStatus = "TLS handshake…"
|
||||
case .authenticating: connectStatus = "Authenticating…"
|
||||
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
||||
case .connected: connectStatus = "Connected"
|
||||
default: break
|
||||
}
|
||||
case .serverIdentity:
|
||||
guard !identityHandled else { break }
|
||||
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
||||
if tofuStatus == .matched {
|
||||
connectingClient?.confirmServerIdentity(accept: true)
|
||||
} else {
|
||||
identityHandled = true
|
||||
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
||||
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
||||
}
|
||||
case .authResult:
|
||||
if ev.result == .ok {
|
||||
guard let client = connectingClient else { break }
|
||||
let perms = client.getPermissions()
|
||||
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
||||
ServerListStore.shared.writeBroadcastCredentials(server: server, nickname: nil)
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
self.session = newSession
|
||||
} else {
|
||||
connectStatus = "Auth failed: \(ev.result.description)"
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
case .disconnected:
|
||||
if session == nil { cancelConnect() }
|
||||
else { session = nil; isConnecting = false }
|
||||
case .error:
|
||||
connectStatus = ev.text ?? "Unknown error"
|
||||
if session == nil { isConnecting = false }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
64
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
64
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import AVFoundation
|
||||
import VoiceCatCore
|
||||
|
||||
@MainActor
|
||||
final class AudioSessionManager {
|
||||
static let shared = AudioSessionManager()
|
||||
|
||||
weak var client: VoiceCatClient?
|
||||
|
||||
func configure() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playAndRecord, mode: .voiceChat,
|
||||
options: [.allowBluetooth, .allowBluetoothA2DP,
|
||||
.defaultToSpeaker, .mixWithOthers])
|
||||
} catch {
|
||||
print("[AudioSession] setCategory failed: \(error)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleInterruption),
|
||||
name: AVAudioSession.interruptionNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleRouteChange),
|
||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
func activateForStreaming() throws {
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
}
|
||||
|
||||
func deactivateAfterStreaming() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false,
|
||||
options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
else { return }
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
client?.audioSuspend()
|
||||
case .ended:
|
||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
client?.audioResume()
|
||||
}
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRouteChange(_ notification: Notification) {
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
||||
}
|
||||
21
clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift
Normal file
21
clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
struct BroadcastCredentials: Codable {
|
||||
var host: String
|
||||
var port: Int
|
||||
var authMode: String
|
||||
var username: String
|
||||
var tofuPinsPath: String
|
||||
|
||||
static func loadFromAppGroup() -> BroadcastCredentials? {
|
||||
let groupId = "group.cat.voice.VoiceCat"
|
||||
guard let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: groupId)
|
||||
else { return nil }
|
||||
let url = container
|
||||
.appendingPathComponent("voicecat", isDirectory: true)
|
||||
.appendingPathComponent("broadcast_credentials.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? JSONDecoder().decode(BroadcastCredentials.self, from: data)
|
||||
}
|
||||
}
|
||||
47
clients/apple/iOS/VoiceCatiOS/Info.plist
Normal file
47
clients/apple/iOS/VoiceCatiOS/Info.plist
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceCat</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
24
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
24
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
struct SavedServer: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var host: String
|
||||
var port: UInt16
|
||||
var authMode: AuthMode
|
||||
var savedUsername: String
|
||||
var keychainTag: String
|
||||
|
||||
enum AuthMode: String, Codable { case guest, password }
|
||||
|
||||
init(id: UUID = UUID(), host: String, port: UInt16,
|
||||
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") {
|
||||
self.id = id
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.authMode = authMode
|
||||
self.savedUsername = savedUsername
|
||||
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
|
||||
}
|
||||
|
||||
var displayString: String { "\(host):\(port)" }
|
||||
}
|
||||
106
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
106
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
final class ServerListStore {
|
||||
static let shared = ServerListStore()
|
||||
|
||||
private let groupId = "group.cat.voice.VoiceCat"
|
||||
private let keychainService = "cat.voice.VoiceCatiOS"
|
||||
|
||||
// MARK: - App Group Container
|
||||
|
||||
var appGroupContainer: URL {
|
||||
guard let url = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: groupId)
|
||||
else {
|
||||
// Fall back to app-only support dir if App Groups are unavailable (e.g. simulator
|
||||
// without entitlements). TOFU pins won't be shared with the broadcast extension,
|
||||
// but connect + auth still work.
|
||||
return FileManager.default.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first!
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private var voicecatDir: URL {
|
||||
let dir = appGroupContainer.appendingPathComponent("voicecat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir,
|
||||
withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
var tofuStorePath: String { voicecatDir.appendingPathComponent("tofu_pins.txt").path }
|
||||
|
||||
private var serversURL: URL { voicecatDir.appendingPathComponent("servers.json") }
|
||||
|
||||
// MARK: - Server list persistence
|
||||
|
||||
func load() -> [SavedServer] {
|
||||
guard let data = try? Data(contentsOf: serversURL),
|
||||
let list = try? JSONDecoder().decode([SavedServer].self, from: data)
|
||||
else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
func save(_ servers: [SavedServer]) {
|
||||
guard let data = try? JSONEncoder().encode(servers) else { return }
|
||||
try? data.write(to: serversURL, options: .atomic)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
func savePassword(_ password: String, tag: String) {
|
||||
let data = Data(password.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var add = query
|
||||
add[kSecValueData] = data
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func loadPassword(tag: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data
|
||||
else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func deletePassword(tag: String) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Broadcast credentials (shared with ReplayKit extension)
|
||||
|
||||
func writeBroadcastCredentials(server: SavedServer, nickname: String?) {
|
||||
let creds = BroadcastCredentials(
|
||||
host: server.host,
|
||||
port: Int(server.port),
|
||||
authMode: server.authMode.rawValue,
|
||||
username: server.authMode == .password ? server.savedUsername : (nickname ?? ""),
|
||||
tofuPinsPath: tofuStorePath
|
||||
)
|
||||
guard let data = try? JSONEncoder().encode(creds) else { return }
|
||||
let url = voicecatDir.appendingPathComponent("broadcast_credentials.json")
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
252
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
252
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import VoiceCatCore
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let senderName: String
|
||||
let text: String
|
||||
let scope: VoiceCatTextScope
|
||||
}
|
||||
|
||||
struct ActivityEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct VoiceState {
|
||||
var micActive = false
|
||||
var selfMuted = false
|
||||
var selfDeafened = false
|
||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||
var vadThreshold: Float = 0.025
|
||||
var level: Float = 0.0
|
||||
var currentDeviceId: String?
|
||||
var localStreamId: UInt32 = 0
|
||||
}
|
||||
|
||||
// MARK: - SessionState
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SessionState {
|
||||
let client: VoiceCatClient
|
||||
let selfUserId: UInt32
|
||||
|
||||
var channels: [Channel] = []
|
||||
var users: [User] = []
|
||||
var currentChannelId: UInt32 = 0
|
||||
var messages: [ChatMessage] = []
|
||||
var activityLog: [ActivityEntry] = []
|
||||
var voiceState = VoiceState()
|
||||
var permissions: Permissions
|
||||
var accounts: [Account] = []
|
||||
var devices: [Device] = []
|
||||
|
||||
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
|
||||
self.client = client
|
||||
self.selfUserId = selfUserId
|
||||
self.permissions = permissions
|
||||
AudioSessionManager.shared.client = client
|
||||
refreshChannels()
|
||||
refreshUsers()
|
||||
refreshDevices()
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
||||
}
|
||||
client.onLevel = { [weak self] _, rms in
|
||||
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
MainActor.assumeIsolated {
|
||||
AudioSessionManager.shared.client = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event dispatch
|
||||
|
||||
func handleEvent(_ ev: VoiceCatEvent) {
|
||||
switch ev.type {
|
||||
case .channelList:
|
||||
refreshChannels()
|
||||
case .userJoined, .userLeft, .userUpdated:
|
||||
refreshUsers()
|
||||
case .textMessage:
|
||||
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
||||
messages.append(ChatMessage(
|
||||
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||
senderName: sender,
|
||||
text: ev.text ?? "",
|
||||
scope: ev.textScope))
|
||||
case .talkState:
|
||||
let talking = ev.u32a != 0
|
||||
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||
case .streamStarted:
|
||||
addActivity("Stream started (user \(ev.userId))")
|
||||
case .streamStopped:
|
||||
addActivity("Stream stopped (user \(ev.userId))")
|
||||
case .joinResult:
|
||||
if ev.result == .ok {
|
||||
currentChannelId = ev.channelId
|
||||
addActivity("Joined channel")
|
||||
refreshUsers()
|
||||
} else {
|
||||
addActivity("Join failed: \(ev.result.description)")
|
||||
}
|
||||
case .error:
|
||||
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||
case .genericResult:
|
||||
if ev.result != .ok {
|
||||
addActivity("Operation failed: \(ev.result.description)")
|
||||
}
|
||||
case .accountList:
|
||||
accounts = client.listAccounts()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func addActivity(_ text: String) {
|
||||
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
|
||||
if activityLog.count > 500 { activityLog.removeFirst() }
|
||||
}
|
||||
|
||||
// MARK: - Data refresh
|
||||
|
||||
func refreshChannels() { channels = client.listChannels() }
|
||||
func refreshUsers() { users = client.listUsers() }
|
||||
func refreshDevices() { devices = client.listDevices(.input) }
|
||||
|
||||
// MARK: - Voice controls
|
||||
|
||||
func joinChannel(_ channelId: UInt32, password: String = "") {
|
||||
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
|
||||
}
|
||||
|
||||
func leaveChannel() {
|
||||
client.leaveChannel()
|
||||
currentChannelId = 0
|
||||
}
|
||||
|
||||
func startMicStream() {
|
||||
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
self.doStartMicStream()
|
||||
} else {
|
||||
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func doStartMicStream() {
|
||||
do {
|
||||
try AudioSessionManager.shared.activateForStreaming()
|
||||
} catch {
|
||||
addActivity("AVAudioSession activate failed: \(error)")
|
||||
return
|
||||
}
|
||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic")
|
||||
let (result, streamId) = client.startStream(desc)
|
||||
if result == .ok {
|
||||
voiceState.micActive = true
|
||||
voiceState.localStreamId = streamId
|
||||
} else {
|
||||
addActivity("Failed to start mic: \(result.description)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopMicStream() {
|
||||
if voiceState.localStreamId != 0 {
|
||||
client.stopStream(voiceState.localStreamId)
|
||||
voiceState.localStreamId = 0
|
||||
}
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
AudioSessionManager.shared.deactivateAfterStreaming()
|
||||
}
|
||||
|
||||
func setMute(_ muted: Bool, deafened: Bool) {
|
||||
client.setSelfMute(micMuted: muted, deafened: deafened)
|
||||
voiceState.selfMuted = muted
|
||||
voiceState.selfDeafened = deafened
|
||||
}
|
||||
|
||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||
client.setInputMode(mode)
|
||||
voiceState.inputMode = mode
|
||||
}
|
||||
|
||||
func setVadThreshold(_ threshold: Float) {
|
||||
client.setVadThreshold(threshold)
|
||||
voiceState.vadThreshold = threshold
|
||||
}
|
||||
|
||||
func setPushToTalk(_ active: Bool) {
|
||||
client.setPushToTalk(active)
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) {
|
||||
client.sendText(scope: scope, targetId: targetId, text: text)
|
||||
}
|
||||
|
||||
// MARK: - Admin
|
||||
|
||||
func kickUser(_ userId: UInt32, reason: String) {
|
||||
client.kickUser(userId, reason: reason.isEmpty ? nil : reason)
|
||||
}
|
||||
|
||||
func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) {
|
||||
client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs)
|
||||
}
|
||||
|
||||
func moveUser(_ userId: UInt32, toChannel channelId: UInt32) {
|
||||
client.moveUser(userId, toChannel: channelId)
|
||||
}
|
||||
|
||||
func setPermissions(_ userId: UInt32, perms: Permissions) {
|
||||
client.setPermission(userId, perms: perms)
|
||||
}
|
||||
|
||||
func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) {
|
||||
client.setServerMute(userId, muted: muted, deafened: deafened)
|
||||
}
|
||||
|
||||
func createChannel(name: String, topic: String) {
|
||||
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
|
||||
passwordProtected: false, password: nil,
|
||||
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
|
||||
client.createChannel(info)
|
||||
}
|
||||
|
||||
func deleteChannel(_ channelId: UInt32) {
|
||||
client.deleteChannel(channelId)
|
||||
}
|
||||
|
||||
func fetchAccountList() {
|
||||
client.requestAccountList()
|
||||
}
|
||||
|
||||
func createAccount(username: String, password: String) {
|
||||
client.createAccount(username, password: password)
|
||||
}
|
||||
|
||||
func deleteAccount(username: String) {
|
||||
client.deleteAccount(username)
|
||||
}
|
||||
|
||||
func resetPassword(username: String, newPassword: String) {
|
||||
client.resetPassword(username, newPassword: newPassword)
|
||||
}
|
||||
}
|
||||
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct AccountsView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateAccount = false
|
||||
@State private var newUsername = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var accountToDelete: Account?
|
||||
@State private var showResetPassword = false
|
||||
@State private var resetForAccount: Account?
|
||||
@State private var resetPassword = ""
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(session.accounts, id: \.username) { account in
|
||||
AccountRowView(account: account)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
accountToDelete = account
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
resetForAccount = account
|
||||
showResetPassword = true
|
||||
} label: {
|
||||
Label("Reset PW", systemImage: "key")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateAccount = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create account")
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.onAppear {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.confirmationDialog("Delete account?", isPresented: Binding(
|
||||
get: { accountToDelete != nil },
|
||||
set: { if !$0 { accountToDelete = nil } }
|
||||
)) {
|
||||
if let a = accountToDelete {
|
||||
Button("Delete \(a.username)", role: .destructive) {
|
||||
session.deleteAccount(username: a.username)
|
||||
accountToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { accountToDelete = nil }
|
||||
}
|
||||
.sheet(isPresented: $showCreateAccount) {
|
||||
CreateAccountSheet(session: session)
|
||||
}
|
||||
.alert("Reset Password", isPresented: $showResetPassword) {
|
||||
SecureField("New password", text: $resetPassword)
|
||||
.accessibilityLabel("New password for account")
|
||||
Button("Reset") {
|
||||
if let a = resetForAccount, !resetPassword.isEmpty {
|
||||
session.resetPassword(username: a.username, newPassword: resetPassword)
|
||||
}
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Enter a new password for \(resetForAccount?.username ?? "").")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountRowView: View {
|
||||
let account: Account
|
||||
|
||||
private var joinedDate: String {
|
||||
let date = Date(timeIntervalSince1970: Double(account.createdAtUnixMs) / 1000)
|
||||
return date.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(account.username)
|
||||
.fontWeight(.medium)
|
||||
if account.isAdmin {
|
||||
Text("admin")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Text("Created \(joinedDate)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(account.username)\(account.isAdmin ? ", administrator" : ""), created \(joinedDate)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreateAccountSheet: View {
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.newPassword)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
session.createAccount(username: username, password: password)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(username.isEmpty || password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift
Normal file
49
clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ActivityLogView: View {
|
||||
@Bindable var session: SessionState
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateStyle = .none
|
||||
fmt.timeStyle = .medium
|
||||
return fmt
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List(session.activityLog) { entry in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(Self.timeFormatter.string(from: entry.timestamp))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.frame(width: 64, alignment: .leading)
|
||||
Text(entry.text)
|
||||
.font(.caption)
|
||||
}
|
||||
.id(entry.id)
|
||||
.listRowSeparator(.hidden)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)")
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: session.activityLog.count) { _, _ in
|
||||
if let last = session.activityLog.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay {
|
||||
if session.activityLog.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Activity",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("Events will appear here as they happen.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let editing: SavedServer?
|
||||
|
||||
@State private var host = ""
|
||||
@State private var port = "7878"
|
||||
@State private var authMode = SavedServer.AuthMode.guest
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var savePassword = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("Hostname or IP", text: $host)
|
||||
.textContentType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Server hostname or IP address")
|
||||
TextField("Port", text: $port)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Port number")
|
||||
}
|
||||
|
||||
Section("Authentication") {
|
||||
Picker("Mode", selection: $authMode) {
|
||||
Text("Guest").tag(SavedServer.AuthMode.guest)
|
||||
Text("Account").tag(SavedServer.AuthMode.password)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityLabel("Authentication mode")
|
||||
|
||||
if authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password (optional)", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
|
||||
Toggle("Save password in Keychain", isOn: $savePassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| UInt16(port) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let s = editing {
|
||||
host = s.host
|
||||
port = "\(s.port)"
|
||||
authMode = s.authMode
|
||||
username = s.savedUsername
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||
|
||||
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||
|
||||
if var s = editing {
|
||||
s.host = trimmedHost
|
||||
s.port = portNum
|
||||
s.authMode = authMode
|
||||
s.savedUsername = authMode == .password ? username : ""
|
||||
appState.updateServer(s, password: pw)
|
||||
} else {
|
||||
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||
authMode: authMode,
|
||||
savedUsername: authMode == .password ? username : "")
|
||||
appState.addServer(s, password: pw)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct BanUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var reason = ""
|
||||
@State private var permanent = true
|
||||
@State private var duration: Double = 60 // minutes
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Ban \(user.nickname)") {
|
||||
TextField("Reason (optional)", text: $reason)
|
||||
.accessibilityLabel("Ban reason, optional")
|
||||
Toggle("Permanent", isOn: $permanent)
|
||||
.accessibilityLabel("Permanent ban")
|
||||
if !permanent {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Slider(value: $duration, in: 1...10080, step: 1)
|
||||
.accessibilityLabel("Ban duration in minutes")
|
||||
Text(formattedDuration)
|
||||
.monospacedDigit()
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Ban User")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Ban", role: .destructive) {
|
||||
let expiresMs: UInt64 = permanent ? 0
|
||||
: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(duration * 60 * 1000)
|
||||
session.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let mins = Int(duration)
|
||||
if mins < 60 { return "\(mins)m" }
|
||||
let hours = mins / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
}
|
||||
38
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
38
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelEditView: View {
|
||||
let channelId: UInt32?
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var topic = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Channel Info") {
|
||||
TextField("Name", text: $name)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityLabel("Channel name")
|
||||
TextField("Topic (optional)", text: $topic)
|
||||
.accessibilityLabel("Channel topic, optional")
|
||||
}
|
||||
}
|
||||
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
session.createChannel(name: name, topic: topic)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
131
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
|
||||
// for leaf channels so OutlineGroup doesn't render expand buttons.
|
||||
struct ChannelNode: Identifiable {
|
||||
let channel: Channel
|
||||
let children: [ChannelNode]?
|
||||
var id: UInt32 { channel.id }
|
||||
}
|
||||
|
||||
struct ChannelTreeView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var channelPassword = ""
|
||||
@State private var passwordChannelId: UInt32?
|
||||
|
||||
var body: some View {
|
||||
List(channelTree, children: \.children) { node in
|
||||
ChannelRowView(node: node, session: session)
|
||||
.onTapGesture {
|
||||
if node.channel.passwordProtected {
|
||||
passwordChannelId = node.channel.id
|
||||
} else {
|
||||
session.joinChannel(node.channel.id)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(node.channel.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateChannel = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create channel")
|
||||
}
|
||||
}
|
||||
if session.currentChannelId != 0 {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Leave", systemImage: "arrow.left.circle") {
|
||||
session.leaveChannel()
|
||||
}
|
||||
.accessibilityLabel("Leave current channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.alert("Channel Password", isPresented: Binding(
|
||||
get: { passwordChannelId != nil },
|
||||
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||
)) {
|
||||
SecureField("Password", text: $channelPassword)
|
||||
.accessibilityLabel("Channel password")
|
||||
Button("Join") {
|
||||
if let cid = passwordChannelId {
|
||||
session.joinChannel(cid, password: channelPassword)
|
||||
}
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var channelTree: [ChannelNode] {
|
||||
buildTree(parentId: 0, channels: session.channels)
|
||||
}
|
||||
|
||||
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
|
||||
channels
|
||||
.filter { $0.parentId == parentId }
|
||||
.map { ch in
|
||||
let kids = buildTree(parentId: ch.id, channels: channels)
|
||||
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
|
||||
}
|
||||
.sorted { $0.channel.name < $1.channel.name }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChannelRowView: View {
|
||||
let node: ChannelNode
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let ch = node.channel
|
||||
let isCurrent = session.currentChannelId == ch.id
|
||||
let usersHere = session.users.filter { $0.channelId == ch.id }
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ch.name)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
if !ch.topic.isEmpty {
|
||||
Text(ch.topic)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !usersHere.isEmpty {
|
||||
Text("\(usersHere.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(usersHere.count) users")
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||
}
|
||||
}
|
||||
94
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var composeText = ""
|
||||
@State private var scope: VoiceCatTextScope = .channel
|
||||
@State private var privateTargetId: UInt32 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Message list
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(session.messages) { msg in
|
||||
ChatBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: session.messages.count) { _, _ in
|
||||
if let last = session.messages.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Compose bar
|
||||
HStack(spacing: 8) {
|
||||
TextField("Message…", text: $composeText, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityLabel("Message text field")
|
||||
.onSubmit { sendMessage() }
|
||||
|
||||
Button {
|
||||
sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| session.currentChannelId == 0)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
session.sendText(text, scope: .channel)
|
||||
composeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
private var timeString: String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateStyle = .none
|
||||
fmt.timeStyle = .short
|
||||
return fmt.string(from: message.timestamp)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(message.senderName)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(message.text)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
|
||||
}
|
||||
}
|
||||
72
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
72
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
if let session = appState.session {
|
||||
if sizeClass == .regular {
|
||||
iPadMainView(session: session)
|
||||
} else {
|
||||
iPhoneMainView(session: session)
|
||||
}
|
||||
} else {
|
||||
ServerListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone layout: TabView
|
||||
|
||||
private struct iPhoneMainView: View {
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ChannelTreeView(session: session)
|
||||
.tabItem {
|
||||
Label("Channels", systemImage: "list.bullet.indent")
|
||||
}
|
||||
ChatView(session: session)
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "message")
|
||||
}
|
||||
ActivityLogView(session: session)
|
||||
.tabItem {
|
||||
Label("Activity", systemImage: "bell")
|
||||
}
|
||||
SettingsView(session: session)
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
VoiceControlsView(session: session)
|
||||
.padding(.bottom, 56) // above tab bar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad layout: NavigationSplitView
|
||||
|
||||
private struct iPadMainView: View {
|
||||
let session: SessionState
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
ChannelTreeView(session: session)
|
||||
.navigationTitle("Channels")
|
||||
} content: {
|
||||
UserListView(session: session)
|
||||
.navigationTitle("Users")
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
ChatView(session: session)
|
||||
VoiceControlsView(session: session)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct MoveUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedChannelId: UInt32 = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(session.channels) { channel in
|
||||
HStack {
|
||||
Text(channel.name)
|
||||
Spacer()
|
||||
if channel.id == selectedChannelId {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedChannelId = channel.id }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(channel.name)\(channel.id == selectedChannelId ? ", selected" : "")")
|
||||
.accessibilityAddTraits(channel.id == selectedChannelId ? .isSelected : [])
|
||||
}
|
||||
.navigationTitle("Move \(user.nickname)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Move") {
|
||||
session.moveUser(user.id, toChannel: selectedChannelId)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(selectedChannelId == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedChannelId = user.channelId
|
||||
}
|
||||
}
|
||||
}
|
||||
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PasswordPromptView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if let server = appState.connectingServer {
|
||||
Text("Connecting to \(server.displayString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if appState.connectingServer?.authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
}
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
|
||||
if !appState.connectStatus.isEmpty && appState.connectStatus.lowercased().contains("failed") {
|
||||
Section {
|
||||
Text(appState.connectStatus)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityLabel("Error: \(appState.connectStatus)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sign In")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
appState.cancelConnect()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Connect") {
|
||||
dismiss()
|
||||
let uname = appState.connectingServer?.savedUsername.isEmpty == false
|
||||
? appState.connectingServer!.savedUsername
|
||||
: username
|
||||
appState.authenticateUser(username: uname, password: password)
|
||||
}
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
username = appState.connectingServer?.savedUsername ?? ""
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
71
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
71
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct PerUserTuningView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var gain: Float = 1.0
|
||||
@State private var muted = false
|
||||
@State private var noiseReduction = false
|
||||
|
||||
private var streamsForUser: [StreamSummary] {
|
||||
session.client.listUserStreams(user.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Volume") {
|
||||
HStack {
|
||||
Text("Gain")
|
||||
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
|
||||
applyToAllStreams()
|
||||
}
|
||||
.accessibilityLabel("Volume gain for \(user.nickname)")
|
||||
Text(String(format: "%.0f%%", gain * 100))
|
||||
.monospacedDigit()
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
}
|
||||
Toggle("Mute", isOn: $muted)
|
||||
.onChange(of: muted) { _, _ in applyToAllStreams() }
|
||||
.accessibilityLabel("Mute \(user.nickname)")
|
||||
}
|
||||
|
||||
Section("Audio Processing") {
|
||||
Toggle("Noise Reduction", isOn: $noiseReduction)
|
||||
.onChange(of: noiseReduction) { _, _ in applyToAllStreams() }
|
||||
.accessibilityLabel("Noise reduction for \(user.nickname)")
|
||||
}
|
||||
}
|
||||
.navigationTitle(user.nickname)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Load from first stream if available
|
||||
let streams = streamsForUser
|
||||
if let first = streams.first {
|
||||
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
||||
if let s = state {
|
||||
gain = s.gain
|
||||
muted = s.muted
|
||||
noiseReduction = s.noiseReduction
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyToAllStreams() {
|
||||
for stream in streamsForUser {
|
||||
session.client.setRemoteStream(
|
||||
userId: user.id, streamId: stream.id,
|
||||
gain: gain, muted: muted, noiseReduction: noiseReduction)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct PermissionsView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var canCreateTempChannel = false
|
||||
@State private var canKick = false
|
||||
@State private var canBan = false
|
||||
@State private var canMoveUsers = false
|
||||
@State private var canAdminAccounts = false
|
||||
@State private var isAdmin = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Permissions for \(user.nickname)") {
|
||||
Toggle("Create Temp Channels", isOn: $canCreateTempChannel)
|
||||
.accessibilityLabel("Can create temporary channels")
|
||||
Toggle("Kick Users", isOn: $canKick)
|
||||
.accessibilityLabel("Can kick users")
|
||||
Toggle("Ban Users", isOn: $canBan)
|
||||
.accessibilityLabel("Can ban users")
|
||||
Toggle("Move Users", isOn: $canMoveUsers)
|
||||
.accessibilityLabel("Can move users between channels")
|
||||
Toggle("Manage Accounts", isOn: $canAdminAccounts)
|
||||
.accessibilityLabel("Can manage server accounts")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Administrator", isOn: $isAdmin)
|
||||
.foregroundStyle(isAdmin ? .orange : .primary)
|
||||
.accessibilityLabel("Full administrator access")
|
||||
} footer: {
|
||||
Text("Administrators bypass all permission checks.")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Permissions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let perms = Permissions(
|
||||
canCreateTempChannel: canCreateTempChannel,
|
||||
canKick: canKick,
|
||||
canBan: canBan,
|
||||
canMoveUsers: canMoveUsers,
|
||||
canAdminAccounts: canAdminAccounts,
|
||||
isAdmin: isAdmin)
|
||||
session.setPermissions(user.id, perms: perms)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ServerIdentityView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let identity: PendingIdentity
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if identity.tofuStatus == .mismatch {
|
||||
Label("Server Identity Mismatch", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityLabel("Warning: server identity mismatch")
|
||||
Text("The server's identity has changed since your last connection. 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.")
|
||||
.foregroundStyle(.primary)
|
||||
} else {
|
||||
Label("New Server Identity", systemImage: "lock.badge.questionmark")
|
||||
.font(.headline)
|
||||
.accessibilityLabel("New server identity")
|
||||
Text("This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting.")
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Server fingerprint")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(identity.displayText.isEmpty ? "(not available)" : identity.displayText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.accessibilityLabel("Server fingerprint: \(identity.displayText)")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Spacer(minLength: 24)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
dismiss()
|
||||
appState.confirmServerIdentity(accept: true)
|
||||
} label: {
|
||||
Text("Accept")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(identity.tofuStatus == .mismatch ? .orange : .blue)
|
||||
.accessibilityLabel("Accept server identity and continue")
|
||||
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
appState.confirmServerIdentity(accept: false)
|
||||
} label: {
|
||||
Text("Reject — Disconnect")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("Reject server identity and disconnect")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Server Identity")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServerListView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var serverToDelete: SavedServer?
|
||||
|
||||
var body: some View {
|
||||
@Bindable var state = appState
|
||||
NavigationStack {
|
||||
Group {
|
||||
if appState.servers.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Servers",
|
||||
systemImage: "server.rack",
|
||||
description: Text("Tap + to add a server.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(appState.servers) { server in
|
||||
Button {
|
||||
appState.connectTo(server)
|
||||
} label: {
|
||||
ServerRowView(server: server)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
serverToDelete = server
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
appState.editingServer = server
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Servers")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
appState.showAddServer = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add server")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $state.showAddServer) {
|
||||
AddServerView(editing: nil)
|
||||
}
|
||||
.sheet(item: $state.editingServer) { server in
|
||||
AddServerView(editing: server)
|
||||
}
|
||||
.sheet(item: $state.pendingIdentity) { identity in
|
||||
ServerIdentityView(identity: identity)
|
||||
}
|
||||
.sheet(isPresented: $state.showPasswordPrompt) {
|
||||
PasswordPromptView()
|
||||
}
|
||||
.overlay {
|
||||
if appState.isConnecting {
|
||||
ConnectingOverlay()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete server?", isPresented: Binding(
|
||||
get: { serverToDelete != nil },
|
||||
set: { if !$0 { serverToDelete = nil } }
|
||||
)) {
|
||||
if let s = serverToDelete {
|
||||
Button("Delete \(s.displayString)", role: .destructive) {
|
||||
appState.removeServer(s)
|
||||
serverToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { serverToDelete = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerRowView: View {
|
||||
let server: SavedServer
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.displayString)
|
||||
.font(.body)
|
||||
Text(server.authMode == .guest
|
||||
? "Guest"
|
||||
: "Account: \(server.savedUsername)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(server.displayString), \(server.authMode == .guest ? "guest" : "account \(server.savedUsername)")")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectingOverlay: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.3).ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text(appState.connectStatus)
|
||||
.foregroundStyle(.white)
|
||||
Button("Cancel") { appState.cancelConnect() }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.white)
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
94
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Bindable var session: SessionState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Voice input section
|
||||
Section("Voice Input") {
|
||||
Picker("Input Mode", selection: Binding(
|
||||
get: { session.voiceState.inputMode },
|
||||
set: { session.setInputMode($0) }
|
||||
)) {
|
||||
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
|
||||
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
|
||||
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
|
||||
}
|
||||
.accessibilityLabel("Voice input mode")
|
||||
|
||||
if session.voiceState.inputMode == .voiceActivation {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
|
||||
.font(.caption)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(session.voiceState.vadThreshold) },
|
||||
set: { session.setVadThreshold(Float($0)) }
|
||||
),
|
||||
in: 0.001...0.1, step: 0.001
|
||||
)
|
||||
.accessibilityLabel("Voice activation threshold")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio device section
|
||||
if !session.devices.isEmpty {
|
||||
Section("Input Device") {
|
||||
Picker("Microphone", selection: Binding(
|
||||
get: { session.voiceState.currentDeviceId ?? "" },
|
||||
set: { id in
|
||||
session.voiceState.currentDeviceId = id.isEmpty ? nil : id
|
||||
if session.voiceState.localStreamId != 0 {
|
||||
session.client.setInputDevice(
|
||||
streamId: session.voiceState.localStreamId,
|
||||
deviceId: id.isEmpty ? nil : id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text("Default").tag("")
|
||||
ForEach(session.devices) { dev in
|
||||
Text(dev.name).tag(dev.id)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Microphone selection")
|
||||
}
|
||||
}
|
||||
|
||||
// Admin section
|
||||
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
|
||||
Section("Administration") {
|
||||
NavigationLink("Manage Accounts") {
|
||||
AccountsView(session: session)
|
||||
}
|
||||
.accessibilityLabel("Manage server accounts")
|
||||
}
|
||||
}
|
||||
|
||||
// Server info
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
session.stopMicStream()
|
||||
appState.disconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "phone.down")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.accessibilityLabel("Disconnect from server")
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
Text(VoiceCatClient.versionString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
144
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
144
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct UserListView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var selectedUser: User?
|
||||
@State private var showPerUserTuning = false
|
||||
@State private var showBanUser = false
|
||||
@State private var showMoveUser = false
|
||||
@State private var showPermissions = false
|
||||
|
||||
var body: some View {
|
||||
let channelUsers = session.currentChannelId == 0
|
||||
? session.users
|
||||
: session.users.filter { $0.channelId == session.currentChannelId }
|
||||
|
||||
List(channelUsers) { user in
|
||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||
.contextMenu {
|
||||
userContextMenu(user: user)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if channelUsers.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Users",
|
||||
systemImage: "person.slash",
|
||||
description: Text(session.currentChannelId == 0 ? "Join a channel to see users." : "No one else here yet.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedUser) { user in
|
||||
if showPerUserTuning {
|
||||
PerUserTuningView(user: user, session: session)
|
||||
} else if showBanUser {
|
||||
BanUserView(user: user, session: session)
|
||||
} else if showMoveUser {
|
||||
MoveUserView(user: user, session: session)
|
||||
} else if showPermissions {
|
||||
PermissionsView(user: user, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func userContextMenu(user: User) -> some View {
|
||||
if user.id != session.selfUserId {
|
||||
Button {
|
||||
selectedUser = user
|
||||
showPerUserTuning = true
|
||||
showBanUser = false; showMoveUser = false; showPermissions = false
|
||||
} label: {
|
||||
Label("Volume / NR", systemImage: "speaker.wave.2")
|
||||
}
|
||||
if session.permissions.canKick || session.permissions.isAdmin {
|
||||
Divider()
|
||||
Button {
|
||||
session.kickUser(user.id, reason: "")
|
||||
} label: {
|
||||
Label("Kick", systemImage: "person.fill.xmark")
|
||||
}
|
||||
if session.permissions.canBan || session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
selectedUser = user
|
||||
showBanUser = true
|
||||
showPerUserTuning = false; showMoveUser = false; showPermissions = false
|
||||
} label: {
|
||||
Label("Ban…", systemImage: "nosign")
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.permissions.canMoveUsers || session.permissions.isAdmin {
|
||||
Button {
|
||||
selectedUser = user
|
||||
showMoveUser = true
|
||||
showPerUserTuning = false; showBanUser = false; showPermissions = false
|
||||
} label: {
|
||||
Label("Move to channel…", systemImage: "arrow.right.circle")
|
||||
}
|
||||
}
|
||||
if session.permissions.isAdmin {
|
||||
Divider()
|
||||
let muted = user.serverMuted
|
||||
Button {
|
||||
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
|
||||
} label: {
|
||||
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
|
||||
}
|
||||
Button {
|
||||
selectedUser = user
|
||||
showPermissions = true
|
||||
showPerUserTuning = false; showBanUser = false; showMoveUser = false
|
||||
} label: {
|
||||
Label("Permissions…", systemImage: "lock.shield")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UserRowView: View {
|
||||
let user: User
|
||||
let isSelf: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
|
||||
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
|
||||
.imageScale(.small)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(user.nickname)
|
||||
.fontWeight(isSelf ? .semibold : .regular)
|
||||
if isSelf {
|
||||
Text("(you)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if user.isGuest {
|
||||
Text("guest")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if user.serverMuted || user.serverDeafened {
|
||||
Text(user.serverDeafened ? "server deafened" : "server muted")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if user.selfDeafened {
|
||||
Image(systemName: "headphones.slash")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
|
||||
}
|
||||
}
|
||||
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct VoiceControlsView: View {
|
||||
@Bindable var session: SessionState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Mic toggle / PTT button
|
||||
if session.voiceState.inputMode == .pushToTalk {
|
||||
PTTButton(session: session)
|
||||
} else {
|
||||
Button {
|
||||
if session.voiceState.micActive {
|
||||
session.stopMicStream()
|
||||
} else {
|
||||
session.startMicStream()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
|
||||
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
|
||||
}
|
||||
.disabled(session.currentChannelId == 0)
|
||||
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
|
||||
}
|
||||
|
||||
// Level meter
|
||||
LevelMeterView(level: session.voiceState.level)
|
||||
.frame(width: 80, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Self mute
|
||||
Button {
|
||||
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
|
||||
.font(.title3)
|
||||
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
|
||||
}
|
||||
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
|
||||
|
||||
// Self deafen
|
||||
Button {
|
||||
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
|
||||
.font(.title3)
|
||||
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
|
||||
}
|
||||
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
|
||||
|
||||
// Disconnect
|
||||
Button(role: .destructive) {
|
||||
session.stopMicStream()
|
||||
session.client.disconnect()
|
||||
} label: {
|
||||
Image(systemName: "phone.down.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.accessibilityLabel("Disconnect from server")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
|
||||
|
||||
private struct PTTButton: View {
|
||||
@Bindable var session: SessionState
|
||||
@GestureState private var isPressing = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isPressing ? Color.blue : Color(.systemGray4))
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay {
|
||||
Image(systemName: "mic.fill")
|
||||
.foregroundStyle(isPressing ? .white : .primary)
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.updating($isPressing) { _, state, _ in state = true }
|
||||
.onChanged { _ in
|
||||
if !isPressing { return }
|
||||
if !session.voiceState.micActive { session.startMicStream() }
|
||||
session.setPushToTalk(true)
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.onEnded { _ in
|
||||
session.setPushToTalk(false)
|
||||
session.stopMicStream()
|
||||
}
|
||||
)
|
||||
.accessibilityLabel("Push to talk, hold to transmit")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Level Meter
|
||||
|
||||
private struct LevelMeterView: View {
|
||||
let level: Float
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(levelColor)
|
||||
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
|
||||
.animation(.linear(duration: 0.05), value: level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var levelColor: Color {
|
||||
if level > 0.15 { return .orange }
|
||||
if level > 0.05 { return .green }
|
||||
return .green.opacity(0.5)
|
||||
}
|
||||
}
|
||||
10
clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements
Normal file
10
clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.cat.voice.VoiceCat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
18
clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift
Normal file
18
clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
@main
|
||||
struct VoiceCatiOSApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
init() {
|
||||
AudioSessionManager.shared.configure()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainView()
|
||||
.environment(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT"
|
||||
|
||||
# ── Build each requested slice ────────────────────────────────────────────────────
|
||||
build_slice() {
|
||||
local preset="$1" slice_name="$2"
|
||||
local preset="$1" slice_name="$2" vcpkg_triplet="$3"
|
||||
echo "[build-xcframework] === $slice_name: configure + build ($preset) ==="
|
||||
if $DO_CONFIGURE; then
|
||||
cmake --preset "$preset"
|
||||
@@ -97,25 +97,29 @@ build_slice() {
|
||||
# Without this, the final executable (test runner / app) would get undefined-symbol
|
||||
# linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references
|
||||
# but doesn't contain. See clients/apple/README.md "Fat static library" section.
|
||||
local vcpkg_libs_dir="$REPO_ROOT/build/$preset/vcpkg_installed"
|
||||
#
|
||||
# IMPORTANT: search only the TARGET triplet's lib dir, not all of vcpkg_installed/.
|
||||
# Cross-compile builds install a HOST triplet directory alongside the target (e.g.
|
||||
# arm64-osx/ next to arm64-ios/) — that host directory contains macOS binaries needed
|
||||
# to build protoc/etc at configure time. Merging those macOS .a files into the iOS
|
||||
# fat library triggers xcodebuild's "binaries with multiple platforms" rejection.
|
||||
local vcpkg_target_lib_dir="$REPO_ROOT/build/$preset/vcpkg_installed/$vcpkg_triplet/lib"
|
||||
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
|
||||
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib"
|
||||
# Collect all .a files (libvoicecat.a + every vcpkg .a). libtool -static concatenates
|
||||
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)"
|
||||
# Collect all .a files (libvoicecat.a + every vcpkg target .a). libtool -static concatenates
|
||||
# object files from all input archives; duplicate-object warnings are benign (the linker
|
||||
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
|
||||
# objects on arm64 — also benign.
|
||||
local all_libs=( "$lib" )
|
||||
# Only release libs (arm64-osx/lib/*.a), NOT debug libs (arm64-osx/debug/lib/*.a) —
|
||||
# vcpkg installs both; the debug libs are ~10x larger and not needed for a Release build.
|
||||
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_libs_dir" -name '*.a' -not -name 'libvoicecat*' -not -path '*/debug/*' | sort)
|
||||
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort)
|
||||
libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true
|
||||
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
|
||||
}
|
||||
|
||||
args=()
|
||||
if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi
|
||||
if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi
|
||||
if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi
|
||||
if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)" "arm64-osx"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi
|
||||
if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)" "arm64-ios"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi
|
||||
if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)" "arm64-ios-simulator"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi
|
||||
|
||||
# ── Stage headers + module map ───────────────────────────────────────────────────
|
||||
# Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers
|
||||
|
||||
Reference in New Issue
Block a user