From dfa906fa4ccf2b6825d50f40d2879372df02ddf0 Mon Sep 17 00:00:00 2001 From: Florent Pillet Date: Tue, 27 Nov 2018 00:01:44 +0100 Subject: [PATCH] Initial commit --- .gitignore | 6 + ChatClient.md | 56 ++ ChatServer.md | 104 +++ README.md | 11 + Server-completed/Package.resolved | 25 + Server-completed/Package.swift | 22 + .../Model/ClientCommand+Codable.swift | 51 ++ .../ChatCommon/Model/ClientCommand.swift | 8 + .../Model/ServerMessage+Codable.swift | 71 ++ .../ChatCommon/Model/ServerMessage.swift | 10 + .../Sources/ChatServer/main.swift | 12 + .../Extensions/ByteBuffer+Data.swift | 17 + .../ClientCommandDecoderChannelHandler.swift | 30 + .../ClientCommandEncoderChannelHandler.swift | 26 + .../ClientCommandLogChannelHandler.swift | 35 + .../Handlers/FramedMessageCodec.swift | 35 + .../Handlers/RawLogChannelHandler.swift | 40 ++ .../Handlers/ServerChatRoomsHandler.swift | 121 ++++ .../ServerMessageDecoderChannelHandler.swift | 30 + .../ServerMessageEncoderChannelHandler.swift | 23 + .../ChatServerLib/Model/ChatRoom.swift | 9 + .../ChatServerLib/Model/ChatUser.swift | 27 + .../Sources/ChatServerLib/ServerMain.swift | 44 ++ .../Channel+ChatServerTests.swift | 32 + .../ChatServerTests/ChatServerTests.swift | 115 ++++ .../ServerMessage+Testing.swift | 29 + .../ChatServerTests/TestingChatClient.swift | 104 +++ .../ChatServerTests/TestingChatServer.swift | 46 ++ Server/Package.resolved | 25 + Server/Package.swift | 22 + .../Model/ClientCommand+Codable.swift | 51 ++ .../ChatCommon/Model/ClientCommand.swift | 8 + .../Model/ServerMessage+Codable.swift | 71 ++ .../ChatCommon/Model/ServerMessage.swift | 10 + Server/Sources/ChatServer/main.swift | 12 + .../Extensions/ByteBuffer+Data.swift | 17 + .../ClientCommandDecoderChannelHandler.swift | 11 + .../ClientCommandLogChannelHandler.swift | 8 + .../Handlers/FramedMessageCodec.swift | 7 + .../Handlers/RawLogChannelHandler.swift | 36 ++ .../Handlers/ServerChatRoomsHandler.swift | 89 +++ .../ServerMessageEncoderChannelHandler.swift | 9 + .../ChatServerLib/Model/ChatRoom.swift | 9 + .../ChatServerLib/Model/ChatUser.swift | 27 + Server/Sources/ChatServerLib/ServerMain.swift | 31 + .../Channel+ChatServerTests.swift | 32 + .../ChatServerTests/ChatServerTests.swift | 115 ++++ .../ServerMessage+Testing.swift | 29 + .../ServerMessageDecoderChannelHandler.swift | 30 + .../ChatServerTests/TestingChatClient.swift | 104 +++ .../ChatServerTests/TestingChatServer.swift | 46 ++ .../ChatClient.xcodeproj/project.pbxproj | 610 ++++++++++++++++++ .../xcschemes/ChatClient.xcscheme | 32 + .../xcschemes/xcschememanagement.plist | 14 + iOS-completed/ChatClient/AppDelegate.swift | 55 ++ iOS-completed/ChatClient/Constants.swift | 9 + .../Controllers/MasterViewController.swift | 120 ++++ .../Controllers/MessagesViewController.swift | 124 ++++ .../ChatClient/Model/ChatClientService.swift | 248 +++++++ .../ChatClient/Model/ChatEntry.swift | 7 + .../ChatClient/Model/MessageBoard.swift | 47 ++ .../AppIcon.appiconset/Contents.json | 98 +++ .../Resources/Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../Resources/Base.lproj/Main.storyboard | 147 +++++ iOS-completed/ChatClient/Resources/Info.plist | 52 ++ .../ChatClientTests/ChatClientTests.swift | 27 + iOS-completed/ChatClientTests/Info.plist | 22 + iOS-completed/Podfile | 11 + iOS-completed/Podfile.lock | 21 + iOS/ChatClient.xcodeproj/project.pbxproj | 610 ++++++++++++++++++ .../xcschemes/ChatClient.xcscheme | 32 + .../xcschemes/xcschememanagement.plist | 14 + iOS/ChatClient/AppDelegate.swift | 55 ++ iOS/ChatClient/Constants.swift | 9 + .../Controllers/MasterViewController.swift | 120 ++++ .../Controllers/MessagesViewController.swift | 124 ++++ iOS/ChatClient/Model/ChatClientService.swift | 119 ++++ iOS/ChatClient/Model/ChatEntry.swift | 7 + iOS/ChatClient/Model/MessageBoard.swift | 47 ++ .../AppIcon.appiconset/Contents.json | 98 +++ .../Resources/Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../Resources/Base.lproj/Main.storyboard | 147 +++++ iOS/ChatClient/Resources/Info.plist | 52 ++ iOS/ChatClientTests/ChatClientTests.swift | 27 + iOS/ChatClientTests/Info.plist | 22 + iOS/Podfile | 11 + iOS/Podfile.lock | 21 + 89 files changed, 5159 insertions(+) create mode 100644 .gitignore create mode 100644 ChatClient.md create mode 100644 ChatServer.md create mode 100644 README.md create mode 100644 Server-completed/Package.resolved create mode 100644 Server-completed/Package.swift create mode 100644 Server-completed/Sources/ChatCommon/Model/ClientCommand+Codable.swift create mode 100644 Server-completed/Sources/ChatCommon/Model/ClientCommand.swift create mode 100644 Server-completed/Sources/ChatCommon/Model/ServerMessage+Codable.swift create mode 100644 Server-completed/Sources/ChatCommon/Model/ServerMessage.swift create mode 100644 Server-completed/Sources/ChatServer/main.swift create mode 100644 Server-completed/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/ClientCommandEncoderChannelHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/ServerMessageDecoderChannelHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift create mode 100644 Server-completed/Sources/ChatServerLib/Model/ChatRoom.swift create mode 100644 Server-completed/Sources/ChatServerLib/Model/ChatUser.swift create mode 100644 Server-completed/Sources/ChatServerLib/ServerMain.swift create mode 100644 Server-completed/Tests/ChatServerTests/Channel+ChatServerTests.swift create mode 100644 Server-completed/Tests/ChatServerTests/ChatServerTests.swift create mode 100644 Server-completed/Tests/ChatServerTests/ServerMessage+Testing.swift create mode 100644 Server-completed/Tests/ChatServerTests/TestingChatClient.swift create mode 100644 Server-completed/Tests/ChatServerTests/TestingChatServer.swift create mode 100644 Server/Package.resolved create mode 100644 Server/Package.swift create mode 100644 Server/Sources/ChatCommon/Model/ClientCommand+Codable.swift create mode 100644 Server/Sources/ChatCommon/Model/ClientCommand.swift create mode 100644 Server/Sources/ChatCommon/Model/ServerMessage+Codable.swift create mode 100644 Server/Sources/ChatCommon/Model/ServerMessage.swift create mode 100644 Server/Sources/ChatServer/main.swift create mode 100644 Server/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift create mode 100644 Server/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift create mode 100644 Server/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift create mode 100644 Server/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift create mode 100644 Server/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift create mode 100644 Server/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift create mode 100644 Server/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift create mode 100644 Server/Sources/ChatServerLib/Model/ChatRoom.swift create mode 100644 Server/Sources/ChatServerLib/Model/ChatUser.swift create mode 100644 Server/Sources/ChatServerLib/ServerMain.swift create mode 100644 Server/Tests/ChatServerTests/Channel+ChatServerTests.swift create mode 100644 Server/Tests/ChatServerTests/ChatServerTests.swift create mode 100644 Server/Tests/ChatServerTests/ServerMessage+Testing.swift create mode 100644 Server/Tests/ChatServerTests/ServerMessageDecoderChannelHandler.swift create mode 100644 Server/Tests/ChatServerTests/TestingChatClient.swift create mode 100644 Server/Tests/ChatServerTests/TestingChatServer.swift create mode 100644 iOS-completed/ChatClient.xcodeproj/project.pbxproj create mode 100644 iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme create mode 100644 iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 iOS-completed/ChatClient/AppDelegate.swift create mode 100644 iOS-completed/ChatClient/Constants.swift create mode 100644 iOS-completed/ChatClient/Controllers/MasterViewController.swift create mode 100644 iOS-completed/ChatClient/Controllers/MessagesViewController.swift create mode 100644 iOS-completed/ChatClient/Model/ChatClientService.swift create mode 100644 iOS-completed/ChatClient/Model/ChatEntry.swift create mode 100644 iOS-completed/ChatClient/Model/MessageBoard.swift create mode 100644 iOS-completed/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iOS-completed/ChatClient/Resources/Assets.xcassets/Contents.json create mode 100644 iOS-completed/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard create mode 100644 iOS-completed/ChatClient/Resources/Base.lproj/Main.storyboard create mode 100644 iOS-completed/ChatClient/Resources/Info.plist create mode 100644 iOS-completed/ChatClientTests/ChatClientTests.swift create mode 100644 iOS-completed/ChatClientTests/Info.plist create mode 100644 iOS-completed/Podfile create mode 100644 iOS-completed/Podfile.lock create mode 100644 iOS/ChatClient.xcodeproj/project.pbxproj create mode 100644 iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme create mode 100644 iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 iOS/ChatClient/AppDelegate.swift create mode 100644 iOS/ChatClient/Constants.swift create mode 100644 iOS/ChatClient/Controllers/MasterViewController.swift create mode 100644 iOS/ChatClient/Controllers/MessagesViewController.swift create mode 100644 iOS/ChatClient/Model/ChatClientService.swift create mode 100644 iOS/ChatClient/Model/ChatEntry.swift create mode 100644 iOS/ChatClient/Model/MessageBoard.swift create mode 100644 iOS/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iOS/ChatClient/Resources/Assets.xcassets/Contents.json create mode 100644 iOS/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard create mode 100644 iOS/ChatClient/Resources/Base.lproj/Main.storyboard create mode 100644 iOS/ChatClient/Resources/Info.plist create mode 100644 iOS/ChatClientTests/ChatClientTests.swift create mode 100644 iOS/ChatClientTests/Info.plist create mode 100644 iOS/Podfile create mode 100644 iOS/Podfile.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..118d7fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.build/ +Packages/ +/Server/*.xcodeproj +*.xcworkspace +Pods/ diff --git a/ChatClient.md b/ChatClient.md new file mode 100644 index 0000000..9f6a586 --- /dev/null +++ b/ChatClient.md @@ -0,0 +1,56 @@ +# Mission: create a chat client + +The client for our chat solution is an iOS application (albeit a simple one, designed to run on iPad). The client connects to the server and talks a simple protocol: client sends `ClientCommand`s, sever replies with `ServerMessage`s. + +The server will accept commands from clients applications that connect to it. It holds the chat rooms, dispatches the messages sent by clients, and supports direct messages between clients. + +To talk to the server, you'll use Network.framework's new API, available in iOS 12, tvOS 12 and macOS Mojave. + +For this project you'll work in the `iOS` directory. If you're stuck or want to check out a hint, the completed project is in the `iOS-Complete` folder. + +## Introduction: the simplicity and versatility of Network.framework + +Let's discuss Network.framework! It' sa powerful networking API which provides a small but powerful API surface. + +Its main components are: + +* `NWEndpoint`, an endpoint in a network connection. +* `NWConnection`, a bidirectional data connection between a local endpoint and a remote endpoint. +* `NWListener`, an object you use to listen for incoming network connections. +* `NWParameter`, an object that stores the protocols to use for connections, options for sending data, and network path constraints. + +Network.framework also replaces goold old Reachability with `NWPath` and `NWPathMonitor`. It add a ton of information and control over the type of connections, network transitions (i.e. wifi to cellular, etc), supports multipath TCP, proxies, TLS, etc etc. We're only going to scratch the surface with this iOS client application. + +## Prelude: familiarize yourself with the application + +It's a bare-bones chat application, nothing complicated about it. All the communication with the server, and carrying the state of each chat room is not in the `ChatClientService` class. This is where you'll be doing all your network-related work. + +To get started, make sure you get the one Pod we need for this application: MessengerKit, a framework that makes it easy to display a chat user interface. + +Make sure you have CocoaPods install: +`$ sudo gem install cocoapods` + +Then simply update pods from within the iOS folder: +`$ pod update` + +## Task 1: setup the necessary bits for NWConnection + +You'll need to create an endpoint (`NWEndpoint`) that describes the server location, and prepare a queue for your connection to run on. This is done in `init` and above. + +## Task 2: fill in the `connect()` method + +Create the `NWConnection` object you need in the `connect()` method. Setup a connection state handler by filling the `setupConnectionStateHandler(_:)` method. You'll learn about the various states a connection can be in. + +## Task 3: write the code that sends packets + +An easy task (as is the rest of this project), you'll fill in the `sendUnframed(command:)` function which sends raw JSON data to the server. But you may want to directly fill in the `sendFramed(command:)` method which sends a JSON packet prefixed with an `UInt32` (big endian) the gives the size of the JSON data. This is to deal with TCP packet fragmentation on the receiving side. + +Filling both method will give you a sense of a very useful distinction in Network.framework: the ability to indicate when the content you are sending is complete, even when you write multiple chunks. + +## Task 4: write the code that receives messages from the server + +Reading messages from the server is slightly more involved than writig, but not much. Again you'll appreciate the simplicity of the Network.framework API which really is a joy to work with. Make sure you feel in both the `readNextUnframedMessage(_:)` and `readNextFrameMessage(_:)` methods to first understand the basic of asynchronous reads in Network.framework, and learn how you split multiple reads and chain them together. + +## Uber-challenge: write the iOS side with Swift-NIO and NIOTransportServices! + +Although no solution to this challenge is presented here, if you worked on the Swift-NIO side of the project with the server you may understand it well enough to write the client side with Swift-NIO and maybe reuse some of the channel handlers you prepared for the server! diff --git a/ChatServer.md b/ChatServer.md new file mode 100644 index 0000000..f0d469f --- /dev/null +++ b/ChatServer.md @@ -0,0 +1,104 @@ +# Mission: create a chat server + +We're going to build a simple chat server that can run on macOS and Linux, using Swift-NIO. + +The server will accept commands from clients applications that connect to it. It holds the chat rooms, dispatches the messages sent by clients, and supports direct messages between clients. + +To simplify the development, most of the infrastructure you need (model, utilities, general project structure) is ready for you to start with. + +For this project you'll work in the `Server` directory. If you're stuck or want to check out a hint, the completed project is in the `Server-Complete` folder. + +## Prelude: environment setup + +The chat server relies on Swift-NIO, which can be obtained using the Swift Package Manager. Firer up a terminal, `cd` to the `Server` folder and run these commands: + +`$ swift package update` + +then + +`$ swift package generate-xcodeproj` + +A new `ChatServer.xcodeproj` project will appear in the Server folder. + +## Introduction: understanding Swift-NIO's general model + +Let's discuss Swift-NIO architecture! The introduction on the repository states that: + +> SwiftNIO is a cross-platform asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients. +> It's like Netty, but written for Swift. + +I'll go with you over the main concepts and building blocks in Swift-NIO: + +* `EventLoop` and `EventLoopGroup`: +* `Channel`, a protocol +* `ChannelHandler` and `ChannelPipeline`: single-purpose data handlers and pipelines to assemble them together +* `ServerBootstrap`, `ClientBootstrap` and `DatagramBootstrap`: helpers to quickly get setup for a server or client +* `EventLoopFuture` and `EventLoopPromise`, asynchronous production of results +* `ByteBuffer`, high performance contiguous storage + +In this introduction and simple server development, we'll focus on the 5 first items, and will make light use of `Future` to setup the server. + +Let me go over Swift-NIO's model, then we'll kick in the first task. + +## Task 1: write a logging channel handler + +Write a simple channel handler named that you'll insert in the processing pipeline and which logs incoming commands from clients. It will take a `ClientCommand` as its in / out type, log what it sees then pass the data on to the next handler. + +Open the `ClientCommandLogHandler.swift` file to get going. Remember that what you process needs to be carried on to the next handler in the pipeline! + + +## Task 1: write the basic packet format encoder and decoder + +You'll need at least one channel handler that decodes the JSON to `ClientCommand` enums, and one that encodes outgoing messages from `ServerCommand` enums. + +Remember that data goes in but also needs to get carried out to the next handler in the pipeline. + +Open the `ClientCommandDecoderChannelHandler.swift` file to get going with the incoming data decoder. + +Next, open `ServerMessageEncoderChannelHandler.swift` to code the outgoing handler. Notice that this time, it will adopt the `MessageToByteEncoder` protocol, worth to know about! + + +## Task 3: create an EventLoopGroup + +An easy one to get started with the actual server. Open `ServerMain.swift` and create your new group. + + +## Task 4: boostrap the server + +This one is more involved as you'll have to understand what `ServerBoostrap` does and how to use it. This all happens in `ServerMain.swift`. + +Hints at what you want to do: + +* Create a `ServerBootstrap` for your EventLoopGroup +* Set options for the main server channel (the one that listens to client connections). Look into the various `ChannelOptions` and pick the ones you need +* Setup a child channel initializer which will configure the processing pipeline for client connections. At a minimum, you'll want to decode JSON to actual `ClientCommand` instances, +* Add the second channel handler which will log the decoded client commands + +At this stage you should be able to start your server, although it won't do much besides logging what comes in. You should be able to test it by running the iOS client and see one incoming message upon connection. + + +## Task 5: create the actual Chat handler channel + +You are now at a point where you're ready to inject the actual functionality of your server: + +- It needs to be a ChannelHandler that will come late in the pipeline +- It must receive `ClientCommand` objects +- It must send `ServerMessage` objects to clients + +Start by opening the file `ServerMain.swift` then fill the gaps in the `startServer(rooms:)`function. + +## Task #6: run the tests + +The tests have already been written for you. If you run tests, either from Xcode or from the commandline, they should mostly pass. "Mostly" because you'll quickly realize that there is one issue left that needs to be taken care of ... + +See, TCP doesn't guarantee that everything that's being sent from one side will arrive in a single piece on the other side. There may be packet fragmentation, which means (and this happens during testing, which establishes real connections internally) that you may have JSON packets that arrive in several pieces. + +The solution to tackle this issue is to frame your packets in a way that make it easy from the receiving end to reassemble, regardless of the number of chunks they have been split into. + +So you'll want to implement a simple framing protocol: send 4 bytes with the length of the data, followed by the data (the JSON representation) itself. + +Open the `FrameMessageDecoder.swift` file and get going if you feel you can do it! Otherwise, you'll find a reference implementation in the complete product source code. + +Once you've coded this part, make sure you uncomment the lines about `FrameMessageCodec` in `TestingChatClient.swift` and `TestingChatServer.swift` ! + +That's all for the server. Congrats for making it this far! \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..faa3ab7 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Swift Alps Networking Workshop + +### Prepared by Florent Pillet - @fpillet + +Welcome to the Swift Alps Networking Workshop! You'll learn a lot about Swift-NIO, a new high performance network application framework from Apple, as well as about Network.framework, the new networking API in iOS 12, tvOS 12 and macOS Mojave which deprecates most of the past networking APIs. + +In this project, you will work on a chat server using Swift-NIO, and a chat client on iOS using Network.framework. You can choose to work on one or both, depending on your interest and your learning speed. Once you get back from Swift Alps, you can check out this material to learn about networking techniques using both frameworks. + +I hope you'll enjoy them as much as I do! + +Please check out `ChatClient.md` and `ChatServer.md` for the challenges with each project. diff --git a/Server-completed/Package.resolved b/Server-completed/Package.resolved new file mode 100644 index 0000000..0a1d621 --- /dev/null +++ b/Server-completed/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "035962e6b6e03c8721a91a9b96dc084289795cb4", + "version": "1.11.0" + } + }, + { + "package": "swift-nio-zlib-support", + "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", + "state": { + "branch": null, + "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", + "version": "1.0.0" + } + } + ] + }, + "version": 1 +} diff --git a/Server-completed/Package.swift b/Server-completed/Package.swift new file mode 100644 index 0000000..f364b7e --- /dev/null +++ b/Server-completed/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:4.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ChatServer", + products: [ + .library(name: "ChatCommon", targets: ["ChatCommon"]), + .library(name: "ChatServerLib", targets: ["ChatServerLib"]), + .executable(name: "ChatServer", targets: ["ChatServer"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "1.11.0"), + ], + targets: [ + .target(name: "ChatCommon", dependencies: ["NIO"]), + .target(name: "ChatServerLib", dependencies: ["NIO","ChatCommon"]), + .target(name: "ChatServer", dependencies: ["ChatServerLib"]), + .testTarget(name: "ChatServerTests", dependencies: ["ChatServerLib"]) + ] +) diff --git a/Server-completed/Sources/ChatCommon/Model/ClientCommand+Codable.swift b/Server-completed/Sources/ChatCommon/Model/ClientCommand+Codable.swift new file mode 100644 index 0000000..ae5c39f --- /dev/null +++ b/Server-completed/Sources/ChatCommon/Model/ClientCommand+Codable.swift @@ -0,0 +1,51 @@ +import Foundation + +extension ClientCommand: Codable { + private enum CodingKeys: String, CodingKey { + case command + case data + } + + private enum Cmd: String, Codable { + case connect, disconnect, message, privateMessage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Cmd.self, forKey: .command) { + case .connect: + let username = try container.decode(String.self, forKey: .data) + self = .connect(username: username) + case .disconnect: + self = .disconnect + case .message: + let msg = try container.decode(MessageData.self, forKey: .data) + self = .message(room: msg.to, text: msg.text) + case .privateMessage: + let msg = try container.decode(MessageData.self, forKey: .data) + self = .privateMessage(username: msg.to, text: msg.text) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .connect(let username): + try container.encode(Cmd.connect, forKey: .command) + try container.encode(username, forKey: .data) + case .disconnect: + try container.encode(Cmd.disconnect, forKey: .command) + case .message(let room, let text): + try container.encode(Cmd.message, forKey: .command) + try container.encode(MessageData(to: room, text: text), forKey: .data) + case .privateMessage(let username, let text): + try container.encode(Cmd.privateMessage, forKey: .command) + try container.encode(MessageData(to: username, text: text), forKey: .data) + } + } +} + +private struct MessageData: Codable { + let to: String + let text: String +} diff --git a/Server-completed/Sources/ChatCommon/Model/ClientCommand.swift b/Server-completed/Sources/ChatCommon/Model/ClientCommand.swift new file mode 100644 index 0000000..d157f28 --- /dev/null +++ b/Server-completed/Sources/ChatCommon/Model/ClientCommand.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum ClientCommand: Equatable { + case connect(username: String) + case disconnect + case message(room: String, text: String) + case privateMessage(username: String, text: String) +} diff --git a/Server-completed/Sources/ChatCommon/Model/ServerMessage+Codable.swift b/Server-completed/Sources/ChatCommon/Model/ServerMessage+Codable.swift new file mode 100644 index 0000000..e5af2b2 --- /dev/null +++ b/Server-completed/Sources/ChatCommon/Model/ServerMessage+Codable.swift @@ -0,0 +1,71 @@ +import Foundation + +extension ServerMessage: Codable { + private enum CodingKeys: String, CodingKey { + case command + case data + } + + private enum Cmd: String, Codable { + case connected, disconnected, rooms, users, message, privateMessage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Cmd.self, forKey: .command) { + case .connected: + let server = try container.decode(String.self, forKey: .data) + self = .connected(to: server) + case .disconnected: + self = .disconnected + case .rooms: + let rooms = try container.decode([String].self, forKey: .data) + self = .rooms(rooms) + case .users: + let users = try container.decode([String].self, forKey: .data) + self = .users(users) + case .message: + let data = try container.decode(MessageData.self, forKey: .data) + self = .message(room: data.to, username: data.from, text: data.text) + case .privateMessage: + let data = try container.decode(MessageData.self, forKey: .data) + self = .privateMessage(from: data.from, to: data.to, text: data.text) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .connected(let to): + try container.encode(Cmd.connected, forKey: .command) + try container.encode(to, forKey: .data) + case .disconnected: + try container.encode(Cmd.disconnected, forKey: .command) + case .rooms(let rooms): + try container.encode(Cmd.rooms, forKey: .command) + try container.encode(rooms, forKey: .data) + case .users(let users): + try container.encode(Cmd.users, forKey: .command) + try container.encode(users, forKey: .data) + case .message(let room, let username, let text): + try container.encode(Cmd.message, forKey: .command) + try container.encode(MessageData(from: username, to: room, text: text), forKey: .data) + case .privateMessage(let from, let to, let text): + try container.encode(Cmd.privateMessage, forKey: .command) + try container.encode(MessageData(from: from, to: to, text: text), forKey: .data) + } + } +} + +// Pieces of data we need to encode / decode JSON + +private struct RoomAndUsername: Codable { + let room: String + let username: String +} + +private struct MessageData: Codable { + let from: String + let to: String + let text: String +} diff --git a/Server-completed/Sources/ChatCommon/Model/ServerMessage.swift b/Server-completed/Sources/ChatCommon/Model/ServerMessage.swift new file mode 100644 index 0000000..a171be5 --- /dev/null +++ b/Server-completed/Sources/ChatCommon/Model/ServerMessage.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum ServerMessage: Equatable { + case connected(to: String) + case disconnected + case rooms([String]) + case users([String]) + case message(room: String, username: String, text: String) + case privateMessage(from: String, to: String, text: String) +} diff --git a/Server-completed/Sources/ChatServer/main.swift b/Server-completed/Sources/ChatServer/main.swift new file mode 100644 index 0000000..3563515 --- /dev/null +++ b/Server-completed/Sources/ChatServer/main.swift @@ -0,0 +1,12 @@ +import ChatServerLib + +guard let server = startServer(rooms: ["Red Team","Blue Team","General","Random"]) else { + fatalError("Failed starting server") +} + +// this will never exit +try! server.1.closeFuture.wait() + +print("Done.") + + diff --git a/Server-completed/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift b/Server-completed/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift new file mode 100644 index 0000000..69df346 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift @@ -0,0 +1,17 @@ +// +// Created by Florent Pillet on 2018-11-21. +// + +import Foundation +import NIO + +extension ByteBuffer { + public mutating func write(_ data: Data) { + writeWithUnsafeMutableBytes { (bufferMutablePointer: UnsafeMutableRawBufferPointer) -> Int in + data.withUnsafeBytes { (pointer: UnsafePointer) -> Void in + bufferMutablePointer.copyMemory(from: UnsafeRawBufferPointer(start: UnsafeRawPointer(pointer), count: data.count)) + } + return data.count + } + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift new file mode 100644 index 0000000..6072c64 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift @@ -0,0 +1,30 @@ +import Foundation +import NIO +import ChatCommon + +public final class ClientCommandDecoderChannelHandler: ChannelInboundHandler { + public typealias InboundIn = ByteBuffer + public typealias InboundOut = ClientCommand + + public init() { } + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + var buffer = unwrapInboundIn(data) + let command = buffer.withUnsafeMutableReadableBytes { (pointer: UnsafeMutableRawBufferPointer) -> ClientCommand? in + guard let baseAddress = pointer.baseAddress else { + return nil + } + do { + let decoded = try JSONDecoder().decode(ClientCommand.self, from: Data(bytesNoCopy: baseAddress, count: pointer.count, deallocator: .none)) + return decoded + } + catch let err { + print("> decoding error: \(err)") + } + return nil + } + if let cmd = command { + ctx.fireChannelRead(self.wrapInboundOut(cmd)) + } + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandEncoderChannelHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandEncoderChannelHandler.swift new file mode 100644 index 0000000..d77440a --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandEncoderChannelHandler.swift @@ -0,0 +1,26 @@ +// +// ClientCommandEncoderChannelHandler.swift +// ChatServerTests +// +// Created by Florent Pillet on 26/11/2018. +// + +import Foundation +import NIO +import ChatCommon + +public final class ClientCommandEncoderChannelHandler: MessageToByteEncoder { + public typealias OutboundIn = ClientCommand + public typealias OutboundOut = ByteBuffer + + public init() { } + + public func encode(ctx: ChannelHandlerContext, data: ClientCommand, out: inout ByteBuffer) throws { + do { + let dataBytes = try JSONEncoder().encode(data) + out.write(dataBytes) + } catch let err { + print("** Failed encoding ClientCommand to JSON. Err=\(err)\nMessage=\(data)") + } + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift new file mode 100644 index 0000000..c43d980 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift @@ -0,0 +1,35 @@ +import Foundation +import NIO +import ChatCommon + +public final class ClientCommandLogChannelHandler: ChannelInboundHandler { + public typealias InboundIn = ClientCommand + public typealias InboundOut = ClientCommand + + public var username = "" + + public init() {} + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + // unwrap data to ClientCommand + let command = unwrapInboundIn(data) + + // remember the user's name + if case .connect(let username) = command { + self.username = username + } + + // log the command + let source: String + if username.isEmpty, let remote = ctx.remoteAddress { + source = remote.description + } else { + source = username + } + + print("Received from \(source): \(command)") + + // carry on to next handler + ctx.fireChannelRead(data) + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift b/Server-completed/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift new file mode 100644 index 0000000..fca3838 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift @@ -0,0 +1,35 @@ +import Foundation +import NIO + +// A simple CODEC that frames / unframes packets by prefixing them with Int32 size (big endian) + +public final class FramedMessageCodec: ByteToMessageDecoder, MessageToByteEncoder { + public typealias InboundIn = ByteBuffer + public typealias InboundOut = ByteBuffer + + public typealias OutboundIn = ByteBuffer + public typealias OutboundOut = ByteBuffer + + public init() { } + + // ByteToMessageDecoder + public var cumulationBuffer: ByteBuffer? + + public func decode(ctx: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState { + guard let frameSize = buffer.getInteger(at: 0, endianness: .big, as: Int32.self), buffer.readableBytes >= frameSize else { + return .needMoreData + } + buffer.moveReaderIndex(forwardBy: MemoryLayout.size) + ctx.fireChannelRead(self.wrapInboundOut(buffer.readSlice(length: Int(frameSize))!)) + return .continue + } + + // MessageToByteEncoder + + public func encode(ctx: ChannelHandlerContext, data: ByteBuffer, out: inout ByteBuffer) throws { + out.write(integer: Int32(data.readableBytes), endianness: .big) + data.withUnsafeReadableBytes { p in + _ = out.write(bytes: p) + } + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift new file mode 100644 index 0000000..dc56bb2 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift @@ -0,0 +1,40 @@ +// +// Created by Florent Pillet on 2018-11-21. +// + +import Foundation +import NIO + +public final class RawLogChannelHandler: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = ByteBuffer + public typealias InboundOut = ByteBuffer + + public typealias OutboundIn = ByteBuffer + public typealias OutboundOut = ByteBuffer + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + // unwrap the incoming data to the declared InboundIn type + let packet = unwrapInboundIn(data) + + // this is text data (JSON) so log it as a string + if let packetString = packet.getString(at: 0, length: packet.readableBytes) { + print("[INCOMING] \(packetString)") + } + + // continue processing packet with next handler + ctx.fireChannelRead(data) + } + + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + // unwrap the outgoing data to the declared OutboundIn type + let packet = unwrapOutboundIn(data) + + // this is text data (JSON) so log it as a string + if let packetString = packet.getString(at: 0, length: packet.readableBytes) { + print("[OUTGOING] \(packetString), promise=\(String(describing: promise))") + } + + // continue writing the data down the outgoing pipeline + ctx.write(data, promise: promise) + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift new file mode 100644 index 0000000..8113a51 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift @@ -0,0 +1,121 @@ +// +// ChatRoomsHandler.swift +// server +// +// Created by Florent Pillet on 20/11/2018. +// + +import Foundation +import NIO +import ChatCommon + +// The main functionality of this server + +public final class ServerChatRoomsHandler: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = ClientCommand + public typealias InboundOut = ClientCommand + + public typealias OutboundIn = Never + public typealias OutboundOut = ServerMessage + + private let syncQueue = DispatchQueue(label: "syncQueue") + + private var online = Set() + private var rooms: [String] + + public init(rooms: [String]) { + self.rooms = rooms + } + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + let clientCommand = unwrapInboundIn(data) + let channel = ctx.channel + syncQueue.async { + switch clientCommand { + case .connect(let username): + self.userConnected(name: username, channel: channel) + case .disconnect: + self.userDisconnected(channel) + case .message(let room, let text): + self.message(room: room, text: text, channel: channel) + case .privateMessage(let username, let text): + self.privateMessage(to: username, text: text, channel: channel) + } + } + + // since we implemented the method, we need to carry the callback + // over to the next handler in the pipeline + ctx.fireChannelRead(data) + } + + public func channelInactive(ctx: ChannelHandlerContext) { + // in case client didn't send is a `disconnect`, make sure we remove + // user from the rooms it was in, and notify others + let channel = ctx.channel + syncQueue.async { self.userDisconnected(channel) } + + // since we implemented the method, we need to carry the callback + // over to the next handler in the pipeline + ctx.fireChannelInactive() + } + + private func push(_ data: ServerMessage, to channel: Channel) { + // send a ServerMessage to one user + channel.writeAndFlush(wrapOutboundOut(data), promise: nil) + } + + /* + * Helper functions + * + */ + + private func onlineUser(_ channel: Channel) -> ChatUser? { + let uniqueIdentifier = ObjectIdentifier(channel) + return online.first { $0.uniqueIdentifier == uniqueIdentifier } + } + + private func userConnected(name: String, channel: Channel) { + let uniqueIdentifier = ObjectIdentifier(channel) + online.insert(ChatUser(name: name, channel: channel, uniqueIdentifier: uniqueIdentifier)) + listRooms(channel) + for user in online { + listUsers(user.channel) + } + } + + private func userDisconnected(_ channel: Channel) { + if let user = onlineUser(channel) { + online.remove(user) + for user in online { + listUsers(user.channel) + } + } + } + + private func listRooms(_ channel: Channel) { + push(ServerMessage.rooms(rooms.sorted()), to: channel) + } + + private func listUsers(_ channel: Channel) { + let users = online.map { $0.name } + push(ServerMessage.users(users.sorted()), to: channel) + } + + private func message(room: String, text: String, channel: Channel) { + guard let user = onlineUser(channel) else { + return + } + let msg = ServerMessage.message(room: room, username: user.name, text: text) + online.forEach { user in self.push(msg, to: user.channel) } + } + + private func privateMessage(to: String, text: String, channel: Channel) { + guard let fromUser = onlineUser(channel), + let toUser = online.first(where: { $0.name == to }) else { + return + } + let message = ServerMessage.privateMessage(from: fromUser.name, to: toUser.name, text: text) + push(message, to: toUser.channel) + push(message, to: fromUser.channel) + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/ServerMessageDecoderChannelHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/ServerMessageDecoderChannelHandler.swift new file mode 100644 index 0000000..a084a90 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/ServerMessageDecoderChannelHandler.swift @@ -0,0 +1,30 @@ +import Foundation +import NIO +import ChatCommon + +public final class ServerMessageDecoderChannelHandler: ChannelInboundHandler { + public typealias InboundIn = ByteBuffer + public typealias InboundOut = ServerMessage + + public init() { } + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + var buffer = unwrapInboundIn(data) + let message = buffer.withUnsafeMutableReadableBytes { (pointer: UnsafeMutableRawBufferPointer) -> ServerMessage? in + guard let baseAddress = pointer.baseAddress else { + return nil + } + do { + let decoded = try JSONDecoder().decode(ServerMessage.self, from: Data(bytesNoCopy: baseAddress, count: pointer.count, deallocator: .none)) + return decoded + } + catch let err { + print("> decoding error: \(err)") + } + return nil + } + if let message = message { + ctx.fireChannelRead(self.wrapInboundOut(message)) + } + } +} diff --git a/Server-completed/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift b/Server-completed/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift new file mode 100644 index 0000000..e2951b3 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift @@ -0,0 +1,23 @@ +// +// Created by Florent Pillet on 2018-11-21. +// + +import Foundation +import NIO +import ChatCommon + +public final class ServerMessageEncoderChannelHandler: MessageToByteEncoder { + public typealias OutboundIn = ServerMessage + public typealias OutboundOut = ByteBuffer + + public init() { } + + public func encode(ctx: ChannelHandlerContext, data: ServerMessage, out: inout ByteBuffer) throws { + do { + let dataBytes = try JSONEncoder().encode(data) + out.write(dataBytes) + } catch let err { + print("** Failed encoding ServerMessage to JSON. Err=\(err)\nMessage=\(data)") + } + } +} diff --git a/Server-completed/Sources/ChatServerLib/Model/ChatRoom.swift b/Server-completed/Sources/ChatServerLib/Model/ChatRoom.swift new file mode 100644 index 0000000..ac74fb7 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Model/ChatRoom.swift @@ -0,0 +1,9 @@ +import Foundation + +public class ChatRoom { + public var rooms = [String:[ChatUser]]() + + public func users(room: String) -> [ChatUser] { + return rooms[room] ?? [] + } +} diff --git a/Server-completed/Sources/ChatServerLib/Model/ChatUser.swift b/Server-completed/Sources/ChatServerLib/Model/ChatUser.swift new file mode 100644 index 0000000..5e989ee --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/Model/ChatUser.swift @@ -0,0 +1,27 @@ +import Foundation +import NIO + +public struct ChatUser { + let name: String + let channel: Channel + let uniqueIdentifier: ObjectIdentifier +} + +extension ChatUser: Equatable { + public static func ==(lhs: ChatUser, rhs: ChatUser) -> Bool { + return lhs.uniqueIdentifier == rhs.uniqueIdentifier + } +} + +extension ChatUser: Comparable { + // This is mainly to sort users in user lists + public static func < (lhs: ChatUser, rhs: ChatUser) -> Bool { + return lhs.name.localizedCompare(rhs.name) == .orderedAscending + } +} + +extension ChatUser: Hashable { + public func hash(into hasher: inout Hasher) { + uniqueIdentifier.hash(into: &hasher) + } +} diff --git a/Server-completed/Sources/ChatServerLib/ServerMain.swift b/Server-completed/Sources/ChatServerLib/ServerMain.swift new file mode 100644 index 0000000..8536ac3 --- /dev/null +++ b/Server-completed/Sources/ChatServerLib/ServerMain.swift @@ -0,0 +1,44 @@ +import Foundation +import NIO + +public func startServer(rooms: [String]) -> (MultiThreadedEventLoopGroup, Channel)? { + // Create an EventLoopGroup that will accept connections. You can dimension its capacity (in number of threads) + // according to the number of cores your computer has, or simply use a hardcoded value. Remember that each + // thread (each EventLoop) can support a large number of connections! + let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) + + // We're going to use a single instance + // to handle chat exchanges between participants + let globalChatHandler = ServerChatRoomsHandler(rooms: rooms) + + let bootstrap = ServerBootstrap(group: group) + // Specify backlog and enable SO_REUSEADDR for the server itself + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + + // Set the handlers that are applied to the accepted Channels + .childChannelInitializer { channel in channel.pipeline.addHandlers([ + FramedMessageCodec(), + RawLogChannelHandler(), + ClientCommandDecoderChannelHandler(), + ClientCommandLogChannelHandler(), + ServerMessageEncoderChannelHandler(), + globalChatHandler], first: true) + } + + // Enable TCP_NODELAY and SO_REUSEADDR for the accepted Channels + .childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1) + .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + do { + let chatServer = try bootstrap.bind(host: "::1", port: 9999).wait() + print("Server running - listening on port \(String(describing: chatServer.localAddress))") + return (group, chatServer) + } + catch let err { + print("Failed bootstrapping server: err=\(err)") + return nil + } +} diff --git a/Server-completed/Tests/ChatServerTests/Channel+ChatServerTests.swift b/Server-completed/Tests/ChatServerTests/Channel+ChatServerTests.swift new file mode 100644 index 0000000..187dade --- /dev/null +++ b/Server-completed/Tests/ChatServerTests/Channel+ChatServerTests.swift @@ -0,0 +1,32 @@ +// +// Channel+ChatServerTests.swift +// ChatServerTests +// +// Created by Florent Pillet on 26/11/2018. +// + +import Foundation +import NIO +import ChatCommon + +extension Channel { + func writeAndFlush(_ string: String) -> EventLoopPromise { + let promise: EventLoopPromise = self.eventLoop.newPromise() + var buffer = self.allocator.buffer(capacity: string.utf8.count) + buffer.write(string: string) + self.writeAndFlush(buffer, promise: promise) + return promise + } + + func writeAndFlush(_ data: Data) -> EventLoopPromise { + let promise: EventLoopPromise = self.eventLoop.newPromise() + var buffer = self.allocator.buffer(capacity: data.count) + buffer.write(data) + self.writeAndFlush(buffer, promise: promise) + return promise + } + + func send(_ command: ClientCommand) throws -> EventLoopFuture { + return writeAndFlush(try JSONEncoder().encode(command)).futureResult + } +} diff --git a/Server-completed/Tests/ChatServerTests/ChatServerTests.swift b/Server-completed/Tests/ChatServerTests/ChatServerTests.swift new file mode 100644 index 0000000..c73bef3 --- /dev/null +++ b/Server-completed/Tests/ChatServerTests/ChatServerTests.swift @@ -0,0 +1,115 @@ +import XCTest +import NIO +import ChatCommon +import ChatServerLib + +let testPort = 9998 + +final class ChatServerTests: XCTestCase { + + var server: (MultiThreadedEventLoopGroup, Channel)! + + override func setUp() { + server = setupChatServer(rooms: ["room1","room2"]) + } + + override func tearDown() { + tearDownServer(server) + } + + private func newChatClient(_ name: String) throws -> ChatClient { + let client = try ChatClient.connect(host: "::1", port: testPort, group: server.0).wait() + try client.send(.connect(username: name)).wait() + return client + } + + func testConnect() throws { + let client = try newChatClient("Jim") + let result = try client.expect(2).wait() + + XCTAssertTrue(result.contains(.rooms(["room1","room2"]))) + XCTAssertTrue(result.contains(.users(["Jim"]))) + } + + func testConnectTwoClients() throws { + let _ = try newChatClient("Jim") + let client2 = try newChatClient("John") + + let result2 = try client2.expect(2).wait() + XCTAssertTrue(result2.contains(.rooms(["room1","room2"]))) + XCTAssertTrue(result2.contains { $0.isUsersList("Jim","John") }) + } + + func testMessageInRoom() throws { + let client = try newChatClient("Jim") + try client.skip(2).wait() + + try client.send(.message(room: "room1", text: "Hello, world")).wait() + + let broadcast = try client.expect().wait() + XCTAssertEqual(broadcast, [.message(room: "room1", username: "Jim", text: "Hello, world")]) + } + + func testMessageInRoomBroadcast() throws { + let jim = try newChatClient("Jim") + let _ = try jim.expect(2).wait() + + let john = try newChatClient("John") + let _ = try john.expect(2).wait() // rooms + users + + let update = try jim.expect(1).wait() // updated users list + XCTAssertTrue(update[0].isUsersList("Jim","John")) + + try john.send(.message(room: "room1", text: "Hello, world")).wait() + + let broadcast1 = try jim.expect().wait() + XCTAssertEqual(broadcast1, [.message(room: "room1", username: "John", text: "Hello, world")]) + + let broadcast2 = try john.expect().wait() + XCTAssertEqual(broadcast2, [.message(room: "room1", username: "John", text: "Hello, world")]) + } + + func testPrivateMessage() throws { + let jim = try newChatClient("Jim") + try jim.skip(2).wait() + + let john = try newChatClient("John") + try john.skip(2).wait() + try jim.skip(1).wait() + + try jim.send(.privateMessage(username: "John", text: "Hello John")).wait() + + let jimReceived = try jim.expect(1).wait() + XCTAssertEqual(jimReceived, [.privateMessage(from: "Jim", to: "John", text: "Hello John")]) + + let johnReceived = try john.expect(1).wait() + XCTAssertEqual(johnReceived, [.privateMessage(from: "Jim", to: "John", text: "Hello John")]) + } + + func testDisconnectUpdate() throws { + let jim = try newChatClient("Jim") + try jim.skip(2).wait() + + let john = try newChatClient("John") + try john.skip(2).wait() + try jim.skip(1).wait() + + try jim.send(.disconnect).wait() + + let johnReceived = try john.expect(1).wait() + XCTAssertEqual(johnReceived, [.users(["John"])]) + } + + func testDisconnectWithoutClientNotifying() throws { + let jim = try newChatClient("Jim") + try jim.skip(2).wait() + let john = try newChatClient("John") + try john.skip(2).wait() + try jim.skip(1).wait() + + try jim.close() + + let johnReceived = try john.expect(1).wait() + XCTAssertEqual(johnReceived, [.users(["John"])]) + } +} diff --git a/Server-completed/Tests/ChatServerTests/ServerMessage+Testing.swift b/Server-completed/Tests/ChatServerTests/ServerMessage+Testing.swift new file mode 100644 index 0000000..e5598f1 --- /dev/null +++ b/Server-completed/Tests/ChatServerTests/ServerMessage+Testing.swift @@ -0,0 +1,29 @@ + +import Foundation +import ChatCommon + +extension ServerMessage { + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + var isDisconnected: Bool { + if case .disconnected = self { return true } + return false + } + + func isRoomsList(_ rooms: String...) -> Bool { + if case .rooms(let names) = self { + return Set(rooms) == Set(names) + } + return false + } + + func isUsersList(_ users: String...) -> Bool { + if case .users(let names) = self { + return Set(users) == Set(names) + } + return false + } +} diff --git a/Server-completed/Tests/ChatServerTests/TestingChatClient.swift b/Server-completed/Tests/ChatServerTests/TestingChatClient.swift new file mode 100644 index 0000000..36a8dca --- /dev/null +++ b/Server-completed/Tests/ChatServerTests/TestingChatClient.swift @@ -0,0 +1,104 @@ +import XCTest +import NIO +import ChatCommon +import ChatServerLib + +fileprivate typealias MessageRecorder = (ServerMessage) -> Void + +fileprivate final class ServerMessageRecorder: ChannelInboundHandler { + typealias InboundIn = ServerMessage + private let recorder: MessageRecorder + + init(recorder: @escaping MessageRecorder) { + self.recorder = recorder + } + + func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + recorder(unwrapInboundIn(data)) + } +} + +// A chat client (written with SwiftNIO) we use to connect and run the tests + +final class ChatClient { + enum ChatClientError: Error { + case responseTimeout + } + + private let lock = NSLock() + private var backlog = [ServerMessage]() + private var expectations = [EventLoopPromise]() + private let channel: Channel + + static func connect(host: String, port: Int, group: EventLoopGroup) -> EventLoopFuture { + let bootstrap = ClientBootstrap(group: group) + .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .channelInitializer { channel in + channel.pipeline.addHandlers([ + FramedMessageCodec(), + ServerMessageDecoderChannelHandler() + ], first: true) + } + return bootstrap.connect(host: host, port: port) + .then { channel in + let client = ChatClient.init(channel: channel) + let future = channel.eventLoop.newSucceededFuture(result: client) + return channel.pipeline + .add(handler: ServerMessageRecorder(recorder: { [weak client] message in client?.record(message: message) })) + .then { future } + } + } + + private init(channel: Channel) { + self.channel = channel + } + + func close() throws { + let promise: EventLoopPromise = channel.eventLoop.newPromise() + channel.close(promise: promise) + try promise.futureResult.wait() + } + + func send(_ command: ClientCommand) throws -> EventLoopFuture { + let future = try channel.send(command) + future.whenFailure { error in XCTFail("Sending command \(command) failed with error \(error)") } + return future + } + + func record(message: ServerMessage) { + lock.lock() + defer { lock.unlock() } + if !expectations.isEmpty { + let promise = expectations.removeFirst() + promise.succeed(result: message) + } else { + backlog.append(message) + } + } + + func expect(_ count: Int = 1, timeout: Int = 1) -> EventLoopFuture<[ServerMessage]> { + return EventLoopFuture.reduce(into: [ServerMessage](), + (0 ..< count).map { _ in expect(timeout: timeout) }, + eventLoop: channel.eventLoop) { ( array:inout [ServerMessage], message: ServerMessage) in + array.append(message) + } + } + + func skip(_ count: Int = 1, timeout: Int = 1) -> EventLoopFuture { + return expect(count, timeout: timeout).map { _ in } + } + + func expect(timeout: Int) -> EventLoopFuture { + lock.lock() + defer { lock.unlock() } + if !backlog.isEmpty { + return channel.eventLoop.newSucceededFuture(result: backlog.removeFirst()) + } + let promise: EventLoopPromise = channel.eventLoop.newPromise() + expectations.append(promise) + let timeoutTask = channel.eventLoop.scheduleTask(in: .seconds(timeout)) { promise.fail(error: ChatClientError.responseTimeout) } + let future = promise.futureResult + future.whenComplete { timeoutTask.cancel() } + return future + } +} diff --git a/Server-completed/Tests/ChatServerTests/TestingChatServer.swift b/Server-completed/Tests/ChatServerTests/TestingChatServer.swift new file mode 100644 index 0000000..fd0820c --- /dev/null +++ b/Server-completed/Tests/ChatServerTests/TestingChatServer.swift @@ -0,0 +1,46 @@ +// +// TestingChatServer.swift +// ChatServerTests +// +// Created by Florent Pillet on 26/11/2018. +// + +import Foundation +import NIO +import ChatCommon +import ChatServerLib + +func setupChatServer(rooms: [String]) -> (MultiThreadedEventLoopGroup, Channel) { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) + + let globalChatHandler = ServerChatRoomsHandler(rooms: rooms) + + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .childChannelInitializer { channel in + channel.pipeline.addHandlers([ + //FramedMessageCodec(), + ClientCommandDecoderChannelHandler(), + ServerMessageEncoderChannelHandler(), + globalChatHandler], first: true) + } + .childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1) + .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let channel = try! bootstrap.bind(host: "::1", port: testPort).wait() + + return (group, channel) +} + +func tearDownServer(_ server: (MultiThreadedEventLoopGroup, Channel)) { + server.0.shutdownGracefully { error in + if let error = error { + print("Shutdown failed with error \(error)") + } else { + try! server.1.closeFuture.wait() + } + } +} diff --git a/Server/Package.resolved b/Server/Package.resolved new file mode 100644 index 0000000..0a1d621 --- /dev/null +++ b/Server/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "035962e6b6e03c8721a91a9b96dc084289795cb4", + "version": "1.11.0" + } + }, + { + "package": "swift-nio-zlib-support", + "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", + "state": { + "branch": null, + "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", + "version": "1.0.0" + } + } + ] + }, + "version": 1 +} diff --git a/Server/Package.swift b/Server/Package.swift new file mode 100644 index 0000000..f364b7e --- /dev/null +++ b/Server/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:4.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ChatServer", + products: [ + .library(name: "ChatCommon", targets: ["ChatCommon"]), + .library(name: "ChatServerLib", targets: ["ChatServerLib"]), + .executable(name: "ChatServer", targets: ["ChatServer"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "1.11.0"), + ], + targets: [ + .target(name: "ChatCommon", dependencies: ["NIO"]), + .target(name: "ChatServerLib", dependencies: ["NIO","ChatCommon"]), + .target(name: "ChatServer", dependencies: ["ChatServerLib"]), + .testTarget(name: "ChatServerTests", dependencies: ["ChatServerLib"]) + ] +) diff --git a/Server/Sources/ChatCommon/Model/ClientCommand+Codable.swift b/Server/Sources/ChatCommon/Model/ClientCommand+Codable.swift new file mode 100644 index 0000000..ae5c39f --- /dev/null +++ b/Server/Sources/ChatCommon/Model/ClientCommand+Codable.swift @@ -0,0 +1,51 @@ +import Foundation + +extension ClientCommand: Codable { + private enum CodingKeys: String, CodingKey { + case command + case data + } + + private enum Cmd: String, Codable { + case connect, disconnect, message, privateMessage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Cmd.self, forKey: .command) { + case .connect: + let username = try container.decode(String.self, forKey: .data) + self = .connect(username: username) + case .disconnect: + self = .disconnect + case .message: + let msg = try container.decode(MessageData.self, forKey: .data) + self = .message(room: msg.to, text: msg.text) + case .privateMessage: + let msg = try container.decode(MessageData.self, forKey: .data) + self = .privateMessage(username: msg.to, text: msg.text) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .connect(let username): + try container.encode(Cmd.connect, forKey: .command) + try container.encode(username, forKey: .data) + case .disconnect: + try container.encode(Cmd.disconnect, forKey: .command) + case .message(let room, let text): + try container.encode(Cmd.message, forKey: .command) + try container.encode(MessageData(to: room, text: text), forKey: .data) + case .privateMessage(let username, let text): + try container.encode(Cmd.privateMessage, forKey: .command) + try container.encode(MessageData(to: username, text: text), forKey: .data) + } + } +} + +private struct MessageData: Codable { + let to: String + let text: String +} diff --git a/Server/Sources/ChatCommon/Model/ClientCommand.swift b/Server/Sources/ChatCommon/Model/ClientCommand.swift new file mode 100644 index 0000000..d157f28 --- /dev/null +++ b/Server/Sources/ChatCommon/Model/ClientCommand.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum ClientCommand: Equatable { + case connect(username: String) + case disconnect + case message(room: String, text: String) + case privateMessage(username: String, text: String) +} diff --git a/Server/Sources/ChatCommon/Model/ServerMessage+Codable.swift b/Server/Sources/ChatCommon/Model/ServerMessage+Codable.swift new file mode 100644 index 0000000..e5af2b2 --- /dev/null +++ b/Server/Sources/ChatCommon/Model/ServerMessage+Codable.swift @@ -0,0 +1,71 @@ +import Foundation + +extension ServerMessage: Codable { + private enum CodingKeys: String, CodingKey { + case command + case data + } + + private enum Cmd: String, Codable { + case connected, disconnected, rooms, users, message, privateMessage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Cmd.self, forKey: .command) { + case .connected: + let server = try container.decode(String.self, forKey: .data) + self = .connected(to: server) + case .disconnected: + self = .disconnected + case .rooms: + let rooms = try container.decode([String].self, forKey: .data) + self = .rooms(rooms) + case .users: + let users = try container.decode([String].self, forKey: .data) + self = .users(users) + case .message: + let data = try container.decode(MessageData.self, forKey: .data) + self = .message(room: data.to, username: data.from, text: data.text) + case .privateMessage: + let data = try container.decode(MessageData.self, forKey: .data) + self = .privateMessage(from: data.from, to: data.to, text: data.text) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .connected(let to): + try container.encode(Cmd.connected, forKey: .command) + try container.encode(to, forKey: .data) + case .disconnected: + try container.encode(Cmd.disconnected, forKey: .command) + case .rooms(let rooms): + try container.encode(Cmd.rooms, forKey: .command) + try container.encode(rooms, forKey: .data) + case .users(let users): + try container.encode(Cmd.users, forKey: .command) + try container.encode(users, forKey: .data) + case .message(let room, let username, let text): + try container.encode(Cmd.message, forKey: .command) + try container.encode(MessageData(from: username, to: room, text: text), forKey: .data) + case .privateMessage(let from, let to, let text): + try container.encode(Cmd.privateMessage, forKey: .command) + try container.encode(MessageData(from: from, to: to, text: text), forKey: .data) + } + } +} + +// Pieces of data we need to encode / decode JSON + +private struct RoomAndUsername: Codable { + let room: String + let username: String +} + +private struct MessageData: Codable { + let from: String + let to: String + let text: String +} diff --git a/Server/Sources/ChatCommon/Model/ServerMessage.swift b/Server/Sources/ChatCommon/Model/ServerMessage.swift new file mode 100644 index 0000000..a171be5 --- /dev/null +++ b/Server/Sources/ChatCommon/Model/ServerMessage.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum ServerMessage: Equatable { + case connected(to: String) + case disconnected + case rooms([String]) + case users([String]) + case message(room: String, username: String, text: String) + case privateMessage(from: String, to: String, text: String) +} diff --git a/Server/Sources/ChatServer/main.swift b/Server/Sources/ChatServer/main.swift new file mode 100644 index 0000000..3563515 --- /dev/null +++ b/Server/Sources/ChatServer/main.swift @@ -0,0 +1,12 @@ +import ChatServerLib + +guard let server = startServer(rooms: ["Red Team","Blue Team","General","Random"]) else { + fatalError("Failed starting server") +} + +// this will never exit +try! server.1.closeFuture.wait() + +print("Done.") + + diff --git a/Server/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift b/Server/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift new file mode 100644 index 0000000..69df346 --- /dev/null +++ b/Server/Sources/ChatServerLib/Extensions/ByteBuffer+Data.swift @@ -0,0 +1,17 @@ +// +// Created by Florent Pillet on 2018-11-21. +// + +import Foundation +import NIO + +extension ByteBuffer { + public mutating func write(_ data: Data) { + writeWithUnsafeMutableBytes { (bufferMutablePointer: UnsafeMutableRawBufferPointer) -> Int in + data.withUnsafeBytes { (pointer: UnsafePointer) -> Void in + bufferMutablePointer.copyMemory(from: UnsafeRawBufferPointer(start: UnsafeRawPointer(pointer), count: data.count)) + } + return data.count + } + } +} diff --git a/Server/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift b/Server/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift new file mode 100644 index 0000000..b4eda1d --- /dev/null +++ b/Server/Sources/ChatServerLib/Handlers/ClientCommandDecoderChannelHandler.swift @@ -0,0 +1,11 @@ +import Foundation +import NIO +import ChatCommon + +// TODO: a handler which decodes the JSON from a ByteBuffer + +//public final class ClientCommandDecoderChannelHandler: ChannelInboundHandler { +// +// public init() { } +// +//} diff --git a/Server/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift b/Server/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift new file mode 100644 index 0000000..d178700 --- /dev/null +++ b/Server/Sources/ChatServerLib/Handlers/ClientCommandLogChannelHandler.swift @@ -0,0 +1,8 @@ +import Foundation +import NIO +import ChatCommon + +// TODO: a channel handler that logs incoming client commands + +//public final class ClientCommandLogChannelHandler: ChannelInboundHandler { +//} diff --git a/Server/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift b/Server/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift new file mode 100644 index 0000000..cda15fa --- /dev/null +++ b/Server/Sources/ChatServerLib/Handlers/FramedMessageCodec.swift @@ -0,0 +1,7 @@ +import Foundation +import NIO + +// TODO: A CODEC that frames / unframes packets by prefixing them with Int32 size (big endian) + +//public final class FramedMessageCodec: ByteToMessageDecoder, MessageToByteEncoder { +//} diff --git a/Server/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift b/Server/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift new file mode 100644 index 0000000..945ad62 --- /dev/null +++ b/Server/Sources/ChatServerLib/Handlers/RawLogChannelHandler.swift @@ -0,0 +1,36 @@ +import Foundation +import NIO + +public final class RawLogChannelHandler: ChannelInboundHandler, ChannelOutboundHandler { + public typealias InboundIn = ByteBuffer + public typealias InboundOut = ByteBuffer + + public typealias OutboundIn = ByteBuffer + public typealias OutboundOut = ByteBuffer + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + // unwrap the incoming data to the declared InboundIn type + let packet = unwrapInboundIn(data) + + // this is text data (JSON) so log it as a string + if let packetString = packet.getString(at: 0, length: packet.readableBytes) { + print("[INCOMING] \(packetString)") + } + + // continue processing packet with next handler + ctx.fireChannelRead(data) + } + + public func write(ctx: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + // unwrap the outgoing data to the declared OutboundIn type + let packet = unwrapOutboundIn(data) + + // this is text data (JSON) so log it as a string + if let packetString = packet.getString(at: 0, length: packet.readableBytes) { + print("[OUTGOING] \(packetString), promise=\(String(describing: promise))") + } + + // continue writing the data down the outgoing pipeline + ctx.write(data, promise: promise) + } +} diff --git a/Server/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift b/Server/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift new file mode 100644 index 0000000..314481b --- /dev/null +++ b/Server/Sources/ChatServerLib/Handlers/ServerChatRoomsHandler.swift @@ -0,0 +1,89 @@ +// +// ChatRoomsHandler.swift +// server +// +// Created by Florent Pillet on 20/11/2018. +// + +import Foundation +import NIO +import ChatCommon + +// The main functionality of this server + +// TODO: make it a handle for IN and OUT data + +public final class ServerChatRoomsHandler { + + // TODO: define the IN and OUT types + + // storage for our helper functions + private var online = Set() + private var rooms: [String] + + public init(rooms: [String]) { + self.rooms = rooms + } + + // TODO: receive and process ClientCommand from clients + + // TODO: handle the case of a disconnected client to update the list of online users + + private func push(_ data: ServerMessage, to channel: Channel) { + // TODO: send a ServerMessage to one user + } + + /* + * Helper functions -- Use them to speed up your development! + * + */ + private func onlineUser(_ channel: Channel) -> ChatUser? { + let uniqueIdentifier = ObjectIdentifier(channel) + return online.first { $0.uniqueIdentifier == uniqueIdentifier } + } + + private func userConnected(name: String, channel: Channel) { + let uniqueIdentifier = ObjectIdentifier(channel) + online.insert(ChatUser(name: name, channel: channel, uniqueIdentifier: uniqueIdentifier)) + listRooms(channel) + for user in online { + listUsers(user.channel) + } + } + + private func userDisconnected(_ channel: Channel) { + if let user = onlineUser(channel) { + online.remove(user) + for user in online { + listUsers(user.channel) + } + } + } + + private func listRooms(_ channel: Channel) { + push(ServerMessage.rooms(rooms.sorted()), to: channel) + } + + private func listUsers(_ channel: Channel) { + let users = online.map { $0.name } + push(ServerMessage.users(users.sorted()), to: channel) + } + + private func message(room: String, text: String, channel: Channel) { + guard let user = onlineUser(channel) else { + return + } + let message = ServerMessage.message(room: room, username: user.name, text: text) + online.forEach { user in self.push(message, to: user.channel) } + } + + private func privateMessage(to: String, text: String, channel: Channel) { + guard let fromUser = onlineUser(channel), + let toUser = online.first(where: { $0.name == to }) else { + return + } + let message = ServerMessage.privateMessage(from: fromUser.name, to: toUser.name, text: text) + push(message, to: toUser.channel) + push(message, to: fromUser.channel) + } +} diff --git a/Server/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift b/Server/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift new file mode 100644 index 0000000..42424ab --- /dev/null +++ b/Server/Sources/ChatServerLib/Handlers/ServerMessageEncoderChannelHandler.swift @@ -0,0 +1,9 @@ + +import Foundation +import NIO +import ChatCommon + +// TODO: encode outgoing `ServerMessage` values to JSON + +//public final class ServerMessageEncoderChannelHandler: MessageToByteEncoder { +//} diff --git a/Server/Sources/ChatServerLib/Model/ChatRoom.swift b/Server/Sources/ChatServerLib/Model/ChatRoom.swift new file mode 100644 index 0000000..ac74fb7 --- /dev/null +++ b/Server/Sources/ChatServerLib/Model/ChatRoom.swift @@ -0,0 +1,9 @@ +import Foundation + +public class ChatRoom { + public var rooms = [String:[ChatUser]]() + + public func users(room: String) -> [ChatUser] { + return rooms[room] ?? [] + } +} diff --git a/Server/Sources/ChatServerLib/Model/ChatUser.swift b/Server/Sources/ChatServerLib/Model/ChatUser.swift new file mode 100644 index 0000000..5e989ee --- /dev/null +++ b/Server/Sources/ChatServerLib/Model/ChatUser.swift @@ -0,0 +1,27 @@ +import Foundation +import NIO + +public struct ChatUser { + let name: String + let channel: Channel + let uniqueIdentifier: ObjectIdentifier +} + +extension ChatUser: Equatable { + public static func ==(lhs: ChatUser, rhs: ChatUser) -> Bool { + return lhs.uniqueIdentifier == rhs.uniqueIdentifier + } +} + +extension ChatUser: Comparable { + // This is mainly to sort users in user lists + public static func < (lhs: ChatUser, rhs: ChatUser) -> Bool { + return lhs.name.localizedCompare(rhs.name) == .orderedAscending + } +} + +extension ChatUser: Hashable { + public func hash(into hasher: inout Hasher) { + uniqueIdentifier.hash(into: &hasher) + } +} diff --git a/Server/Sources/ChatServerLib/ServerMain.swift b/Server/Sources/ChatServerLib/ServerMain.swift new file mode 100644 index 0000000..6de53e6 --- /dev/null +++ b/Server/Sources/ChatServerLib/ServerMain.swift @@ -0,0 +1,31 @@ + +import Foundation +import NIO + +public func startServer(rooms: [String]) -> (MultiThreadedEventLoopGroup, Channel)? { + // Create an EventLoopGroup that will accept connections. You can dimension its capacity (in number of threads) + // according to the number of cores your computer has, or simply use a hardcoded value. Remember that each + // thread (each EventLoop) can support a large number of connections! + + // TODO: create your EventLoopFrom + + // We're going to use a single instance + // to handle chat exchanges between participants + + // TODO: create your chat handler singleton + + // Bootstrap the server! + // TODO: let's do it + + do { + // Bind server to the receiving port to start listening + // TODO: bind server + + print("Server running - listening on port \(String(describing: chatServer.localAddress))") + return (group, chatServer) + } + catch let err { + print("Failed bootstrapping server: err=\(err)") + return nil + } +} diff --git a/Server/Tests/ChatServerTests/Channel+ChatServerTests.swift b/Server/Tests/ChatServerTests/Channel+ChatServerTests.swift new file mode 100644 index 0000000..187dade --- /dev/null +++ b/Server/Tests/ChatServerTests/Channel+ChatServerTests.swift @@ -0,0 +1,32 @@ +// +// Channel+ChatServerTests.swift +// ChatServerTests +// +// Created by Florent Pillet on 26/11/2018. +// + +import Foundation +import NIO +import ChatCommon + +extension Channel { + func writeAndFlush(_ string: String) -> EventLoopPromise { + let promise: EventLoopPromise = self.eventLoop.newPromise() + var buffer = self.allocator.buffer(capacity: string.utf8.count) + buffer.write(string: string) + self.writeAndFlush(buffer, promise: promise) + return promise + } + + func writeAndFlush(_ data: Data) -> EventLoopPromise { + let promise: EventLoopPromise = self.eventLoop.newPromise() + var buffer = self.allocator.buffer(capacity: data.count) + buffer.write(data) + self.writeAndFlush(buffer, promise: promise) + return promise + } + + func send(_ command: ClientCommand) throws -> EventLoopFuture { + return writeAndFlush(try JSONEncoder().encode(command)).futureResult + } +} diff --git a/Server/Tests/ChatServerTests/ChatServerTests.swift b/Server/Tests/ChatServerTests/ChatServerTests.swift new file mode 100644 index 0000000..c73bef3 --- /dev/null +++ b/Server/Tests/ChatServerTests/ChatServerTests.swift @@ -0,0 +1,115 @@ +import XCTest +import NIO +import ChatCommon +import ChatServerLib + +let testPort = 9998 + +final class ChatServerTests: XCTestCase { + + var server: (MultiThreadedEventLoopGroup, Channel)! + + override func setUp() { + server = setupChatServer(rooms: ["room1","room2"]) + } + + override func tearDown() { + tearDownServer(server) + } + + private func newChatClient(_ name: String) throws -> ChatClient { + let client = try ChatClient.connect(host: "::1", port: testPort, group: server.0).wait() + try client.send(.connect(username: name)).wait() + return client + } + + func testConnect() throws { + let client = try newChatClient("Jim") + let result = try client.expect(2).wait() + + XCTAssertTrue(result.contains(.rooms(["room1","room2"]))) + XCTAssertTrue(result.contains(.users(["Jim"]))) + } + + func testConnectTwoClients() throws { + let _ = try newChatClient("Jim") + let client2 = try newChatClient("John") + + let result2 = try client2.expect(2).wait() + XCTAssertTrue(result2.contains(.rooms(["room1","room2"]))) + XCTAssertTrue(result2.contains { $0.isUsersList("Jim","John") }) + } + + func testMessageInRoom() throws { + let client = try newChatClient("Jim") + try client.skip(2).wait() + + try client.send(.message(room: "room1", text: "Hello, world")).wait() + + let broadcast = try client.expect().wait() + XCTAssertEqual(broadcast, [.message(room: "room1", username: "Jim", text: "Hello, world")]) + } + + func testMessageInRoomBroadcast() throws { + let jim = try newChatClient("Jim") + let _ = try jim.expect(2).wait() + + let john = try newChatClient("John") + let _ = try john.expect(2).wait() // rooms + users + + let update = try jim.expect(1).wait() // updated users list + XCTAssertTrue(update[0].isUsersList("Jim","John")) + + try john.send(.message(room: "room1", text: "Hello, world")).wait() + + let broadcast1 = try jim.expect().wait() + XCTAssertEqual(broadcast1, [.message(room: "room1", username: "John", text: "Hello, world")]) + + let broadcast2 = try john.expect().wait() + XCTAssertEqual(broadcast2, [.message(room: "room1", username: "John", text: "Hello, world")]) + } + + func testPrivateMessage() throws { + let jim = try newChatClient("Jim") + try jim.skip(2).wait() + + let john = try newChatClient("John") + try john.skip(2).wait() + try jim.skip(1).wait() + + try jim.send(.privateMessage(username: "John", text: "Hello John")).wait() + + let jimReceived = try jim.expect(1).wait() + XCTAssertEqual(jimReceived, [.privateMessage(from: "Jim", to: "John", text: "Hello John")]) + + let johnReceived = try john.expect(1).wait() + XCTAssertEqual(johnReceived, [.privateMessage(from: "Jim", to: "John", text: "Hello John")]) + } + + func testDisconnectUpdate() throws { + let jim = try newChatClient("Jim") + try jim.skip(2).wait() + + let john = try newChatClient("John") + try john.skip(2).wait() + try jim.skip(1).wait() + + try jim.send(.disconnect).wait() + + let johnReceived = try john.expect(1).wait() + XCTAssertEqual(johnReceived, [.users(["John"])]) + } + + func testDisconnectWithoutClientNotifying() throws { + let jim = try newChatClient("Jim") + try jim.skip(2).wait() + let john = try newChatClient("John") + try john.skip(2).wait() + try jim.skip(1).wait() + + try jim.close() + + let johnReceived = try john.expect(1).wait() + XCTAssertEqual(johnReceived, [.users(["John"])]) + } +} diff --git a/Server/Tests/ChatServerTests/ServerMessage+Testing.swift b/Server/Tests/ChatServerTests/ServerMessage+Testing.swift new file mode 100644 index 0000000..e5598f1 --- /dev/null +++ b/Server/Tests/ChatServerTests/ServerMessage+Testing.swift @@ -0,0 +1,29 @@ + +import Foundation +import ChatCommon + +extension ServerMessage { + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + var isDisconnected: Bool { + if case .disconnected = self { return true } + return false + } + + func isRoomsList(_ rooms: String...) -> Bool { + if case .rooms(let names) = self { + return Set(rooms) == Set(names) + } + return false + } + + func isUsersList(_ users: String...) -> Bool { + if case .users(let names) = self { + return Set(users) == Set(names) + } + return false + } +} diff --git a/Server/Tests/ChatServerTests/ServerMessageDecoderChannelHandler.swift b/Server/Tests/ChatServerTests/ServerMessageDecoderChannelHandler.swift new file mode 100644 index 0000000..a084a90 --- /dev/null +++ b/Server/Tests/ChatServerTests/ServerMessageDecoderChannelHandler.swift @@ -0,0 +1,30 @@ +import Foundation +import NIO +import ChatCommon + +public final class ServerMessageDecoderChannelHandler: ChannelInboundHandler { + public typealias InboundIn = ByteBuffer + public typealias InboundOut = ServerMessage + + public init() { } + + public func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + var buffer = unwrapInboundIn(data) + let message = buffer.withUnsafeMutableReadableBytes { (pointer: UnsafeMutableRawBufferPointer) -> ServerMessage? in + guard let baseAddress = pointer.baseAddress else { + return nil + } + do { + let decoded = try JSONDecoder().decode(ServerMessage.self, from: Data(bytesNoCopy: baseAddress, count: pointer.count, deallocator: .none)) + return decoded + } + catch let err { + print("> decoding error: \(err)") + } + return nil + } + if let message = message { + ctx.fireChannelRead(self.wrapInboundOut(message)) + } + } +} diff --git a/Server/Tests/ChatServerTests/TestingChatClient.swift b/Server/Tests/ChatServerTests/TestingChatClient.swift new file mode 100644 index 0000000..333213d --- /dev/null +++ b/Server/Tests/ChatServerTests/TestingChatClient.swift @@ -0,0 +1,104 @@ +import XCTest +import NIO +import ChatCommon +import ChatServerLib + +fileprivate typealias MessageRecorder = (ServerMessage) -> Void + +fileprivate final class ServerMessageRecorder: ChannelInboundHandler { + typealias InboundIn = ServerMessage + private let recorder: MessageRecorder + + init(recorder: @escaping MessageRecorder) { + self.recorder = recorder + } + + func channelRead(ctx: ChannelHandlerContext, data: NIOAny) { + recorder(unwrapInboundIn(data)) + } +} + +// A chat client (written with SwiftNIO) we use to connect and run the tests + +final class ChatClient { + enum ChatClientError: Error { + case responseTimeout + } + + private let lock = NSLock() + private var backlog = [ServerMessage]() + private var expectations = [EventLoopPromise]() + private let channel: Channel + + static func connect(host: String, port: Int, group: EventLoopGroup) -> EventLoopFuture { + let bootstrap = ClientBootstrap(group: group) + .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .channelInitializer { channel in + channel.pipeline.addHandlers([ + //FramedMessageCodec(), + ServerMessageDecoderChannelHandler() + ], first: true) + } + return bootstrap.connect(host: host, port: port) + .then { channel in + let client = ChatClient.init(channel: channel) + let future = channel.eventLoop.newSucceededFuture(result: client) + return channel.pipeline + .add(handler: ServerMessageRecorder(recorder: { [weak client] message in client?.record(message: message) })) + .then { future } + } + } + + private init(channel: Channel) { + self.channel = channel + } + + func close() throws { + let promise: EventLoopPromise = channel.eventLoop.newPromise() + channel.close(promise: promise) + try promise.futureResult.wait() + } + + func send(_ command: ClientCommand) throws -> EventLoopFuture { + let future = try channel.send(command) + future.whenFailure { error in XCTFail("Sending command \(command) failed with error \(error)") } + return future + } + + func record(message: ServerMessage) { + lock.lock() + defer { lock.unlock() } + if !expectations.isEmpty { + let promise = expectations.removeFirst() + promise.succeed(result: message) + } else { + backlog.append(message) + } + } + + func expect(_ count: Int = 1, timeout: Int = 1) -> EventLoopFuture<[ServerMessage]> { + return EventLoopFuture.reduce(into: [ServerMessage](), + (0 ..< count).map { _ in expect(timeout: timeout) }, + eventLoop: channel.eventLoop) { ( array:inout [ServerMessage], message: ServerMessage) in + array.append(message) + } + } + + func skip(_ count: Int = 1, timeout: Int = 1) -> EventLoopFuture { + return expect(count, timeout: timeout).map { _ in } + } + + func expect(timeout: Int) -> EventLoopFuture { + lock.lock() + defer { lock.unlock() } + if !backlog.isEmpty { + return channel.eventLoop.newSucceededFuture(result: backlog.removeFirst()) + } + let promise: EventLoopPromise = channel.eventLoop.newPromise() + expectations.append(promise) + let timeoutTask = channel.eventLoop.scheduleTask(in: .seconds(timeout)) { promise.fail(error: ChatClientError.responseTimeout) } + let future = promise.futureResult + future.whenComplete { timeoutTask.cancel() } + return future + } +} diff --git a/Server/Tests/ChatServerTests/TestingChatServer.swift b/Server/Tests/ChatServerTests/TestingChatServer.swift new file mode 100644 index 0000000..fd0820c --- /dev/null +++ b/Server/Tests/ChatServerTests/TestingChatServer.swift @@ -0,0 +1,46 @@ +// +// TestingChatServer.swift +// ChatServerTests +// +// Created by Florent Pillet on 26/11/2018. +// + +import Foundation +import NIO +import ChatCommon +import ChatServerLib + +func setupChatServer(rooms: [String]) -> (MultiThreadedEventLoopGroup, Channel) { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) + + let globalChatHandler = ServerChatRoomsHandler(rooms: rooms) + + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .childChannelInitializer { channel in + channel.pipeline.addHandlers([ + //FramedMessageCodec(), + ClientCommandDecoderChannelHandler(), + ServerMessageEncoderChannelHandler(), + globalChatHandler], first: true) + } + .childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1) + .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let channel = try! bootstrap.bind(host: "::1", port: testPort).wait() + + return (group, channel) +} + +func tearDownServer(_ server: (MultiThreadedEventLoopGroup, Channel)) { + server.0.shutdownGracefully { error in + if let error = error { + print("Shutdown failed with error \(error)") + } else { + try! server.1.closeFuture.wait() + } + } +} diff --git a/iOS-completed/ChatClient.xcodeproj/project.pbxproj b/iOS-completed/ChatClient.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fa470df --- /dev/null +++ b/iOS-completed/ChatClient.xcodeproj/project.pbxproj @@ -0,0 +1,610 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 1A62D084C1FE204D61215CC4 /* Pods_ChatClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CE43286B5E6B6DC6227977E /* Pods_ChatClient.framework */; }; + 3D3F35C921A697F300849259 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35C821A697F300849259 /* AppDelegate.swift */; }; + 3D3F35CB21A697F300849259 /* MasterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35CA21A697F300849259 /* MasterViewController.swift */; }; + 3D3F35CD21A697F300849259 /* MessagesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35CC21A697F300849259 /* MessagesViewController.swift */; }; + 3D3F35D021A697F300849259 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D3F35CE21A697F300849259 /* Main.storyboard */; }; + 3D3F35D221A697F400849259 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3D3F35D121A697F400849259 /* Assets.xcassets */; }; + 3D3F35D521A697F400849259 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D3F35D321A697F400849259 /* LaunchScreen.storyboard */; }; + 3D3F35E021A697F500849259 /* ChatClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35DF21A697F500849259 /* ChatClientTests.swift */; }; + 3D5C042521AC4D210000DAD2 /* ClientCommand+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042121AC4D210000DAD2 /* ClientCommand+Codable.swift */; }; + 3D5C042621AC4D210000DAD2 /* ClientCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042221AC4D210000DAD2 /* ClientCommand.swift */; }; + 3D5C042721AC4D210000DAD2 /* ServerMessage+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042321AC4D210000DAD2 /* ServerMessage+Codable.swift */; }; + 3D5C042821AC4D210000DAD2 /* ServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042421AC4D210000DAD2 /* ServerMessage.swift */; }; + 3DAACFDD21A6D39800F4955D /* ChatEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAACFDC21A6D39800F4955D /* ChatEntry.swift */; }; + 3DAACFDF21A76B4500F4955D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAACFDE21A76B4500F4955D /* Constants.swift */; }; + D8D4BC0CF2B4CCAAB3F03421 /* MessageBoard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4BBF60C14CFE77DF30BB8 /* MessageBoard.swift */; }; + D8D4BD7ACE3B8A8D475CB916 /* ChatClientService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4BE49AFDF976BDE66BE86 /* ChatClientService.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 3D3F35DC21A697F500849259 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3D3F35BD21A697F300849259 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3D3F35C421A697F300849259; + remoteInfo = ChatClient; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 1853E6A9B284C57EF0FB7A20 /* Pods-ChatClient.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatClient.release.xcconfig"; path = "Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient.release.xcconfig"; sourceTree = ""; }; + 3CE43286B5E6B6DC6227977E /* Pods_ChatClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ChatClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D3F35C521A697F300849259 /* ChatClient.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChatClient.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D3F35C821A697F300849259 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 3D3F35CA21A697F300849259 /* MasterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterViewController.swift; sourceTree = ""; }; + 3D3F35CC21A697F300849259 /* MessagesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewController.swift; sourceTree = ""; }; + 3D3F35CF21A697F300849259 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 3D3F35D121A697F400849259 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 3D3F35D421A697F400849259 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 3D3F35D621A697F400849259 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3D3F35DB21A697F500849259 /* ChatClientTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ChatClientTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D3F35DF21A697F500849259 /* ChatClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientTests.swift; sourceTree = ""; }; + 3D3F35E121A697F500849259 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3D5C042121AC4D210000DAD2 /* ClientCommand+Codable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ClientCommand+Codable.swift"; sourceTree = ""; }; + 3D5C042221AC4D210000DAD2 /* ClientCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClientCommand.swift; sourceTree = ""; }; + 3D5C042321AC4D210000DAD2 /* ServerMessage+Codable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ServerMessage+Codable.swift"; sourceTree = ""; }; + 3D5C042421AC4D210000DAD2 /* ServerMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerMessage.swift; sourceTree = ""; }; + 3DAACFDC21A6D39800F4955D /* ChatEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatEntry.swift; sourceTree = ""; }; + 3DAACFDE21A76B4500F4955D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 3FBE361C985C4D3B0B8557BE /* Pods-ChatClient.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatClient.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient.debug.xcconfig"; sourceTree = ""; }; + D8D4BBF60C14CFE77DF30BB8 /* MessageBoard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageBoard.swift; sourceTree = ""; }; + D8D4BE49AFDF976BDE66BE86 /* ChatClientService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatClientService.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3D3F35C221A697F300849259 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A62D084C1FE204D61215CC4 /* Pods_ChatClient.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3D3F35D821A697F500849259 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3D3F35BC21A697F300849259 = { + isa = PBXGroup; + children = ( + 3D3F35C721A697F300849259 /* ChatClient */, + 3D3F35DE21A697F500849259 /* ChatClientTests */, + 3D3F35C621A697F300849259 /* Products */, + 7ACA2487E04A4AF5845EA919 /* Pods */, + D6B3E4076E7E7376C31D9D1C /* Frameworks */, + ); + sourceTree = ""; + }; + 3D3F35C621A697F300849259 /* Products */ = { + isa = PBXGroup; + children = ( + 3D3F35C521A697F300849259 /* ChatClient.app */, + 3D3F35DB21A697F500849259 /* ChatClientTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 3D3F35C721A697F300849259 /* ChatClient */ = { + isa = PBXGroup; + children = ( + 3D5C041F21AC4D210000DAD2 /* ChatCommon */, + 3DC9273221A9C37F001FBB17 /* Resources */, + 3DC9273121A9C376001FBB17 /* Controllers */, + 3D3F35C821A697F300849259 /* AppDelegate.swift */, + D8D4B85B9E8DDEC8F9D4D63D /* Model */, + 3DAACFDE21A76B4500F4955D /* Constants.swift */, + ); + path = ChatClient; + sourceTree = ""; + }; + 3D3F35DE21A697F500849259 /* ChatClientTests */ = { + isa = PBXGroup; + children = ( + 3D3F35DF21A697F500849259 /* ChatClientTests.swift */, + 3D3F35E121A697F500849259 /* Info.plist */, + ); + path = ChatClientTests; + sourceTree = ""; + }; + 3D5C041F21AC4D210000DAD2 /* ChatCommon */ = { + isa = PBXGroup; + children = ( + 3D5C042021AC4D210000DAD2 /* Model */, + ); + name = ChatCommon; + path = ../../Server/Sources/ChatCommon; + sourceTree = ""; + }; + 3D5C042021AC4D210000DAD2 /* Model */ = { + isa = PBXGroup; + children = ( + 3D5C042221AC4D210000DAD2 /* ClientCommand.swift */, + 3D5C042121AC4D210000DAD2 /* ClientCommand+Codable.swift */, + 3D5C042421AC4D210000DAD2 /* ServerMessage.swift */, + 3D5C042321AC4D210000DAD2 /* ServerMessage+Codable.swift */, + ); + path = Model; + sourceTree = ""; + }; + 3DC9273121A9C376001FBB17 /* Controllers */ = { + isa = PBXGroup; + children = ( + 3D3F35CA21A697F300849259 /* MasterViewController.swift */, + 3D3F35CC21A697F300849259 /* MessagesViewController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + 3DC9273221A9C37F001FBB17 /* Resources */ = { + isa = PBXGroup; + children = ( + 3D3F35D621A697F400849259 /* Info.plist */, + 3D3F35CE21A697F300849259 /* Main.storyboard */, + 3D3F35D121A697F400849259 /* Assets.xcassets */, + 3D3F35D321A697F400849259 /* LaunchScreen.storyboard */, + ); + path = Resources; + sourceTree = ""; + }; + 7ACA2487E04A4AF5845EA919 /* Pods */ = { + isa = PBXGroup; + children = ( + 3FBE361C985C4D3B0B8557BE /* Pods-ChatClient.debug.xcconfig */, + 1853E6A9B284C57EF0FB7A20 /* Pods-ChatClient.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + D6B3E4076E7E7376C31D9D1C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3CE43286B5E6B6DC6227977E /* Pods_ChatClient.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + D8D4B85B9E8DDEC8F9D4D63D /* Model */ = { + isa = PBXGroup; + children = ( + D8D4BE49AFDF976BDE66BE86 /* ChatClientService.swift */, + 3DAACFDC21A6D39800F4955D /* ChatEntry.swift */, + D8D4BBF60C14CFE77DF30BB8 /* MessageBoard.swift */, + ); + path = Model; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 3D3F35C421A697F300849259 /* ChatClient */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3D3F35E421A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClient" */; + buildPhases = ( + 6A7AE25E7DB9A24573AF4C71 /* [CP] Check Pods Manifest.lock */, + 3D3F35C121A697F300849259 /* Sources */, + 3D3F35C221A697F300849259 /* Frameworks */, + 3D3F35C321A697F300849259 /* Resources */, + 8B7A19F8FAC24BD6F570C5F8 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ChatClient; + productName = ChatClient; + productReference = 3D3F35C521A697F300849259 /* ChatClient.app */; + productType = "com.apple.product-type.application"; + }; + 3D3F35DA21A697F500849259 /* ChatClientTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3D3F35E721A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClientTests" */; + buildPhases = ( + 3D3F35D721A697F500849259 /* Sources */, + 3D3F35D821A697F500849259 /* Frameworks */, + 3D3F35D921A697F500849259 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3D3F35DD21A697F500849259 /* PBXTargetDependency */, + ); + name = ChatClientTests; + productName = ChatClientTests; + productReference = 3D3F35DB21A697F500849259 /* ChatClientTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3D3F35BD21A697F300849259 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1010; + LastUpgradeCheck = 1010; + ORGANIZATIONNAME = SwiftAlps; + TargetAttributes = { + 3D3F35C421A697F300849259 = { + CreatedOnToolsVersion = 10.1; + }; + 3D3F35DA21A697F500849259 = { + CreatedOnToolsVersion = 10.1; + TestTargetID = 3D3F35C421A697F300849259; + }; + }; + }; + buildConfigurationList = 3D3F35C021A697F300849259 /* Build configuration list for PBXProject "ChatClient" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 3D3F35BC21A697F300849259; + productRefGroup = 3D3F35C621A697F300849259 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 3D3F35C421A697F300849259 /* ChatClient */, + 3D3F35DA21A697F500849259 /* ChatClientTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 3D3F35C321A697F300849259 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D3F35D521A697F400849259 /* LaunchScreen.storyboard in Resources */, + 3D3F35D221A697F400849259 /* Assets.xcassets in Resources */, + 3D3F35D021A697F300849259 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3D3F35D921A697F500849259 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6A7AE25E7DB9A24573AF4C71 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ChatClient-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8B7A19F8FAC24BD6F570C5F8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/MessengerKit/MessengerKit.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessengerKit.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 3D3F35C121A697F300849259 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D3F35CD21A697F300849259 /* MessagesViewController.swift in Sources */, + 3D5C042521AC4D210000DAD2 /* ClientCommand+Codable.swift in Sources */, + 3D5C042821AC4D210000DAD2 /* ServerMessage.swift in Sources */, + 3DAACFDF21A76B4500F4955D /* Constants.swift in Sources */, + 3D3F35CB21A697F300849259 /* MasterViewController.swift in Sources */, + 3DAACFDD21A6D39800F4955D /* ChatEntry.swift in Sources */, + 3D5C042721AC4D210000DAD2 /* ServerMessage+Codable.swift in Sources */, + 3D3F35C921A697F300849259 /* AppDelegate.swift in Sources */, + 3D5C042621AC4D210000DAD2 /* ClientCommand.swift in Sources */, + D8D4BD7ACE3B8A8D475CB916 /* ChatClientService.swift in Sources */, + D8D4BC0CF2B4CCAAB3F03421 /* MessageBoard.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3D3F35D721A697F500849259 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D3F35E021A697F500849259 /* ChatClientTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 3D3F35DD21A697F500849259 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3D3F35C421A697F300849259 /* ChatClient */; + targetProxy = 3D3F35DC21A697F500849259 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 3D3F35CE21A697F300849259 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 3D3F35CF21A697F300849259 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 3D3F35D321A697F400849259 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 3D3F35D421A697F400849259 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 3D3F35E221A697F500849259 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 3D3F35E321A697F500849259 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3D3F35E521A697F500849259 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3FBE361C985C4D3B0B8557BE /* Pods-ChatClient.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClient/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClient; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 3D3F35E621A697F500849259 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1853E6A9B284C57EF0FB7A20 /* Pods-ChatClient.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClient/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClient; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 3D3F35E821A697F500849259 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClientTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClientTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ChatClient.app/ChatClient"; + }; + name = Debug; + }; + 3D3F35E921A697F500849259 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClientTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClientTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ChatClient.app/ChatClient"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3D3F35C021A697F300849259 /* Build configuration list for PBXProject "ChatClient" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D3F35E221A697F500849259 /* Debug */, + 3D3F35E321A697F500849259 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3D3F35E421A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClient" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D3F35E521A697F500849259 /* Debug */, + 3D3F35E621A697F500849259 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3D3F35E721A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClientTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D3F35E821A697F500849259 /* Debug */, + 3D3F35E921A697F500849259 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 3D3F35BD21A697F300849259 /* Project object */; +} diff --git a/iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme b/iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme new file mode 100644 index 0000000..ecdbdf3 --- /dev/null +++ b/iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist b/iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..56a119d --- /dev/null +++ b/iOS-completed/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + ChatClient.xcscheme + + orderHint + 0 + + + + diff --git a/iOS-completed/ChatClient/AppDelegate.swift b/iOS-completed/ChatClient/AppDelegate.swift new file mode 100644 index 0000000..1291695 --- /dev/null +++ b/iOS-completed/ChatClient/AppDelegate.swift @@ -0,0 +1,55 @@ +// +// AppDelegate.swift +// ChatClient +// +// Created by Florent Pillet on 22/11/2018. +// Copyright © 2018 SwiftAlps. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + let splitViewController = window!.rootViewController as! UISplitViewController + let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController + navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem + splitViewController.delegate = self + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + // MARK: - Split view + + func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool { + return false + } + +} + diff --git a/iOS-completed/ChatClient/Constants.swift b/iOS-completed/ChatClient/Constants.swift new file mode 100644 index 0000000..0532ad1 --- /dev/null +++ b/iOS-completed/ChatClient/Constants.swift @@ -0,0 +1,9 @@ + +import Foundation + +struct Constants { + static let username = "John" + + static let serverAddress = "::1" + static let serverPort = 9999 +} diff --git a/iOS-completed/ChatClient/Controllers/MasterViewController.swift b/iOS-completed/ChatClient/Controllers/MasterViewController.swift new file mode 100644 index 0000000..d19580d --- /dev/null +++ b/iOS-completed/ChatClient/Controllers/MasterViewController.swift @@ -0,0 +1,120 @@ +// +// MasterViewController.swift +// ChatClient +// +// Created by Florent Pillet on 22/11/2018. +// Copyright © 2018 SwiftAlps. All rights reserved. +// + +import UIKit + +class MasterViewController: UITableViewController { + + var detailViewController: MessagesViewController? = nil + + let chatService = ChatClientService(username: Constants.username, serverAddress: Constants.serverAddress, serverPort: Constants.serverPort) + + // stuff we need to update the display + private var rooms = [MessageBoard]() + private var users = [MessageBoard]() + + private func sendMessage(_ board: MessageBoard, _ message: String) { + chatService.sendMessage(board: board, message: message) + } + + private func processMessage(_ message: ServerMessage) { + switch message { + case .rooms, .users: + updateRoomsAndUsers() + + default: + break + } + DispatchQueue.main.async { + self.messagesViewController()?.processServerMessage(message) + } + } + + private func updateRoomsAndUsers() { + self.rooms = self.chatService.messageBoards.keys.filter { $0.isRoom }.sortedByDisplayName() + self.users = self.chatService.messageBoards.keys.filter { !$0.isRoom }.sortedByDisplayName() + DispatchQueue.main.async { + self.tableView.reloadData() + } + } + + private func messagesViewController() -> MessagesViewController? { + return (splitViewController?.viewControllers.last as? UINavigationController)?.topViewController as? MessagesViewController + } + + // MARK: - Basic ViewController stuff + + override func viewDidLoad() { + super.viewDidLoad() + messagesViewController()?.view.isHidden = true + + // in real life, avoid doing this as you're introducing a retain cycle + chatService.newMessageNotification = self.processMessage + chatService.connect() + } + + override func viewWillAppear(_ animated: Bool) { + clearsSelectionOnViewWillAppear = splitViewController!.isCollapsed + super.viewWillAppear(animated) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "showDetail" { + if let indexPath = tableView.indexPathForSelectedRow { + let controller = (segue.destination as! UINavigationController).topViewController as! MessagesViewController + controller.view.isHidden = false + controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem + controller.navigationItem.leftItemsSupplementBackButton = true + + let board = (indexPath.section == 0) ? rooms[indexPath.row] : users[indexPath.row] + + controller.configure(myName: chatService.username, + board: board, + boardContents: chatService.messageBoards[board] ?? [], + sendMessage: self.sendMessage) + } else { + let controller = (segue.destination as! UINavigationController).topViewController + controller?.view.isHidden = true + } + } + } + + // MARK: - Table View + + override func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return section == 0 ? rooms.count : users.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + if indexPath.section == 0 { + let name = rooms[indexPath.row].displayName + cell.textLabel!.text = "#\(name)" + } else { + let name = users[indexPath.row].displayName + cell.textLabel!.text = "@\(name)" + } + return cell + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let label = UILabel() + label.text = (section == 0) ? " Rooms" : " Users" + label.textColor = UIColor.darkGray + return label + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 40.0 + } +} + diff --git a/iOS-completed/ChatClient/Controllers/MessagesViewController.swift b/iOS-completed/ChatClient/Controllers/MessagesViewController.swift new file mode 100644 index 0000000..8a944ea --- /dev/null +++ b/iOS-completed/ChatClient/Controllers/MessagesViewController.swift @@ -0,0 +1,124 @@ +import UIKit +import MessengerKit + +struct User: MSGUser { + var displayName: String + var avatar: UIImage? + var isSender: Bool +} + +typealias SendMessageCallback = (MessageBoard, String) -> Void + +class MessagesViewController: MSGMessengerViewController { + + var sendMessageCallback: SendMessageCallback = { (_,_) in } + + // users in the room + private var myself: User? + private var others: [User] = [] + + // this holds all the messages to display + private var messages = [[MSGMessage]]() + private var nextMessageID = 1 + + // we are either in a room, or in private talk with another user + private var board: MessageBoard? = nil + + override func viewDidLoad() { + super.viewDidLoad() + dataSource = self + messageInputView.addTarget(self, action: #selector(sendMessage), for: .primaryActionTriggered) + } + + @objc func sendMessage() { + guard let board = board else { return } + sendMessageCallback(board, messageInputView.message) + } + + func configure(myName: String, board: MessageBoard, boardContents: [ChatMessage], sendMessage: @escaping SendMessageCallback) { + self.sendMessageCallback = sendMessage + self.board = board + self.messages = [] + self.myself = User(displayName: myName, avatar: nil, isSender: true) + for message in boardContents { + appendMessage(.text(message.text), from: message.user) + } + self.collectionView.reloadData() + switch board { + case .room(let roomName): + self.title = "Group chat in #\(roomName)" + case .user(let userName): + self.title = "Private chat with @\(userName)" + } + } + + func processServerMessage(_ serverMessage: ServerMessage) { + guard let board = board else { return } + switch serverMessage { + case .connected, .disconnected, .rooms, .users: + // chat display is not interested in these ones + break + + case .message(let inRoom, let username, let text): + if case .room(let room) = board, room == inRoom { + appendMessage(.text(text), from: username) + collectionView.reloadData() + } + + case .privateMessage(let from, let to, let text): + if case .user(let withUser) = board, withUser == from || withUser == to { + appendMessage(.text(text), from: from) + collectionView.reloadData() + } + } + } +} + +extension MessagesViewController { + private func appendMessage(_ messageBody: MSGMessageBody, from: String) { + let id = self.nextMessageID + self.nextMessageID += 1 + let user: User + if let myself = myself, myself.displayName == from { + user = myself + } else { + if let existing = others.first(where: { $0.displayName == from }) { + user = existing + } else { + user = User(displayName: from, avatar: nil, isSender: false) + others.append(user) + } + } + let message = MSGMessage(id: id, body: messageBody, user: user, sentAt: Date()) + + if var lastGroup = messages.last, let lastMessage = lastGroup.last, lastMessage.user.displayName == from { + lastGroup.append(message) + messages[messages.count-1] = lastGroup + } else { + messages.append([message]) + } + } + +} + +extension MessagesViewController: MSGDataSource { + func numberOfSections() -> Int { + return messages.count + } + + func numberOfMessages(in section: Int) -> Int { + return messages[section].count + } + + func message(for indexPath: IndexPath) -> MSGMessage { + return messages[indexPath.section][indexPath.item] + } + + func footerTitle(for section: Int) -> String? { + return "Just now" + } + + func headerTitle(for section: Int) -> String? { + return messages[section].first?.user.displayName + } +} diff --git a/iOS-completed/ChatClient/Model/ChatClientService.swift b/iOS-completed/ChatClient/Model/ChatClientService.swift new file mode 100644 index 0000000..d2990ec --- /dev/null +++ b/iOS-completed/ChatClient/Model/ChatClientService.swift @@ -0,0 +1,248 @@ +import Foundation +import Network + +typealias MessageReceivedCallback = (ServerMessage) -> Void +typealias ChatMessage = (user: String, text: String) + +final class ChatClientService { + + // Network.framework needs a dedicated queue to operate on + private let serverQueue = DispatchQueue(label: "chat-server-queue", qos: .background) + + // This is the connection we operate on, once established + private var connection: NWConnection? = nil + let serverEndpoint: NWEndpoint + + // Some internal stuff for us + let username: String + + // Some state we keep + private(set) var loggedIn = false + private(set) var messageBoards = [MessageBoard:[ChatMessage]]() + + // Configurable notification callback that fires when we receive a message from the server + var newMessageNotification: MessageReceivedCallback? = nil + + init(username: String, serverAddress: String, serverPort: Int) { + self.username = username + self.serverEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(serverAddress), port: NWEndpoint.Port(rawValue: UInt16(serverPort))!) + } + + func send(command: ClientCommand) { + sendFramed(command: command) + } + + private func sendUnframed(command: ClientCommand) { + do { + let jsonData = try JSONEncoder().encode(command) + + connection?.send(content: jsonData, completion: .contentProcessed({ error in + if let error = error { + print("Error sending message: \(error)") + } + })) + } + catch let err { + print("Failed encoding JSON command: err=\(err)") + } + } + + private func sendFramed(command: ClientCommand) { + do { + let jsonData = try JSONEncoder().encode(command) + + var frameSize = NSSwapHostIntToBig(UInt32(jsonData.count)) + let headerData = Data(bytes: &frameSize, count: 4) + connection?.send(content: headerData, isComplete: false, completion: .contentProcessed({ error in + if let error = error { + print("Error sending frame header: \(error)") + } + })) + + // send message over to the connection + connection?.send(content: jsonData, completion: .contentProcessed({ error in + if let error = error { + print("Error sending frame contents: \(error)") + } + })) + } + catch let err { + print("Failed encoding JSON command: err=\(err)") + } + } + + func connect() { + self.connection = NWConnection(to: serverEndpoint, using: .tcp) + guard let connection = self.connection else { + fatalError("Failed creating NWConnection") + } + setupConnectionStateHandler(connection) + connection.start(queue: serverQueue) + readNextMessage(connection) + } + + func disconnect() { + connection?.cancel() + } + + private func setupConnectionStateHandler(_ connection: NWConnection) { + connection.stateUpdateHandler = { (newState) in + switch (newState) { + case .setup: + print("Connection setup") + + case .preparing: + print("Connection preparing") + + case .ready: + print("Connection established") + self.send(command: .connect(username: self.username)) + + case .waiting(let error): + print("Connection to server waiting to establish, error=\(error)") + self.serverQueue.asyncAfter(deadline: .now()+1) { + self.connect() + } + + case .failed(let error): + print("Connection to server failed, error=\(error)") + self.serverQueue.asyncAfter(deadline: .now()+1) { + // retry after 1 second + self.connect() + } + + case .cancelled: + print("Connection was cancelled, not retrying") + break + } + } + } + + private func readNextMessage(_ connection: NWConnection) { + readNextFramedMessage(connection) + } + + private func readNextUnframedMessage(_ connection: NWConnection) { + // Read a simple message from the connection. Note that this may turn to be unsafe + // in case of packet fragmentation + connection.receive(minimumIncompleteLength: 2, maximumLength: 256000) { (data: Data?, context: NWConnection.ContentContext?, complete: Bool, error: NWError?) in + if let error = error { + print("receiveMessage returned an error: \(error)") + self.readNextMessage(connection) + return + } + + guard let data = data else { + return + } + + do { + let message = try JSONDecoder().decode(ServerMessage.self, from: data) + self.process(message: message) + DispatchQueue.main.async { + self.newMessageNotification?(message) + } + } + catch let decodingErr { + print("JSON decoding error: \(decodingErr)") + } + self.readNextMessage(connection) + } + } + + private func readNextFramedMessage(_ connection: NWConnection) { + // Read message encoded on the server with `FramedMessageCodec`: + // 4 bytes header giving the contents of the packed, followed by the packet data + let headerSize = MemoryLayout.size + connection.receive(minimumIncompleteLength: headerSize, maximumLength: headerSize) { (data: Data?, _, _, error: NWError?) in + if let error = error { + print("Error reading frame header: \(error)") + self.readNextMessage(connection) + return + } + + guard let data = data else { + return + } + + var frameSize: UInt32 = 0 + _ = data.copyBytes(to: UnsafeMutableBufferPointer(start: &frameSize, count: MemoryLayout.size)) + frameSize = NSSwapBigIntToHost(frameSize) + + connection.receive(minimumIncompleteLength: Int(frameSize), maximumLength: Int(frameSize), completion: { (data: Data?, _, _, error: NWError?) in + if let error = error { + print("Error reading frame contents: \(error)") + self.readNextMessage(connection) + return + } + + guard let data = data else { + return + } + + do { + let message = try JSONDecoder().decode(ServerMessage.self, from: data) + self.process(message: message) + DispatchQueue.main.async { + self.newMessageNotification?(message) + } + } + catch let decodingErr { + print("JSON decoding error: \(decodingErr)") + } + self.readNextMessage(connection) + }) + } + } + + func sendMessage(board: MessageBoard, message: String) { + switch board { + case .room(let room): + send(command: .message(room: room, text: message)) + + case .user(let toUser): + send(command: .privateMessage(username: toUser, text: message)) + } + } + + private func process(message: ServerMessage) { + print("Processing server message: \(message)") + switch message { + + case .connected: + loggedIn = true + + case .disconnected: + loggedIn = false + + case .rooms(let roomNames): + roomNames.forEach { + let board = MessageBoard.room($0) + if self.messageBoards[board] == nil { + self.messageBoards[board] = [] + } + } + + case .users(let users): + users.forEach { + let board = MessageBoard.user($0) + if self.messageBoards[board] == nil { + self.messageBoards[board] = [] + } + } + + case .message(let room, let username, let text): + let board = MessageBoard.room(room) + var entries = messageBoards[board] ?? [] + entries.append(ChatMessage(user: username, text: text)) + messageBoards[board] = entries + + case .privateMessage(let from, let to, let text): + let party = from == username ? to : from + let board = MessageBoard.user(party) + var entries = messageBoards[board] ?? [] + entries.append(ChatMessage(user: from, text: text)) + messageBoards[board] = entries + } + } +} diff --git a/iOS-completed/ChatClient/Model/ChatEntry.swift b/iOS-completed/ChatClient/Model/ChatEntry.swift new file mode 100644 index 0000000..fda2851 --- /dev/null +++ b/iOS-completed/ChatClient/Model/ChatEntry.swift @@ -0,0 +1,7 @@ +import Foundation + +enum ChatEntry { + case message(ChatMessage) + case userJoined(String) + case userLeft(String) +} diff --git a/iOS-completed/ChatClient/Model/MessageBoard.swift b/iOS-completed/ChatClient/Model/MessageBoard.swift new file mode 100644 index 0000000..f6b1777 --- /dev/null +++ b/iOS-completed/ChatClient/Model/MessageBoard.swift @@ -0,0 +1,47 @@ +import Foundation + +// A "message board" is either a chat root or a direct discussion with another user +enum MessageBoard { + case room(String) + case user(String) + + var displayName: String { + switch self { + case .room(let name), .user(let name): + return name + } + } + + var isRoom: Bool { + if case .room = self { + return true + } + return false + } +} + +extension MessageBoard: Equatable { + public static func ==(lhs: MessageBoard, rhs: MessageBoard) -> Bool { + switch (lhs, rhs) { + case (.room(let left), .room(let right)), (.user(let left), .user(let right)): + return left == right + default: + return false + } + } +} + +extension MessageBoard: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case .room(let roomName): roomName.hash(into: &hasher) + case .user(let userName): userName.hash(into: &hasher) + } + } +} + +extension Collection where Element == MessageBoard { + func sortedByDisplayName() -> [MessageBoard] { + return self.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending } + } +} diff --git a/iOS-completed/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS-completed/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/iOS-completed/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/iOS-completed/ChatClient/Resources/Assets.xcassets/Contents.json b/iOS-completed/ChatClient/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/iOS-completed/ChatClient/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/iOS-completed/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard b/iOS-completed/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..bfa3612 --- /dev/null +++ b/iOS-completed/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS-completed/ChatClient/Resources/Base.lproj/Main.storyboard b/iOS-completed/ChatClient/Resources/Base.lproj/Main.storyboard new file mode 100644 index 0000000..79ecbb3 --- /dev/null +++ b/iOS-completed/ChatClient/Resources/Base.lproj/Main.storyboard @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS-completed/ChatClient/Resources/Info.plist b/iOS-completed/ChatClient/Resources/Info.plist new file mode 100644 index 0000000..76fe0e4 --- /dev/null +++ b/iOS-completed/ChatClient/Resources/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UIStatusBarTintParameters + + UINavigationBar + + Style + UIBarStyleDefault + Translucent + + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/iOS-completed/ChatClientTests/ChatClientTests.swift b/iOS-completed/ChatClientTests/ChatClientTests.swift new file mode 100644 index 0000000..16157f6 --- /dev/null +++ b/iOS-completed/ChatClientTests/ChatClientTests.swift @@ -0,0 +1,27 @@ + +import XCTest +@testable import ChatClient + +class ChatClientTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/iOS-completed/ChatClientTests/Info.plist b/iOS-completed/ChatClientTests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/iOS-completed/ChatClientTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/iOS-completed/Podfile b/iOS-completed/Podfile new file mode 100644 index 0000000..2595b4a --- /dev/null +++ b/iOS-completed/Podfile @@ -0,0 +1,11 @@ +platform :ios, '12.0' +project 'ChatClient.xcodeproj' + +target 'ChatClient' do + use_frameworks! + + # Ruby get the absolute path of the Common folder + # common_folder = File.expand_path("../Common", File.dirname(__FILE__)) + + pod 'MessengerKit', :git => 'https://github.com/steve228uk/MessengerKit.git' +end diff --git a/iOS-completed/Podfile.lock b/iOS-completed/Podfile.lock new file mode 100644 index 0000000..4b8cf53 --- /dev/null +++ b/iOS-completed/Podfile.lock @@ -0,0 +1,21 @@ +PODS: + - MessengerKit (1.0.0) + +DEPENDENCIES: + - MessengerKit (from `https://github.com/steve228uk/MessengerKit.git`) + +EXTERNAL SOURCES: + MessengerKit: + :git: https://github.com/steve228uk/MessengerKit.git + +CHECKOUT OPTIONS: + MessengerKit: + :commit: 0aa7d371d8cd2527b7bb650cbbb05fd4e2229a9e + :git: https://github.com/steve228uk/MessengerKit.git + +SPEC CHECKSUMS: + MessengerKit: 98afa44728b8e12672ece6db7764433e72832bf7 + +PODFILE CHECKSUM: bd5ec7f3f5ce6705bb12bc27a0a89a4f9e20f644 + +COCOAPODS: 1.5.3 diff --git a/iOS/ChatClient.xcodeproj/project.pbxproj b/iOS/ChatClient.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fa470df --- /dev/null +++ b/iOS/ChatClient.xcodeproj/project.pbxproj @@ -0,0 +1,610 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 1A62D084C1FE204D61215CC4 /* Pods_ChatClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CE43286B5E6B6DC6227977E /* Pods_ChatClient.framework */; }; + 3D3F35C921A697F300849259 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35C821A697F300849259 /* AppDelegate.swift */; }; + 3D3F35CB21A697F300849259 /* MasterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35CA21A697F300849259 /* MasterViewController.swift */; }; + 3D3F35CD21A697F300849259 /* MessagesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35CC21A697F300849259 /* MessagesViewController.swift */; }; + 3D3F35D021A697F300849259 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D3F35CE21A697F300849259 /* Main.storyboard */; }; + 3D3F35D221A697F400849259 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3D3F35D121A697F400849259 /* Assets.xcassets */; }; + 3D3F35D521A697F400849259 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D3F35D321A697F400849259 /* LaunchScreen.storyboard */; }; + 3D3F35E021A697F500849259 /* ChatClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3F35DF21A697F500849259 /* ChatClientTests.swift */; }; + 3D5C042521AC4D210000DAD2 /* ClientCommand+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042121AC4D210000DAD2 /* ClientCommand+Codable.swift */; }; + 3D5C042621AC4D210000DAD2 /* ClientCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042221AC4D210000DAD2 /* ClientCommand.swift */; }; + 3D5C042721AC4D210000DAD2 /* ServerMessage+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042321AC4D210000DAD2 /* ServerMessage+Codable.swift */; }; + 3D5C042821AC4D210000DAD2 /* ServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5C042421AC4D210000DAD2 /* ServerMessage.swift */; }; + 3DAACFDD21A6D39800F4955D /* ChatEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAACFDC21A6D39800F4955D /* ChatEntry.swift */; }; + 3DAACFDF21A76B4500F4955D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAACFDE21A76B4500F4955D /* Constants.swift */; }; + D8D4BC0CF2B4CCAAB3F03421 /* MessageBoard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4BBF60C14CFE77DF30BB8 /* MessageBoard.swift */; }; + D8D4BD7ACE3B8A8D475CB916 /* ChatClientService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4BE49AFDF976BDE66BE86 /* ChatClientService.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 3D3F35DC21A697F500849259 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3D3F35BD21A697F300849259 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3D3F35C421A697F300849259; + remoteInfo = ChatClient; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 1853E6A9B284C57EF0FB7A20 /* Pods-ChatClient.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatClient.release.xcconfig"; path = "Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient.release.xcconfig"; sourceTree = ""; }; + 3CE43286B5E6B6DC6227977E /* Pods_ChatClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ChatClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D3F35C521A697F300849259 /* ChatClient.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChatClient.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D3F35C821A697F300849259 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 3D3F35CA21A697F300849259 /* MasterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterViewController.swift; sourceTree = ""; }; + 3D3F35CC21A697F300849259 /* MessagesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewController.swift; sourceTree = ""; }; + 3D3F35CF21A697F300849259 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 3D3F35D121A697F400849259 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 3D3F35D421A697F400849259 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 3D3F35D621A697F400849259 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3D3F35DB21A697F500849259 /* ChatClientTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ChatClientTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3D3F35DF21A697F500849259 /* ChatClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientTests.swift; sourceTree = ""; }; + 3D3F35E121A697F500849259 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3D5C042121AC4D210000DAD2 /* ClientCommand+Codable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ClientCommand+Codable.swift"; sourceTree = ""; }; + 3D5C042221AC4D210000DAD2 /* ClientCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClientCommand.swift; sourceTree = ""; }; + 3D5C042321AC4D210000DAD2 /* ServerMessage+Codable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ServerMessage+Codable.swift"; sourceTree = ""; }; + 3D5C042421AC4D210000DAD2 /* ServerMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerMessage.swift; sourceTree = ""; }; + 3DAACFDC21A6D39800F4955D /* ChatEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatEntry.swift; sourceTree = ""; }; + 3DAACFDE21A76B4500F4955D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 3FBE361C985C4D3B0B8557BE /* Pods-ChatClient.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatClient.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient.debug.xcconfig"; sourceTree = ""; }; + D8D4BBF60C14CFE77DF30BB8 /* MessageBoard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageBoard.swift; sourceTree = ""; }; + D8D4BE49AFDF976BDE66BE86 /* ChatClientService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatClientService.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3D3F35C221A697F300849259 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A62D084C1FE204D61215CC4 /* Pods_ChatClient.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3D3F35D821A697F500849259 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3D3F35BC21A697F300849259 = { + isa = PBXGroup; + children = ( + 3D3F35C721A697F300849259 /* ChatClient */, + 3D3F35DE21A697F500849259 /* ChatClientTests */, + 3D3F35C621A697F300849259 /* Products */, + 7ACA2487E04A4AF5845EA919 /* Pods */, + D6B3E4076E7E7376C31D9D1C /* Frameworks */, + ); + sourceTree = ""; + }; + 3D3F35C621A697F300849259 /* Products */ = { + isa = PBXGroup; + children = ( + 3D3F35C521A697F300849259 /* ChatClient.app */, + 3D3F35DB21A697F500849259 /* ChatClientTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 3D3F35C721A697F300849259 /* ChatClient */ = { + isa = PBXGroup; + children = ( + 3D5C041F21AC4D210000DAD2 /* ChatCommon */, + 3DC9273221A9C37F001FBB17 /* Resources */, + 3DC9273121A9C376001FBB17 /* Controllers */, + 3D3F35C821A697F300849259 /* AppDelegate.swift */, + D8D4B85B9E8DDEC8F9D4D63D /* Model */, + 3DAACFDE21A76B4500F4955D /* Constants.swift */, + ); + path = ChatClient; + sourceTree = ""; + }; + 3D3F35DE21A697F500849259 /* ChatClientTests */ = { + isa = PBXGroup; + children = ( + 3D3F35DF21A697F500849259 /* ChatClientTests.swift */, + 3D3F35E121A697F500849259 /* Info.plist */, + ); + path = ChatClientTests; + sourceTree = ""; + }; + 3D5C041F21AC4D210000DAD2 /* ChatCommon */ = { + isa = PBXGroup; + children = ( + 3D5C042021AC4D210000DAD2 /* Model */, + ); + name = ChatCommon; + path = ../../Server/Sources/ChatCommon; + sourceTree = ""; + }; + 3D5C042021AC4D210000DAD2 /* Model */ = { + isa = PBXGroup; + children = ( + 3D5C042221AC4D210000DAD2 /* ClientCommand.swift */, + 3D5C042121AC4D210000DAD2 /* ClientCommand+Codable.swift */, + 3D5C042421AC4D210000DAD2 /* ServerMessage.swift */, + 3D5C042321AC4D210000DAD2 /* ServerMessage+Codable.swift */, + ); + path = Model; + sourceTree = ""; + }; + 3DC9273121A9C376001FBB17 /* Controllers */ = { + isa = PBXGroup; + children = ( + 3D3F35CA21A697F300849259 /* MasterViewController.swift */, + 3D3F35CC21A697F300849259 /* MessagesViewController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + 3DC9273221A9C37F001FBB17 /* Resources */ = { + isa = PBXGroup; + children = ( + 3D3F35D621A697F400849259 /* Info.plist */, + 3D3F35CE21A697F300849259 /* Main.storyboard */, + 3D3F35D121A697F400849259 /* Assets.xcassets */, + 3D3F35D321A697F400849259 /* LaunchScreen.storyboard */, + ); + path = Resources; + sourceTree = ""; + }; + 7ACA2487E04A4AF5845EA919 /* Pods */ = { + isa = PBXGroup; + children = ( + 3FBE361C985C4D3B0B8557BE /* Pods-ChatClient.debug.xcconfig */, + 1853E6A9B284C57EF0FB7A20 /* Pods-ChatClient.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + D6B3E4076E7E7376C31D9D1C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3CE43286B5E6B6DC6227977E /* Pods_ChatClient.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + D8D4B85B9E8DDEC8F9D4D63D /* Model */ = { + isa = PBXGroup; + children = ( + D8D4BE49AFDF976BDE66BE86 /* ChatClientService.swift */, + 3DAACFDC21A6D39800F4955D /* ChatEntry.swift */, + D8D4BBF60C14CFE77DF30BB8 /* MessageBoard.swift */, + ); + path = Model; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 3D3F35C421A697F300849259 /* ChatClient */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3D3F35E421A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClient" */; + buildPhases = ( + 6A7AE25E7DB9A24573AF4C71 /* [CP] Check Pods Manifest.lock */, + 3D3F35C121A697F300849259 /* Sources */, + 3D3F35C221A697F300849259 /* Frameworks */, + 3D3F35C321A697F300849259 /* Resources */, + 8B7A19F8FAC24BD6F570C5F8 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ChatClient; + productName = ChatClient; + productReference = 3D3F35C521A697F300849259 /* ChatClient.app */; + productType = "com.apple.product-type.application"; + }; + 3D3F35DA21A697F500849259 /* ChatClientTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3D3F35E721A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClientTests" */; + buildPhases = ( + 3D3F35D721A697F500849259 /* Sources */, + 3D3F35D821A697F500849259 /* Frameworks */, + 3D3F35D921A697F500849259 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3D3F35DD21A697F500849259 /* PBXTargetDependency */, + ); + name = ChatClientTests; + productName = ChatClientTests; + productReference = 3D3F35DB21A697F500849259 /* ChatClientTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3D3F35BD21A697F300849259 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1010; + LastUpgradeCheck = 1010; + ORGANIZATIONNAME = SwiftAlps; + TargetAttributes = { + 3D3F35C421A697F300849259 = { + CreatedOnToolsVersion = 10.1; + }; + 3D3F35DA21A697F500849259 = { + CreatedOnToolsVersion = 10.1; + TestTargetID = 3D3F35C421A697F300849259; + }; + }; + }; + buildConfigurationList = 3D3F35C021A697F300849259 /* Build configuration list for PBXProject "ChatClient" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 3D3F35BC21A697F300849259; + productRefGroup = 3D3F35C621A697F300849259 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 3D3F35C421A697F300849259 /* ChatClient */, + 3D3F35DA21A697F500849259 /* ChatClientTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 3D3F35C321A697F300849259 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D3F35D521A697F400849259 /* LaunchScreen.storyboard in Resources */, + 3D3F35D221A697F400849259 /* Assets.xcassets in Resources */, + 3D3F35D021A697F300849259 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3D3F35D921A697F500849259 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6A7AE25E7DB9A24573AF4C71 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ChatClient-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8B7A19F8FAC24BD6F570C5F8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/MessengerKit/MessengerKit.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessengerKit.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-ChatClient/Pods-ChatClient-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 3D3F35C121A697F300849259 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D3F35CD21A697F300849259 /* MessagesViewController.swift in Sources */, + 3D5C042521AC4D210000DAD2 /* ClientCommand+Codable.swift in Sources */, + 3D5C042821AC4D210000DAD2 /* ServerMessage.swift in Sources */, + 3DAACFDF21A76B4500F4955D /* Constants.swift in Sources */, + 3D3F35CB21A697F300849259 /* MasterViewController.swift in Sources */, + 3DAACFDD21A6D39800F4955D /* ChatEntry.swift in Sources */, + 3D5C042721AC4D210000DAD2 /* ServerMessage+Codable.swift in Sources */, + 3D3F35C921A697F300849259 /* AppDelegate.swift in Sources */, + 3D5C042621AC4D210000DAD2 /* ClientCommand.swift in Sources */, + D8D4BD7ACE3B8A8D475CB916 /* ChatClientService.swift in Sources */, + D8D4BC0CF2B4CCAAB3F03421 /* MessageBoard.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3D3F35D721A697F500849259 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D3F35E021A697F500849259 /* ChatClientTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 3D3F35DD21A697F500849259 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3D3F35C421A697F300849259 /* ChatClient */; + targetProxy = 3D3F35DC21A697F500849259 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 3D3F35CE21A697F300849259 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 3D3F35CF21A697F300849259 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 3D3F35D321A697F400849259 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 3D3F35D421A697F400849259 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 3D3F35E221A697F500849259 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 3D3F35E321A697F500849259 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3D3F35E521A697F500849259 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3FBE361C985C4D3B0B8557BE /* Pods-ChatClient.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClient/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClient; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 3D3F35E621A697F500849259 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1853E6A9B284C57EF0FB7A20 /* Pods-ChatClient.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClient/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClient; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 3D3F35E821A697F500849259 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClientTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClientTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ChatClient.app/ChatClient"; + }; + name = Debug; + }; + 3D3F35E921A697F500849259 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ChatClientTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.swiftalps.chatClient.ChatClientTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.2; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ChatClient.app/ChatClient"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3D3F35C021A697F300849259 /* Build configuration list for PBXProject "ChatClient" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D3F35E221A697F500849259 /* Debug */, + 3D3F35E321A697F500849259 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3D3F35E421A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClient" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D3F35E521A697F500849259 /* Debug */, + 3D3F35E621A697F500849259 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3D3F35E721A697F500849259 /* Build configuration list for PBXNativeTarget "ChatClientTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3D3F35E821A697F500849259 /* Debug */, + 3D3F35E921A697F500849259 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 3D3F35BD21A697F300849259 /* Project object */; +} diff --git a/iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme b/iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme new file mode 100644 index 0000000..ecdbdf3 --- /dev/null +++ b/iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/ChatClient.xcscheme @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist b/iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..56a119d --- /dev/null +++ b/iOS/ChatClient.xcodeproj/xcuserdata/fpillet.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + ChatClient.xcscheme + + orderHint + 0 + + + + diff --git a/iOS/ChatClient/AppDelegate.swift b/iOS/ChatClient/AppDelegate.swift new file mode 100644 index 0000000..1291695 --- /dev/null +++ b/iOS/ChatClient/AppDelegate.swift @@ -0,0 +1,55 @@ +// +// AppDelegate.swift +// ChatClient +// +// Created by Florent Pillet on 22/11/2018. +// Copyright © 2018 SwiftAlps. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + let splitViewController = window!.rootViewController as! UISplitViewController + let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController + navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem + splitViewController.delegate = self + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + // MARK: - Split view + + func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool { + return false + } + +} + diff --git a/iOS/ChatClient/Constants.swift b/iOS/ChatClient/Constants.swift new file mode 100644 index 0000000..0532ad1 --- /dev/null +++ b/iOS/ChatClient/Constants.swift @@ -0,0 +1,9 @@ + +import Foundation + +struct Constants { + static let username = "John" + + static let serverAddress = "::1" + static let serverPort = 9999 +} diff --git a/iOS/ChatClient/Controllers/MasterViewController.swift b/iOS/ChatClient/Controllers/MasterViewController.swift new file mode 100644 index 0000000..d19580d --- /dev/null +++ b/iOS/ChatClient/Controllers/MasterViewController.swift @@ -0,0 +1,120 @@ +// +// MasterViewController.swift +// ChatClient +// +// Created by Florent Pillet on 22/11/2018. +// Copyright © 2018 SwiftAlps. All rights reserved. +// + +import UIKit + +class MasterViewController: UITableViewController { + + var detailViewController: MessagesViewController? = nil + + let chatService = ChatClientService(username: Constants.username, serverAddress: Constants.serverAddress, serverPort: Constants.serverPort) + + // stuff we need to update the display + private var rooms = [MessageBoard]() + private var users = [MessageBoard]() + + private func sendMessage(_ board: MessageBoard, _ message: String) { + chatService.sendMessage(board: board, message: message) + } + + private func processMessage(_ message: ServerMessage) { + switch message { + case .rooms, .users: + updateRoomsAndUsers() + + default: + break + } + DispatchQueue.main.async { + self.messagesViewController()?.processServerMessage(message) + } + } + + private func updateRoomsAndUsers() { + self.rooms = self.chatService.messageBoards.keys.filter { $0.isRoom }.sortedByDisplayName() + self.users = self.chatService.messageBoards.keys.filter { !$0.isRoom }.sortedByDisplayName() + DispatchQueue.main.async { + self.tableView.reloadData() + } + } + + private func messagesViewController() -> MessagesViewController? { + return (splitViewController?.viewControllers.last as? UINavigationController)?.topViewController as? MessagesViewController + } + + // MARK: - Basic ViewController stuff + + override func viewDidLoad() { + super.viewDidLoad() + messagesViewController()?.view.isHidden = true + + // in real life, avoid doing this as you're introducing a retain cycle + chatService.newMessageNotification = self.processMessage + chatService.connect() + } + + override func viewWillAppear(_ animated: Bool) { + clearsSelectionOnViewWillAppear = splitViewController!.isCollapsed + super.viewWillAppear(animated) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "showDetail" { + if let indexPath = tableView.indexPathForSelectedRow { + let controller = (segue.destination as! UINavigationController).topViewController as! MessagesViewController + controller.view.isHidden = false + controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem + controller.navigationItem.leftItemsSupplementBackButton = true + + let board = (indexPath.section == 0) ? rooms[indexPath.row] : users[indexPath.row] + + controller.configure(myName: chatService.username, + board: board, + boardContents: chatService.messageBoards[board] ?? [], + sendMessage: self.sendMessage) + } else { + let controller = (segue.destination as! UINavigationController).topViewController + controller?.view.isHidden = true + } + } + } + + // MARK: - Table View + + override func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return section == 0 ? rooms.count : users.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + if indexPath.section == 0 { + let name = rooms[indexPath.row].displayName + cell.textLabel!.text = "#\(name)" + } else { + let name = users[indexPath.row].displayName + cell.textLabel!.text = "@\(name)" + } + return cell + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let label = UILabel() + label.text = (section == 0) ? " Rooms" : " Users" + label.textColor = UIColor.darkGray + return label + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 40.0 + } +} + diff --git a/iOS/ChatClient/Controllers/MessagesViewController.swift b/iOS/ChatClient/Controllers/MessagesViewController.swift new file mode 100644 index 0000000..8a944ea --- /dev/null +++ b/iOS/ChatClient/Controllers/MessagesViewController.swift @@ -0,0 +1,124 @@ +import UIKit +import MessengerKit + +struct User: MSGUser { + var displayName: String + var avatar: UIImage? + var isSender: Bool +} + +typealias SendMessageCallback = (MessageBoard, String) -> Void + +class MessagesViewController: MSGMessengerViewController { + + var sendMessageCallback: SendMessageCallback = { (_,_) in } + + // users in the room + private var myself: User? + private var others: [User] = [] + + // this holds all the messages to display + private var messages = [[MSGMessage]]() + private var nextMessageID = 1 + + // we are either in a room, or in private talk with another user + private var board: MessageBoard? = nil + + override func viewDidLoad() { + super.viewDidLoad() + dataSource = self + messageInputView.addTarget(self, action: #selector(sendMessage), for: .primaryActionTriggered) + } + + @objc func sendMessage() { + guard let board = board else { return } + sendMessageCallback(board, messageInputView.message) + } + + func configure(myName: String, board: MessageBoard, boardContents: [ChatMessage], sendMessage: @escaping SendMessageCallback) { + self.sendMessageCallback = sendMessage + self.board = board + self.messages = [] + self.myself = User(displayName: myName, avatar: nil, isSender: true) + for message in boardContents { + appendMessage(.text(message.text), from: message.user) + } + self.collectionView.reloadData() + switch board { + case .room(let roomName): + self.title = "Group chat in #\(roomName)" + case .user(let userName): + self.title = "Private chat with @\(userName)" + } + } + + func processServerMessage(_ serverMessage: ServerMessage) { + guard let board = board else { return } + switch serverMessage { + case .connected, .disconnected, .rooms, .users: + // chat display is not interested in these ones + break + + case .message(let inRoom, let username, let text): + if case .room(let room) = board, room == inRoom { + appendMessage(.text(text), from: username) + collectionView.reloadData() + } + + case .privateMessage(let from, let to, let text): + if case .user(let withUser) = board, withUser == from || withUser == to { + appendMessage(.text(text), from: from) + collectionView.reloadData() + } + } + } +} + +extension MessagesViewController { + private func appendMessage(_ messageBody: MSGMessageBody, from: String) { + let id = self.nextMessageID + self.nextMessageID += 1 + let user: User + if let myself = myself, myself.displayName == from { + user = myself + } else { + if let existing = others.first(where: { $0.displayName == from }) { + user = existing + } else { + user = User(displayName: from, avatar: nil, isSender: false) + others.append(user) + } + } + let message = MSGMessage(id: id, body: messageBody, user: user, sentAt: Date()) + + if var lastGroup = messages.last, let lastMessage = lastGroup.last, lastMessage.user.displayName == from { + lastGroup.append(message) + messages[messages.count-1] = lastGroup + } else { + messages.append([message]) + } + } + +} + +extension MessagesViewController: MSGDataSource { + func numberOfSections() -> Int { + return messages.count + } + + func numberOfMessages(in section: Int) -> Int { + return messages[section].count + } + + func message(for indexPath: IndexPath) -> MSGMessage { + return messages[indexPath.section][indexPath.item] + } + + func footerTitle(for section: Int) -> String? { + return "Just now" + } + + func headerTitle(for section: Int) -> String? { + return messages[section].first?.user.displayName + } +} diff --git a/iOS/ChatClient/Model/ChatClientService.swift b/iOS/ChatClient/Model/ChatClientService.swift new file mode 100644 index 0000000..8efe66b --- /dev/null +++ b/iOS/ChatClient/Model/ChatClientService.swift @@ -0,0 +1,119 @@ +import Foundation +import Network + +typealias MessageReceivedCallback = (ServerMessage) -> Void +typealias ChatMessage = (user: String, text: String) + +final class ChatClientService { + + // This is the connection we operate on, once established + private var connection: NWConnection? = nil + let serverEndpoint: NWEndpoint + + // Some internal stuff for us + let username: String + + // Some state we keep + private(set) var loggedIn = false + private(set) var messageBoards = [MessageBoard:[ChatMessage]]() + + // Configurable notification callback that fires when we receive a message from the server + var newMessageNotification: MessageReceivedCallback? = nil + + init(username: String, serverAddress: String, serverPort: Int) { + self.username = username + // TODO: prepare the endpoint that will connect to the server + } + + func send(command: ClientCommand) { + sendFramed(command: command) + } + + private func sendUnframed(command: ClientCommand) { + // TODO: send an unframed JSON packet to the server + } + + private func sendFramed(command: ClientCommand) { + // TODO: send a framed JSON packet to the server + } + + func connect() { + // TODO: setup the connection to the server + + setupConnectionStateHandler(connection) + + // TODO: start the connection and start listening to server messages + } + + func disconnect() { + connection?.cancel() + } + + private func setupConnectionStateHandler(_ connection: NWConnection) { + } + + private func readNextMessage(_ connection: NWConnection) { + readNextFramedMessage(connection) + } + + private func readNextUnframedMessage(_ connection: NWConnection) { + // TODO: Read a simple message from the connection. Note that this may turn to be unsafe + // in case of packet fragmentation + } + + private func readNextFramedMessage(_ connection: NWConnection) { + // TODO: Read message encoded on the server with `FramedMessageCodec`: + // 4 bytes header giving the contents of the packed, followed by the packet data + } + + func sendMessage(board: MessageBoard, message: String) { + switch board { + case .room(let room): + send(command: .message(room: room, text: message)) + + case .user(let toUser): + send(command: .privateMessage(username: toUser, text: message)) + } + } + + private func process(message: ServerMessage) { + print("Processing server message: \(message)") + switch message { + + case .connected: + loggedIn = true + + case .disconnected: + loggedIn = false + + case .rooms(let roomNames): + roomNames.forEach { + let board = MessageBoard.room($0) + if self.messageBoards[board] == nil { + self.messageBoards[board] = [] + } + } + + case .users(let users): + users.forEach { + let board = MessageBoard.user($0) + if self.messageBoards[board] == nil { + self.messageBoards[board] = [] + } + } + + case .message(let room, let username, let text): + let board = MessageBoard.room(room) + var entries = messageBoards[board] ?? [] + entries.append(ChatMessage(user: username, text: text)) + messageBoards[board] = entries + + case .privateMessage(let from, let to, let text): + let party = from == username ? to : from + let board = MessageBoard.user(party) + var entries = messageBoards[board] ?? [] + entries.append(ChatMessage(user: from, text: text)) + messageBoards[board] = entries + } + } +} diff --git a/iOS/ChatClient/Model/ChatEntry.swift b/iOS/ChatClient/Model/ChatEntry.swift new file mode 100644 index 0000000..fda2851 --- /dev/null +++ b/iOS/ChatClient/Model/ChatEntry.swift @@ -0,0 +1,7 @@ +import Foundation + +enum ChatEntry { + case message(ChatMessage) + case userJoined(String) + case userLeft(String) +} diff --git a/iOS/ChatClient/Model/MessageBoard.swift b/iOS/ChatClient/Model/MessageBoard.swift new file mode 100644 index 0000000..f6b1777 --- /dev/null +++ b/iOS/ChatClient/Model/MessageBoard.swift @@ -0,0 +1,47 @@ +import Foundation + +// A "message board" is either a chat root or a direct discussion with another user +enum MessageBoard { + case room(String) + case user(String) + + var displayName: String { + switch self { + case .room(let name), .user(let name): + return name + } + } + + var isRoom: Bool { + if case .room = self { + return true + } + return false + } +} + +extension MessageBoard: Equatable { + public static func ==(lhs: MessageBoard, rhs: MessageBoard) -> Bool { + switch (lhs, rhs) { + case (.room(let left), .room(let right)), (.user(let left), .user(let right)): + return left == right + default: + return false + } + } +} + +extension MessageBoard: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case .room(let roomName): roomName.hash(into: &hasher) + case .user(let userName): userName.hash(into: &hasher) + } + } +} + +extension Collection where Element == MessageBoard { + func sortedByDisplayName() -> [MessageBoard] { + return self.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending } + } +} diff --git a/iOS/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/iOS/ChatClient/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/iOS/ChatClient/Resources/Assets.xcassets/Contents.json b/iOS/ChatClient/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/iOS/ChatClient/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/iOS/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard b/iOS/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..bfa3612 --- /dev/null +++ b/iOS/ChatClient/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/ChatClient/Resources/Base.lproj/Main.storyboard b/iOS/ChatClient/Resources/Base.lproj/Main.storyboard new file mode 100644 index 0000000..79ecbb3 --- /dev/null +++ b/iOS/ChatClient/Resources/Base.lproj/Main.storyboard @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/ChatClient/Resources/Info.plist b/iOS/ChatClient/Resources/Info.plist new file mode 100644 index 0000000..76fe0e4 --- /dev/null +++ b/iOS/ChatClient/Resources/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UIStatusBarTintParameters + + UINavigationBar + + Style + UIBarStyleDefault + Translucent + + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/iOS/ChatClientTests/ChatClientTests.swift b/iOS/ChatClientTests/ChatClientTests.swift new file mode 100644 index 0000000..16157f6 --- /dev/null +++ b/iOS/ChatClientTests/ChatClientTests.swift @@ -0,0 +1,27 @@ + +import XCTest +@testable import ChatClient + +class ChatClientTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/iOS/ChatClientTests/Info.plist b/iOS/ChatClientTests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/iOS/ChatClientTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/iOS/Podfile b/iOS/Podfile new file mode 100644 index 0000000..2595b4a --- /dev/null +++ b/iOS/Podfile @@ -0,0 +1,11 @@ +platform :ios, '12.0' +project 'ChatClient.xcodeproj' + +target 'ChatClient' do + use_frameworks! + + # Ruby get the absolute path of the Common folder + # common_folder = File.expand_path("../Common", File.dirname(__FILE__)) + + pod 'MessengerKit', :git => 'https://github.com/steve228uk/MessengerKit.git' +end diff --git a/iOS/Podfile.lock b/iOS/Podfile.lock new file mode 100644 index 0000000..4b8cf53 --- /dev/null +++ b/iOS/Podfile.lock @@ -0,0 +1,21 @@ +PODS: + - MessengerKit (1.0.0) + +DEPENDENCIES: + - MessengerKit (from `https://github.com/steve228uk/MessengerKit.git`) + +EXTERNAL SOURCES: + MessengerKit: + :git: https://github.com/steve228uk/MessengerKit.git + +CHECKOUT OPTIONS: + MessengerKit: + :commit: 0aa7d371d8cd2527b7bb650cbbb05fd4e2229a9e + :git: https://github.com/steve228uk/MessengerKit.git + +SPEC CHECKSUMS: + MessengerKit: 98afa44728b8e12672ece6db7764433e72832bf7 + +PODFILE CHECKSUM: bd5ec7f3f5ce6705bb12bc27a0a89a4f9e20f644 + +COCOAPODS: 1.5.3