├── .editorconfig ├── .github └── workflows │ ├── app.yml │ ├── bluetooth.yml │ ├── cli.yml │ ├── kit.yml │ ├── simulation-protocol.yml │ └── simulation-server.yml ├── .gitignore ├── .gitlab-ci.yml ├── DistributedChat.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Distributed Chat.xcscheme ├── DistributedChatApp ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 100.png │ │ ├── 1024.png │ │ ├── 114.png │ │ ├── 120.png │ │ ├── 144.png │ │ ├── 152.png │ │ ├── 167.png │ │ ├── 172.png │ │ ├── 180.png │ │ ├── 196.png │ │ ├── 20.png │ │ ├── 216.png │ │ ├── 29.png │ │ ├── 40.png │ │ ├── 48.png │ │ ├── 50.png │ │ ├── 55.png │ │ ├── 57.png │ │ ├── 58.png │ │ ├── 60.png │ │ ├── 72.png │ │ ├── 76.png │ │ ├── 80.png │ │ ├── 87.png │ │ ├── 88.png │ │ └── Contents.json │ └── Contents.json ├── Audio │ ├── AudioPlayer.swift │ └── AudioRecorder.swift ├── DistributedChatApp.entitlements ├── DistributedChatApp.swift ├── Info.plist ├── Model │ ├── Channel.swift │ ├── ChatAttachment+Transferable.swift │ ├── ChatChannel+Transferable.swift │ ├── ChatMessage+Transferable.swift │ ├── MessageHistoryStyle.swift │ ├── Messages.swift │ ├── Network.swift │ ├── Profile.swift │ ├── Settings.swift │ ├── TransferableError.swift │ ├── URL+ChatChannel.swift │ ├── UTType+ChatMessage.swift │ └── Users.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Utils │ ├── CollectionUtils.swift │ ├── DataUtils.swift │ ├── EnvironmentUtils.swift │ ├── PersistenceError.swift │ ├── PersistenceUtils.swift │ └── URLUtils.swift ├── View │ ├── AttachmentView.swift │ ├── AutoFocusTextField.swift │ ├── BubbleMessageView.swift │ ├── ChannelSnippetView.swift │ ├── ChannelView.swift │ ├── ChannelsView.swift │ ├── ChatStatus+Color.swift │ ├── ClosableStatusBar.swift │ ├── CompactMessageView.swift │ ├── ContactAttachmentView.swift │ ├── ContactPicker.swift │ ├── ContentView.swift │ ├── EnumPicker.swift │ ├── FileAttachmentView.swift │ ├── ImageAttachmentView.swift │ ├── ImagePicker.swift │ ├── MessageComposeView.swift │ ├── MessageHistoryView.swift │ ├── MessageView.swift │ ├── NetworkView.swift │ ├── NewChannelView.swift │ ├── PlainMessageView.swift │ ├── PresenceView.swift │ ├── ProfileView.swift │ ├── QuickLookAttachment.swift │ ├── QuickLookAttachmentView.swift │ ├── QuickLookView.swift │ ├── SettingsView.swift │ ├── ViewUtils.swift │ ├── VoiceNoteAttachmentView.swift │ ├── VoiceNoteRecordButton.swift │ └── WaveformView.swift └── ViewModel │ └── Navigation.swift ├── DistributedChatAppTests ├── DistributedChatAppTests.swift └── Info.plist ├── DistributedChatAppUITests ├── DistributedChatAppUITests.swift └── Info.plist ├── DistributedChatBluetooth ├── .gitignore ├── Package.resolved ├── Package.swift ├── Sources │ └── DistributedChatBluetooth │ │ ├── Controller │ │ ├── BluetoothLinuxError.swift │ │ ├── BluetoothLinuxTransport.swift │ │ └── CoreBluetoothTransport.swift │ │ └── Model │ │ ├── CoreBluetoothSettings.swift │ │ └── NearbyUser.swift └── Tests │ └── DistributedChatBluetoothTests │ └── DistributedChatBluetoothTests.swift ├── DistributedChatCLI ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── distributed-chat.xcscheme ├── Package.resolved ├── Package.swift ├── README.md └── Sources │ └── DistributedChatCLI │ ├── CLILogHandler.swift │ ├── ChatREPL.swift │ ├── Controller │ └── SimulationTransport.swift │ ├── Model │ └── Network.swift │ ├── Utils │ ├── LoggerLevel+ExpressibleByArgument.swift │ └── URL+ExpressibleByArgument.swift │ └── main.swift ├── DistributedChatKit ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── DistributedChatKit.xcscheme ├── Package.swift ├── Sources │ └── DistributedChatKit │ │ ├── Controller │ │ ├── ChatController.swift │ │ ├── ChatTransport.swift │ │ ├── ChatTransportWrapper.swift │ │ └── MockTransport.swift │ │ ├── Model │ │ ├── ChatAttachment.swift │ │ ├── ChatAttachmentContent.swift │ │ ├── ChatAttachmentType.swift │ │ ├── ChatChannel.swift │ │ ├── ChatCryptoCipherData.swift │ │ ├── ChatCryptoError.swift │ │ ├── ChatCryptoKeys.swift │ │ ├── ChatDeletion.swift │ │ ├── ChatMessage.swift │ │ ├── ChatMessageCache.swift │ │ ├── ChatMessageContent.swift │ │ ├── ChatMessageListCache.swift │ │ ├── ChatPresence.swift │ │ ├── ChatProtocol.swift │ │ ├── ChatStatus.swift │ │ └── ChatUser.swift │ │ └── Utils │ │ ├── DecodeError.swift │ │ ├── Either.swift │ │ ├── Either3.swift │ │ ├── JSONUtils.swift │ │ ├── RepeatingTimer.swift │ │ └── StringUtils.swift └── Tests │ └── DistributedChatKitTests │ └── ChatMessageTests.swift ├── DistributedChatSimulationProtocol ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── DistributedChatSimulationProtocol.xcscheme ├── Package.swift ├── README.md └── Sources │ └── DistributedChatSimulationProtocol │ └── SimulationProtocol.swift ├── DistributedChatSimulationServer ├── .dockerignore ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── distributed-chat-simulation-server.xcscheme ├── Dockerfile ├── Package.resolved ├── Package.swift ├── Public │ ├── script.js │ └── styles.css ├── README.md ├── Resources │ └── Views │ │ └── index.leaf ├── Sources │ ├── DistributedChatSimulationServer │ │ ├── MessagingHandler.swift │ │ ├── configure.swift │ │ └── routes.swift │ └── DistributedChatSimulationServerMain │ │ └── main.swift ├── Tests │ └── DistributedChatSimulationServerTests │ │ └── DistributedChatSimulationServerTests.swift └── docker-compose.yml ├── Images ├── channel.png ├── channels.png ├── lockscreen.png └── logo.svg ├── LICENSE ├── README.md └── Scripts ├── .gitignore ├── README.md ├── js-test-client ├── .editorconfig ├── README.md ├── gatt_client.js ├── gatt_constants.js ├── gatt_server.js ├── package-lock.json └── package.json ├── python-test-client ├── README.md ├── gatt_constants.py └── test_client.py └── start_clis /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = true 13 | 14 | [*.yml] 15 | indent_size = 2 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.github/workflows/app.yml: -------------------------------------------------------------------------------- 1 | name: App 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: macos-14 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set Xcode version 17 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 18 | - name: Build 19 | run: xcodebuild build -scheme "Distributed Chat" -sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 15 Pro" 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/bluetooth.yml: -------------------------------------------------------------------------------- 1 | name: Bluetooth 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | os: ['ubuntu-latest', 'macos-14'] 15 | swift: ['5.10'] 16 | runs-on: '${{ matrix.os }}' 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: swift-actions/setup-swift@v2 21 | with: 22 | swift-version: ${{ matrix.swift }} 23 | - name: Build 24 | run: swift build 25 | working-directory: DistributedChatBluetooth 26 | - name: Test 27 | run: swift test 28 | working-directory: DistributedChatBluetooth 29 | -------------------------------------------------------------------------------- /.github/workflows/cli.yml: -------------------------------------------------------------------------------- 1 | name: CLI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | os: ['ubuntu-latest', 'macos-14'] 15 | swift: ['5.10'] 16 | runs-on: '${{ matrix.os }}' 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: swift-actions/setup-swift@v2 21 | with: 22 | swift-version: ${{ matrix.swift }} 23 | - name: Build 24 | run: swift build 25 | working-directory: DistributedChatCLI 26 | -------------------------------------------------------------------------------- /.github/workflows/kit.yml: -------------------------------------------------------------------------------- 1 | name: Kit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | os: ['ubuntu-latest', 'macos-14'] 15 | swift: ['5.10'] 16 | runs-on: '${{ matrix.os }}' 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: swift-actions/setup-swift@v2 21 | with: 22 | swift-version: ${{ matrix.swift }} 23 | - name: Build 24 | run: swift build 25 | working-directory: DistributedChatKit 26 | - name: Test 27 | run: swift test 28 | working-directory: DistributedChatKit 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/simulation-protocol.yml: -------------------------------------------------------------------------------- 1 | name: Simulation Protocol 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | os: ['ubuntu-latest', 'macos-14'] 15 | swift: ['5.10'] 16 | runs-on: '${{ matrix.os }}' 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: swift-actions/setup-swift@v2 21 | with: 22 | swift-version: ${{ matrix.swift }} 23 | - name: Build 24 | run: swift build 25 | working-directory: DistributedChatSimulationProtocol 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/simulation-server.yml: -------------------------------------------------------------------------------- 1 | name: Simulation Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | os: ['ubuntu-latest', 'macos-14'] 15 | swift: ['5.10'] 16 | runs-on: '${{ matrix.os }}' 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: swift-actions/setup-swift@v2 21 | with: 22 | swift-version: ${{ matrix.swift }} 23 | - name: Build 24 | run: swift build 25 | working-directory: DistributedChatSimulationServer 26 | - name: Test 27 | run: swift test 28 | working-directory: DistributedChatSimulationServer 29 | 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | .vscode 4 | __pycache__ 5 | *.pyc 6 | *~ 7 | Packages 8 | xcuserdata/ 9 | node_modules 10 | local 11 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: "swift:5.10" 2 | 3 | stages: 4 | - build-libs 5 | - test-libs 6 | - build-exes 7 | - test-exes 8 | 9 | cache: 10 | paths: 11 | - "*/.build" 12 | 13 | distributed-chat:build: 14 | stage: build-libs 15 | script: 16 | - (cd DistributedChatKit && swift build) 17 | 18 | distributed-chat:test: 19 | stage: test-libs 20 | script: 21 | - (cd DistributedChatKit && swift test) 22 | needs: 23 | - distributed-chat:build 24 | 25 | simulation-protocol:build: 26 | stage: build-libs 27 | script: 28 | - (cd DistributedChatSimulationProtocol && swift build) 29 | 30 | cli:build: 31 | stage: build-exes 32 | script: 33 | - (cd DistributedChatCLI && swift build) 34 | needs: 35 | - distributed-chat:build 36 | - simulation-protocol:build 37 | 38 | simulation-server:build: 39 | stage: build-exes 40 | script: 41 | - (cd DistributedChatSimulationServer && swift build) 42 | needs: 43 | - simulation-protocol:build 44 | 45 | simulation-server:test: 46 | stage: test-exes 47 | script: 48 | - (cd DistributedChatSimulationServer && swift test) 49 | needs: 50 | - simulation-server:build 51 | 52 | app:build: 53 | stage: build-exes 54 | rules: 55 | - when: never # TODO: Reenable 56 | tags: 57 | - macos 58 | script: 59 | - xcodebuild build -scheme DistributedChatApp -sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 11,OS=14.3" 60 | needs: 61 | - distributed-chat:build 62 | -------------------------------------------------------------------------------- /DistributedChat.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DistributedChat.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DistributedChat.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DistributedChat.xcodeproj/xcshareddata/xcschemes/Distributed Chat.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 45 | 51 | 52 | 53 | 54 | 55 | 65 | 67 | 73 | 74 | 75 | 76 | 82 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/100.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/144.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/172.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/196.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/216.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/48.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/50.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/55.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/72.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/AppIcon.appiconset/88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/DistributedChatApp/Assets.xcassets/AppIcon.appiconset/88.png -------------------------------------------------------------------------------- /DistributedChatApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DistributedChatApp/Audio/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioPlayer.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import AVFoundation 9 | import Combine 10 | import Logging 11 | import Foundation 12 | 13 | fileprivate let log = Logger(label: "DistributedChatApp.AudioPlayer") 14 | 15 | class AudioPlayer: NSObject, ObservableObject, AVAudioPlayerDelegate { 16 | private var player: AVAudioPlayer? = nil { 17 | didSet { 18 | isReady = player != nil 19 | } 20 | } 21 | 22 | var url: URL? = nil { 23 | willSet { 24 | if let url = newValue, 25 | let data = try? Data.smartContents(of: url), 26 | let player = try? AVAudioPlayer(data: data) { 27 | player.delegate = self 28 | self.player = player 29 | } else { 30 | player = nil 31 | } 32 | } 33 | } 34 | 35 | @Published var isReady: Bool = false 36 | @Published var isPlaying: Bool = false { 37 | willSet { 38 | if newValue != isPlaying { 39 | if newValue { 40 | play() 41 | } else { 42 | pause() 43 | } 44 | } 45 | } 46 | } 47 | 48 | private func play() { 49 | do { 50 | try AVAudioSession.sharedInstance().setActive(true) 51 | } catch { 52 | log.warning("Could not activate audio session: \(error)") 53 | } 54 | player?.prepareToPlay() 55 | player?.play() 56 | } 57 | 58 | private func pause() { 59 | player?.pause() 60 | } 61 | 62 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 63 | isPlaying = false 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /DistributedChatApp/Audio/AudioRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioRecorder.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import AVFoundation 9 | import Foundation 10 | import Combine 11 | import Logging 12 | 13 | fileprivate let log = Logger(label: "DistributedChatApp.AudioRecorder") 14 | 15 | /// An audio recorder that writes to a custom file in Recordings. 16 | class AudioRecorder: NSObject, ObservableObject, AVAudioRecorderDelegate { 17 | private let recorder: AVAudioRecorder 18 | 19 | @Published var isRecording: Bool = false { 20 | didSet { 21 | if isRecording { 22 | record() 23 | } else { 24 | stop() 25 | } 26 | } 27 | } 28 | @Published private(set) var isCompleted: Bool = false 29 | let url: URL 30 | 31 | init(name: String) throws { 32 | url = persistenceFileURL(path: "Recordings/\(name).m4a") 33 | recorder = try AVAudioRecorder(url: url, settings: [ 34 | AVEncoderAudioQualityKey: AVAudioQuality.low.rawValue, 35 | AVNumberOfChannelsKey: 1, 36 | AVFormatIDKey: Int(kAudioFormatMPEG4AAC), 37 | AVSampleRateKey: 12_000.0 // Hz 38 | ]) 39 | 40 | super.init() 41 | recorder.delegate = self 42 | } 43 | 44 | private func record() { 45 | do { 46 | try AVAudioSession.sharedInstance().setActive(true) 47 | } catch { 48 | log.warning("Could not activate audio session: \(error)") 49 | } 50 | recorder.prepareToRecord() 51 | recorder.record() 52 | isCompleted = false 53 | } 54 | 55 | private func stop() { 56 | recorder.stop() 57 | } 58 | 59 | func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully: Bool) { 60 | if successfully { 61 | isCompleted = true 62 | } else { 63 | log.warning("Did not successfully finish recording.") 64 | } 65 | } 66 | 67 | func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { 68 | log.error("An encode error occurred: \(error.map { "\($0)" } ?? "?")") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /DistributedChatApp/DistributedChatApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.device.audio-input 8 | 9 | com.apple.security.device.bluetooth 10 | 11 | com.apple.security.device.camera 12 | 13 | com.apple.security.network.client 14 | 15 | com.apple.security.personal-information.photos-library 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /DistributedChatApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UTExportedTypeDeclarations 6 | 7 | 8 | UTTypeConformsTo 9 | 10 | public.json 11 | 12 | UTTypeIdentifier 13 | dev.fwcd.DistributedChat.ChatMessage 14 | UTTypeTagSpecification 15 | 16 | 17 | 18 | CFBundleDevelopmentRegion 19 | $(DEVELOPMENT_LANGUAGE) 20 | CFBundleDisplayName 21 | Distributed Chat 22 | CFBundleExecutable 23 | $(EXECUTABLE_NAME) 24 | CFBundleIdentifier 25 | $(PRODUCT_BUNDLE_IDENTIFIER) 26 | CFBundleInfoDictionaryVersion 27 | 6.0 28 | CFBundleName 29 | $(PRODUCT_NAME) 30 | CFBundlePackageType 31 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 32 | CFBundleShortVersionString 33 | $(MARKETING_VERSION) 34 | CFBundleURLTypes 35 | 36 | 37 | CFBundleURLName 38 | fwcd.DistributedChat 39 | CFBundleURLSchemes 40 | 41 | distributedchat 42 | 43 | 44 | 45 | CFBundleVersion 46 | 1 47 | ITSAppUsesNonExemptEncryption 48 | 49 | LSRequiresIPhoneOS 50 | 51 | LSSupportsOpeningDocumentsInPlace 52 | 53 | NSBluetoothAlwaysUsageDescription 54 | The app needs Bluetooth to establish a mesh network over which chat messages are transmitted. 55 | NSBluetoothPeripheralUsageDescription 56 | The app needs to connect to other devices running the app over Bluetooth to establish a mesh network for chat messages. 57 | NSCameraUsageDescription 58 | The app lets you captures images and videos and send them to a channel. 59 | NSDownloadsFolderUsageDescription 60 | The app lets you save files in your downloads folder 61 | NSMicrophoneUsageDescription 62 | The app lets you record voice notes with your microphone. 63 | NSPhotoLibraryAddUsageDescription 64 | The app lets you save images from message attachments to your local photo library. 65 | UIApplicationSceneManifest 66 | 67 | UIApplicationSupportsMultipleScenes 68 | 69 | 70 | UIApplicationSupportsIndirectInputEvents 71 | 72 | UIBackgroundModes 73 | 74 | bluetooth-central 75 | bluetooth-peripheral 76 | 77 | UIFileSharingEnabled 78 | 79 | UILaunchScreen 80 | 81 | UIRequiredDeviceCapabilities 82 | 83 | armv7 84 | 85 | UISupportedInterfaceOrientations 86 | 87 | UIInterfaceOrientationPortrait 88 | UIInterfaceOrientationLandscapeLeft 89 | UIInterfaceOrientationLandscapeRight 90 | 91 | UISupportedInterfaceOrientations~ipad 92 | 93 | UIInterfaceOrientationPortrait 94 | UIInterfaceOrientationPortraitUpsideDown 95 | UIInterfaceOrientationLandscapeLeft 96 | UIInterfaceOrientationLandscapeRight 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/Channel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Channel.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/22/21. 6 | // 7 | 8 | import DistributedChatKit 9 | 10 | struct Channel: Identifiable { 11 | let name: String? 12 | var messages: [ChatMessage] 13 | 14 | var displayName: String { name ?? "global" } 15 | var id: String { displayName } 16 | } 17 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/ChatAttachment+Transferable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatAttachment+Transferable.swift 3 | // DistributedChat 4 | // 5 | // Created on 04.08.24 6 | // 7 | 8 | import CoreTransferable 9 | import DistributedChatKit 10 | import SwiftUI 11 | 12 | extension ChatAttachment: Transferable { 13 | public static var transferRepresentation: some TransferRepresentation { 14 | ProxyRepresentation { attachment in 15 | guard let data = try? attachment.extractedData(), 16 | let uiImage = UIImage(data: data) else { throw TransferableError.couldNotEncodeImageAttachment(attachment) } 17 | return Image(uiImage: uiImage) 18 | } 19 | .exportingCondition { attachment in 20 | attachment.type == .image 21 | } 22 | 23 | // TODO: Add other types of attachments and their representations 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/ChatChannel+Transferable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatChannel+Transferable.swift 3 | // DistributedChat 4 | // 5 | // Created on 04.08.24 6 | // 7 | 8 | import CoreTransferable 9 | import DistributedChatKit 10 | 11 | extension ChatChannel: Transferable { 12 | public static var transferRepresentation: some TransferRepresentation { 13 | ProxyRepresentation(exporting: URL.init(_:)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/ChatMessage+Transferable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatMessage+Transferable.swift 3 | // DistributedChat 4 | // 5 | // Created on 04.08.24 6 | // 7 | 8 | import CoreTransferable 9 | import DistributedChatKit 10 | 11 | extension ChatMessage: Transferable { 12 | public static var transferRepresentation: some TransferRepresentation { 13 | CodableRepresentation(contentType: .chatMessage, encoder: makeJSONEncoder(), decoder: makeJSONDecoder()) 14 | ProxyRepresentation(exporting: \.displayContent) 15 | .exportingCondition { !$0.displayContent.isEmpty } 16 | ProxyRepresentation { message in 17 | guard let attachment = message.attachments?.first else { 18 | throw TransferableError.noAttachmentsFound 19 | } 20 | return attachment 21 | } 22 | .exportingCondition { !($0.attachments?.isEmpty ?? true) } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/MessageHistoryStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageHistoryStyle.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | enum MessageHistoryStyle: String, CaseIterable, Hashable, CustomStringConvertible, Codable { 9 | case compact = "Compact" 10 | case bubbles = "Bubbles" 11 | 12 | var description: String { rawValue } 13 | } 14 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/Messages.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Messages.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/22/21. 6 | // 7 | 8 | import Combine 9 | import DistributedChatKit 10 | import Foundation 11 | import Logging 12 | 13 | fileprivate let log = Logger(label: "DistributedChatApp.Messages") 14 | 15 | class Messages: ObservableObject { 16 | @Published var autoReadChannels: Set = [] 17 | @Published(persistingTo: "Messages/unreadMessageIds.json") var unreadMessageIds: Set = [] 18 | @Published(persistingTo: "Messages/pinnedChannels.json") private(set) var pinnedChannels: Set = [.global] 19 | @Published(persistingTo: "Messages/messages.json") private(set) var messages: [UUID: ChatMessage] = [:] 20 | 21 | var unreadChannels: Set { Set(unreadMessageIds.compactMap { messages[$0] }.map(\.channel)) } 22 | var channels: [ChatChannel] { 23 | pinnedChannels.sorted { String(describing: $0) < String(describing: $1) } + messages.values 24 | .sorted { $0.timestamp > $1.timestamp } 25 | .map(\.channel) 26 | .filter { !pinnedChannels.contains($0) } 27 | .distinct 28 | } 29 | 30 | var users: Set { 31 | Set([UUID: [ChatMessage]](grouping: messages.values, by: { $0.author.id }) 32 | .values 33 | .compactMap { $0.max { $0.timestamp < $1.timestamp }?.author }) // Use the newest version of the user 34 | } 35 | 36 | init() {} 37 | 38 | init(messages: [ChatMessage]) { 39 | self.messages = Dictionary(messages.map { ($0.id, $0) }, uniquingKeysWith: { k, _ in k }) 40 | } 41 | 42 | subscript(channel: ChatChannel?) -> [ChatMessage] { 43 | messages.values 44 | .filter { $0.channel == channel } 45 | .sorted { $0.timestamp < $1.timestamp } 46 | } 47 | 48 | subscript(id: UUID) -> ChatMessage? { 49 | messages[id] 50 | } 51 | 52 | func append(message: ChatMessage) { 53 | var message = message 54 | 55 | if let indices = message.attachments?.indices { 56 | for i in indices { 57 | message.attachments![i] = storeLocally(attachment: message.attachments![i]) 58 | } 59 | } 60 | 61 | messages[message.id] = message 62 | if !autoReadChannels.contains(message.channel) { 63 | unreadMessageIds.insert(message.id) 64 | } 65 | } 66 | 67 | private func storeLocally(attachment: ChatAttachment) -> ChatAttachment { 68 | var attachment = attachment 69 | let baseURL = URL(string: "distributedchat:///attachment/\(attachment.name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)")! 70 | let fileName = baseURL.lastPathComponent 71 | let fileExtension = fileName.contains(".") ? ".\(fileName.split(separator: ".").last!)" : "" 72 | var url = baseURL 73 | var i = 1 74 | 75 | while (try? url.smartCheckResourceIsReachable()) ?? false { 76 | url = baseURL.deletingPathExtension().appendingPathExtension("\(i)\(fileExtension)") 77 | i += 1 78 | } 79 | 80 | do { 81 | let data = try attachment.extractedData() 82 | try data.smartWrite(to: url) 83 | attachment.content = .url(url) 84 | attachment.compression = nil 85 | } catch { 86 | log.error("Could not store attachment: \(error)") 87 | } 88 | 89 | return attachment 90 | } 91 | 92 | func clear(channel: ChatChannel?) { 93 | messages = messages.filter { $0.value.channel != channel } 94 | } 95 | 96 | func markAsRead(channel: ChatChannel?) { 97 | unreadMessageIds = unreadMessageIds.filter { messages[$0]?.channel != channel } 98 | } 99 | 100 | func pin(channel: ChatChannel) { 101 | pinnedChannels.insert(channel) 102 | } 103 | 104 | func unpin(channel: ChatChannel) { 105 | if channel != .global { // #global cannot be unpinned 106 | pinnedChannels.remove(channel) 107 | } 108 | } 109 | 110 | func deleteMessage(id: UUID) { 111 | messages[id] = nil 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/Network.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Network.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import DistributedChatBluetooth 10 | import Combine 11 | import Foundation 12 | 13 | class Network: ObservableObject { 14 | /// The ID of our node. 15 | let myId: UUID 16 | /// Nodes that are in immediate reach, i.e. in Bluetooth LE range. 17 | @Published var nearbyUsers: [NearbyUser] 18 | /// Nodes that are reachable via the network. 19 | /// TODO: Expire old presences after a certain timeout 20 | @Published private(set) var presences: [UUID: ChatPresence] 21 | private var messages: Messages 22 | 23 | var offlinePresences: [UUID: ChatPresence] { 24 | Dictionary(uniqueKeysWithValues: messages.users 25 | .filter { !presences.keys.contains($0.id) } 26 | .map { ($0.id, ChatPresence(user: $0, status: .offline)) }) 27 | } 28 | var allPresences: [ChatPresence] { 29 | presences.values.sorted { $0.user.displayName < $1.user.displayName } 30 | + offlinePresences.values.sorted { $0.user.displayName < $1.user.displayName } 31 | } 32 | 33 | init(myId: UUID = UUID(), nearbyUsers: [NearbyUser] = [], presences: [ChatPresence] = [], messages: Messages = Messages()) { 34 | self.myId = myId 35 | self.nearbyUsers = nearbyUsers 36 | self.presences = Dictionary(presences.map { ($0.user.id, $0) }, uniquingKeysWith: { k, _ in k }) 37 | self.messages = messages 38 | } 39 | 40 | func register(presence: ChatPresence) { 41 | presences[presence.user.id] = presence 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/Profile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Profile.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import Combine 9 | import DistributedChatKit 10 | 11 | class Profile: ObservableObject { 12 | @Published(persistingTo: "Profile/presence.json") var presence: ChatPresence = ChatPresence() 13 | 14 | var me: ChatUser { presence.user } 15 | } 16 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/Settings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Settings.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import Combine 9 | import DistributedChatBluetooth 10 | import Foundation 11 | 12 | class Settings: ObservableObject { 13 | @Published(persistingTo: "Settings/presentation.json") var presentation = PresentationSettings() 14 | @Published(persistingTo: "Settings/bluetooth.json") var bluetooth = CoreBluetoothSettings() 15 | 16 | struct PresentationSettings: Codable { 17 | var messageHistoryStyle: MessageHistoryStyle = .bubbles 18 | var showChannelPreviews: Bool = true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/TransferableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransferableError.swift 3 | // DistributedChat 4 | // 5 | // Created on 04.08.24 6 | // 7 | 8 | import DistributedChatKit 9 | 10 | enum TransferableError: Error { 11 | case couldNotEncodeImageAttachment(ChatAttachment) 12 | case noAttachmentsFound 13 | } 14 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/URL+ChatChannel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+ChatChannel.swift 3 | // DistributedChat 4 | // 5 | // Created on 04.08.24 6 | // 7 | 8 | import Foundation 9 | import DistributedChatKit 10 | 11 | extension URL { 12 | init(_ channel: ChatChannel) { 13 | self.init(string: "distributedchat:///channel/\(channel)")! 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/UTType+ChatMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTType+ChatMessage.swift 3 | // DistributedChat 4 | // 5 | // Created on 04.08.24 6 | // 7 | 8 | import UniformTypeIdentifiers 9 | 10 | extension UTType { 11 | static var chatMessage: UTType { 12 | UTType(exportedAs: "dev.fwcd.DistributedChat.ChatMessage") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DistributedChatApp/Model/Users.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Users.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/5/21. 6 | // 7 | 8 | import Combine 9 | 10 | class Users: ObservableObject { 11 | // TODO 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DistributedChatApp/Utils/CollectionUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | extension Sequence where Element: Hashable { 9 | /// Filters only distinct elements. 10 | var distinct: [Element] { 11 | var found = Set() 12 | var xs = [Element]() 13 | for x in self { 14 | let (inserted, _) = found.insert(x) 15 | if inserted { 16 | xs.append(x) 17 | } 18 | } 19 | return xs 20 | } 21 | } 22 | 23 | extension Collection { 24 | var nilIfEmpty: Self? { 25 | isEmpty ? nil : self 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /DistributedChatApp/Utils/DataUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/2/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import Foundation 10 | import Logging 11 | 12 | fileprivate let log = Logger(label: "DistributedChatApp.DataUtils") 13 | 14 | enum ChatAttachmentExtractionError: Error { 15 | case cannotExtractEncryptedData 16 | } 17 | 18 | extension ChatAttachment { 19 | func extractedData() throws -> Data { 20 | var extracted: Data 21 | switch content { 22 | case .url(let url): 23 | extracted = try Data.smartContents(of: url) 24 | case .encrypted(_): 25 | throw ChatAttachmentExtractionError.cannotExtractEncryptedData 26 | case .data(let data): 27 | extracted = data 28 | } 29 | if let compression = compression { 30 | extracted = try extracted.decompressed(with: compression) 31 | } 32 | return extracted 33 | } 34 | } 35 | 36 | extension ChatAttachment.Compression { 37 | var algorithm: NSData.CompressionAlgorithm { 38 | switch self { 39 | case .lz4: 40 | return .lz4 41 | case .lzma: 42 | return .lzma 43 | case .lzfse: 44 | return .lzfse 45 | case .zlib: 46 | return .zlib 47 | } 48 | } 49 | } 50 | 51 | extension Data { 52 | /// Reads a potentially security-scoped or distributedchat-schemed resource. 53 | static func smartContents(of url: URL) throws -> Data { 54 | do { 55 | return try Data(contentsOf: url.smartResolved) 56 | } catch { 57 | log.debug("Could not read \(url) directly, trying security-scoped access...") 58 | 59 | guard url.startAccessingSecurityScopedResource() else { throw PersistenceError.couldNotReadSecurityScoped } 60 | defer { url.stopAccessingSecurityScopedResource() } 61 | 62 | var error: NSError? = nil 63 | var caughtError: Error? = nil 64 | var data: Data? = nil 65 | 66 | NSFileCoordinator().coordinate(readingItemAt: url, error: &error) { url2 in 67 | do { 68 | data = try Data(contentsOf: url) 69 | } catch { 70 | caughtError = error 71 | } 72 | } 73 | 74 | if let error = error { 75 | throw error 76 | } else if let caughtError = caughtError { 77 | throw caughtError 78 | } 79 | 80 | guard let unwrappedData = data else { throw PersistenceError.couldNotReadData } 81 | return unwrappedData 82 | } 83 | } 84 | 85 | /// Writes a potentially distributedchat-schemed resources. 86 | func smartWrite(to url: URL) throws { 87 | try write(to: url.smartResolved) 88 | } 89 | 90 | /// Compresses the data with the given algorithm. 91 | func compressed(with compression: ChatAttachment.Compression) throws -> Data { 92 | try (self as NSData).compressed(using: compression.algorithm) as Data 93 | } 94 | 95 | /// Decompresses the data with the given algorithm. 96 | func decompressed(with compression: ChatAttachment.Compression) throws -> Data { 97 | try (self as NSData).decompressed(using: compression.algorithm) as Data 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /DistributedChatApp/Utils/EnvironmentUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/5/21. 6 | // 7 | 8 | import Foundation 9 | 10 | func isRunningInSwiftUIPreview() -> Bool { 11 | #if DEBUG 12 | return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 13 | #else 14 | return false 15 | #endif 16 | } 17 | -------------------------------------------------------------------------------- /DistributedChatApp/Utils/PersistenceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceError.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | enum PersistenceError: Error { 9 | case couldNotReadSecurityScoped 10 | case couldNotReadData 11 | case invalidDistributedChatURL(String) 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatApp/Utils/PersistenceUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistenceUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import Foundation 10 | import Combine 11 | import Logging 12 | 13 | private let encoder = makeJSONEncoder() 14 | private let decoder = makeJSONDecoder() 15 | private let log = Logger(label: "DistributedChatApp.PersistenceUtils") 16 | private let persistenceEnabled = !isRunningInSwiftUIPreview() 17 | private var subscriptions = [String: AnyCancellable]() 18 | 19 | func persistenceFileURL(path: String) -> URL { 20 | let url = path 21 | .split(separator: "/") 22 | .reduce(try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)) { 23 | $0.appendingPathComponent(String($1)) 24 | } 25 | 26 | log.debug("Creating directory for auto-persisted value") 27 | try! FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) 28 | 29 | return url 30 | } 31 | 32 | extension Published where Value: Codable { 33 | init(wrappedValue: Value, persistingTo path: String) { 34 | if persistenceEnabled { 35 | let url = persistenceFileURL(path: path) 36 | let save = { (value: Value) in 37 | do { 38 | try encoder.encode(value).write(to: url) 39 | } catch { 40 | log.error("Could not write to file") 41 | } 42 | } 43 | 44 | do { 45 | self.init(initialValue: try decoder.decode(Value.self, from: Data.smartContents(of: url))) 46 | } catch { 47 | log.debug("Could not read file: \(error)") 48 | self.init(initialValue: wrappedValue) 49 | } 50 | 51 | subscriptions[path] = projectedValue.sink(receiveValue: save) 52 | } else { 53 | // If persistence is disabled (e.g. in testing/preview contexts), 54 | // just initialize the propery as usual. 55 | self.init(initialValue: wrappedValue) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DistributedChatApp/Utils/URLUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import Foundation 9 | import UniformTypeIdentifiers 10 | 11 | extension URL { 12 | var mimeType: String { 13 | UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" 14 | } 15 | 16 | var isDistributedChatSchemed: Bool { 17 | scheme == "distributedchat" 18 | } 19 | var distributedChatAttachmentURL: URL? { 20 | // distributedchat:///attachment/a/b.txt refers to /Attachments/a/b.txt 21 | 22 | if isDistributedChatSchemed && pathComponents[..<2] == ["/", "attachment"] { 23 | return persistenceFileURL(path: "Attachments/\(pathComponents[2...].joined(separator: "/"))") 24 | } else { 25 | return nil 26 | } 27 | } 28 | var smartResolved: URL { 29 | distributedChatAttachmentURL ?? self 30 | } 31 | 32 | func smartCheckResourceIsReachable() throws -> Bool { 33 | try smartResolved.checkResourceIsReachable() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /DistributedChatApp/View/AttachmentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttachmentView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct AttachmentView: View { 12 | let attachment: ChatAttachment 13 | var voiceNoteColor: Color = .primary 14 | 15 | var body: some View { 16 | switch attachment.type { 17 | case .voiceNote: 18 | VoiceNoteAttachmentView(attachment: attachment, color: voiceNoteColor) 19 | case .image: 20 | ImageAttachmentView(attachment: attachment) 21 | case .contact: 22 | ContactAttachmentView(attachment: attachment) 23 | default: 24 | FileAttachmentView(attachment: attachment) 25 | } 26 | } 27 | } 28 | 29 | #Preview { 30 | AttachmentView(attachment: ChatAttachment(name: "test.txt", content: .url(URL(string: "data:text/plain;base64,dGVzdDEyMwo=")!))) 31 | } 32 | -------------------------------------------------------------------------------- /DistributedChatApp/View/AutoFocusTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutoFocusTextField.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | 11 | struct AutoFocusTextField: UIViewRepresentable { 12 | let placeholder: String 13 | @Binding var text: String 14 | var onCommit: (() -> Void)? 15 | 16 | func makeCoordinator() -> Coordinator { 17 | Coordinator(text: $text, onCommit: onCommit) 18 | } 19 | 20 | func makeUIView(context: Context) -> UITextField { 21 | let textField = UITextField(frame: .zero) 22 | textField.delegate = context.coordinator 23 | return textField 24 | } 25 | 26 | func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext) { 27 | uiView.placeholder = placeholder 28 | uiView.text = text 29 | context.coordinator.onCommit = onCommit 30 | 31 | if !context.coordinator.isFirstResponder { 32 | uiView.becomeFirstResponder() 33 | } 34 | } 35 | 36 | class Coordinator: NSObject, UITextFieldDelegate { 37 | @Binding var text: String 38 | var isFirstResponder: Bool = false 39 | var onCommit: (() -> Void)? 40 | 41 | init(text: Binding, onCommit: (() -> Void)?) { 42 | _text = text 43 | self.onCommit = onCommit 44 | } 45 | 46 | func textFieldDidChangeSelection(_ textField: UITextField) { 47 | text = textField.text ?? "" 48 | } 49 | 50 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 51 | onCommit?() 52 | return onCommit == nil 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DistributedChatApp/View/BubbleMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BubbleMessageView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct BubbleMessageView: View { 12 | let message: ChatMessage 13 | let isMe: Bool 14 | var onPressRepliedMessage: ((UUID) -> Void)? = nil 15 | 16 | @EnvironmentObject private var messages: Messages 17 | 18 | var body: some View { 19 | VStack(alignment: isMe ? .trailing : .leading) { 20 | if let id = message.repliedToMessageId, let referenced = messages[id] { 21 | Button { 22 | onPressRepliedMessage?(id) 23 | } label: { 24 | HStack { 25 | Image(systemName: "arrowshape.turn.up.backward") 26 | PlainMessageView(message: referenced) 27 | } 28 | .foregroundColor(.secondary) 29 | } 30 | } 31 | ZStack { 32 | VStack(alignment: .leading) { 33 | if message.isEncrypted { 34 | HStack { 35 | Image(systemName: "lock.fill") 36 | Text("Encrypted") 37 | .foregroundColor(isMe ? .white : .gray) 38 | } 39 | } else { 40 | HStack { 41 | if message.wasEncrypted ?? false { 42 | Image(systemName: "lock") 43 | } 44 | Text(message.author.displayName) 45 | } 46 | .font(.caption) 47 | .foregroundColor(isMe ? .white : .gray) 48 | if let content = message.content.asText, !content.isEmpty { 49 | Text(content) 50 | } 51 | ForEach(message.attachments ?? []) { attachment in 52 | AttachmentView(attachment: attachment, voiceNoteColor: isMe ? .white : .black) 53 | } 54 | } 55 | } 56 | .foregroundColor(isMe ? .white : .black) 57 | .padding(10) 58 | .background(isMe 59 | ? LinearGradient(gradient: Gradient(colors: [ 60 | .accentColor.opacity(0.9), 61 | .accentColor, 62 | ]), startPoint: .top, endPoint: .bottom) 63 | : LinearGradient(gradient: Gradient(colors: [ 64 | Color(red: 0.9, green: 0.9, blue: 0.9), 65 | Color(red: 0.9, green: 0.9, blue: 0.9) 66 | ]), startPoint: .top, endPoint: .bottom) 67 | ) 68 | .cornerRadius(10) 69 | } 70 | } 71 | } 72 | } 73 | 74 | #Preview { 75 | let message1 = ChatMessage(author: ChatUser(name: "Alice"), content: "Hi!") 76 | let message2 = ChatMessage(author: ChatUser(name: "Bob"), content: "This is a long\nmultiline message!", repliedToMessageId: message1.id, wasEncrypted: true) 77 | let message3 = ChatMessage(author: ChatUser(name: "Charles"), content: .encrypted(ChatCryptoCipherData(sealed: Data(), signature: Data(), ephemeralPublicKey: Data())), repliedToMessageId: message1.id) 78 | let messages = Messages(messages: [ 79 | message1, 80 | message2, 81 | message3 82 | ]) 83 | 84 | return VStack { 85 | BubbleMessageView(message: message1, isMe: false) 86 | BubbleMessageView(message: message2, isMe: true) 87 | BubbleMessageView(message: message3, isMe: true) 88 | } 89 | .environmentObject(messages) 90 | } 91 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ChannelSnippetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelSnippetView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import SwiftUI 9 | import DistributedChatKit 10 | 11 | struct ChannelSnippetView: View { 12 | let channel: ChatChannel 13 | 14 | @EnvironmentObject private var messages: Messages 15 | @EnvironmentObject private var settings: Settings 16 | @EnvironmentObject private var network: Network 17 | 18 | var body: some View { 19 | HStack { 20 | if messages.unreadChannels.contains(channel) { 21 | Image(systemName: "circlebadge.fill") 22 | .foregroundColor(.blue) 23 | } else if case .dm(_) = channel { 24 | Image(systemName: "at") 25 | } else { 26 | Image(systemName: "number") 27 | } 28 | VStack(alignment: .leading) { 29 | Text(channel.rawDisplayName(with: network)) 30 | .font(.headline) 31 | if let message = messages[channel].last, 32 | settings.presentation.showChannelPreviews { 33 | PlainMessageView(message: message) 34 | .font(.subheadline) 35 | .foregroundColor(.secondary) 36 | } 37 | } 38 | if messages.pinnedChannels.contains(channel) { 39 | Spacer() 40 | Image(systemName: "pin.circle.fill") 41 | } 42 | } 43 | } 44 | } 45 | 46 | #Preview { 47 | let messages = Messages() 48 | let settings = Settings() 49 | let network = Network(messages: messages) 50 | 51 | return ChannelSnippetView(channel: .room("test")) 52 | .environmentObject(messages) 53 | .environmentObject(settings) 54 | .environmentObject(network) 55 | } 56 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ChannelView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChannelView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/22/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct ChannelView: View { 12 | let channel: ChatChannel 13 | let controller: ChatController 14 | 15 | @EnvironmentObject private var messages: Messages 16 | @EnvironmentObject private var network: Network 17 | @State private var replyingToMessageId: UUID? 18 | 19 | var body: some View { 20 | MessageHistoryView( 21 | channel: channel, 22 | controller: controller, 23 | replyingToMessageId: $replyingToMessageId 24 | ) 25 | .safeAreaInset(edge: .bottom) { 26 | MessageComposeView( 27 | channel: channel, 28 | controller: controller, 29 | replyingToMessageId: $replyingToMessageId 30 | ) 31 | .padding(10) 32 | .background(.regularMaterial) 33 | } 34 | .navigationTitle(channel.displayName(with: network)) 35 | .navigationBarTitleDisplayMode(.inline) 36 | .onAppear { 37 | messages.autoReadChannels.insert(channel) 38 | messages.markAsRead(channel: channel) 39 | } 40 | .onDisappear { 41 | messages.autoReadChannels.remove(channel) 42 | } 43 | } 44 | } 45 | 46 | #Preview { 47 | let controller = ChatController(transport: MockTransport()) 48 | let alice = controller.me 49 | let bob = ChatUser(name: "Bob") 50 | let messages = Messages(messages: [ 51 | ChatMessage(author: alice, content: "Hello!"), 52 | ChatMessage(author: bob, content: "Hi!"), 53 | ChatMessage(author: bob, content: "This is fancy!"), 54 | ]) 55 | let settings = Settings() 56 | let network = Network(myId: controller.me.id, messages: messages) 57 | let navigation = Navigation() 58 | 59 | return ChannelView(channel: .global, controller: controller) 60 | .environmentObject(messages) 61 | .environmentObject(settings) 62 | .environmentObject(network) 63 | .environmentObject(navigation) 64 | } 65 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ChatStatus+Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatStatus+Color.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/28/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | extension ChatStatus { 12 | var color: Color { 13 | switch self { 14 | case .online: 15 | return .green 16 | case .away: 17 | return .yellow 18 | case .busy: 19 | return .red 20 | case .offline: 21 | return .gray 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ClosableStatusBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosableStatusBar.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ClosableStatusBar: View where V: View { 11 | let onClose: () -> Void 12 | let content: () -> V 13 | 14 | var body: some View { 15 | HStack { 16 | content() 17 | Spacer() 18 | Button(action: onClose) { 19 | Image(systemName: "xmark.circle") 20 | } 21 | } 22 | } 23 | } 24 | 25 | #Preview { 26 | ClosableStatusBar {} content: { 27 | Text("Test") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /DistributedChatApp/View/CompactMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompactMessageView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct CompactMessageView: View { 12 | let message: ChatMessage 13 | 14 | var body: some View { 15 | HStack(alignment: .top) { 16 | if message.isEncrypted { 17 | Image(systemName: "lock.fill") 18 | Text("Encrypted") 19 | } else { 20 | Text("\(message.author.displayName):") 21 | .fontWeight(.bold) 22 | if let content = message.content.asText { 23 | Text(content) 24 | } 25 | ForEach(message.attachments ?? []) { attachment in 26 | AttachmentView(attachment: attachment) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | CompactMessageView(message: ChatMessage(author: ChatUser(name: "Alice"), content: "Test")) 35 | } 36 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ContactAttachmentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContactAttachmentView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/2/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct ContactAttachmentView: View { 12 | let attachment: ChatAttachment 13 | 14 | var body: some View { 15 | QuickLookAttachmentView(attachment: attachment) { 16 | HStack { 17 | Image(systemName: "person.fill") 18 | Text(attachment.name) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/17/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct ContentView: View { 12 | let controller: ChatController 13 | 14 | @EnvironmentObject private var messages: Messages 15 | @EnvironmentObject private var navigation: Navigation 16 | 17 | var body: some View { 18 | TabView { 19 | ChannelsView(channels: messages.channels, controller: controller) 20 | .tabItem { 21 | VStack { 22 | Image(systemName: "message.fill") 23 | Text("Channels") 24 | } 25 | } 26 | NetworkView() 27 | .tabItem { 28 | VStack { 29 | Image(systemName: "network") 30 | Text("Network") 31 | } 32 | } 33 | ProfileView() 34 | .tabItem { 35 | VStack { 36 | Image(systemName: "person.circle.fill") 37 | Text("Profile") 38 | } 39 | } 40 | SettingsView() 41 | .tabItem { 42 | VStack { 43 | Image(systemName: "gear") 44 | Text("Settings") 45 | } 46 | } 47 | } 48 | .environmentObject(messages) 49 | } 50 | } 51 | 52 | #Preview { 53 | let settings = Settings() 54 | let messages = Messages() 55 | let navigation = Navigation() 56 | let profile = Profile() 57 | let network = Network(myId: profile.me.id, messages: messages) 58 | 59 | return ContentView(controller: ChatController(transport: MockTransport())) 60 | .environmentObject(settings) 61 | .environmentObject(messages) 62 | .environmentObject(navigation) 63 | .environmentObject(network) 64 | .environmentObject(profile) 65 | } 66 | -------------------------------------------------------------------------------- /DistributedChatApp/View/EnumPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnumPicker.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A segmented picker that displays all cases from a string-based enum. 11 | public struct EnumPicker: View 12 | where 13 | L: View, 14 | T: CaseIterable & CustomStringConvertible & Hashable, 15 | T.AllCases: RandomAccessCollection, 16 | T.AllCases.Index == Int { 17 | @Binding private var selection: T 18 | private let label: L 19 | 20 | public init(selection: Binding, label: L) { 21 | self._selection = selection 22 | self.label = label 23 | } 24 | 25 | public var body: some View { 26 | Picker(selection: $selection, label: label) { 27 | ForEach(0.. Void 17 | 18 | func makeCoordinator() -> Coordinator { 19 | Coordinator(onComplete: onComplete) 20 | } 21 | 22 | func makeUIViewController(context: Context) -> UIImagePickerController { 23 | let vc = UIImagePickerController() 24 | vc.sourceType = sourceType.usingUIKit 25 | vc.delegate = context.coordinator 26 | return vc 27 | } 28 | 29 | func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { 30 | // Do nothing 31 | } 32 | 33 | enum SourceType { 34 | case photoLibrary 35 | case savedPhotosAlbum 36 | case camera 37 | 38 | var usingUIKit: UIImagePickerController.SourceType { 39 | switch self { 40 | case .photoLibrary: 41 | return .photoLibrary 42 | case .savedPhotosAlbum: 43 | return .savedPhotosAlbum 44 | case .camera: 45 | return .camera 46 | } 47 | } 48 | } 49 | 50 | class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { 51 | private let onComplete: (URL?) -> Void 52 | 53 | init(onComplete: @escaping (URL?) -> Void) { 54 | self.onComplete = onComplete 55 | } 56 | 57 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { 58 | if picker.sourceType == .camera, let image = info[.originalImage] as? UIImage { 59 | let url = persistenceFileURL(path: "CameraRoll/\(UUID()).jpg") 60 | do { 61 | try image.jpegData(compressionQuality: 0.4)?.smartWrite(to: url) 62 | onComplete(url) 63 | } catch { 64 | log.error("Could not write image \(error)") 65 | onComplete(nil) 66 | } 67 | } else if let url = info[.imageURL] as? URL { 68 | onComplete(url) 69 | } else { 70 | log.warning("No image picked") 71 | onComplete(nil) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /DistributedChatApp/View/MessageHistoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageHistoryView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct MessageHistoryView: View { 13 | let channel: ChatChannel? 14 | let controller: ChatController 15 | @Binding var replyingToMessageId: UUID? 16 | 17 | @EnvironmentObject private var messages: Messages 18 | @EnvironmentObject private var settings: Settings 19 | 20 | var body: some View { 21 | ScrollView(.vertical) { 22 | ScrollViewReader { scrollView in 23 | VStack(alignment: .leading) { 24 | ForEach(messages[channel]) { message in 25 | MessageView(message: message, controller: controller, replyingToMessageId: $replyingToMessageId) { id in 26 | scrollView.scrollTo(id) 27 | } 28 | } 29 | } 30 | .padding(15) 31 | .frame( // Ensure that the VStack actually fills the parent's width 32 | minWidth: 0, 33 | maxWidth: .infinity, 34 | minHeight: 0, 35 | maxHeight: .infinity, 36 | alignment: .topLeading 37 | ) 38 | .onAppear { 39 | if let id = messages[channel].last?.id { 40 | scrollView.scrollTo(id) 41 | } 42 | } 43 | .onChange(of: messages.messages) { 44 | if let id = messages[channel].last?.id { 45 | scrollView.scrollTo(id) 46 | } 47 | } 48 | } 49 | } 50 | .scrollDismissesKeyboard(.interactively) 51 | } 52 | } 53 | 54 | #Preview { 55 | let controller = ChatController(transport: MockTransport()) 56 | let alice = controller.me 57 | let bob = ChatUser(name: "Bob") 58 | let messages = Messages(messages: [ 59 | ChatMessage(author: alice, content: "Hello!"), 60 | ChatMessage(author: bob, content: "Hi!"), 61 | ChatMessage(author: bob, content: "This is fancy!"), 62 | ]) 63 | let settings = Settings() 64 | let navigation = Navigation() 65 | let replyingToMessageId: UUID? = nil 66 | 67 | return MessageHistoryView(channel: nil, controller: controller, replyingToMessageId: .constant(replyingToMessageId)) 68 | .environmentObject(messages) 69 | .environmentObject(settings) 70 | .environmentObject(navigation) 71 | } 72 | -------------------------------------------------------------------------------- /DistributedChatApp/View/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/1/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct MessageView: View { 12 | let message: ChatMessage 13 | let controller: ChatController 14 | @Binding var replyingToMessageId: UUID? 15 | var onJumpToMessage: ((UUID) -> Void)? = nil 16 | 17 | @EnvironmentObject private var messages: Messages 18 | @EnvironmentObject private var settings: Settings 19 | @EnvironmentObject private var navigation: Navigation 20 | 21 | var body: some View { 22 | let menuItems = Group { 23 | Button { 24 | messages.deleteMessage(id: message.id) 25 | } label: { 26 | Label("Delete Locally", systemImage: "trash") 27 | } 28 | Button { 29 | replyingToMessageId = message.id 30 | } label: { 31 | Label("Reply", systemImage: "arrowshape.turn.up.left.fill") 32 | } 33 | if messages.unreadMessageIds.contains(message.id) { 34 | Button { 35 | messages.unreadMessageIds.remove(message.id) 36 | } label: { 37 | Label("Mark as Read", systemImage: "circlebadge") 38 | } 39 | } else { 40 | Button { 41 | messages.unreadMessageIds.insert(message.id) 42 | } label: { 43 | Label("Mark as Unread", systemImage: "circlebadge.fill") 44 | } 45 | } 46 | if !message.displayContent.isEmpty { 47 | ShareLink(item: message.displayContent) { 48 | Label("Share Text", systemImage: "square.and.arrow.up") 49 | } 50 | } 51 | ForEach(message.attachments ?? []) { attachment in 52 | if let url = attachment.content.asURL { 53 | ShareLink(item: url.smartResolved) { 54 | Label("Share \(attachment.type) (\(attachment.name))", systemImage: "square.and.arrow.up") 55 | } 56 | } 57 | } 58 | if !message.displayContent.isEmpty { 59 | Button { 60 | UIPasteboard.general.string = message.displayContent 61 | } label: { 62 | Label("Copy Text", systemImage: "doc.on.doc") 63 | } 64 | } 65 | Button { 66 | UIPasteboard.general.string = message.id.uuidString 67 | } label: { 68 | Label("Copy Message ID", systemImage: "doc.on.doc") 69 | } 70 | Button { 71 | UIPasteboard.general.url = URL(string: "distributedchat:///message/\(message.id)") 72 | } label: { 73 | Label("Copy Message URL", systemImage: "doc.on.doc.fill") 74 | } 75 | Button { 76 | UIPasteboard.general.string = message.author.id.uuidString 77 | } label: { 78 | Label("Copy Author ID", systemImage: "doc.on.doc") 79 | } 80 | Button { 81 | UIPasteboard.general.string = message.author.name 82 | } label: { 83 | Label("Copy Author Name", systemImage: "doc.on.doc") 84 | } 85 | Button { 86 | navigation.open(channel: .dm([controller.me.id, message.author.id])) 87 | } label: { 88 | Label("Open DM channel", systemImage: "at") 89 | } 90 | } 91 | 92 | VStack { 93 | switch settings.presentation.messageHistoryStyle { 94 | case .compact: 95 | CompactMessageView(message: message) 96 | .contextMenu { menuItems } 97 | case .bubbles: 98 | let isMe = controller.me.id == message.author.id 99 | HStack { 100 | if isMe { Spacer() } 101 | BubbleMessageView(message: message, isMe: isMe) { repliedToId in 102 | onJumpToMessage?(repliedToId) 103 | } 104 | .contextMenu { menuItems } 105 | if !isMe { Spacer() } 106 | } 107 | } 108 | } 109 | .draggable(message) 110 | } 111 | } 112 | 113 | #Preview { 114 | let controller = ChatController(transport: MockTransport()) 115 | let alice = controller.me 116 | let bob = ChatUser(name: "Bob") 117 | let messages = Messages(messages: [ 118 | ChatMessage(author: alice, content: "Hello!"), 119 | ChatMessage(author: bob, content: "Hi!"), 120 | ChatMessage(author: bob, content: "This is fancy!"), 121 | ]) 122 | let settings = Settings() 123 | let navigation = Navigation() 124 | let replyingToMessageId: UUID? = nil 125 | 126 | return MessageView(message: messages.messages.values.first { $0.content == "Hello!" }!, controller: controller, replyingToMessageId: .constant(replyingToMessageId)) 127 | .environmentObject(messages) 128 | .environmentObject(settings) 129 | .environmentObject(navigation) 130 | } 131 | -------------------------------------------------------------------------------- /DistributedChatApp/View/NetworkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import DistributedChatBluetooth 10 | import SwiftUI 11 | 12 | struct NetworkView: View { 13 | @EnvironmentObject private var network: Network 14 | @EnvironmentObject private var navigation: Navigation 15 | 16 | var body: some View { 17 | NavigationStack { 18 | Form { 19 | Section(header: Text("Nearby Users")) { 20 | List(network.nearbyUsers) { user in 21 | HStack { 22 | Text(user.displayName) 23 | Spacer() 24 | if let rssi = user.rssi { 25 | Image(systemName: "antenna.radiowaves.left.and.right") 26 | Text("\(rssi) dB") 27 | } 28 | } 29 | .contextMenu { 30 | if let chatUser = user.chatUser { 31 | Button { 32 | UIPasteboard.general.string = chatUser.id.uuidString 33 | } label: { 34 | Label("Copy User ID", systemImage: "doc.on.doc") 35 | } 36 | Button { 37 | UIPasteboard.general.string = chatUser.name 38 | } label: { 39 | Label("Copy User Name", systemImage: "doc.on.doc") 40 | } 41 | Button { 42 | navigation.open(channel: .dm([network.myId, chatUser.id])) 43 | } label: { 44 | Label("Open DM channel", systemImage: "at") 45 | } 46 | } 47 | Button { 48 | UIPasteboard.general.string = user.peripheralIdentifier.uuidString 49 | } label: { 50 | Label("Copy Peripheral ID", systemImage: "doc.on.doc") 51 | } 52 | if let peripheralName = user.peripheralName { 53 | Button { 54 | UIPasteboard.general.string = peripheralName 55 | } label: { 56 | Label("Copy Peripheral Name", systemImage: "doc.on.doc") 57 | } 58 | } 59 | if let rssi = user.rssi { 60 | Button { 61 | UIPasteboard.general.string = String(rssi) 62 | } label: { 63 | Label("Copy RSSI", systemImage: "doc.on.doc") 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | Section(header: Text("Presences")) { 71 | List(network.allPresences) { presence in 72 | PresenceView(presence: presence) 73 | } 74 | } 75 | } 76 | .navigationTitle("Network") 77 | } 78 | } 79 | } 80 | 81 | #Preview { 82 | let alice = ChatUser(name: "Alice") 83 | let bob = ChatUser(name: "Bob") 84 | let network = Network(nearbyUsers: [ 85 | NearbyUser(peripheralIdentifier: UUID(uuidString: "6b61a69b-f4b4-4321-92db-9d61653ddaf6")!, chatUser: alice, rssi: -49), 86 | NearbyUser(peripheralIdentifier: UUID(uuidString: "b7b7d248-9640-490d-8187-44fc9ebfa1ff")!, chatUser: bob, rssi: -55), 87 | ], presences: [ 88 | ChatPresence(user: alice, status: .online), 89 | ChatPresence(user: bob, status: .away, info: "At the gym"), 90 | ], messages: Messages()) 91 | let navigation = Navigation() 92 | 93 | return NetworkView() 94 | .environmentObject(network) 95 | .environmentObject(navigation) 96 | } 97 | -------------------------------------------------------------------------------- /DistributedChatApp/View/NewChannelView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewChannelView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import SwiftUI 9 | import DistributedChatKit 10 | 11 | // TODO: Support creation of DM channels 12 | 13 | struct NewChannelView: View { 14 | let onCommit: (ChatChannel) -> Void 15 | 16 | @EnvironmentObject private var network: Network 17 | @State private var channelNameDraft: String = "" 18 | 19 | var body: some View { 20 | VStack { 21 | HStack { 22 | Image(systemName: "number") 23 | AutoFocusTextField(placeholder: "new-room-channel", text: $channelNameDraft, onCommit: { 24 | if !channelNameDraft.isEmpty { 25 | // Enforce lower-kebab-case 26 | let finalDraft = channelNameDraft 27 | .lowercased() 28 | .trimmingCharacters(in: .whitespacesAndNewlines) 29 | .replacingOccurrences(of: " ", with: "-") 30 | 31 | onCommit(.room(finalDraft)) 32 | } 33 | }) 34 | .font(.title2) 35 | } 36 | Text("...or add a DM channel:") 37 | .font(.caption) 38 | List(network.allPresences) { presence in 39 | Button { 40 | onCommit(.dm([network.myId, presence.user.id])) 41 | } label: { 42 | PresenceView(presence: presence) 43 | } 44 | } 45 | } 46 | .padding(20) 47 | } 48 | } 49 | 50 | #Preview { 51 | let network = Network() 52 | 53 | return NewChannelView { _ in } 54 | .environmentObject(network) 55 | } 56 | -------------------------------------------------------------------------------- /DistributedChatApp/View/PlainMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlainMessageView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct PlainMessageView: View { 12 | let message: ChatMessage 13 | 14 | var body: some View { 15 | Text("\(message.author.displayName): \(message.displayContent)") 16 | } 17 | } 18 | 19 | #Preview { 20 | PlainMessageView(message: ChatMessage(author: ChatUser(name: "Alice"), content: "Test")) 21 | } 22 | -------------------------------------------------------------------------------- /DistributedChatApp/View/PresenceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresenceView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/5/21. 6 | // 7 | 8 | import SwiftUI 9 | import DistributedChatKit 10 | 11 | struct PresenceView: View { 12 | let presence: ChatPresence 13 | 14 | @EnvironmentObject private var network: Network 15 | @EnvironmentObject private var navigation: Navigation 16 | 17 | var body: some View { 18 | HStack { 19 | Image(systemName: "circlebadge.fill") 20 | .foregroundColor(presence.status.color) 21 | VStack(alignment: .leading) { 22 | Text(presence.user.displayName) 23 | .multilineTextAlignment(.leading) 24 | if !presence.info.isEmpty { 25 | Text(presence.info) 26 | .multilineTextAlignment(.leading) 27 | .font(.subheadline) 28 | .foregroundColor(.secondary) 29 | } 30 | } 31 | } 32 | .contextMenu { 33 | Button { 34 | UIPasteboard.general.string = presence.user.id.uuidString 35 | } label: { 36 | Label("Copy User ID", systemImage: "doc.on.doc") 37 | } 38 | Button { 39 | UIPasteboard.general.string = presence.user.name 40 | } label: { 41 | Label("Copy User Name", systemImage: "doc.on.doc") 42 | } 43 | if !presence.info.isEmpty { 44 | Button { 45 | UIPasteboard.general.string = presence.info 46 | } label: { 47 | Label("Copy Status Info", systemImage: "doc.on.doc") 48 | } 49 | } 50 | Button { 51 | navigation.open(channel: .dm([network.myId, presence.user.id])) 52 | } label: { 53 | Label("Open DM channel", systemImage: "at") 54 | } 55 | } 56 | } 57 | } 58 | 59 | #Preview { 60 | let network = Network() 61 | let navigation = Navigation() 62 | 63 | return PresenceView(presence: ChatPresence(user: .init())) 64 | .environmentObject(network) 65 | .environmentObject(navigation) 66 | } 67 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProfileView: View { 11 | @EnvironmentObject private var profile: Profile 12 | 13 | var body: some View { 14 | NavigationStack { 15 | VStack(alignment: .center, spacing: 40) { 16 | EnumPicker(selection: $profile.presence.status, label: ZStack(alignment: .bottomTrailing) { 17 | Image(systemName: "person.circle.fill") 18 | .resizable() 19 | .frame(width: 80, height: 80, alignment: .center) 20 | .foregroundColor(.primary) 21 | Circle() 22 | .frame(width: 20, height: 20, alignment: .center) 23 | .foregroundColor(profile.presence.status.color) 24 | }) 25 | .pickerStyle(MenuPickerStyle()) 26 | 27 | VStack { 28 | TextField("Your nickname", text: $profile.presence.user.name) 29 | .font(.title2) 30 | .multilineTextAlignment(.center) 31 | TextField("Your custom status", text: $profile.presence.info) 32 | .multilineTextAlignment(.center) 33 | } 34 | } 35 | .padding(20) 36 | .navigationTitle("Profile") 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | let profile = Profile() 43 | 44 | return ProfileView() 45 | .environmentObject(profile) 46 | } 47 | -------------------------------------------------------------------------------- /DistributedChatApp/View/QuickLookAttachment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickLookAttachment.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import Foundation 10 | import Logging 11 | import QuickLook 12 | 13 | fileprivate let log = Logger(label: "DistributedChatApp.QuickLookAttachment") 14 | 15 | class QuickLookAttachment: NSObject, QLPreviewItem { 16 | private let attachment: ChatAttachment 17 | private let tempURL: URL? 18 | 19 | var previewItemURL: URL? { tempURL ?? attachment.content.asURL?.smartResolved } 20 | var previewItemTitle: String? { attachment.name } 21 | 22 | init(attachment: ChatAttachment, useTempFile: Bool = false) throws { 23 | self.attachment = attachment 24 | 25 | if useTempFile, let attachmentUrl = attachment.content.asURL { 26 | let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.name) 27 | try Data.smartContents(of: attachmentUrl).smartWrite(to: url) // might overwrite an old file with that attachment name 28 | tempURL = url 29 | } else { 30 | tempURL = nil 31 | } 32 | } 33 | 34 | deinit { 35 | if let url = tempURL { 36 | log.info("Cleaning up temporary file from attachment...") 37 | try? FileManager.default.removeItem(at: url) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /DistributedChatApp/View/QuickLookAttachmentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageAttachmentView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 2/2/21. 6 | // 7 | 8 | import DistributedChatKit 9 | import SwiftUI 10 | 11 | struct QuickLookAttachmentView: View where Content: View { 12 | private let attachment: ChatAttachment 13 | private let content: () -> Content 14 | 15 | @State private var quickLookShown: Bool = false 16 | 17 | var body: some View { 18 | Button { 19 | quickLookShown = true 20 | } label: { 21 | content() 22 | } 23 | .sheet(isPresented: $quickLookShown) { 24 | NavigationStack { 25 | Group { 26 | if let item = try? QuickLookAttachment(attachment: attachment) { QuickLookView(item: item) 27 | .ignoresSafeArea() 28 | } 29 | } 30 | .navigationTitle(attachment.name) 31 | .navigationBarTitleDisplayMode(.inline) 32 | .toolbar { 33 | ToolbarItem(placement: .topBarLeading) { 34 | ShareLink(item: attachment, preview: SharePreview("Test")) { 35 | Image(systemName: "square.and.arrow.up") 36 | } 37 | } 38 | ToolbarItem(placement: .topBarTrailing) { 39 | Button("Close") { 40 | quickLookShown = false 41 | } 42 | .foregroundColor(.primary) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | init(attachment: ChatAttachment, @ViewBuilder content: @escaping () -> Content) { 50 | self.attachment = attachment 51 | self.content = content 52 | } 53 | } 54 | 55 | #Preview { 56 | QuickLookAttachmentView(attachment: ChatAttachment(name: "test.txt", content: .url(URL(string: "data:text/plain;base64,dGVzdDEyMwo=")!))) { 57 | Text("Test") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /DistributedChatApp/View/QuickLookView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickLookView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/24/21. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | import QuickLook 11 | 12 | struct QuickLookView: UIViewControllerRepresentable where I: QLPreviewItem { 13 | let item: I 14 | 15 | func makeCoordinator() -> Coordinator { 16 | Coordinator(item: item) 17 | } 18 | 19 | func makeUIViewController(context: Context) -> some UIViewController { 20 | let vc = QLPreviewController() 21 | vc.delegate = context.coordinator 22 | vc.dataSource = context.coordinator 23 | return vc 24 | } 25 | 26 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { 27 | // Do nothing 28 | } 29 | 30 | class Coordinator: NSObject, QLPreviewControllerDelegate, QLPreviewControllerDataSource { 31 | private let item: I 32 | 33 | init(item: I) { 34 | self.item = item 35 | } 36 | 37 | func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 38 | 1 39 | } 40 | 41 | func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { 42 | item 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DistributedChatApp/View/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | @EnvironmentObject private var settings: Settings 12 | 13 | var body: some View { 14 | NavigationStack { 15 | Form { 16 | Section(header: Text("Presentation")) { 17 | EnumPicker(selection: $settings.presentation.messageHistoryStyle, label: Text("Message History Style")) 18 | .pickerStyle(SegmentedPickerStyle()) 19 | Toggle(isOn: $settings.presentation.showChannelPreviews) { 20 | Text("Show Channel Previews") 21 | } 22 | } 23 | Section(header: Text("Bluetooth")) { 24 | Toggle(isOn: $settings.bluetooth.advertisingEnabled) { 25 | Text("Advertise to nearby devices") 26 | } 27 | Toggle(isOn: $settings.bluetooth.scanningEnabled) { 28 | Text("Scan for nearby devices") 29 | } 30 | Toggle(isOn: $settings.bluetooth.monitorSignalStrength) { 31 | Text("Monitor signal strengths") 32 | } 33 | if settings.bluetooth.monitorSignalStrength { 34 | HStack { 35 | Text("Monitoring interval in seconds") 36 | Spacer() 37 | TextField("sec", text: Binding( 38 | get: { String(settings.bluetooth.monitorSignalStrengthInterval) }, 39 | set: { 40 | if let value = Int($0) { 41 | settings.bluetooth.monitorSignalStrengthInterval = value 42 | } 43 | } 44 | )) 45 | .multilineTextAlignment(.trailing) 46 | .fixedSize() 47 | .keyboardType(.numberPad) 48 | } 49 | } 50 | } 51 | } 52 | .navigationTitle("Settings") 53 | } 54 | } 55 | } 56 | 57 | #Preview { 58 | let settings = Settings() 59 | 60 | return SettingsView() 61 | .environmentObject(settings) 62 | } 63 | -------------------------------------------------------------------------------- /DistributedChatApp/View/ViewUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | import SwiftUI 9 | import DistributedChatKit 10 | 11 | /// The size of icons e.g. in the compose bar 12 | let iconSize: CGFloat = 22 13 | 14 | /// The displayed name of the 'global' channel, internally represented with nil 15 | fileprivate let globalChannelName = "global" 16 | 17 | extension ChatChannel { 18 | func rawDisplayName(with network: Network) -> String { 19 | switch self { 20 | case .room(let name): 21 | return name 22 | case .dm(let userIds): 23 | if userIds.count == 1, let userId = userIds.first { 24 | return name(of: userId, with: network) 25 | } else { 26 | return userIds 27 | .filter { $0 != network.myId } 28 | .map { name(of: $0, with: network) } 29 | .joined(separator: ",") 30 | } 31 | case .global: 32 | return globalChannelName 33 | } 34 | } 35 | 36 | private func name(of userId: UUID, with network: Network) -> String { 37 | (network.presences[userId] ?? network.offlinePresences[userId])?.user.displayName ?? userId.uuidString 38 | } 39 | 40 | func displayName(with network: Network) -> String { 41 | switch self { 42 | case .dm(_): 43 | return "@\(rawDisplayName(with: network))" 44 | default: 45 | return "#\(rawDisplayName(with: network))" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /DistributedChatApp/View/VoiceNoteAttachmentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoiceNoteAttachmentView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import AVFoundation 9 | import AVKit 10 | import DistributedChatKit 11 | import SwiftUI 12 | 13 | struct VoiceNoteAttachmentView: View { 14 | let attachment: ChatAttachment 15 | let color: Color 16 | 17 | @StateObject private var player = AudioPlayer() 18 | 19 | var body: some View { 20 | HStack { 21 | if player.isReady { 22 | Button { 23 | player.isPlaying = !player.isPlaying 24 | } label: { 25 | if player.isPlaying { 26 | Image(systemName: "pause.fill") 27 | } else { 28 | Image(systemName: "play.fill") 29 | } 30 | } 31 | .font(.system(size: 24)) 32 | if let url = player.url { 33 | WaveformView(url: url, color: color) 34 | .frame(width: 80, height: 30) 35 | } 36 | } 37 | } 38 | .onAppear { 39 | if let url = attachment.content.asURL { 40 | player.url = url 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /DistributedChatApp/View/VoiceNoteRecordButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoiceNoteRecordButton.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VoiceNoteRecordButton: View { 11 | let onFinishRecording: (URL) -> Void 12 | 13 | @StateObject private var recorder = try! AudioRecorder(name: "voiceNote") 14 | 15 | @ViewBuilder 16 | var body: some View { 17 | HStack { 18 | if recorder.isRecording { 19 | HStack { 20 | Image(systemName: "stop.fill") 21 | .scaleEffect(4.0) 22 | } 23 | .foregroundColor(.red) 24 | } else { 25 | Image(systemName: "mic.fill") 26 | .foregroundColor(.blue) 27 | } 28 | } 29 | .onReceive(recorder.$isCompleted) { 30 | if $0 { 31 | onFinishRecording(recorder.url) 32 | } 33 | } 34 | .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { isRecording in 35 | recorder.isRecording = isRecording 36 | }) {} 37 | } 38 | } 39 | 40 | #Preview { 41 | VoiceNoteRecordButton { _ in } 42 | } 43 | -------------------------------------------------------------------------------- /DistributedChatApp/View/WaveformView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WaveformView.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/30/21. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | import FDWaveformView 11 | 12 | struct WaveformView: UIViewRepresentable { 13 | let url: URL 14 | let color: Color 15 | 16 | func makeCoordinator() -> Coordinator { 17 | Coordinator() 18 | } 19 | 20 | func makeUIView(context: Context) -> some UIView { 21 | let view = FDWaveformView() 22 | view.delegate = context.coordinator 23 | view.audioURL = url.smartResolved 24 | view.wavesColor = UIColor(color) 25 | view.progressColor = UIColor(color) 26 | view.doesAllowScroll = false 27 | view.doesAllowStretch = false 28 | view.doesAllowScrubbing = false 29 | return view 30 | } 31 | 32 | func updateUIView(_ uiView: UIViewType, context: Context) { 33 | // TODO 34 | } 35 | 36 | class Coordinator: NSObject, FDWaveformViewDelegate { 37 | // TODO 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DistributedChatApp/ViewModel/Navigation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Navigation.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/25/21. 6 | // 7 | 8 | import Combine 9 | import DistributedChatKit 10 | 11 | class Navigation: ObservableObject { 12 | @Published var activeChannel: ChatChannel? = nil 13 | 14 | func open(channel: ChatChannel) { 15 | activeChannel = channel 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /DistributedChatAppTests/DistributedChatAppTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DistributedChatAppTests.swift 3 | // DistributedChatAppTests 4 | // 5 | // Created by Fredrik on 1/17/21. 6 | // 7 | 8 | import XCTest 9 | @testable import DistributedChatApp 10 | 11 | class DistributedChatAppTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /DistributedChatAppTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /DistributedChatAppUITests/DistributedChatAppUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DistributedChatAppUITests.swift 3 | // DistributedChatAppUITests 4 | // 5 | // Created by Fredrik on 1/17/21. 6 | // 7 | 8 | import XCTest 9 | 10 | class DistributedChatAppUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /DistributedChatAppUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "e87f5d47364fb89fcf77fc0f369785dee2b937cb81059f72753dede915152106", 3 | "pins" : [ 4 | { 5 | "identity" : "bluetooth", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/PureSwift/Bluetooth.git", 8 | "state" : { 9 | "revision" : "974ee4313fc8fe707bd5298009b810c0bca45d44", 10 | "version" : "6.4.3" 11 | } 12 | }, 13 | { 14 | "identity" : "bluetoothlinux", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/PureSwift/BluetoothLinux.git", 17 | "state" : { 18 | "branch" : "master", 19 | "revision" : "372fd17a1fcff56d13b8bdc4d082478cda1e9f65" 20 | } 21 | }, 22 | { 23 | "identity" : "gatt", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/PureSwift/GATT.git", 26 | "state" : { 27 | "branch" : "master", 28 | "revision" : "113a71d853b9c59cf2119c49fbc4bfc1421d9ae2" 29 | } 30 | }, 31 | { 32 | "identity" : "socket", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/PureSwift/Socket.git", 35 | "state" : { 36 | "branch" : "main", 37 | "revision" : "489e63b9cf0998f820f9f994f25cd5cb5f602404" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-crypto", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-crypto.git", 44 | "state" : { 45 | "revision" : "60f13f60c4d093691934dc6cfdf5f508ada1f894", 46 | "version" : "2.6.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-log", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-log.git", 53 | "state" : { 54 | "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", 55 | "version" : "1.6.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-system", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/PureSwift/swift-system", 62 | "state" : { 63 | "branch" : "feature/dynamic-lib", 64 | "revision" : "bd3d1f6321a489ee443860ebe0f26ff689c938ad" 65 | } 66 | } 67 | ], 68 | "version" : 3 69 | } 70 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | var packageDependencies: [Package.Dependency] = [ 7 | .package(path: "../DistributedChatKit"), 8 | ] 9 | 10 | var targetDependencies: [Target.Dependency] = [ 11 | .product(name: "DistributedChatKit", package: "DistributedChatKit"), 12 | ] 13 | 14 | #if os(Linux) 15 | packageDependencies += [ 16 | .package(url: "https://github.com/PureSwift/BluetoothLinux.git", branch: "master"), 17 | .package(url: "https://github.com/PureSwift/GATT.git", branch: "master"), 18 | ] 19 | 20 | targetDependencies += [ 21 | .product(name: "BluetoothLinux", package: "BluetoothLinux", condition: .when(platforms: [.linux])), 22 | .product(name: "GATT", package: "GATT", condition: .when(platforms: [.linux])), 23 | ] 24 | #endif 25 | 26 | let package = Package( 27 | name: "DistributedChatBluetooth", 28 | platforms: [ 29 | .macOS(.v10_15), 30 | .iOS(.v13), 31 | ], 32 | products: [ 33 | // Products define the executables and libraries a package produces, making them visible to other packages. 34 | .library( 35 | name: "DistributedChatBluetooth", 36 | targets: ["DistributedChatBluetooth"] 37 | ), 38 | ], 39 | dependencies: packageDependencies, 40 | targets: [ 41 | // Targets are the basic building blocks of a package, defining a module or a test suite. 42 | // Targets can depend on other targets in this package and products from dependencies. 43 | .target( 44 | name: "DistributedChatBluetooth", 45 | dependencies: targetDependencies 46 | ), 47 | .testTarget( 48 | name: "DistributedChatBluetoothTests", 49 | dependencies: ["DistributedChatBluetooth"] 50 | ), 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/Sources/DistributedChatBluetooth/Controller/BluetoothLinuxError.swift: -------------------------------------------------------------------------------- 1 | public enum BluetoothLinuxError: Error { 2 | case tooFewHostControllers(String) 3 | case noServices 4 | case noCharacteristics 5 | case bleScanFailed(String) 6 | } 7 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/Sources/DistributedChatBluetooth/Model/CoreBluetoothSettings.swift: -------------------------------------------------------------------------------- 1 | public struct CoreBluetoothSettings: Codable { 2 | public var advertisingEnabled: Bool 3 | public var scanningEnabled: Bool 4 | public var monitorSignalStrength: Bool 5 | public var monitorSignalStrengthInterval: Int 6 | 7 | public init( 8 | advertisingEnabled: Bool = true, 9 | scanningEnabled: Bool = true, 10 | monitorSignalStrength: Bool = true, 11 | monitorSignalStrengthInterval: Int = 5 // seconds 12 | ) { 13 | self.advertisingEnabled = advertisingEnabled 14 | self.scanningEnabled = scanningEnabled 15 | self.monitorSignalStrength = monitorSignalStrength 16 | self.monitorSignalStrengthInterval = monitorSignalStrengthInterval 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/Sources/DistributedChatBluetooth/Model/NearbyUser.swift: -------------------------------------------------------------------------------- 1 | import DistributedChatKit 2 | import Foundation 3 | 4 | public struct NearbyUser: Identifiable, Hashable { 5 | public var peripheralIdentifier: UUID 6 | public var peripheralName: String? 7 | public var chatUser: ChatUser? 8 | public var rssi: Int? 9 | 10 | public var id: UUID { peripheralIdentifier } 11 | public var displayName: String { chatUser?.displayName ?? peripheralName ?? peripheralIdentifier.uuidString } 12 | 13 | public init( 14 | peripheralIdentifier: UUID, 15 | peripheralName: String? = nil, 16 | chatUser: ChatUser? = nil, 17 | rssi: Int? = nil // in db 18 | ) { 19 | self.peripheralIdentifier = peripheralIdentifier 20 | self.peripheralName = peripheralName 21 | self.chatUser = chatUser 22 | self.rssi = rssi 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DistributedChatBluetooth/Tests/DistributedChatBluetoothTests/DistributedChatBluetoothTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DistributedChatBluetooth 3 | 4 | final class DistributedChatBluetoothTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatCLI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /DistributedChatCLI/.swiftpm/xcode/xcshareddata/xcschemes/distributed-chat.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /DistributedChatCLI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DistributedChatCLI", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13) 11 | ], 12 | products: [ 13 | .executable( 14 | name: "distributed-chat", 15 | targets: ["DistributedChatCLI"] 16 | ) 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | .package(path: "../DistributedChatKit"), 21 | .package(path: "../DistributedChatBluetooth"), 22 | .package(path: "../DistributedChatSimulationProtocol"), 23 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), 24 | .package(url: "https://github.com/vapor/websocket-kit.git", from: "2.6.1"), 25 | .package(name: "LineNoise", url: "https://github.com/andybest/linenoise-swift.git", branch: "master"), 26 | ], 27 | targets: [ 28 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 29 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 30 | .target( 31 | name: "DistributedChatCLI", 32 | dependencies: [ 33 | .product(name: "DistributedChatKit", package: "DistributedChatKit"), 34 | .product(name: "DistributedChatBluetooth", package: "DistributedChatBluetooth"), 35 | .product(name: "DistributedChatSimulationProtocol", package: "DistributedChatSimulationProtocol"), 36 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 37 | .product(name: "WebSocketKit", package: "websocket-kit"), 38 | .product(name: "LineNoise", package: "LineNoise"), 39 | ] 40 | ) 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /DistributedChatCLI/README.md: -------------------------------------------------------------------------------- 1 | # DistributedChatCLI 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /DistributedChatCLI/Sources/DistributedChatCLI/CLILogHandler.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | 3 | struct CLILogHandler: LogHandler { 4 | let label: String 5 | var logLevel: Logger.Level 6 | var metadata: Logger.Metadata = [:] 7 | 8 | init(label: String, logLevel: Logger.Level = .info) { 9 | self.label = label 10 | self.logLevel = logLevel 11 | } 12 | 13 | func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, file: String, function: String, line: UInt) { 14 | let shortLabel = label.split(separator: ".").last.map(String.init) ?? label 15 | print("\r> \(shortLabel): \(message)\r") 16 | } 17 | 18 | subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { 19 | get { metadata[metadataKey] } 20 | set { metadata[metadataKey] = newValue } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DistributedChatCLI/Sources/DistributedChatCLI/Controller/SimulationTransport.swift: -------------------------------------------------------------------------------- 1 | import DistributedChatKit 2 | import DistributedChatSimulationProtocol 3 | import Foundation 4 | import Logging 5 | import NIO 6 | import WebSocketKit 7 | 8 | fileprivate let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) 9 | fileprivate let encoder = makeJSONEncoder() 10 | fileprivate let decoder = makeJSONDecoder() 11 | fileprivate let log = Logger(label: "SimulationTransport") 12 | 13 | public class SimulationTransport: ChatTransport { 14 | private let ws: WebSocket 15 | 16 | private init(ws: WebSocket) { 17 | self.ws = ws 18 | } 19 | 20 | /// Asynchronously connects to the given URL. 21 | public static func connect(url: URL, name: String, _ handler: @escaping (SimulationTransport) -> Void) { 22 | let _ = WebSocket.connect(to: url, on: group) { ws in 23 | do { 24 | // Identify ourselves with our username to the simulation server 25 | let protoMessage = SimulationProtocol.Message.hello(.init(name: name)) 26 | ws.send(String(data: try encoder.encode(protoMessage), encoding: .utf8)!) 27 | 28 | handler(SimulationTransport(ws: ws)) 29 | } catch { 30 | log.error("Error while encoding hello message: \(error)") 31 | } 32 | } 33 | } 34 | 35 | public func broadcast(_ content: String) { 36 | do { 37 | let protoMessage = SimulationProtocol.Message.broadcast(.init(content: content)) 38 | ws.send(String(data: try encoder.encode(protoMessage), encoding: .utf8)!) 39 | } catch { 40 | log.error("Could not encode simulation protocol message: \(error)") 41 | } 42 | } 43 | 44 | public func onReceive(_ handler: @escaping (String) -> Void) { 45 | ws.eventLoop.execute { 46 | self.ws.onText { _, raw in 47 | do { 48 | let protoMessage = try decoder.decode(SimulationProtocol.Message.self, from: raw.data(using: .utf8)!) 49 | if case let .broadcastNotification(bc) = protoMessage { 50 | handler(bc.content) 51 | } 52 | } catch { 53 | log.error("Could not decode simulation protocol message: \(error)") 54 | } 55 | } 56 | } 57 | } 58 | 59 | deinit { 60 | try! ws.close().wait() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /DistributedChatCLI/Sources/DistributedChatCLI/Model/Network.swift: -------------------------------------------------------------------------------- 1 | import DistributedChatKit 2 | import Foundation 3 | 4 | class Network { 5 | private(set) var presences: [UUID: ChatPresence] = [:] 6 | 7 | @discardableResult 8 | func register(presence: ChatPresence) -> Bool { 9 | let id = presence.user.id 10 | let hasChanged = presences[id] != presence 11 | presences[id] = presence 12 | return hasChanged 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DistributedChatCLI/Sources/DistributedChatCLI/Utils/LoggerLevel+ExpressibleByArgument.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Logging 3 | 4 | extension Logger.Level: ExpressibleByArgument { 5 | public init?(argument: String) { 6 | self.init(rawValue: argument.lowercased()) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /DistributedChatCLI/Sources/DistributedChatCLI/Utils/URL+ExpressibleByArgument.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | 4 | extension URL: ExpressibleByArgument { 5 | public init?(argument: String) { 6 | self.init(string: argument) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /DistributedChatCLI/Sources/DistributedChatCLI/main.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Dispatch 3 | import DistributedChatBluetooth 4 | import DistributedChatKit 5 | import Foundation 6 | import Logging 7 | import LineNoise 8 | 9 | fileprivate let log = Logger(label: "DistributedChatCLI.main") 10 | 11 | struct DistributedChatCLI: AsyncParsableCommand { 12 | static let configuration = CommandConfiguration( 13 | commandName: "distributed-chat" 14 | ) 15 | 16 | @Argument(help: "The messaging WebSocket URL of the simulation server to connect to.") 17 | var simulationMessagingURL: URL = URL(string: "ws://localhost:8080/messaging")! 18 | 19 | @Flag(help: """ 20 | Use Bluetooth LE-based transport instead of the simulation server. 21 | This enables communication with 'real' iOS nodes and is currently only supported on Linux. 22 | Note that this also enables both central and peripheral mode by default, which requires 2 host controllers (i.e. bluetooth adapters). If you only want one of these modes, set --central-only or --peripheral-only. 23 | """) 24 | var bluetooth: Bool = false 25 | 26 | @Flag(help: """ 27 | Whether to only act as a GATT central (i.e. only be able to send messages) via Bluetooth LE. 28 | Only used if --bluetooth is set. 29 | """) 30 | var centralOnly: Bool = false 31 | 32 | @Flag(help: """ 33 | Whether to only act as a GATT peripheral (i.e. only be able to receive messages) via Bluetooth LE. 34 | Only used if --bluetooth is set. 35 | """) 36 | var peripheralOnly: Bool = false 37 | 38 | @Option(help: "The username to use.") 39 | var name: String = "\(ProcessInfo.processInfo.environment.keys.contains("IDE_DISABLED_OS_ACTIVITY_DT_MODE") ? "Xcode" : "CLI") User \(Int.random(in: 10_000..<100_000))" 40 | 41 | @Option(help: "The logging level") 42 | var level: Logger.Level = .info 43 | 44 | func run() async throws { 45 | LoggingSystem.bootstrap { label in 46 | CLILogHandler(label: label, logLevel: label.starts(with: "DistributedChatCLI.") ? level : .info) 47 | } 48 | 49 | let me = ChatUser(name: name) 50 | 51 | if bluetooth { 52 | try await runWithBluetoothLE(me: me) 53 | } else { 54 | runWithSimulationServer(me: me) 55 | } 56 | } 57 | 58 | private func runWithBluetoothLE(me: ChatUser) async throws { 59 | #if os(Linux) 60 | log.info("Initializing Bluetooth Linux transport...") 61 | 62 | var actAsCentral = centralOnly 63 | var actAsPeripheral = peripheralOnly 64 | 65 | if !centralOnly && !peripheralOnly { 66 | actAsCentral = true 67 | actAsPeripheral = true 68 | } 69 | 70 | let transport = try await BluetoothLinuxTransport(actAsPeripheral: actAsPeripheral, actAsCentral: actAsCentral, me: me) 71 | runREPL(transport: transport, me: me) 72 | #else 73 | log.error("The Bluetooth stack is currently Linux-only and requires BluetoothLinux! (TODO: Share the CoreBluetooth-based backend from the iOS app with a potential Mac version of the CLI)") 74 | #endif 75 | } 76 | 77 | private func runWithSimulationServer(me: ChatUser) { 78 | log.info("Connecting to \(simulationMessagingURL)...") 79 | 80 | SimulationTransport.connect(url: simulationMessagingURL, name: name) { transport in 81 | DispatchQueue.main.async { 82 | log.info("Connected to \(simulationMessagingURL)") 83 | runREPL(transport: transport, me: me) 84 | } 85 | } 86 | 87 | // Block the main thread 88 | dispatchMain() 89 | } 90 | 91 | private func runREPL(transport: ChatTransport, me: ChatUser) { 92 | let repl = ChatREPL(transport: transport, me: me) 93 | repl.run() 94 | Foundation.exit(EXIT_SUCCESS) 95 | } 96 | } 97 | 98 | DistributedChatCLI.main() 99 | -------------------------------------------------------------------------------- /DistributedChatKit/.gitignore: -------------------------------------------------------------------------------- 1 | *.xcodeproj 2 | Package.resolved 3 | -------------------------------------------------------------------------------- /DistributedChatKit/.swiftpm/xcode/xcshareddata/xcschemes/DistributedChatKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 56 | 62 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /DistributedChatKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DistributedChatKit", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "DistributedChatKit", 16 | targets: ["DistributedChatKit"] 17 | ) 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), 22 | .package(url: "https://github.com/apple/swift-crypto.git", from: "2.2.4"), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 26 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 27 | .target( 28 | name: "DistributedChatKit", 29 | dependencies: [ 30 | .product(name: "Logging", package: "swift-log"), 31 | .product(name: "Crypto", package: "swift-crypto") 32 | ] 33 | ), 34 | .testTarget( 35 | name: "DistributedChatKitTests", 36 | dependencies: [ 37 | .target(name: "DistributedChatKit") 38 | ] 39 | ) 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Controller/ChatTransport.swift: -------------------------------------------------------------------------------- 1 | /// The transport layer used to perform all communication 2 | /// with other nodes. 3 | /// 4 | /// Could e.g. be based on Bluetooth LE in the real app 5 | /// or the simulation protocol when used with the CLI. 6 | public protocol ChatTransport { 7 | /// Sends a string to all reachable nodes. 8 | func broadcast(_ raw: String) 9 | 10 | /// Adds a handler that is fired whenever a string is 11 | /// received from a node in reach. 12 | func onReceive(_ handler: @escaping (String) -> Void) 13 | } 14 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Controller/ChatTransportWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | fileprivate let encoder = makeJSONEncoder() 5 | fileprivate let decoder = makeJSONDecoder() 6 | fileprivate let log = Logger(label: "DistributedChat.ChatTransportWrapper") 7 | 8 | /// An abstraction of the transport layer that 9 | /// operates on (JSON-)codable protocol messages 10 | /// rather than strings. 11 | @available(iOS 13, *) 12 | class ChatTransportWrapper where T: Codable & Identifiable { 13 | private let transport: ChatTransport 14 | private var receiveListeners: [(T) -> Void] = [] 15 | private var receivedProtoMessageIds: Set = [] 16 | 17 | init(transport: ChatTransport) { 18 | self.transport = transport 19 | 20 | transport.onReceive { [unowned self] json in 21 | do { 22 | let protoMessage = try decoder.decode(T.self, from: json.data(using: .utf8)!) 23 | 24 | if !receivedProtoMessageIds.contains(protoMessage.id) { 25 | receivedProtoMessageIds.insert(protoMessage.id) 26 | for listener in receiveListeners { 27 | listener(protoMessage) 28 | } 29 | } 30 | } catch { 31 | log.error("Could not decode protocol message: \(error)") 32 | } 33 | } 34 | } 35 | 36 | /// Sends a protocol message to all reachable nodes. 37 | func broadcast(_ protoMessage: T) { 38 | do { 39 | receivedProtoMessageIds.insert(protoMessage.id) 40 | 41 | let json = String(data: try encoder.encode(protoMessage), encoding: .utf8)! 42 | transport.broadcast(json) 43 | } catch { 44 | log.error("Could not encode protocol message: \(error)") 45 | } 46 | } 47 | 48 | /// Adds a handler that is fired whenever a protocol message is 49 | /// received from a node in reach. 50 | func onReceive(_ handler: @escaping (T) -> Void) { 51 | receiveListeners.append(handler) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Controller/MockTransport.swift: -------------------------------------------------------------------------------- 1 | public class MockTransport: ChatTransport { 2 | public init() {} 3 | 4 | public func broadcast(_ raw: String) {} 5 | 6 | public func onReceive(_ handler: @escaping (String) -> Void) {} 7 | } 8 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatAttachment.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ChatAttachment: Codable, Identifiable, Hashable { 4 | public var id: UUID 5 | public var type: ChatAttachmentType 6 | public var name: String 7 | public var content: ChatAttachmentContent 8 | public var compression: Compression? 9 | 10 | public var isEncrypted: Bool { content.isEncrypted } 11 | 12 | public init( 13 | id: UUID = UUID(), 14 | type: ChatAttachmentType = .file, 15 | name: String, 16 | content: ChatAttachmentContent, 17 | compression: Compression? = nil 18 | ) { 19 | self.id = id 20 | self.type = type 21 | self.name = name 22 | self.content = content 23 | self.compression = compression 24 | } 25 | 26 | public enum Compression: Int, Codable, Hashable { 27 | case lzfse = 0 28 | case lz4 = 1 29 | case lzma = 2 30 | case zlib = 3 31 | } 32 | 33 | public func encrypted(with sender: ChatCryptoKeys.Private, for recipient: ChatCryptoKeys.Public) throws -> ChatAttachment { 34 | if case .url(_) = content { throw ChatCryptoError.urlIsNotEncryptable } 35 | guard case let .data(plain) = content else { throw ChatCryptoError.alreadyEncrypted } 36 | 37 | var newAttachment = self 38 | newAttachment.content = .encrypted(try sender.encrypt(plain: plain, for: recipient)) 39 | 40 | return newAttachment 41 | } 42 | 43 | public func decrypted(with recipient: ChatCryptoKeys.Private, from sender: ChatCryptoKeys.Public) throws -> ChatAttachment { 44 | guard case let .encrypted(cipherData) = content else { throw ChatCryptoError.alreadyEncrypted } 45 | 46 | var newAttachment = self 47 | newAttachment.content = .data(try recipient.decrypt(cipher: cipherData, by: sender)) 48 | 49 | return newAttachment 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatAttachmentContent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ChatAttachmentContent: Hashable, Codable { 4 | case url(URL) 5 | case encrypted(ChatCryptoCipherData) 6 | case data(Data) 7 | 8 | public var asURL: URL? { 9 | guard case let .url(url) = self else { return nil } 10 | return url 11 | } 12 | public var asEncrypted: ChatCryptoCipherData? { 13 | guard case let .encrypted(cipherData) = self else { return nil } 14 | return cipherData 15 | } 16 | public var asData: Data? { 17 | guard case let .data(data) = self else { return nil } 18 | return data 19 | } 20 | 21 | public var isURL: Bool { asURL != nil } 22 | public var isEncrypted: Bool { asEncrypted != nil } 23 | public var isData: Bool { asData != nil } 24 | 25 | public enum CodingKeys: String, CodingKey { 26 | case type 27 | case data 28 | } 29 | 30 | public init(from decoder: Decoder) throws { 31 | let container = try decoder.container(keyedBy: CodingKeys.self) 32 | let type = try container.decode(String.self, forKey: .type) 33 | 34 | switch type { 35 | case "url": 36 | self = .url(try container.decode(URL.self, forKey: .data)) 37 | case "encrypted": 38 | self = .encrypted(try container.decode(ChatCryptoCipherData.self, forKey: .data)) 39 | case "data": 40 | self = .data(try container.decode(Data.self, forKey: .data)) 41 | default: 42 | throw DecodeError.unknownType(type) 43 | } 44 | } 45 | 46 | public func encode(to encoder: Encoder) throws { 47 | var container = encoder.container(keyedBy: CodingKeys.self) 48 | 49 | switch self { 50 | case .url(let url): 51 | try container.encode("url", forKey: .type) 52 | try container.encode(url, forKey: .data) 53 | case .encrypted(let encrypted): 54 | try container.encode("encrypted", forKey: .type) 55 | try container.encode(encrypted, forKey: .data) 56 | case .data(let data): 57 | try container.encode("data", forKey: .type) 58 | try container.encode(data, forKey: .data) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatAttachmentType.swift: -------------------------------------------------------------------------------- 1 | public enum ChatAttachmentType: String, Codable, Hashable, CaseIterable, CustomStringConvertible { 2 | case file = "File" 3 | case image = "Image" 4 | case contact = "Contact" 5 | case voiceNote = "VoiceNote" 6 | 7 | public var description: String { rawValue } 8 | } 9 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatChannel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | fileprivate let separator: Character = ":" 4 | fileprivate let userIdSeparator: Character = "," 5 | 6 | public enum ChatChannel: Codable, Hashable, CustomStringConvertible { 7 | /// The global channel. 8 | case global 9 | /// A public chatroom-style channel. 10 | case room(String) 11 | /// A direct messaging channel. All included members' ids are specified here. 12 | case dm(Set) 13 | 14 | public var description: String { 15 | switch self { 16 | case .global: 17 | return "global" 18 | case .room(let name): 19 | return "room\(separator)\(name)" 20 | case .dm(let userIds): 21 | return "dm\(separator)\(userIds.map(\.uuidString).joined(separator: String(userIdSeparator)))" 22 | } 23 | } 24 | 25 | public enum CodingKeys: String, CodingKey { 26 | case type 27 | case data 28 | } 29 | 30 | public enum ChannelError: Error { 31 | case unknownType(String) 32 | case couldNotParse(String) 33 | case invalidUUID(String) 34 | } 35 | 36 | public init(parsing str: String) throws { 37 | let split = str.split(separator: separator, maxSplits: 1).map(String.init) 38 | guard split.count == 2 else { throw ChannelError.couldNotParse(str) } 39 | 40 | try self.init(type: split[0], data: split[1]) 41 | } 42 | 43 | public init(from decoder: Decoder) throws { 44 | let container = try decoder.container(keyedBy: CodingKeys.self) 45 | let type = try container.decode(String.self, forKey: .type) 46 | let data = try container.decodeIfPresent(String.self, forKey: .data) 47 | 48 | try self.init(type: type, data: data) 49 | } 50 | 51 | private init(type: String, data: String?) throws { 52 | switch type { 53 | case "global": 54 | self = .global 55 | case "room": 56 | guard let data else { throw DecodeError.missingChannelData } 57 | self = .room(data) 58 | case "dm": 59 | guard let data else { throw DecodeError.missingChannelData } 60 | let userIds = try Set(data 61 | .split(separator: userIdSeparator) 62 | .map(String.init) 63 | .map { (raw: String) -> UUID in 64 | guard let userId = UUID(uuidString: raw) else { throw ChannelError.invalidUUID(data) } 65 | return userId 66 | }) 67 | self = .dm(userIds) 68 | default: 69 | throw ChannelError.unknownType("Unknown channel type \(type)") 70 | } 71 | } 72 | 73 | public func encode(to encoder: Encoder) throws { 74 | var container = encoder.container(keyedBy: CodingKeys.self) 75 | 76 | switch self { 77 | case .room(let name): 78 | try container.encode("room", forKey: .type) 79 | try container.encode(name, forKey: .data) 80 | case .dm(let userIds): 81 | try container.encode("dm", forKey: .type) 82 | try container.encode(userIds.map(\.uuidString).joined(separator: String(userIdSeparator)), forKey: .data) 83 | case .global: 84 | try container.encode("global", forKey: .type) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatCryptoCipherData.swift: -------------------------------------------------------------------------------- 1 | import Crypto 2 | import Foundation 3 | 4 | public struct ChatCryptoCipherData: Codable, Hashable { 5 | public let sealed: Data 6 | public let signature: Data 7 | public let ephemeralPublicKey: Data 8 | 9 | public init(sealed: Data, signature: Data, ephemeralPublicKey: Data) { 10 | self.sealed = sealed 11 | self.signature = signature 12 | self.ephemeralPublicKey = ephemeralPublicKey 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatCryptoError.swift: -------------------------------------------------------------------------------- 1 | public enum ChatCryptoError: Error { 2 | case invalidBase64(String) 3 | case invalidSignature 4 | case couldNotDecode(String) 5 | case alreadyEncrypted 6 | case alreadyDecrypted 7 | case nonEncodableText 8 | case urlIsNotEncryptable 9 | } 10 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatCryptoKeys.swift: -------------------------------------------------------------------------------- 1 | import Crypto 2 | import Foundation 3 | 4 | // Based on CryptoKit sample from https://developer.apple.com/documentation/cryptokit/performing_common_cryptographic_operations 5 | // BSD-3-licensed, Copyright 2020 Apple Inc. 6 | 7 | fileprivate let salt = "DistributedChat.ChatCrypto".data(using: .utf8)! 8 | 9 | public enum ChatCryptoKeys { 10 | public struct Public: Codable { 11 | public let encryptionKey: Curve25519.KeyAgreement.PublicKey 12 | public let signingKey: Curve25519.Signing.PublicKey 13 | 14 | public enum CodingKeys: String, CodingKey { 15 | case encryptionKey = "e" 16 | case signingKey = "s" 17 | } 18 | 19 | init( 20 | encryptionKey: Curve25519.KeyAgreement.PublicKey, 21 | signingKey: Curve25519.Signing.PublicKey 22 | ) { 23 | self.encryptionKey = encryptionKey 24 | self.signingKey = signingKey 25 | } 26 | 27 | public init(from decoder: Decoder) throws { 28 | let container = try decoder.container(keyedBy: CodingKeys.self) 29 | 30 | let rawEncryptionKey = try container.decode(Data.self, forKey: .encryptionKey) 31 | let rawSigningKey = try container.decode(Data.self, forKey: .signingKey) 32 | 33 | encryptionKey = try .init(rawRepresentation: rawEncryptionKey) 34 | signingKey = try .init(rawRepresentation: rawSigningKey) 35 | } 36 | 37 | public func encode(to encoder: Encoder) throws { 38 | var container = encoder.container(keyedBy: CodingKeys.self) 39 | 40 | let rawEncryptionKey = encryptionKey.rawRepresentation 41 | let rawSigningKey = signingKey.rawRepresentation 42 | 43 | try container.encode(rawEncryptionKey, forKey: .encryptionKey) 44 | try container.encode(rawSigningKey, forKey: .signingKey) 45 | } 46 | } 47 | 48 | public struct Private { 49 | public let encryptionKey: Curve25519.KeyAgreement.PrivateKey 50 | public let signingKey: Curve25519.Signing.PrivateKey 51 | 52 | public var publicKeys: Public { 53 | Public( 54 | encryptionKey: encryptionKey.publicKey, 55 | signingKey: signingKey.publicKey 56 | ) 57 | } 58 | 59 | public init() { 60 | encryptionKey = .init() 61 | signingKey = .init() 62 | } 63 | 64 | /// Encrypts data for the given recipient's public keys using 65 | /// X25519 key agreement, ed25519 signatures and the symmetric 66 | /// ChaCha20-Poly1305 cipher. 67 | public func encrypt(plain: Data, for recipient: Public) throws -> ChatCryptoCipherData { 68 | let ephemeralKey = Curve25519.KeyAgreement.PrivateKey() 69 | let ephemeralPublicKey = ephemeralKey.publicKey 70 | let sharedSecret = try ephemeralKey.sharedSecretFromKeyAgreement(with: recipient.encryptionKey) 71 | 72 | let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( 73 | using: SHA256.self, 74 | salt: salt, 75 | sharedInfo: ephemeralPublicKey.rawRepresentation 76 | + recipient.signingKey.rawRepresentation 77 | + signingKey.publicKey.rawRepresentation, 78 | outputByteCount: 32 79 | ) 80 | 81 | let sealed = try ChaChaPoly.seal(plain, using: symmetricKey).combined 82 | let signature = try signingKey.signature(for: sealed + ephemeralPublicKey.rawRepresentation + recipient.encryptionKey.rawRepresentation) 83 | return ChatCryptoCipherData(sealed: sealed, signature: signature, ephemeralPublicKey: ephemeralPublicKey.rawRepresentation) 84 | } 85 | 86 | /// Decrypts data from the given sender's public keys using 87 | /// X25519 key agreement, ed25519 signatures and the symmetric 88 | /// ChaCha20-Poly1305 cipher. 89 | public func decrypt(cipher: ChatCryptoCipherData, by sender: Public) throws -> Data { 90 | guard sender.signingKey.isValidSignature(cipher.signature, for: cipher.sealed + cipher.ephemeralPublicKey + encryptionKey.publicKey.rawRepresentation) else { 91 | throw ChatCryptoError.invalidSignature 92 | } 93 | 94 | let ephemeralPublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: cipher.ephemeralPublicKey) 95 | let sharedSecret = try encryptionKey.sharedSecretFromKeyAgreement(with: ephemeralPublicKey) 96 | 97 | let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( 98 | using: SHA256.self, 99 | salt: salt, 100 | sharedInfo: ephemeralPublicKey.rawRepresentation 101 | + signingKey.publicKey.rawRepresentation 102 | + sender.signingKey.rawRepresentation, 103 | outputByteCount: 32 104 | ) 105 | 106 | let box = try ChaChaPoly.SealedBox(combined: cipher.sealed) 107 | return try ChaChaPoly.open(box, using: symmetricKey) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatDeletion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ChatDeletion: Identifiable, Codable, Hashable { 4 | public var messageId: UUID 5 | public var author: ChatUser 6 | public var id: UUID { messageId } 7 | 8 | public init(messageId: UUID, author: ChatUser) { 9 | self.messageId = messageId 10 | self.author = author 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatMessage.swift: -------------------------------------------------------------------------------- 1 | import Crypto 2 | import Foundation 3 | import Logging 4 | 5 | fileprivate let log = Logger(label: "DistributedChat.ChatMessage") 6 | 7 | public struct ChatMessage: Identifiable, Hashable, Codable { 8 | public let id: UUID 9 | public var timestamp: Date 10 | public var author: ChatUser 11 | public var content: ChatMessageContent 12 | public var channel: ChatChannel 13 | public var attachments: [ChatAttachment]? 14 | public var repliedToMessageId: UUID? 15 | public var wasEncrypted: Bool? 16 | 17 | public var isEncrypted: Bool { content.isEncrypted || (attachments?.contains(where: \.isEncrypted) ?? false) } 18 | public var dmRecipientId: UUID? { 19 | if case let .dm(userIds) = channel, userIds.count == 2, userIds.contains(author.id) { 20 | return userIds.first { $0 != author.id } 21 | } 22 | return nil 23 | } 24 | 25 | public var displayContent: String { 26 | content.description 27 | } 28 | 29 | public init( 30 | id: UUID = UUID(), 31 | timestamp: Date = Date(), 32 | author: ChatUser, 33 | content: ChatMessageContent, 34 | channel: ChatChannel = .global, 35 | attachments: [ChatAttachment]? = nil, 36 | repliedToMessageId: UUID? = nil, 37 | wasEncrypted: Bool? = nil 38 | ) { 39 | self.id = id 40 | self.timestamp = timestamp 41 | self.author = author 42 | self.content = content 43 | self.channel = channel 44 | self.attachments = attachments 45 | self.repliedToMessageId = repliedToMessageId 46 | self.wasEncrypted = wasEncrypted 47 | } 48 | 49 | /// Checks whether the given user id should receive the message. 50 | public func isReceived(by userId: UUID) -> Bool { 51 | switch channel { 52 | case .dm(let userIds): 53 | return userIds.contains(userId) 54 | default: 55 | return true 56 | } 57 | } 58 | 59 | /// Encrypts a message for the recipient if it's a two-person DM. 60 | public func encryptedIfNeeded(with sender: ChatCryptoKeys.Private, keyFinder: (UUID) -> ChatCryptoKeys.Public?) -> ChatMessage { 61 | if let recipientId = dmRecipientId, let recipientKeys = keyFinder(recipientId) { 62 | do { 63 | return try encrypted(with: sender, for: recipientKeys) 64 | } catch { 65 | log.warning("Could not encrypt message: \(self)") 66 | } 67 | } 68 | return self 69 | } 70 | 71 | /// Decrypts a message from the author if it's a two-person DM. 72 | public func decryptedIfNeeded(with recipient: ChatCryptoKeys.Private, keyFinder: (UUID) -> ChatCryptoKeys.Public?) -> ChatMessage { 73 | if isEncrypted, let senderKeys = keyFinder(author.id) { 74 | do { 75 | return try decrypted(with: recipient, from: senderKeys) 76 | } catch { 77 | log.debug("Could not decrypt message: \(self)") 78 | } 79 | } 80 | return self 81 | } 82 | 83 | public func encrypted(with sender: ChatCryptoKeys.Private, for recipient: ChatCryptoKeys.Public) throws -> ChatMessage { 84 | guard !isEncrypted else { throw ChatCryptoError.alreadyEncrypted } 85 | guard let data = content.asText?.data(using: .utf8) else { throw ChatCryptoError.nonEncodableText } 86 | 87 | var newMessage = self 88 | newMessage.content = .encrypted(try sender.encrypt(plain: data, for: recipient)) 89 | newMessage.attachments = try attachments?.map { try $0.encrypted(with: sender, for: recipient) } 90 | 91 | return newMessage 92 | } 93 | 94 | public func decrypted(with recipient: ChatCryptoKeys.Private, from sender: ChatCryptoKeys.Public) throws -> ChatMessage { 95 | guard case let .encrypted(cipherData) = content else { throw ChatCryptoError.alreadyEncrypted } 96 | guard let text = try String(data: recipient.decrypt(cipher: cipherData, by: sender), encoding: .utf8) else { throw ChatCryptoError.nonEncodableText } 97 | 98 | var newMessage = self 99 | newMessage.content = .text(text) 100 | newMessage.attachments = try attachments?.map { try $0.decrypted(with: recipient, from: sender) } 101 | newMessage.wasEncrypted = true 102 | 103 | return newMessage 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatMessageCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ChatMessageCache { 4 | var size: Int { get set } 5 | 6 | mutating func store(message: ChatMessage) 7 | 8 | @discardableResult 9 | mutating func deleteMessage(id: UUID) -> Bool 10 | 11 | mutating func getStoredMessages(required: ((ChatMessage) -> Bool)?) -> [ChatMessage] 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatMessageContent.swift: -------------------------------------------------------------------------------- 1 | public enum ChatMessageContent: Hashable, Codable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation, CustomStringConvertible { 2 | case text(String) 3 | case encrypted(ChatCryptoCipherData) 4 | 5 | public var asText: String? { 6 | guard case let .text(text) = self else { return nil } 7 | return text 8 | } 9 | public var asEncrypted: ChatCryptoCipherData? { 10 | guard case let .encrypted(cipherData) = self else { return nil } 11 | return cipherData 12 | } 13 | 14 | public var isText: Bool { asText != nil } 15 | public var isEncrypted: Bool { asEncrypted != nil } 16 | 17 | public var description: String { 18 | switch self { 19 | case .text(let text): 20 | return text 21 | case .encrypted(let encrypted): 22 | return "" 23 | } 24 | } 25 | 26 | public enum CodingKeys: String, CodingKey { 27 | case type 28 | case data 29 | } 30 | 31 | public init(stringLiteral: String) { 32 | self = .text(stringLiteral) 33 | } 34 | 35 | public init(from decoder: Decoder) throws { 36 | let container = try decoder.container(keyedBy: CodingKeys.self) 37 | let type = try container.decode(String.self, forKey: .type) 38 | 39 | switch type { 40 | case "text": 41 | self = .text(try container.decode(String.self, forKey: .data)) 42 | case "encrypted": 43 | self = .encrypted(try container.decode(ChatCryptoCipherData.self, forKey: .data)) 44 | default: 45 | throw DecodeError.unknownType(type) 46 | } 47 | } 48 | 49 | public func encode(to encoder: Encoder) throws { 50 | var container = encoder.container(keyedBy: CodingKeys.self) 51 | 52 | switch self { 53 | case .text(let text): 54 | try container.encode("text", forKey: .type) 55 | try container.encode(text, forKey: .data) 56 | case .encrypted(let encrypted): 57 | try container.encode("encrypted", forKey: .type) 58 | try container.encode(encrypted, forKey: .data) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatMessageListCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | fileprivate let log = Logger(label: "DistributedChat.ChatMessageListCache") 5 | 6 | public struct ChatMessageListCache: ChatMessageCache { 7 | // TODO: Perhaps use a more efficient data structure, e.g. 8 | // a cyclic buffer to make cropping efficient or 9 | // a priority queue to make insertion efficient? 10 | private var list: [ChatMessage] 11 | public var size: Int { 12 | didSet { 13 | if size < 0 { fatalError("Cache size cannot be less than zero!") } 14 | crop() 15 | } 16 | } 17 | 18 | public init(size: Int) { 19 | self.list = [ChatMessage]() 20 | self.size = size 21 | } 22 | 23 | public mutating func store(message: ChatMessage) { 24 | // Add new item via insertion sort 25 | if list.isEmpty { 26 | list.append(message) 27 | } else if !contains(id: message.id) { 28 | for (index, value) in list.enumerated() { 29 | if value.timestamp <= message.timestamp && (index == list.count - 1 || list[index + 1].timestamp > message.timestamp) { 30 | list.insert(message, at: index) 31 | } 32 | } 33 | 34 | crop() 35 | log.debug("Stored chat messages: \(list.map(\.displayContent))") 36 | } 37 | } 38 | 39 | @discardableResult 40 | public mutating func deleteMessage(id: UUID) -> Bool { 41 | for (index, value) in list.enumerated() { 42 | if value.id == id { 43 | list.remove(at: index) 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | public func getStoredMessages(required: ((ChatMessage) -> Bool)?) -> [ChatMessage] { 51 | guard let required = required else { return list } 52 | 53 | var returnValue: [ChatMessage] = [ChatMessage]() 54 | for item in list { 55 | if required(item) { 56 | returnValue.append(item) 57 | } 58 | } 59 | return returnValue 60 | } 61 | 62 | private func contains(id: UUID) -> Bool { 63 | for item in list { 64 | if item.id == id { 65 | return true 66 | } 67 | } 68 | return false 69 | } 70 | 71 | private mutating func crop() { 72 | // Remove logically oldest items until list is small enough 73 | let delta = list.count - size 74 | if delta > 0 { 75 | list.removeFirst(delta) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatPresence.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ChatPresence: Identifiable, Codable, Hashable { 4 | public var user: ChatUser 5 | public var status: ChatStatus 6 | public var info: String 7 | 8 | public var id: UUID { user.id } 9 | 10 | public init(user: ChatUser = ChatUser(), status: ChatStatus = .online, info: String = "") { 11 | self.user = user 12 | self.status = status 13 | self.info = info 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ChatProtocol { 4 | public struct MessageRequest: Hashable, Codable { 5 | /// The newest timestamp from a received message for a specific author 6 | public var vectorTime: [UUID: Date] = [:] 7 | } 8 | 9 | public struct Message: Identifiable, Codable { 10 | public var id: UUID // unique for EVERY message with the sole exception of rebroadcasts 11 | public var timestamp: Date // the (physical) timestamp 12 | public var sourceUserId: UUID // the possibly indirect source user 13 | public var destinationUserId: UUID? // the possibly indirect recipient user, if there is any 14 | public var addedChatMessages: [ChatMessage]? 15 | public var updatedPresences: [ChatPresence]? 16 | public var deletedChatMessages: [ChatDeletion]? 17 | public var messageRequest: MessageRequest? 18 | public var logicalClock: Int 19 | 20 | public init( 21 | id: UUID = UUID(), 22 | timestamp: Date = Date(), 23 | sourceUserId: UUID, 24 | destinationUserId: UUID? = nil, 25 | addedChatMessages: [ChatMessage]? = nil, 26 | updatedPresences: [ChatPresence]? = nil, 27 | deletedChatMessages: [ChatDeletion]? = nil, 28 | messageRequest: MessageRequest? = nil, 29 | logicalClock: Int 30 | ) { 31 | self.id = id 32 | self.timestamp = timestamp 33 | self.sourceUserId = sourceUserId 34 | self.destinationUserId = destinationUserId 35 | self.addedChatMessages = addedChatMessages 36 | self.updatedPresences = updatedPresences 37 | self.deletedChatMessages = deletedChatMessages 38 | self.logicalClock = logicalClock 39 | self.messageRequest = messageRequest 40 | } 41 | 42 | /// Whether the given user is the destination. 43 | public func isDestination(userId: UUID) -> Bool { 44 | destinationUserId.map { $0 == userId } ?? true 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatStatus.swift: -------------------------------------------------------------------------------- 1 | public enum ChatStatus: String, Codable, Hashable, CaseIterable, CustomStringConvertible { 2 | case online = "Online" 3 | case away = "Away" 4 | case busy = "Busy" 5 | case offline = "Offline" 6 | 7 | public var description: String { rawValue } 8 | } 9 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Model/ChatUser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ChatUser: Identifiable, Hashable, Codable { 4 | public let id: UUID 5 | public var publicKeys: ChatCryptoKeys.Public? 6 | public var name: String 7 | public var logicalClock: Int 8 | 9 | public var displayName: String { name.isEmpty ? "User \(id.uuidString.prefix(5))" : name } 10 | 11 | public init(id: UUID = UUID(), publicKeys: ChatCryptoKeys.Public? = nil, name: String = "", logicalClock: Int = 0) { 12 | self.id = id 13 | self.publicKeys = publicKeys 14 | self.name = name 15 | self.logicalClock = logicalClock 16 | } 17 | 18 | // Users are only combined by id and name 19 | 20 | public static func ==(lhs: Self, rhs: Self) -> Bool { 21 | lhs.id == rhs.id && lhs.name == rhs.name 22 | } 23 | 24 | public func hash(into hasher: inout Hasher) { 25 | hasher.combine(id) 26 | hasher.combine(name) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Utils/DecodeError.swift: -------------------------------------------------------------------------------- 1 | public enum DecodeError: Error { 2 | case couldNotDecode 3 | case missingChannelData 4 | case unknownType(String) 5 | } 6 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Utils/Either.swift: -------------------------------------------------------------------------------- 1 | public enum Either { 2 | case left(L) 3 | case right(R) 4 | 5 | var asLeft: L? { 6 | if case .left(let left) = self { return left } 7 | return nil 8 | } 9 | var asRight: R? { 10 | if case .right(let right) = self { return right } 11 | return nil 12 | } 13 | var isLeft: Bool { asLeft != nil } 14 | var isRight: Bool { asRight != nil } 15 | } 16 | 17 | extension Either: Equatable where L: Equatable, R: Equatable {} 18 | 19 | extension Either: Hashable where L: Hashable, R: Hashable {} 20 | 21 | extension Either: ExpressibleByUnicodeScalarLiteral where R: ExpressibleByStringLiteral { 22 | public init(unicodeScalarLiteral value: R.UnicodeScalarLiteralType) { 23 | self = .right(R.init(unicodeScalarLiteral: value)) 24 | } 25 | } 26 | 27 | extension Either: ExpressibleByExtendedGraphemeClusterLiteral where R: ExpressibleByStringLiteral { 28 | public init(extendedGraphemeClusterLiteral value: R.ExtendedGraphemeClusterLiteralType) { 29 | self = .right(R.init(extendedGraphemeClusterLiteral: value)) 30 | } 31 | } 32 | 33 | extension Either: ExpressibleByStringLiteral where R: ExpressibleByStringLiteral { 34 | public init(stringLiteral value: R.StringLiteralType) { 35 | self = .right(R.init(stringLiteral: value)) 36 | } 37 | } 38 | 39 | extension Either: Codable where L: Codable, R: Codable { 40 | public init(from decoder: Decoder) throws { 41 | let container = try decoder.singleValueContainer() 42 | if let left = try? container.decode(L.self) { 43 | self = .left(left) 44 | } else if let right = try? container.decode(R.self) { 45 | self = .right(right) 46 | } else { 47 | throw DecodeError.couldNotDecode 48 | } 49 | } 50 | 51 | public func encode(to encoder: Encoder) throws { 52 | var container = encoder.singleValueContainer() 53 | 54 | switch self { 55 | case .left(let left): 56 | try container.encode(left) 57 | case .right(let right): 58 | try container.encode(right) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Utils/Either3.swift: -------------------------------------------------------------------------------- 1 | public enum Either3 { 2 | case left(L) 3 | case center(C) 4 | case right(R) 5 | 6 | var asLeft: L? { 7 | if case .left(let left) = self { return left } 8 | return nil 9 | } 10 | var asCenter: C? { 11 | if case .center(let center) = self { return center } 12 | return nil 13 | } 14 | var asRight: R? { 15 | if case .right(let right) = self { return right } 16 | return nil 17 | } 18 | var isLeft: Bool { asLeft != nil } 19 | var isCenter: Bool { asCenter != nil } 20 | var isRight: Bool { asRight != nil } 21 | } 22 | 23 | extension Either3: Equatable where L: Equatable, C: Equatable, R: Equatable {} 24 | 25 | extension Either3: Hashable where L: Hashable, C: Hashable, R: Hashable {} 26 | 27 | extension Either3: ExpressibleByUnicodeScalarLiteral where R: ExpressibleByStringLiteral { 28 | public init(unicodeScalarLiteral value: R.UnicodeScalarLiteralType) { 29 | self = .right(R.init(unicodeScalarLiteral: value)) 30 | } 31 | } 32 | 33 | extension Either3: ExpressibleByExtendedGraphemeClusterLiteral where R: ExpressibleByStringLiteral { 34 | public init(extendedGraphemeClusterLiteral value: R.ExtendedGraphemeClusterLiteralType) { 35 | self = .right(R.init(extendedGraphemeClusterLiteral: value)) 36 | } 37 | } 38 | extension Either3: ExpressibleByStringLiteral where R: ExpressibleByStringLiteral { 39 | public init(stringLiteral value: R.StringLiteralType) { 40 | self = .right(R.init(stringLiteral: value)) 41 | } 42 | } 43 | 44 | extension Either3: Codable where L: Codable, C: Codable, R: Codable { 45 | public init(from decoder: Decoder) throws { 46 | let container = try decoder.singleValueContainer() 47 | if let left = try? container.decode(L.self) { 48 | self = .left(left) 49 | } else if let center = try? container.decode(C.self) { 50 | self = .center(center) 51 | } else if let right = try? container.decode(R.self) { 52 | self = .right(right) 53 | } else { 54 | throw DecodeError.couldNotDecode 55 | } 56 | } 57 | 58 | public func encode(to encoder: Encoder) throws { 59 | var container = encoder.singleValueContainer() 60 | 61 | switch self { 62 | case .left(let left): 63 | try container.encode(left) 64 | case .center(let center): 65 | try container.encode(center) 66 | case .right(let right): 67 | try container.encode(right) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Utils/JSONUtils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public func makeJSONEncoder() -> JSONEncoder { 4 | let encoder = JSONEncoder() 5 | encoder.dateEncodingStrategy = .secondsSince1970 6 | return encoder 7 | } 8 | 9 | public func makeJSONDecoder() -> JSONDecoder { 10 | let decoder = JSONDecoder() 11 | decoder.dateDecodingStrategy = .secondsSince1970 12 | return decoder 13 | } 14 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Utils/RepeatingTimer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import Dispatch 4 | 5 | fileprivate let log = Logger(label: "DistributedChat.RepeatingTimer") 6 | 7 | /// A simple wrapper around GCD's timer that repeatedly invokes a handler. 8 | class RepeatingTimer { 9 | private let timer: DispatchSourceTimer 10 | 11 | init(interval: TimeInterval, handler: @escaping () -> Void) { 12 | timer = DispatchSource.makeTimerSource() 13 | timer.schedule(deadline: .now() + interval, repeating: interval) 14 | timer.setEventHandler(handler: handler) 15 | timer.resume() 16 | log.debug("Starting timer") 17 | } 18 | 19 | deinit { 20 | log.debug("Cancelling timer") 21 | timer.cancel() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DistributedChatKit/Sources/DistributedChatKit/Utils/StringUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringUtils.swift 3 | // DistributedChatApp 4 | // 5 | // Created by Fredrik on 1/23/21. 6 | // 7 | 8 | extension String { 9 | public func pluralized(with n: Int) -> String { 10 | n == 1 ? self : "\(self)s" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatKit/Tests/DistributedChatKitTests/ChatMessageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DistributedChatKit 3 | 4 | fileprivate let encoder = JSONEncoder() 5 | fileprivate let decoder = JSONDecoder() 6 | 7 | final class ChatMessageTests: XCTestCase { 8 | func testSerialization() throws { 9 | let alice = ChatUser(name: "Alice") 10 | let bob = ChatUser(name: "Bob") 11 | 12 | let message1 = ChatMessage(author: alice, content: "Hi \(123)!") // implicitly .right through ExpressibleByStringLiteral 13 | let message2 = ChatMessage(author: alice, content: .encrypted(.init(sealed: Data([0, 1, 2]), signature: Data([3, 4]), ephemeralPublicKey: Data([5, 6, 7])))) 14 | let message3 = ChatMessage(author: bob, content: "Test", attachments: [ 15 | ChatAttachment(type: .file, name: "example.html", content: .url(URL(string: "https://example.com")!)), 16 | ChatAttachment(type: .voiceNote, name: "test.mp3", content: .encrypted(.init(sealed: Data([8, 9, 10]), signature: Data(), ephemeralPublicKey: Data([13])))) 17 | ]) 18 | 19 | try XCTAssertEqual(message1, coded(message1)) 20 | try XCTAssertEqual(message2, coded(message2)) 21 | try XCTAssertEqual(message3, coded(message3)) 22 | } 23 | 24 | private func coded(_ value: T) throws -> T where T: Codable { 25 | try decoder.decode(T.self, from: encoder.encode(value)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /DistributedChatSimulationProtocol/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | Package.resolved 7 | -------------------------------------------------------------------------------- /DistributedChatSimulationProtocol/.swiftpm/xcode/xcshareddata/xcschemes/DistributedChatSimulationProtocol.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /DistributedChatSimulationProtocol/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DistributedChatSimulationProtocol", 8 | products: [ 9 | // Products define the executables and libraries a package produces, and make them visible to other packages. 10 | .library( 11 | name: "DistributedChatSimulationProtocol", 12 | targets: ["DistributedChatSimulationProtocol"] 13 | ), 14 | ], 15 | targets: [ 16 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 17 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 18 | .target( 19 | name: "DistributedChatSimulationProtocol", 20 | dependencies: [] 21 | ) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /DistributedChatSimulationProtocol/README.md: -------------------------------------------------------------------------------- 1 | # DistributedChatSimulationProtocol 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | Packages 4 | *.xcodeproj 5 | xcuserdata 6 | db.sqlite 7 | DerivedData/ 8 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/.swiftpm/xcode/xcshareddata/xcschemes/distributed-chat-simulation-server.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 61 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:5.10 as build 5 | 6 | # Install OS updates and, if needed, sqlite3 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Set up a build area 13 | WORKDIR /build 14 | 15 | # First just resolve dependencies. 16 | # This creates a cached layer that can be reused 17 | # as long as your Package.swift/Package.resolved 18 | # files do not change. 19 | COPY ./Package.* ./ 20 | RUN swift package resolve 21 | 22 | # Copy entire repo into container 23 | COPY . . 24 | 25 | # Build everything, with optimizations and test discovery 26 | RUN swift build --enable-test-discovery -c release 27 | 28 | # Switch to the staging area 29 | WORKDIR /staging 30 | 31 | # Copy main executable to staging area 32 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./ 33 | 34 | # Copy any resouces from the public directory and views directory if the directories exist 35 | # Ensure that by default, neither the directory nor any of its contents are writable. 36 | RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 37 | RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 38 | 39 | # ================================ 40 | # Run image 41 | # ================================ 42 | FROM swift:5.10-slim 43 | 44 | # Make sure all system packages are up to date. 45 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true && \ 46 | apt-get -q update && apt-get -q dist-upgrade -y && rm -r /var/lib/apt/lists/* 47 | 48 | # Create a vapor user and group with /app as its home directory 49 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor 50 | 51 | # Switch to the new home directory 52 | WORKDIR /app 53 | 54 | # Copy built executable and any staged resources from builder 55 | COPY --from=build --chown=vapor:vapor /staging /app 56 | 57 | # Ensure all further commands run as the vapor user 58 | USER vapor:vapor 59 | 60 | # Let Docker bind to port 8080 61 | EXPOSE 8080 62 | 63 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment 64 | ENTRYPOINT ["./Run"] 65 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 66 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DistributedChatSimulationServer", 8 | platforms: [ 9 | .macOS(.v10_15) 10 | ], 11 | products: [ 12 | .executable( 13 | name: "distributed-chat-simulation-server", 14 | targets: ["DistributedChatSimulationServerMain"] 15 | ) 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | .package(path: "../DistributedChatSimulationProtocol"), 20 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 21 | .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 26 | .target( 27 | name: "DistributedChatSimulationServer", 28 | dependencies: [ 29 | .product(name: "Vapor", package: "vapor"), 30 | .product(name: "Leaf", package: "leaf"), 31 | .product(name: "DistributedChatSimulationProtocol", package: "DistributedChatSimulationProtocol") 32 | ] 33 | ), 34 | .target( 35 | name: "DistributedChatSimulationServerMain", 36 | dependencies: [ 37 | .target(name: "DistributedChatSimulationServer") 38 | ] 39 | ), 40 | .testTarget( 41 | name: "DistributedChatSimulationServerTests", 42 | dependencies: [ 43 | .target(name: "DistributedChatSimulationServer"), 44 | .product(name: "XCTVapor", package: "vapor"), 45 | ] 46 | ) 47 | ] 48 | ) 49 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Public/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | body { 8 | font-family: Arial, Helvetica, sans-serif; 9 | text-align: center; 10 | display: flex; 11 | justify-content: safe center; 12 | align-items: safe center; 13 | } 14 | 15 | h1 { 16 | margin: 0; 17 | padding: 10px 0; 18 | } 19 | 20 | .wrapper { 21 | width: min(1000px, 100%); 22 | margin: 0 auto; 23 | box-shadow: 0 0 20px rgb(175, 175, 175); 24 | } 25 | 26 | .settings { 27 | display: flex; 28 | flex-direction: column; 29 | background-color: rgb(247, 247, 247); 30 | padding: 5px 0; 31 | } 32 | 33 | .settings-bar { 34 | display: flex; 35 | flex-direction: row; 36 | flex-wrap: wrap; 37 | justify-content: center; 38 | margin: 0; 39 | } 40 | 41 | .setting { 42 | margin: 5px 10px; 43 | display: flex; 44 | flex-direction: row; 45 | align-items: center; 46 | } 47 | 48 | .setting > * { 49 | margin: 0 5px; 50 | } 51 | 52 | #graph { 53 | width: 100%; 54 | height: 500px; 55 | } 56 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/README.md: -------------------------------------------------------------------------------- 1 | # DistributedChatSimulationServer 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Resources/Views/index.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #(title) 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

#(title)

14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 |
36 |
37 | 38 | 39 | ? 40 |
41 |
42 | 43 | 44 | ? 45 | s 46 |
47 |
48 |
49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Sources/DistributedChatSimulationServer/configure.swift: -------------------------------------------------------------------------------- 1 | import Leaf 2 | import Vapor 3 | 4 | // configures your application 5 | public func configure(_ app: Application) throws { 6 | app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) 7 | 8 | app.views.use(.leaf) 9 | 10 | // register routes 11 | try routes(app) 12 | } 13 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Sources/DistributedChatSimulationServer/routes.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | func routes(_ app: Application) throws { 4 | app.get { req in 5 | req.view.render("index", [ 6 | "title": "Distributed Chat Simulation Server" 7 | ]) 8 | } 9 | 10 | let handler = MessagingHandler() 11 | app.webSocket("messaging") { req, ws in 12 | handler.connect(ws) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Sources/DistributedChatSimulationServerMain/main.swift: -------------------------------------------------------------------------------- 1 | import DistributedChatSimulationServer 2 | import Vapor 3 | 4 | var env = try Environment.detect() 5 | try LoggingSystem.bootstrap(from: &env) 6 | let app = Application(env) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | try app.run() 10 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/Tests/DistributedChatSimulationServerTests/DistributedChatSimulationServerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import DistributedChatSimulationServer 2 | import XCTVapor 3 | 4 | final class DistributedChatSimulationServerTests: XCTestCase { 5 | var app: Application! 6 | 7 | override func setUpWithError() throws { 8 | app = Application(.testing) 9 | try configure(app) 10 | } 11 | 12 | override func tearDown() { 13 | app.shutdown() 14 | } 15 | 16 | func testWebFrontend() throws { 17 | try app.test(.GET, "/", afterResponse: { res in 18 | XCTAssertEqual(res.status, .ok) 19 | XCTAssert(res.body.string.contains("

Distributed Chat Simulation Server

")) 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DistributedChatSimulationServer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for Vapor 2 | # 3 | # Install Docker on your system to run and test 4 | # your Vapor app in a production-like environment. 5 | # 6 | # Note: This file is intended for testing and does not 7 | # implement best practices for a production deployment. 8 | # 9 | # Learn more: https://docs.docker.com/compose/reference/ 10 | # 11 | # Build images: docker-compose build 12 | # Start app: docker-compose up app 13 | # Stop all: docker-compose down 14 | # 15 | version: '3.7' 16 | 17 | x-shared_environment: &shared_environment 18 | LOG_LEVEL: ${LOG_LEVEL:-debug} 19 | 20 | services: 21 | app: 22 | image: test:latest 23 | build: 24 | context: . 25 | environment: 26 | <<: *shared_environment 27 | ports: 28 | - '8080:8080' 29 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. 30 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 31 | -------------------------------------------------------------------------------- /Images/channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/Images/channel.png -------------------------------------------------------------------------------- /Images/channels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/Images/channels.png -------------------------------------------------------------------------------- /Images/lockscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwcd/distributed-chat/14c51e5d9f19c68fdbff3c2e013ce4fb34fe3302/Images/lockscreen.png -------------------------------------------------------------------------------- /Images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 62 | 68 | 74 | 80 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Distributed Chat 2 | 3 | [![Kit](https://github.com/fwcd/distributed-chat/actions/workflows/kit.yml/badge.svg)](https://github.com/fwcd/distributed-chat/actions/workflows/kit.yml) 4 | [![Bluetooth](https://github.com/fwcd/distributed-chat/actions/workflows/bluetooth.yml/badge.svg)](https://github.com/fwcd/distributed-chat/actions/workflows/bluetooth.yml) 5 | [![App](https://github.com/fwcd/distributed-chat/actions/workflows/app.yml/badge.svg)](https://github.com/fwcd/distributed-chat/actions/workflows/app.yml) 6 | [![CLI](https://github.com/fwcd/distributed-chat/actions/workflows/cli.yml/badge.svg)](https://github.com/fwcd/distributed-chat/actions/workflows/cli.yml) 7 | [![Simulation Protocol](https://github.com/fwcd/distributed-chat/actions/workflows/simulation-protocol.yml/badge.svg)](https://github.com/fwcd/distributed-chat/actions/workflows/simulation-protocol.yml) 8 | [![Simulation Server](https://github.com/fwcd/distributed-chat/actions/workflows/simulation-server.yml/badge.svg)](https://github.com/fwcd/distributed-chat/actions/workflows/simulation-server.yml) 9 | 10 |
11 |

12 | Logo 13 |

14 | 15 |

16 | Lock Screen Screenshot 17 | Channels Screen Screenshot 18 | Channel Screenshot 19 |

20 |
21 | 22 | A distributed chat messenger that uses Bluetooth LE mesh networks. 23 | 24 | * Fully decentralized architecture, no server or Internet connection required 25 | * Message caching, delayed transmission 26 | * Public and private end-to-end-encrypted messaging channels 27 | * Voice messages, image, file and contact attachments 28 | * Full simulation environment with configurable nodes, links and much more included 29 | * Cross-platform, portable core 30 | 31 | ## Components 32 | 33 | The project consists of the following components: 34 | 35 | * `DistributedChatKit`: The abstract application, platform-independent, transport-independent (uses interface for broadcasting/receiving messages) 36 | * `DistributedChatBluetooth`: An abstraction over platform-specific Bluetooth LE transports 37 | * `DistributedChatApp`: The iOS/macOS implementation, uses Bluetooth LE as transport, does **not** require a server 38 | * `DistributedChatCLI`: The CLI implementation, uses either HTTP/WebSockets as transport with the simulation server or Bluetooth LE (WIP) 39 | * `DistributedChatSimulationProtocol`: The high-level JSON-based protocol used between CLI and simulation server 40 | * `DistributedChatSimulationServer`: The companion server for the CLI, relays messages between connected CLI nodes, provides web-interface for configuring links between nodes 41 | * `Scripts`: Scripts for launching CLI instances conveniently and for testing the BLE transport 42 | 43 | The dependency graph between these packages looks like this: 44 | 45 | ```mermaid 46 | %%{init: {"flowchart": {"defaultRenderer": "elk"}} }%% 47 | flowchart BT 48 | subgraph cross-platform 49 | DistributedChatBluetooth --> DistributedChatKit 50 | DistributedChatCLI --> DistributedChatKit 51 | DistributedChatCLI --> DistributedChatBluetooth 52 | DistributedChatCLI --> DistributedChatSimulationProtocol 53 | DistributedChatSimulationServer --> DistributedChatSimulationProtocol 54 | end 55 | subgraph "Apple platforms" 56 | DistributedChatApp --> DistributedChatKit 57 | DistributedChatApp --> DistributedChatBluetooth 58 | end 59 | ``` 60 | 61 | ## Building and Running 62 | 63 | First, make sure to have Swift 5.10+ or newer installed. Recent versions for Ubuntu and macOS can be found [here](https://swift.org/download/). 64 | 65 | ### Simulation Server 66 | 67 | To run the simulation server, navigate into the directory `DistributedChatSimulationServer` and execute: 68 | 69 | ```sh 70 | swift run 71 | ``` 72 | 73 | The web interface should now be accessible at `http://localhost:8080`. 74 | 75 | ### CLI 76 | 77 | To start a single instance of the CLI, make sure that the simulation server is running, navigate into `DistributedChatCLI` and execute: 78 | 79 | ```sh 80 | swift run distributed-chat --name Alice 81 | ``` 82 | 83 | You can substitute any name for Alice. Once the CLI has started, the chosen name should show up as a node in the simulation server's web interface. 84 | 85 | For convenience, there is a bash script for starting multiple instances of the CLI together in a single `tmux` session. To use it, navigate into the root directory of this repository and run 86 | 87 | ```sh 88 | Scripts/start_clis Alice Bob Charles Dave 89 | ``` 90 | 91 | ...or however many clients you want to start. To stop all clients at once, press `Ctrl + B` then type `:kill-session` and press enter. 92 | 93 | ### iOS app 94 | 95 | Building and running the iOS app is only possible on macOS, so make sure to have the following available: 96 | 97 | * Xcode 15.4+ 98 | * Swift 5.10+ (should be included with Xcode) 99 | * optionally an iOS device 100 | 101 | The open the `DistributedChatApp` subdirectory in Xcode and build/run the project. 102 | -------------------------------------------------------------------------------- /Scripts/.gitignore: -------------------------------------------------------------------------------- 1 | connect_l2cap 2 | -------------------------------------------------------------------------------- /Scripts/README.md: -------------------------------------------------------------------------------- 1 | # Scripts 2 | 3 | A collection of scripts useful for developing or testing the Distributed Chat app. 4 | -------------------------------------------------------------------------------- /Scripts/js-test-client/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 3 | -------------------------------------------------------------------------------- /Scripts/js-test-client/README.md: -------------------------------------------------------------------------------- 1 | # JS Test Client 2 | 3 | Acts as a GATT server and client for exchanging chat messages with real iOS nodes. 4 | 5 | ## Usage 6 | 7 | First make sure to have Node.js 8.9.0 installed (you can use a version manager like `n` for this), then run 8 | 9 | ``` 10 | sudo apt-get install bluetooth bluez libbluetooth-dev libudev-dev 11 | ``` 12 | 13 | to install the system dependencies, finally 14 | 15 | ``` 16 | npm install 17 | ``` 18 | 19 | to install the npm dependencies. Now you can either... 20 | 21 | * Launch the GATT server/peripheral using `sudo npm run server` 22 | * Launch the GATT client/central using `sudo npm run client` 23 | -------------------------------------------------------------------------------- /Scripts/js-test-client/gatt_client.js: -------------------------------------------------------------------------------- 1 | // Acts as a GATT client/central for 2 | // exchanging chat messages with real iOS nodes. 3 | 4 | const noble = require('@abandonware/noble'); // Central/GATT client 5 | const readline = require('readline'); 6 | const { v4: uuid4 } = require('uuid'); 7 | const { 8 | serviceUUID, 9 | inboxCharacteristicUUID, 10 | userIDCharacteristicUUID, 11 | userNameCharacteristicUUID, 12 | myID, 13 | myName 14 | } = require('./gatt_constants'); 15 | 16 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 17 | 18 | function question(query) { 19 | return new Promise(resolve => { 20 | rl.question(query, answer => { 21 | resolve(answer); 22 | }); 23 | }); 24 | } 25 | 26 | // GATT client 27 | 28 | noble.on('discover', async peripheral => { 29 | console.log(`Found peripheral ${peripheral}`); 30 | await noble.stopScanningAsync(); 31 | await peripheral.connectAsync(); 32 | 33 | const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync([serviceUUID], [inboxCharacteristicUUID, userNameCharacteristicUUID, userIDCharacteristicUUID]); 34 | const inboxChar = characteristics.find(c => c.uuid === inboxCharacteristicUUID); 35 | const userNameChar = characteristics.find(c => c.uuid === userNameCharacteristicUUID); 36 | const userIDChar = characteristics.find(c => c.uuid === userIDCharacteristicUUID); 37 | 38 | if (inboxChar && userNameChar && userIDChar) { 39 | console.log(`Discovered our characteristics!`); 40 | const userName = (await userNameChar.readAsync()).toString('utf-8'); 41 | const userID = (await userIDChar.readAsync()).toString('utf-8'); 42 | let logicalClock = 0; 43 | 44 | while (true) { 45 | const content = await question('Please enter a message: '); 46 | const timestamp = Date.now() / 1000.0; 47 | const json = JSON.stringify({ 48 | id: uuid4(), 49 | timestamp: timestamp, 50 | sourceUserId: myID, 51 | visitedUsers: [], 52 | logicalClock: logicalClock, 53 | addedChatMessages: [ 54 | { 55 | id: uuid4(), 56 | timestamp: timestamp, 57 | author: { 58 | id: myID, 59 | name: myName, 60 | logicalClock: logicalClock 61 | }, 62 | content: { 63 | type: 'text', 64 | data: content 65 | } 66 | } 67 | ] 68 | }) + '\n'; 69 | await inboxChar.writeAsync(Buffer.from(json, 'utf-8'), false); 70 | logicalClock++; 71 | } 72 | } 73 | }); 74 | 75 | noble.on('stateChange', async state => { 76 | if (state === 'poweredOn') { 77 | console.log('Scanning for devices...'); 78 | await noble.startScanningAsync([serviceUUID], false); 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /Scripts/js-test-client/gatt_constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | serviceUUID: '59553ceb2ffa40188a6c453a5292044d', 3 | inboxCharacteristicUUID: '440a594c3cc2494aa08abe8dd23549ff', 4 | userNameCharacteristicUUID: 'b2234f402c0b401b8145c612b9a7bae1', 5 | userIDCharacteristicUUID: '13a4d26e0a754fde93404974e3da3100', 6 | myName: 'Test Client', 7 | myID: 'e12ccc30-cb41-48e1-b293-48e365642d44' 8 | }; 9 | -------------------------------------------------------------------------------- /Scripts/js-test-client/gatt_server.js: -------------------------------------------------------------------------------- 1 | // Acts as a GATT server/peripheral for 2 | // exchanging chat messages with real iOS nodes. 3 | 4 | // NOTE: Accepting service discovery requests seems 5 | // to have some issues with bluetoothd currently, 6 | // see https://github.com/noble/bleno/issues/24. 7 | // The workaround (for now) is to manually disable 8 | // bluetoothd via systemd, then re-enable it directly: 9 | // 10 | // sudo systemctl stop bluetooth 11 | // sudo hciconfig hci0 up 12 | // 13 | // Once you are done, remember to restart bluetoothd: 14 | // 15 | // sudo systemctl start bluetooth 16 | 17 | const bleno = require('@abandonware/bleno'); // Peripheral/GATT server 18 | const { 19 | serviceUUID, 20 | inboxCharacteristicUUID, 21 | userIDCharacteristicUUID, 22 | userNameCharacteristicUUID, 23 | myID, 24 | myName 25 | } = require('./gatt_constants'); 26 | 27 | function handle(error) { 28 | if (error) { 29 | console.log(error); 30 | return false; 31 | } 32 | return true; 33 | } 34 | 35 | // GATT server 36 | 37 | bleno.on('advertisingStart', err => { 38 | if (!handle(err)) return; 39 | 40 | console.log('Setting services...'); 41 | bleno.setServices([ 42 | new bleno.PrimaryService({ 43 | uuid: serviceUUID, 44 | characteristics: [ 45 | new bleno.Characteristic({ 46 | uuid: inboxCharacteristicUUID, 47 | properties: ['write'], 48 | secure: [], 49 | descriptors: [] 50 | }), 51 | new bleno.Characteristic({ 52 | uuid: userNameCharacteristicUUID, 53 | properties: ['read'], 54 | secure: [], 55 | value: Buffer.from(myName, 'utf-8'), 56 | descriptors: [] 57 | }), 58 | new bleno.Characteristic({ 59 | uuid: userIDCharacteristicUUID, 60 | properties: ['read'], 61 | secure: [], 62 | value: Buffer.from(myID, 'utf-8'), 63 | descriptors: [] 64 | }) 65 | ] 66 | }) 67 | ]); 68 | }); 69 | 70 | bleno.on('stateChange', state => { 71 | if (state === 'poweredOn') { 72 | console.log('Starting to advertise...'); 73 | bleno.startAdvertising(myName, [serviceUUID], err => handle(err)); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /Scripts/js-test-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-test-client", 3 | "version": "1.0.0", 4 | "description": "Distributed Chat test client for Node.js", 5 | "main": "gatt_client.js", 6 | "scripts": { 7 | "client": "node ./gatt_client.js", 8 | "server": "node ./gatt_server.js", 9 | "start": "node ./gatt_client.js" 10 | }, 11 | "author": "fwcd", 12 | "license": "MPL-2.0", 13 | "dependencies": { 14 | "@abandonware/bleno": "^0.5.1-3", 15 | "@abandonware/noble": "^1.9.2-11", 16 | "readline": "^1.3.0", 17 | "uuid": "^8.3.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Scripts/python-test-client/README.md: -------------------------------------------------------------------------------- 1 | # Python Test Client 2 | 3 | Acts as a GATT client for sending chat messages to real iOS nodes. 4 | 5 | ## Usage 6 | 7 | First make sure to have Python 3 and Pip installed, then run 8 | 9 | ``` 10 | pip3 install bluepy 11 | ``` 12 | 13 | to install the dependencies and 14 | 15 | ``` 16 | python3 test_client.py 17 | ``` 18 | 19 | to launch the client. 20 | -------------------------------------------------------------------------------- /Scripts/python-test-client/gatt_constants.py: -------------------------------------------------------------------------------- 1 | SERVICE_UUID = '59553ceb-2ffa-4018-8a6c-453a5292044d' # Distributed Chat GATT service UUID 2 | CHARACTERISTIC_UUID = '440a594c-3cc2-494a-a08a-be8dd23549ff' # 'Message inbox' GATT characteristic 3 | # Is part of the mentioned GATT service 4 | -------------------------------------------------------------------------------- /Scripts/python-test-client/test_client.py: -------------------------------------------------------------------------------- 1 | # This is a small script that acts as test client for 2 | # sending chat messages to real iOS nodes running the 3 | # DistributedChat service. 4 | # 5 | # Once our DistributedChat service has been discovered, it 6 | # prompts for a string to write to our 'message inbox' 7 | # characteristic. 8 | # 9 | # Note that the script only acts as a GATT central and 10 | # NOT a peripheral, i.e. it does not expose such a service 11 | # with a 'message inbox' itself (thereby making it only 12 | # possible to send chat messages, not receive). 13 | # 14 | # To use, run 'pip3 install bluepy', then 'python3 test_client.py'. 15 | # NOTE: This script MUST run as root! 16 | 17 | import json 18 | import time 19 | from bluepy.btle import Scanner, Peripheral 20 | from uuid import uuid4 21 | from gatt_constants import SERVICE_UUID, CHARACTERISTIC_UUID 22 | 23 | scanner = Scanner() 24 | 25 | while True: 26 | print('Scanning for devices...') 27 | devices = scanner.scan(10.0) 28 | for dev in devices: 29 | print(f'Device {dev.addr} (RSSI: {dev.rssi})') 30 | for (adtype, desc, value) in dev.getScanData(): 31 | print(f'Adtype: {adtype}, desc: {desc}, value: {value}') 32 | if adtype == 7 and value == SERVICE_UUID: 33 | print(' >> Found the DistributedChat service, finding characteristics...') 34 | peripheral = Peripheral(dev.addr, dev.addrType, dev.iface) 35 | characteristics = peripheral.getCharacteristics(uuid=CHARACTERISTIC_UUID) 36 | if characteristics: 37 | my_name = 'Test Client' 38 | my_id = str(uuid4()) 39 | logical_clock = 0 40 | while True: 41 | content = input(f' >> Enter a chat message to send: ') 42 | # See ChatProtocol.Message in DistributedChat package for a 43 | # description of the JSON message structure. 44 | timestamp = time.time() 45 | s = (json.dumps({ 46 | 'id': str(uuid4()), 47 | 'timestamp': timestamp, 48 | 'logicalClock': logical_clock, 49 | 'sourceUserId': my_id, 50 | 'visitedUsers': [], 51 | 'addedChatMessages': [ 52 | { 53 | 'id': str(uuid4()), 54 | 'timestamp': timestamp, 55 | 'author': { 56 | 'id': my_id, 57 | 'name': my_name, 58 | 'logicalClock': logical_clock 59 | }, 60 | 'content': { 61 | 'type': 'text', 62 | 'data': content 63 | } 64 | } 65 | ] 66 | }) + '\n').encode('utf8') 67 | c = characteristics[0] 68 | c.write(s, withResponse=True) 69 | print(' >> Wrote successfully!') 70 | logical_clock += 1 71 | else: 72 | print(' >> Could not find our characteristic. :(') 73 | peripheral.disconnect() 74 | -------------------------------------------------------------------------------- /Scripts/start_clis: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A small script to start multiple instances of 3 | # the CLI using the list of names provided as 4 | # arguments. 5 | 6 | CLI_DIR="$(dirname $(dirname "${BASH_SOURCE[0]}"))/DistributedChatCLI" 7 | command="" 8 | 9 | for name in $@; do 10 | if [ -z "$command" ]; then 11 | subcommand="new-session" 12 | else 13 | subcommand="split-window -v" 14 | fi 15 | command+="$subcommand 'cd $CLI_DIR && swift run distributed-chat --name $name' \; " 16 | done 17 | 18 | if [ -n "$command" ]; then 19 | command="tmux $command select-layout even-vertical \; attach" 20 | eval $command 21 | else 22 | echo "Usage: $0 [name...]" 23 | fi 24 | --------------------------------------------------------------------------------