Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove Starscream Dependency + Rewrite WebSocket Wrapper #10

Merged
merged 10 commits into from
Apr 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 22 additions & 35 deletions Swiftcord.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,13 @@
"version": null
}
},
{
"package": "Starscream",
"repositoryURL": "https://github.com/daltoniam/Starscream.git",
"state": {
"branch": null,
"revision": "df8d82047f6654d8e4b655d1b1525c64e1059d21",
"version": "4.0.4"
}
},
{
"package": "swiftui-cached-async-image",
"repositoryURL": "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
"state": {
"branch": null,
"revision": "6c3847d5a94538213c0ce2c6a42e36a3c5d99042",
"version": "2.0.0"
"revision": "eeb1565d780d1b75d045e21b5ca2a1e3650b0fc2",
"version": "2.1.0"
}
}
]
Expand Down
18 changes: 18 additions & 0 deletions Swiftcord/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// AppDelegate.swift
// Swiftcord
//
// Created by Vincent on 4/14/22.
//

import Foundation
import AppKit

class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
/// Close the app when there are no more open windows
/// This is mostly to fix bugs occuring when windows are
/// reopened after all windows are closed
return true
}
}
13 changes: 5 additions & 8 deletions Swiftcord/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import SwiftUI
import CoreData
import os

struct CustomHorizontalDivider: View {
var body: some View {
Expand All @@ -32,7 +33,7 @@ struct ContentView: View {
@EnvironmentObject var gateway: DiscordGateway
@EnvironmentObject var state: UIState

let log = Logger(tag: "ContentView")
private let log = Logger(category: "ContentView")

var body: some View {
HStack(spacing: 0) {
Expand Down Expand Up @@ -121,22 +122,18 @@ struct ContentView: View {
if tk != nil {
state.attemptLogin = false
let _ = Keychain.save(key: "token", data: tk!)
gateway.initWSConn() // Reconnect to the socket
gateway.connect() // Reconnect to the socket
}
})
.onAppear {
let _ = gateway.onStateChange.addHandler { (connected, resuming, error) in
log.d("Connection state change: \(connected), \(resuming)")
}
let _ = gateway.onAuthFailure.addHandler {
state.attemptLogin = true
state.loadingState = .initial
log.d("User isn't logged in, attempting login")
log.debug("User isn't logged in, attempting login")
}
let _ = gateway.onEvent.addHandler { (evt, d) in
switch evt {
case .ready:
state.loadingState = .gatewayConn
case .ready: state.loadingState = .gatewayConn
default: break
}
}
Expand Down
230 changes: 39 additions & 191 deletions Swiftcord/DiscordAPI/Gateway/DiscordGateway.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,219 +6,67 @@
//

import Foundation
import Starscream
import os

class DiscordGateway: WebSocketDelegate, ObservableObject {
class DiscordGateway: ObservableObject {
// Events
let onStateChange = EventDispatch<(Bool, Bool, GatewayCloseCode?)>()
let onEvent = EventDispatch<(GatewayEvent, GatewayData)>()
let onAuthFailure = EventDispatch<Void>()

// Config
let missedACKTolerance: Int
let connTimeout: Double

// WebSocket object
private(set) var socket: WebSocket!
private var socket: RobustWebSocket!

// State
@Published private(set) var isConnected = false
@Published private(set) var isReconnecting = false // Attempt to resume broken conn
@Published private(set) var doNotResume = false // Cannot resume
@Published private(set) var missedACK = 0
@Published private(set) var seq: Int? = nil // Sequence int of latest received payload
@Published private(set) var viability = true
@Published private(set) var connTimes = 0
private(set) var authFailed = false {
didSet {
if authFailed { onAuthFailure.notify() }
cache = CachedState() // Clear the cache
}
}
@Published private(set) var sessionID: String? = nil
// State cache
@Published var cache: CachedState = CachedState()

// Logger
let log = Logger(tag: "DiscordGateway")
private var evtListenerID: EventDispatch.HandlerIdentifier? = nil,
authFailureListenerID: EventDispatch.HandlerIdentifier? = nil

// Dispatch queue
let queue: DispatchQueue

func incMissedACK() { missedACK += 1 }
// Logger
private let log = Logger(category: "DiscordGateway")

func initWSConn() {
authFailed = false

var request = URLRequest(url: URL(string: apiConfig.gateway)!)
request.timeoutInterval = connTimeout
socket = WebSocket(request: request)
socket.delegate = self
socket.callbackQueue = queue

log.i("Attempting connection to Gateway: \(apiConfig.gateway)")
socket.connect()

// If connection isn't connected after timeout, try again
let curConnCnt = connTimes
DispatchQueue.main.asyncAfter(deadline: .now() + connTimeout) {
if !self.isConnected && self.connTimes == curConnCnt {
self.log.w("Connection timed out, trying to reconnect")
self.isReconnecting = false
self.attemptReconnect()
}
}
public func logout() {
log.debug("Logging out on request")
let _ = Keychain.remove(key: "token")
// socket.disconnect(closeCode: 1000)
socket.close(code: .normalClosure)
// authFailed = true
onAuthFailure.notify()
}

// Attempt reconnection with resume after 1-5s as per spec
func attemptReconnect(resume: Bool = true, overrideViability: Bool = false) {
log.d("Resume called")
if authFailed {
log.e("Not reconnecting - auth failed")
return
}
// Kill connection if connection is still active
if isConnected { self.socket.forceDisconnect() }
guard viability || overrideViability, !isReconnecting else { return }
isReconnecting = true
if !resume { doNotResume = true }
let reconnectAfter = 1000 + Int(Double(4000) * Double.random(in: 0...1))
log.i("Reconnecting in \(reconnectAfter)ms")
DispatchQueue.main.asyncAfter(
deadline: .now() +
.milliseconds(reconnectAfter)
) {
self.log.d("Attempting reconnection now")
self.log.d("Can resume: \(!self.doNotResume)")
self.initWSConn() // Recreate WS object because sometimes it gets stuck in a "not gonna reconnect" state
}
public func connect() {
socket.open()
}

// Log out the user - delete token from keychain and disconnect connection
func logOut() {
log.d("Logging out...")
let _ = Keychain.remove(key: "token")
socket.disconnect(closeCode: 1000)
authFailed = true
private func handleEvt(type: GatewayEvent, data: GatewayData) {
switch (type) {
case .ready:
guard let d = data as? ReadyEvt else { return }
self.cache.guilds = d.guilds
self.cache.user = d.user
log.info("Gateway ready")
default: break
}
onEvent.notify(event: (type, data))
log.info("Dispatched event <\(type.rawValue, privacy: .public)>")
}

init(connectionTimeout: Double = 5, maxMissedACK: Int = 3) {
missedACKTolerance = maxMissedACK
connTimeout = connectionTimeout
queue = DispatchQueue(label: "com.swiftcord.gatewayQueue", qos: .background, attributes: .concurrent, autoreleaseFrequency: .workItem, target: .global(qos: .background))
initWSConn()
}

// MARK: Low level receive handler
func didReceive(event: WebSocketEvent, client: WebSocket) {
switch (event) {
case .connected(_):
log.i("Gateway Connected")
isReconnecting = false
isConnected = true
connTimes += 1
onStateChange.notify(event: (isConnected, isReconnecting, nil))
case .disconnected(_, let c):
isConnected = false
guard let code = GatewayCloseCode(rawValue: Int(c)) else {
log.e("Unknown close code: \(c)")
return
}
// Check if code isn't an unrecoverable code, then attempt resume
if code != .authenthicationFail { attemptReconnect() }
log.w("Gateway Disconnected: \(code)")
switch code {
case .authenthicationFail: authFailed = true
default: log.w("Unhandled gateway close code:", code)
}
onStateChange.notify(event: (isConnected, isReconnecting, code))
case .text(let string): self.handleIncoming(received: string)
case .error(let error):
isConnected = false
attemptReconnect()
onStateChange.notify(event: (isConnected, isReconnecting, nil))
log.e("Connection error: \(String(describing: error))")
case .cancelled:
isConnected = false
onStateChange.notify(event: (isConnected, isReconnecting, nil))
log.d("Connection cancelled")
case .binary(_): break // Won't receive binary
case .ping(_): break // Don't care
case .pong(_): break // Don't care
case .viabilityChanged(let viability):
// If viability is false, reconnection will most likely fail
log.d("Viability changed: \(viability)")
if viability && !self.viability {
// We should reconnect since connection is now viable
attemptReconnect(resume: true, overrideViability: true)
}
self.viability = viability
case .reconnectSuggested(_):
log.d("Reconnect suggested!")
socket = RobustWebSocket()
evtListenerID = socket.onEvent.addHandler { [weak self] (t, d) in
self?.handleEvt(type: t, data: d)
}
authFailureListenerID = socket.onAuthFailure.addHandler(handler: { [weak self] in
self?.onAuthFailure.notify()
})
}

func handleIncoming(received: String) {
guard let decoded = try? JSONDecoder().decode(GatewayIncoming.self, from: received.data(using: .utf8)!)
else { return }

DispatchQueue.main.async {
if (decoded.s != nil) { self.seq = decoded.s } // Update sequence
deinit {
if let evtListenerID = evtListenerID {
let _ = socket.onEvent.removeHandler(handler: evtListenerID)
}

switch (decoded.op) {
case .heartbeat:
// Immediately send heartbeat as requested
log.d("Send heartbeat by server request")
sendToGateway(op: .heartbeat, d: GatewayHeartbeat())
case .hello:
// Start heartbeating and send identify
guard let d = decoded.d as? GatewayHello else { return }
initHeartbeat(interval: d.heartbeat_interval)

// Check if we're attempting to and can resume
if isReconnecting && !doNotResume && sessionID != nil && seq != nil {
log.i("Attempting resume")
guard let resume = getResume(seq: seq!, sessionID: sessionID!)
else { return }
sendToGateway(op: .resume, d: resume)
}
else {
log.d("Sending identify:", isConnected, !doNotResume, sessionID ?? "No sessionID", seq ?? -1)
// Send identify
DispatchQueue.main.async {
self.seq = nil // Clear sequence #
self.isReconnecting = false // Resuming failed/not attempted
}
guard let identify = getIdentify() else {
log.d("Token not in keychain")
authFailed = true
socket.disconnect(closeCode: 1000)
return
}
sendToGateway(op: .identify, d: identify)
}
case .heartbeatAck: DispatchQueue.main.async { self.missedACK = 0 }
case .dispatchEvent:
guard let type = decoded.t else { return }
guard let data = decoded.d else { return }
switch (type) {
case .ready:
guard let d = data as? ReadyEvt else { return }
DispatchQueue.main.async {
self.doNotResume = false
self.sessionID = d.session_id
self.cache.guilds = d.guilds
self.cache.user = d.user
}
log.i("Gateway ready")
default: log.i("Dispatched event <\(type)>")
}
onEvent.notify(event: (type, data))
case .invalidSession:
// Check if the session can be resumed
let shouldResume = (decoded.primitiveData as? Bool) ?? false
attemptReconnect(resume: shouldResume)
default: log.w("Unimplemented opcode: \(decoded.op)")
if let authFailureListenerID = authFailureListenerID {
let _ = socket.onAuthFailure.removeHandler(handler: authFailureListenerID)
}
}
}
42 changes: 0 additions & 42 deletions Swiftcord/DiscordAPI/Gateway/GatewayHeartbeat.swift

This file was deleted.

Loading