vapor and apns

Sending Apple push notifications from a Vapor web app turns out to be pretty easy using the VaporJWT package.

import Foundation  
import CLibreSSL  
import Vapor  
import VaporJWT

public class APNSService {  
  public enum LaunchError: Error {
    case loadAuthKeyFailed
    case setSecretFailed
    case setTokenFailed
  }

  fileprivate static let topic = "<your.app.bundle.id>"
  fileprivate static let teamID = "<your team id>"
  fileprivate static let keyID = "<your key id>"
#if Xcode
  fileprivate static let apnsServer = "api.development.push.apple.com"
#else
  fileprivate static let apnsServer = "api.push.apple.com"
#endif
  fileprivate static let tokenUpdateInterval = TimeInterval(50 * 60)
  fileprivate static let tokenQueue = DispatchQueue(label: "token", attributes: .concurrent)

  fileprivate let secret: String
  fileprivate var prevToken: String = ""
  fileprivate var prevTokenDate = Date(timeIntervalSince1970: 0)

  fileprivate init() throws {
    var pKey = EVP_PKEY_new()
    let fp = fopen("</path/to/your/.p8 file>", "r")

    guard fp != nil else {
      throw LaunchError.loadAuthKeyFailed
    }

    PEM_read_PrivateKey(fp, &pKey, nil, nil)

    fclose(fp)

    let ecKey = EVP_PKEY_get1_EC_KEY(pKey)

    EC_KEY_set_conv_form(ecKey, POINT_CONVERSION_UNCOMPRESSED)

    guard let privateKeyBigNum = BN_bn2hex(EC_KEY_get0_private_key(ecKey)),
          let privateKeyHex = String.init(validatingUTF8: privateKeyBigNum),
          let privateKeyData = Data(hexString:  "00" + privateKeyHex) else {
      throw LaunchError.setSecretFailed
    }

    self.secret = privateKeyData.base64EncodedString()

    guard !self.token().isEmpty else {
      throw LaunchError.setTokenFailed
    }
  }

  public func sendNotification(to deviceToken: String, alert: String?, badge: Int?, sound: String?, payload: [String: Node]) {
    guard let url = URL(string: "https://\(APNSService.apnsServer)/3/device/\(deviceToken)") else {
      print("invalid url for device token \(deviceToken)")
      return
    }

    var apsDict: [String: Node] = [:]

    if let alert = alert {
      apsDict["alert"] = Node(alert)
    }

    if let badge = badge {
      apsDict["badge"] = Node(badge)
    }

    if let sound = sound {
      apsDict["sound"] = Node(sound)
    }

    var bodyDict = payload

    bodyDict["aps"] = Node(apsDict)

    guard let json = try? JSON(node: bodyDict),
          let bytes = try? json.makeBytes() else {
      print("failed to create json for \(bodyDict)")
      return
    }

    var request = URLRequest(url: url)

    request.httpMethod = "POST"
    request.httpBody = Data(bytes)

    request.addValue("0", forHTTPHeaderField: "apns-expiration")
    request.addValue("10", forHTTPHeaderField: "apns-priority")
    request.addValue(APNSService.topic, forHTTPHeaderField: "apns-topic")

    request.addValue("bearer \(self.token())", forHTTPHeaderField: "Authorization")

    let task = URLSession.shared.dataTask(with: request) { data, response, error in
      if let error = error {
        print("send push notification failed with error \(error)")
      }

      if let response = response as? HTTPURLResponse {
        if response.statusCode < 200 || response.statusCode > 299 {
          let reason = String(data: data ?? Data(), encoding: .utf8) ?? "nil"
          print("send push notification failed with status code \(response.statusCode), reason \(reason)")
        }
      }
    }

    task.resume()
  }

  fileprivate func token() -> String {
    let now = Date()
    var prevToken = ""
    var prevTokenDate = Date()

    APNSService.tokenQueue.sync() {
      prevToken = self.prevToken
      prevTokenDate = self.prevTokenDate
    }

    if !prevToken.isEmpty && now.timeIntervalSince(prevTokenDate) < APNSService.tokenUpdateInterval {
      return prevToken
    }

    guard let jwt = try? JWT(additionalHeaders: [KeyID(APNSService.keyID)],
                             payload: Node([IssuerClaim(APNSService.teamID), IssuedAtClaim()]),
                             encoding: Base64URLEncoding(),
                             signer: ES256(encodedKey: self.secret)),
          let token = try? jwt.createToken() else {
      return prevToken
    }

    APNSService.tokenQueue.sync(flags: .barrier) {
      self.prevToken = token
      self.prevTokenDate = now
    }

    return token
  }
}

fileprivate struct KeyID: Header {  
  static let name = "kid"

  var node: Node

  init(_ keyID: String) {
    node = Node(keyID)
  }
}

pro git

From the Hacker News discussion of Purposes, Concepts, Misfits, and a Redesign of Git.

the app store parabola

Lots of fake five-star reviews, lots of unhappy one-star reviews, very little in the middle. I wonder what percentage of app ratings look like this.

mnmlly mnml

Enter Andrew Kim, at the time a minimalist young gun. Kim's site is paradoxically called "Minimally Minimal." In other words, according to Kim, the site is the least minimal it could possibly be. But this is the opposite of the case, as the site is actually quite minimal.

Eli Schiff is my favorite grumpy old man.

Ma’agalim

Fantastic animation, needs to be seen full-screen.