First version
This commit is contained in:
parent
046c5a4f25
commit
6994a66883
245
Sources/App/Log.swift
Normal file
245
Sources/App/Log.swift
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: Public API
|
||||||
|
|
||||||
|
func log(_ message: String, file: String = #file, line: Int = #line) {
|
||||||
|
Log.log(info: message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(error message: String, file: String = #file, line: Int = #line) {
|
||||||
|
Log.log(error: message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(warning message: String, file: String = #file, line: Int = #line) {
|
||||||
|
Log.log(warning: message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(info message: String, file: String = #file, line: Int = #line) {
|
||||||
|
Log.log(info: message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(debug message: String, file: String = #file, line: Int = #line) {
|
||||||
|
Log.log(debug: message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Log {
|
||||||
|
|
||||||
|
enum Level: Int, Comparable {
|
||||||
|
case debug = 1
|
||||||
|
case info = 2
|
||||||
|
case warning = 3
|
||||||
|
case error = 4
|
||||||
|
case none = 5
|
||||||
|
|
||||||
|
var tag: String {
|
||||||
|
switch self {
|
||||||
|
case .debug: return "DEBUG"
|
||||||
|
case .info: return "INFO"
|
||||||
|
case .warning: return "WARN"
|
||||||
|
case .error: return "ERROR"
|
||||||
|
case .none: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .debug: return "Debug"
|
||||||
|
case .info: return "Infos"
|
||||||
|
case .warning: return "Warnungen"
|
||||||
|
case .error: return "Fehler"
|
||||||
|
case .none: return "Kein Logging"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func < (lhs: Level, rhs: Level) -> Bool {
|
||||||
|
lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static var logLevels = [String : Level]()
|
||||||
|
|
||||||
|
private static var fileHandle: FileHandle?
|
||||||
|
|
||||||
|
private(set) static var path: URL?
|
||||||
|
|
||||||
|
/// The date formatter for the logging timestamps
|
||||||
|
private static let df: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateStyle = .short
|
||||||
|
df.timeStyle = .short
|
||||||
|
df.locale = Locale(identifier: "de")
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Prevent any initializations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Public API
|
||||||
|
|
||||||
|
static func log(error message: String, file: String = #file, line: Int = #line) {
|
||||||
|
log(message, level: .error, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func log(warning message: String, file: String = #file, line: Int = #line) {
|
||||||
|
log(message, level: .warning, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func log(info message: String, file: String = #file, line: Int = #line) {
|
||||||
|
log(message, level: .info, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func log(debug message: String, file: String = #file, line: Int = #line) {
|
||||||
|
log(message, level: .debug, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func log(raw message: String) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The global log level.
|
||||||
|
|
||||||
|
Used when no specific log level is set for a file via `LogLevel.logLevel()`
|
||||||
|
*/
|
||||||
|
static var globalLevel: Level = .debug
|
||||||
|
|
||||||
|
/**
|
||||||
|
Remove the custom log level for a file, reverting it to the `globalLevel`
|
||||||
|
|
||||||
|
Best called without arguments within the file to modify: `Log.clearLogLevel()`
|
||||||
|
*/
|
||||||
|
static func defaultLogLevel(file: String = #file) {
|
||||||
|
logLevels[name(from: file)] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Set a custom log level for a file, ignoring the global default.
|
||||||
|
|
||||||
|
Example: `Log.set(logLevel = .debug)`
|
||||||
|
*/
|
||||||
|
static func set(logLevel: Level, file: String = #file) {
|
||||||
|
logLevels[name(from: file)] = logLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Set the file to log to.
|
||||||
|
*/
|
||||||
|
static func set(logFile: URL) {
|
||||||
|
fileHandle?.closeFile()
|
||||||
|
if !FileManager.default.fileExists(atPath: logFile.path) {
|
||||||
|
do {
|
||||||
|
try "New log started.\n".data(using: .utf8)!.write(to: logFile)
|
||||||
|
} catch {
|
||||||
|
print("Failed to start logging to \(logFile.path): \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let f = try FileHandle(forWritingTo: logFile)
|
||||||
|
path = logFile
|
||||||
|
fileHandle = f
|
||||||
|
print("Log file set to \(logFile.path)")
|
||||||
|
} catch {
|
||||||
|
print("No file handle to write log: \(error)")
|
||||||
|
fileHandle = nil
|
||||||
|
path = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Close the log file.
|
||||||
|
|
||||||
|
Subsequent logging event will no longer be written to the log file.
|
||||||
|
Set a new log file by calling `Log.set(logFile:)`
|
||||||
|
*/
|
||||||
|
static func close() {
|
||||||
|
fileHandle?.closeFile()
|
||||||
|
fileHandle = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Clear all data from the log file.
|
||||||
|
|
||||||
|
The log file will remain active, to close it permanently call `Log.close()`
|
||||||
|
*/
|
||||||
|
static func clear() {
|
||||||
|
guard let f = fileHandle, let p = path else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.closeFile()
|
||||||
|
try? FileManager.default.removeItem(at: p)
|
||||||
|
fileHandle = try? FileHandle(forWritingTo: p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the full text from the log file.
|
||||||
|
*/
|
||||||
|
static func fullText() -> String? {
|
||||||
|
guard let u = path else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? String(contentsOf: u)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the full data from the log file.
|
||||||
|
*/
|
||||||
|
static func data() -> Data? {
|
||||||
|
guard let f = fileHandle, let p = path else {
|
||||||
|
print("No log file set to get data")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f.closeFile()
|
||||||
|
defer {
|
||||||
|
do {
|
||||||
|
fileHandle = try FileHandle(forWritingTo: p)
|
||||||
|
} catch {
|
||||||
|
fileHandle = nil
|
||||||
|
path = nil
|
||||||
|
print("Failed to create log file handle: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try Data(contentsOf: p)
|
||||||
|
} catch {
|
||||||
|
print("Failed to get log data: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private helper
|
||||||
|
|
||||||
|
/// Get the pure file name for a file path from `#file`
|
||||||
|
fileprivate static func name(from file: String) -> String {
|
||||||
|
file.components(separatedBy: "/").last!.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the pure file name tag if the specified log level is high enough
|
||||||
|
fileprivate static func tagIf(logLevel: Level, isSufficientFor file: String) -> String? {
|
||||||
|
let tag = name(from: file)
|
||||||
|
let requiredLevel = logLevels[tag] ?? globalLevel
|
||||||
|
if logLevel >= requiredLevel {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func log(_ message: String, level: Level, file: String, line: Int) {
|
||||||
|
guard let tag = tagIf(logLevel: level, isSufficientFor: file) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let date = df.string(from: Date())
|
||||||
|
let msg = "[\(date)][\(level.tag)][\(tag):\(line)] \(message)"
|
||||||
|
log(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func log(_ msg: String) {
|
||||||
|
print(msg)
|
||||||
|
if let f = fileHandle, let data = (msg + "\n").data(using: .utf8) {
|
||||||
|
f.write(data)
|
||||||
|
try? f.synchronize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
Sources/App/ServerConfiguration.swift
Normal file
24
Sources/App/ServerConfiguration.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerConfiguration: Codable {
|
||||||
|
|
||||||
|
let logPath: String
|
||||||
|
|
||||||
|
let privateKeyFilePath: String
|
||||||
|
|
||||||
|
let teamIdentifier: String
|
||||||
|
|
||||||
|
let keyIdentifier: String
|
||||||
|
|
||||||
|
let topic: String
|
||||||
|
|
||||||
|
let messageTitle: String
|
||||||
|
|
||||||
|
let messageBody: String
|
||||||
|
|
||||||
|
let buttonPin: Int
|
||||||
|
|
||||||
|
let bounceTime: Double
|
||||||
|
|
||||||
|
let tokens: [String]
|
||||||
|
}
|
12
Sources/App/ServerStatus.swift
Normal file
12
Sources/App/ServerStatus.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ServerStatus: Int, Codable {
|
||||||
|
|
||||||
|
case running = 0
|
||||||
|
|
||||||
|
case somePushTokensFailed = 1
|
||||||
|
|
||||||
|
case pushFailed = 2
|
||||||
|
|
||||||
|
case starting = 3
|
||||||
|
}
|
13
Sources/App/TokenUpload.swift
Normal file
13
Sources/App/TokenUpload.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TokenUpload: Codable {
|
||||||
|
|
||||||
|
let currentToken: Data
|
||||||
|
|
||||||
|
let previousToken: Data?
|
||||||
|
|
||||||
|
enum CodingKeys: Int, CodingKey {
|
||||||
|
case currentToken = 1
|
||||||
|
case previousToken = 2
|
||||||
|
}
|
||||||
|
}
|
135
Sources/App/configure.swift
Normal file
135
Sources/App/configure.swift
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import Vapor
|
||||||
|
import SwiftyGPIO
|
||||||
|
import APNSwift
|
||||||
|
import NIO
|
||||||
|
import Crypto
|
||||||
|
|
||||||
|
private struct Payload: Codable {}
|
||||||
|
|
||||||
|
private var apnsConfiguration: APNSClientConfiguration!
|
||||||
|
private var apnsNotification: APNSAlertNotification<Payload>!
|
||||||
|
private let apnsEventGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||||
|
private let apnsRequestEncoder = JSONEncoder()
|
||||||
|
private let apnsResponseDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
// configures your application
|
||||||
|
public func configure(_ app: Application) throws {
|
||||||
|
|
||||||
|
let resourcesFolderUrl = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
||||||
|
let configUrl = resourcesFolderUrl.appendingPathComponent("config.json")
|
||||||
|
|
||||||
|
let config: ServerConfiguration
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: configUrl)
|
||||||
|
config = try JSONDecoder().decode(from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load config: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard configureAPNS(config) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configureGPIO(config)
|
||||||
|
|
||||||
|
// register routes
|
||||||
|
try routes(app)
|
||||||
|
|
||||||
|
serverStatus = .running
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension JSONDecoder {
|
||||||
|
|
||||||
|
func decode<T>(from data: Data) throws -> T where T: Decodable {
|
||||||
|
try self.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(keyAtPath path: String) -> P256.Signing.PrivateKey? {
|
||||||
|
do {
|
||||||
|
guard let key = try P256.Signing.PrivateKey.loadFrom(filePath: path) else {
|
||||||
|
print("Failed to read key \(path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
} catch {
|
||||||
|
print("Failed to load key \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAPNS(_ config: ServerConfiguration) -> Bool {
|
||||||
|
guard let key = load(keyAtPath: config.privateKeyFilePath) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let authentication = APNSClientConfiguration.AuthenticationMethod.jwt(
|
||||||
|
privateKey: key,
|
||||||
|
keyIdentifier: config.keyIdentifier,
|
||||||
|
teamIdentifier: config.teamIdentifier)
|
||||||
|
|
||||||
|
|
||||||
|
apnsConfiguration = APNSClientConfiguration(
|
||||||
|
authenticationMethod: authentication,
|
||||||
|
environment: .sandbox)
|
||||||
|
|
||||||
|
let alert = APNSAlertNotificationContent(
|
||||||
|
title: .raw(config.messageTitle),
|
||||||
|
body: .raw(config.messageBody))
|
||||||
|
|
||||||
|
apnsNotification = APNSAlertNotification(
|
||||||
|
alert: alert,
|
||||||
|
expiration: .none,
|
||||||
|
priority: .immediately,
|
||||||
|
topic: config.topic,
|
||||||
|
payload: Payload(),
|
||||||
|
sound: .default)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureGPIO(_ config: ServerConfiguration) {
|
||||||
|
let gpio = RaspberryGPIO(
|
||||||
|
name: "GPIO\(config.buttonPin)",
|
||||||
|
id: config.buttonPin,
|
||||||
|
baseAddr: 0x7E000000)
|
||||||
|
|
||||||
|
gpio.direction = .IN
|
||||||
|
gpio.pull = .down
|
||||||
|
gpio.bounceTime = config.bounceTime
|
||||||
|
gpio.onChange { _ in
|
||||||
|
log(info: "Push detected")
|
||||||
|
sendPush()
|
||||||
|
}
|
||||||
|
log(info: "GPIO \(config.buttonPin) configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendPush() {
|
||||||
|
Task(priority: .userInitiated) {
|
||||||
|
let client = APNSClient(
|
||||||
|
configuration: apnsConfiguration,
|
||||||
|
eventLoopGroupProvider: .shared(apnsEventGroup),
|
||||||
|
responseDecoder: apnsResponseDecoder,
|
||||||
|
requestEncoder: apnsRequestEncoder)
|
||||||
|
log(info: "Client created")
|
||||||
|
do {
|
||||||
|
for token in knownTokens {
|
||||||
|
log(info: "Sending push to \(token.prefix(6))...")
|
||||||
|
try await client.sendAlertNotification(
|
||||||
|
apnsNotification,
|
||||||
|
deviceToken: token,
|
||||||
|
deadline: .now() + .seconds(10))
|
||||||
|
log(info: "Sent push to \(token.prefix(6))...")
|
||||||
|
}
|
||||||
|
} catch let error as APNSError {
|
||||||
|
print(error)
|
||||||
|
} catch {
|
||||||
|
log(error: error.localizedDescription)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
log("Closing client")
|
||||||
|
try client.syncShutdown()
|
||||||
|
} catch {
|
||||||
|
log(error: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
Sources/App/routes.swift
Normal file
36
Sources/App/routes.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Vapor
|
||||||
|
import BinaryCodable
|
||||||
|
|
||||||
|
private let responseEncoder = BinaryEncoder()
|
||||||
|
private let requestDecoder = BinaryDecoder()
|
||||||
|
|
||||||
|
var serverStatus: ServerStatus = .starting
|
||||||
|
|
||||||
|
var knownTokens = Set<String>()
|
||||||
|
|
||||||
|
func routes(_ app: Application) throws {
|
||||||
|
|
||||||
|
app.post("token") { req async throws -> HTTPResponseStatus in
|
||||||
|
guard let data = req.body.data?.allReadableBytes else {
|
||||||
|
return .badRequest
|
||||||
|
}
|
||||||
|
let tokenUpload: TokenUpload = try requestDecoder.decode(from: data)
|
||||||
|
if let oldToken = tokenUpload.previousToken {
|
||||||
|
knownTokens.remove(oldToken.hex)
|
||||||
|
}
|
||||||
|
knownTokens.insert(tokenUpload.currentToken.hex)
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("status") { req async throws -> Response in
|
||||||
|
let data = try responseEncoder.encode(serverStatus)
|
||||||
|
return Response(status: .ok, body: .init(data: data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ByteBuffer {
|
||||||
|
|
||||||
|
var allReadableBytes: Data? {
|
||||||
|
getData(at: 0, length: readableBytes)
|
||||||
|
}
|
||||||
|
}
|
9
Sources/Run/main.swift
Normal file
9
Sources/Run/main.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import App
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
var env = try Environment.detect()
|
||||||
|
try LoggingSystem.bootstrap(from: &env)
|
||||||
|
let app = Application(env)
|
||||||
|
defer { app.shutdown() }
|
||||||
|
try configure(app)
|
||||||
|
try app.run()
|
16
Tests/AppTests/AppTests.swift
Normal file
16
Tests/AppTests/AppTests.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
@testable import App
|
||||||
|
import XCTVapor
|
||||||
|
|
||||||
|
final class AppTests: XCTestCase {
|
||||||
|
|
||||||
|
func testHelloWorld() throws {
|
||||||
|
let app = Application(.testing)
|
||||||
|
defer { app.shutdown() }
|
||||||
|
try configure(app)
|
||||||
|
|
||||||
|
try app.test(.GET, "status") { res in
|
||||||
|
XCTAssertEqual(res.status, .ok)
|
||||||
|
XCTAssertEqual(res.body.getData(at: 0, length: 1), Data([3]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user