Jacob’s Tech Tavern

Jacob’s Tech Tavern

一款能告诉你何时获得 314159 的 2FA 应用

献给 2010 年代早期互联网瘾君子的独立项目

Jacob Bartlett Feb 19, 2024

这是一个非常有趣的项目:我不仅成功地触动了我热爱发现模式的极客大脑,而且还解决了一些巧妙的处理、线程和优化问题!

像所有在 2010 年代早期成年的边缘少年一样,我有点怀念像 4chan 这样的图片论坛的鼎盛时期。在纳粹毁掉一切之前,它们是早期互联网狂野西部的最后堡垒。

其中一个经典的梗是 GET,你会为正确预测你随机生成的帖子 ID 包含一个有趣的数字序列而感到无比自豪。

Check ‘em

现在,所有的普通人都长大了并找到了工作,我们最接近昔日魔法的时刻就是多因素身份验证码。

如果你知道,你就会知道

不得不使用你的银行、你的电子邮件或你的云服务重新验证的苦差事。 当你得到一个真正_漂亮的_数字,如 787000123450 时,心中会闪过一丝喜悦。

灵感来了。

这些 MFA 代码使用一种常见的算法,每 30 秒刷新一次。 我们只接触到 6 位数身份验证码中可能出现的 dubs、trips、quads、quints 和 sextuples 的一小部分。

我所有的独立项目一样,我有一个清晰的愿景,可以围绕它进行构建:

如果你的 2FA 应用程序每次出现酷炫数字时都告诉你呢?

我知道我该怎么做了。

如果你喜欢应用但讨厌阅读,现在就跳过并下载 Check 'em: The Based 2FA App

概念验证

我不需要太多移动部件来找出这是否可行。

最小可行产品

如果这个概念——在出现很酷的 2FA 数字时收到通知——成立,那么我可以将其变成一个真正的应用,具有以下几个关键功能:

我知道我正在做一些事情:我向 90% 的人解释这件事时,他们认为我是个白痴。 另外 10% 的人只看到了纯粹的光辉。

构建概念验证

TOTP

TOTP,或 基于时间的一次性密码 ,是一个非常简单的概念。 它是一个身份验证过程,使用两个输入:

该算法确定性地散列这两个输入,以创建你熟知的 6 位数代码。 这种散列算法非常典型,可以在 Apple 的 CryptoKit 中找到。 感谢我们在 Apple 论坛上的朋友们,这是完整的 TOTP 算法的全部内容:

// CodeGenerator.swift
private let secret = Data(base64Encoded: "AAAAAAAAAAAAAAAAAAAAAAAAAAA")!
func otpCode(date: Date = Date()) -> String {
  let digits = 6
  let period = TimeInterval(30)
  let counter = UInt64(date.timeIntervalSince1970 / period)
  let counterBytes = (0..<8).reversed().map { UInt8(counter >> (8 * $0) & 0xff) }
  let hash = HMAC<Insecure.SHA1>.authenticationCode(for: counterBytes, using: SymmetricKey(data: secret))
  let offset = Int(hash.suffix(1)[0] & 0x0f)
  let hash32 = hash
    .dropFirst(offset)
    .prefix(4)
    .reduce(0, { ($0 << 8) | UInt32($1) })
  let hash31 = hash32 & 0x7FFF_FFFF
  let pad = String(repeating: "0", count: digits)
  return String((pad + String(hash31)).suffix(digits))
}

为了确保它正常工作,我在我的 Google 帐户上设置了 2FA,并使用该算法在我的应用程序中显示了密钥。

顺便说一句,我得到一个非常好的代码来确认我的 2fa 设置 而且,就像魔法一样(经过一些烦人的 base32 到 base64 的转换),Google 接受了我的 2FA!

确认 2fa 代码 现在我们已经有了 2FA 的基本框架,我们可以实现概念验证的最后一部分:生成通知。

应用程序限制

我们的主要限制在于我们的移动设备。

我们实际上不能永远保持后台进程(如 2FA 生成)运行,并且 当然 不能在后端推送服务器上存储用户密钥。

因此,为了使这个概念可行,我们必须偷偷摸摸:预先计算未来的 2FA 代码,并安排在它们出现在现实生活中的时间交付。

此外,我们一次只能在 iOS 上安排 64 次推送,因此我们应该:

  1. 保存一两个通知,要求用户重新进入该应用程序。
  2. 通过点击通知来激励用户打开该应用程序,从而触发 2FA 代码的重新计算。

现在我们知道我们的 POC 将如何工作,让我们开始构建吧。

寻找我们的第一个 GET

让我们来装饰一下我们不起眼的 2FA 代码。

我们计划预先计算许多代码,然后实现某种正则表达式来检测每个代码是否是 GET——值得 “检查”。

我超级简单的 SwiftUI 视图可以方便地显示这些代码,使用 UICollectionView-backed List 来确保不错的性能(vanilla VStackScrollView 中,早在 10,000 个项目之前就会开始嘎吱作响!)。

// ContentView.swift
struct ContentView: View {
  
  var body: some View {
    List {
      ForEach(makeOTPs(), id: \.self) {
        Text($0)
          .fontDesign(.monospaced)
          .font(.title)
          .kerning(4)
      }
      .frame(maxWidth: .infinity)
    }
  }
    
  func makeOTPs() -> [String] {
    (0..<10_000).map {
      otpCode(increment: $0)
    }
  }
}

到目前为止看起来不错。

初始 2FA 代码列表 现在,我们可以添加一个简单的基于正则表达式的评估器来检查 trips——也就是说,TOTP 包含一个由三个匹配数字组成的序列,例如 120333

extension String {
  func checkThoseTrips() -> Bool {
    (try? /(\d)\1\1/.firstMatch(in: self)) != nil
  }
}

我们向我们的 Text 视图添加了一个 fontWeight 修饰符,以便在滚动时轻松检测这些 GET。

Text($0)
  .fontWeight($0.checkThoseTrips() ? .heavy : .light)

Et viola! Check those trips

检查那些 trips! 我们甚至可以对我们的正则表达式进行基本修改来检测神圣的 quads ——我将把它作为练习留给读者。

检查那些 quads!

一个毫无意义但有趣的观察

我们粗心的 ForEach 实现会导致一个警告:

ForEach<Array<String>, String, Text>: the ID 312678 occurs multiple 
times within the collection, this will give undefined results!

我们实际上得到了几十个这样的警告!

在这里,使用代码字符串作为视图标识是一个坏主意 由于我们生成了 10,000 个 OTP,因此极有可能有几个匹配项——这与 生日问题 相同,其中可能的匹配项对数超过一百万。

产生稀有的 GET

让我们开始计算一些有趣的代码。

这里的关键是预先计算以展望未来:TOTP 是密钥和日期输入的确定性哈希。 因此,我们可以输入一长串未来的日期,以确定你在什么时间看到什么 OTP 代码。

让我们调整到我们的 OTP 生成以返回代码和日期:

// TOTP.swift
struct OTP {
  let date: Date
  let code: String
}
func otpCode(date: Date = Date(), increment: Int = 0) -> OTP {
  let digits = 6
  let period = TimeInterval(30)
  let adjustedDate = date.addingTimeInterval(period * Double(increment))
  let counter = UInt64(adjustedDate.timeIntervalSince1970 / period)
  let counterBytes = (0..<8).reversed().map { UInt8(counter >> (8 * $0) & 0xff) }
  let hash = HMAC<Insecure.SHA1>.authenticationCode(for: counterBytes, using: SymmetricKey(data: secret))
  let offset = Int(hash.suffix(1)[0] & 0x0f)
  let hash32 = hash
    .dropFirst(offset)
    .prefix(4)
    .reduce(0, { ($0 << 8) | UInt32($1) })
  let hash31 = hash32 & 0x7FFF_FFFF
  let pad = String(repeating: "0", count: digits)
  let code = String((pad + String(hash31)).suffix(digits))
  return OTP(date: adjustedDate, code: code)
}

为了测试这一点,让我们生成大量的这些代码,并搜索 GET 的完整序列:quints

func interestingCodes() -> [OTP] {
  (0..<1_000_000)
    .map { otpCode(increment: $0) }
    .filter { $0.code.checkThoseQuints() }
}

在我的 M1 运行散列函数进行一些数字运算后——大约 30 秒——我们得到了一些非常可检查的 GET。

这……这太美了。 检查一下。

安排我们的通知

看到好的数字固然有趣,但如果不能在现实生活中真正使用 GET 进行实际身份验证,那么该应用程序的概念并不比随机数生成机器好。

现在我们知道有趣数字何时到达,我们想要排队一个推送通知,以便我们实时捕获该数字:

// NotificationScheduler.swift
private func createNotification(for otp: OTP) {
  let center = UNUserNotificationCenter.current()
  let content = UNMutableNotificationContent()
  content.title = "Quads GET!!"
  content.body = otp.code
  content.sound = UNNotificationSound.default
  let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: otp.date)
  let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
  let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
  center.add(request) { (error) in
    // ... 
  }
}

这些是在生成我们在视图中使用的 interestingCodes 后立即安排的。 不久之后,我一次收到了 2 个精彩的推送通知!

我仍然每次收到订阅者时都告诉我的妻子 当我确认此通知与现实中出现的数字相对应时,这变得更加令人兴奋!

在 2FA 应用程序本身中找到 quads 这个应用程序现在已经超越了随机数生成器:此代码确实可以用于登录我的 Google 帐户。

有趣性

为了确定不同类型的有趣数字,我们需要引入 有趣性 的概念。 这可能包括,但不限于,几种可能的数字序列:

这些类型的有趣数字可以枚举为……好吧,作为枚举案例,可以选择为我们生成的每个 OTP 创建。

// Interestingness.swift
enum Interestingness {
  
  case sexts
  case quints
  case quads
  init?(code: String) {
    if code.checkThoseSexts() {
      self = .sexts
    // ...
  var title: String {
    switch self {
    case .sexts: return "Sextuples GET!!!"
    // ...
  
  func body(code: String) -> String {
    switch self {
    case .sexts: return "Check those sexts: \(code)"
    // ...

我们使用的每个 checkThose 方法都包装一个不同的正则表达式,我们按照我们最关心的顺序运行它们——例如,sextuples 比 quads 稀有 100 倍。

经过一次迟来的重构之后,我们创建了我们的概念验证。 让我们回顾一下:

我将休息一下,玩几天这个应用程序。 我怀疑我可能掌握了一个很酷的应用程序的基础。

构建最小可行产品

我已经使用该应用程序几天了,这个精简的 POC 包含了我想法的核心。 我 非常 喜欢它。 我迫不及待地想第一次获得 sextuples。

现在是时候在概念的基础上添加一些实质内容并构建一个成熟的 2FA 应用程序了。 正如我之前所说,这实际上只需要 4 个主要新功能:

最后,一个非功能性要求:我需要做一些工作来优化非常慢的代码生成——也许使用批处理或本地持久性。

人机界面指南

我无意对设计做任何花哨的事情——标准的 Apple List 视图组件将在开箱即用的情况下使我走得很远,符合 HIG

让我们保持我们的 UX 简洁明了:我知道该功能主要存在于推送通知中;并且非常完美。 这意味着将二维码扫描仪和设置隐藏在工具栏按钮后面,这些按钮显示模态流程。

我的 MVP 的基本 List UI

扫描 2FA 密钥

几个开源库将为我节省大量时间来完成典型的任务。CodeScanner 提供简单的 SwiftUI 二维码扫描,以及 KeychainAccess 轻松将这些 2FA 帐户密钥存储在钥匙串中。

此扫描仪库使用相机访问将二维码转换为易于解析的 URL,例如:

otpauth://totp/Google%3Atest%40gmail.com?secret=bv7exx7sltbcqffec1qyxscueydwsu5h&issuer=Google

现在,我们可以轻松地将我们的帐户添加到该应用程序中!

Check 'em:现在带有一个二维码扫描仪!

选择你喜欢的数字

使用 SwiftUI @AppStorage,以及 List 和一些 Toggle,我们可以轻松构建一个用户设置屏幕。

初始设置屏幕 我在 onDisappear 中使用了一个闭包来告诉父视图再次开始数字运算并重新安排通知。 这是我批量处理所有内容的最简单方法,而不是每次切换更改时都运行昂贵的计算。

// CodeView.swift
var body: some View { 
  // ... 
  .sheet(isPresented: $showSettings) {
    SettingsView(onDisappear: {
      viewModel.recomputeNotifications()
    })
  }
}

迟来的客户研究

听着,我是一个独立开发者,我可以在构建过程中做到这一点!

我决定下载一些其他的 2FA 应用程序,看看是否有任何我可以复制的想法。 坦率地说,我预计会有一个非常拥挤且竞争激烈的应用程序市场,但其中一些确实很糟糕。

2FA 应用程序领域:大多非常糟糕 说真的,其中 50% 以上在你可以使用它们之前就抛出了一个非常激进的付费墙……当有非常好的免费选项时。

难道没有人为了好玩而制作应用程序了吗?

尽管有这种付费墙动物园,但我还是设法记下了一些可以借鉴的好主意。

我复制的想法列表

多个 2FA 帐户

当然,这对于拥有多个帐户的任何人来说都非常重要。 更多帐户也意味着更多获得稀有 GET 的机会!

更新我的钥匙串代码,现在我们可以扫描多个二维码,持久化我们的帐户数据(包括密钥),并且它们可以完美地用于登录我的各种帐户!

我还实现了正确的内置 List 功能,因此我们可以滑动删除我们不再需要的代码。

在进行我的竞争对手分析时,我发现 Google Authenticator 保留了我多年前的所有 2FA 代码,这些代码是我在上一个 iPhone 上添加的!

我当时意识到我在数据层犯了两个错误。

首先,将我们的钥匙串同步到 iCloud 意味着帐户会出现在你所有其他的 Apple 设备上。 使用 Keychain Access 库可以轻松实现这一点:

// KeychainManager.swift 
self.keychain = Keychain().synchronizable(true)

其次,我患有闪亮物体综合症:在我急于使用 SwiftData 作为持久层时,我只使用钥匙串来存储密钥,并通过新的框架持久化其余的帐户元数据。

这意味着我无法在任何其他设备上获取我的帐户——密钥本身毫无用处!

因此,我意识到我必须将整个 Account 放在钥匙串上。

我的新方法将二维码 URL 完整地保存在钥匙串上。 现在,Account 对象本身是短暂的; 每次应用程序加载时都会从 URL 重新计算。

这意味着 Account 可以出现在你登录的任何 iDevice 上! 这种短暂的方法巧妙地一石二鸟。 现在我们在需要加载时从钥匙串中获取它们时使用 Accounts:

// AccountManager.swift 
func fetchAccounts() throws -> [Account] {
  try KeychainManager.shared.fetchAll()
    .compactMap { createAccount(from: $0) }
}
private func createAccount(from urlString: String) -> Account? {
  guard let url = URL(string: urlString),
     let account = SecretURLParser.shared.account2FA(from: url) else {
    return nil
  }
  return account
}

我的代码有点混乱,但最终的应用程序总共大约有 1,500 行代码——当我想写一篇关于 DI 的文章时,我将使用适当的 DI 框架重建它。 如果你是一名初级工程师,请不要在家里尝试!

我做了很多通用的编码工作来改进 UI 并很好地重构代码,但在我的开发过程中也有一些非常有趣的宝石。

查找帐户图标

这在很大程度上是一个锦上添花的事情,但最好的开源应用程序也做了同样的事情,所以我认为我 至少 必须和它一样好。

幸运的是,有一个鲜为人知的 Google API,它会在网站上抓取 FavIcon,并允许你以多种分辨率下载它们。

我如何找出网站? 我发现通过简单地使用二维码上的 issuer 属性并尝试使用 .com 获得了相当不错的结果。

struct FavIcon {
  
  let url: URL
  
  init(issuer: String) {
    let domain = "\(issuer).com"
    let url = URL(string: "https://www.google.com/s2/favicons?sz=128&domain=\(domain)")!
    self.url = url
  }
}

在这里,我使用了 CachedAsyncImage 库,以便在图标上获得极快的加载性能。

每个 2FA 帐户的图像 我还添加了一个 Metal 着色器来处理背景移除,并使图标稍微突出一些。

这是 SwiftUI 视图扩展:

// View+ColorEffect.swift
import SwiftUI
extension View {
  
  func eraseBackground(backgroundColor: Color = Color(uiColor: UIColor.secondarySystemBackground)) -> some View {
    modifier(EraseBackgroundShader(backgroundColor: backgroundColor))
  }
}
struct EraseBackgroundShader: ViewModifier {
  
  let backgroundColor: Color
  
  func body(content: Content) -> some View {
    content
      .colorEffect(ShaderLibrary.eraseBackground(
        .color(backgroundColor)
      ))
  }
}

当然,这是 MSL 着色器代码:

#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
[[ stitchable ]]
half4 eraseBackground(
  float2 position,
  half4 color,
  half4 backgroundColor
) {
  
  if (color.r >= 0.95 && color.g >= 0.95 && color.b >= 0.95) {
    return backgroundColor;
  }
  return color;
}

它们看起来就是这样的。 它们还不错,但不是很棒。

Metal 着色器用于移除图标上的白色背景 我已经开始过度设计了。 让我们先把这件事搁置一边,看看我们稍后会怎么想。

润色 UI

作为一个基本的 2fa 应用程序,它现在运行得相当好。

谁会想到为了领先于大多数人,我只需要不设置一个非常激进的付费墙(每周 4.99 美元? 说真的?!)

在完成了对计时、基本 UI 和数据存储的一些样板软件开发工作之后,它现在确实运行得非常好——坚持使用基本的 SwiftUI 组件是确保东西 “正常工作” * 的绝佳方法。

* 并且有助于使一切都可访问!

我还实现了一些我通过竞争对手研究发现的不错的 QoL 功能,例如点击复制。

我利用了可访问性工具(如 @ScaledMetricViewThatFits)来确保应用程序无论你的视觉需求如何都能正常工作。 通过紧密结合 Apple 的基本 SwiftUI 组件和颜色,我甚至可以免费获得浅色模式。

// AccountView.swift
@ScaledMetric(relativeTo: .largeTitle) private var iconSize: CGFloat = 36
private var icon: some View {
  CachedAsyncImage(url: FavIcon(issuer: account.issuer).url, content: {
    $0
      .resizable()
      .aspectRatio(contentMode: .fit)
    
  }, placeholder: {
    Text(String(account.issuer.first?.uppercased() ?? account.name.first?.uppercased() ?? ""))
      .font(.largeTitle)
      .monospaced()
  })
  .frame(width: iconSize, height: iconSize, alignment: .center)
}
private var code: some View {
  ViewThatFits {
    HStack(alignment: .center, spacing: 16) {
      codeText
    }
    VStack(alignment: .leading, spacing: 4) {
      codeText
    }
  }
}

以最大的可访问性字体大小检查 'em

使应用程序更有趣(性)

为了提高真正的核心价值主张,我实现了更多有趣性选项:

其中一些是实现起来很有趣的小型 leet 代码谜题,一些是烦人的正则表达式,而一些则非常简单。

func checkThatCounting() -> Bool {
  let characters = Array(self)
  for i in 1..<characters.count {
    if let prevDigit = Int(String(characters[i - 1])),
      let currentDigit = Int(String(characters[i])),
      currentDigit != prevDigit + 1 {
      return false
    }
  }
  return true
}
func checkThatPalindrome() -> Bool {
  self == String(self.reversed())
}
func checkThoseRepeatedThrees() -> Bool {
  self.prefix(3) == self.suffix(3)
}
func checkThoseHunderedThousands() -> Bool {
  suffix(5) == "00000"
}

概率论

现在我已经更新了设置 UI,以便你可以按稀有度(常见、稀有和超稀有)或按类型(例如重复、常数、序列或整数)进行排序。

在设置菜单上切换 我如何计算每个稀有度级别的概率?

对于像 012345 这样的完美计数序列,在一百万种可能的数字组合中只有 6 种可能的序列(最多 567890)。

30 秒乘以 1 百万种组合,除以 6 种可能的序列,意味着对于每个帐户,你可能只会期望平均每 5 百万秒发生一次完美的计数序列——也就是说,平均 每 58 天

这非常超稀有。

但是,像 123321 这样的回文有 1000 个可能的 3 序列数字组成。 这意味着你可能会 每 0.34 天 平均看到它们! 常见得多。

在中间,像重复的二(例如 141414)这样的数字有 100 个可能的数字(0099),因此它们平均 每 3.5 天 发生一次。 所以,非常稀有,但不是 稀有。

其中一些序列(如 quads)的编号计算有点困难,因此更简单的方法是生成数千万个 OTP,并计算每种有趣性的发生率,以了解它们的相对频率。

提高性能

该应用程序可以非常快速地处理 64 个有趣的 2FA 代码,但仅当我启用了所有常见的 Interestingness 时。 当我只需要超稀有的 GET 时,处理需要很长时间。

我需要调用分块——在处理数百万个潜在的 OTP 时,只要发现有效的有趣代码,就立即返回并安排通知。

我的老朋友 Combine 框架为我们提供了一个简洁的解决方案!

// CodeGenerator.swift 
var codeSubject = PassthroughSubject<OTP, Never>()
func generateCodes(accounts: [Account]) { 
  // ...
  codeSubject.send(otp)
}

我还使用了一些 Task,以便我们可以取消并重新启动计算,以防用户在处理过程中更改其设置。 分离这些任务可确保我们的加密和字符串分析操作的繁重处理不会占用 UI 线程。

// CodeViewModel.swift
private var otpComputationTask: Task<Void, Never>?
private var notificationSchedulingTask: Task<Void, Never>?
func recomputeNotifications() {
  handleNotificationScheduling()
  handleOTPComputation()
}
 
private func handleNotificationScheduling() {
  notificationSchedulingTask?.cancel()
  notificationSchedulingTask = Task.detached(priority: .high) {
    guard await NotificationScheduler.shared.isAuthorized() else { return }
    NotificationScheduler.shared.cancelNotifications()
    for await (code, count) in CodeGenerator.shared.codeSubject.values {
      try? await NotificationScheduler.shared.scheduleNotification(for: code)
    }
  }
}
private func handleOTPComputation() {
  let accounts = accounts
  otpComputationTask?.cancel()
  otpComputationTask = Task.detached(priority: .high) {
    guard await NotificationScheduler.shared.isAuthorized() else { return }
    CodeGenerator.shared.generateCodes(accounts: accounts)
  }
}

现在,调度运行得相当顺利,以序列形式输出,而不是单个大块!

Scheduled repeatedTwos: 292929 @ 2024-02-25 23:33:30 +0000
Scheduled repeatedTwos: 878787 @ 2024-02-26 06:03:30 +0000
Scheduled quints: 666660 @ 2024-02-26 10:54:00 +0000
Scheduled quints: 255555 @ 2024-02-26 21:11:00 +0000
Scheduled repeatedTwos: 606060 @ 2024-02-26 23:27:00 +0000
Scheduled sexts: 666666 @ 2024-04-16 23:22:00 +0000
Scheduled boltzmannConstant: 141023 @ 2024-04-19 02:05:00 +0000
Scheduled counting: 012345 @ 2024-04-20 04:51:30 +0000
Scheduled planksConstant: 661034 @ 2024-04-20 05:38:00 +0000

应用程序图标

这是困扰我的那个。 我迫切地想使用真正的 check 'em meme 作为应用程序图标。 这简直太完美了。

Check ‘em!” 然而,我的好朋友指出,我们在 Lionsgate 电影公司的好朋友可能会感到有点好斗。

但我必须拥有它!

也许还有希望:

Lionsgate 的 许可 页面 与你不同,我对美国的版权制度有信心。

用于请求使用电影剧照的已部分完成的表格 现在我们玩等待游戏。

16 天后…… 板球声。

我已经对美国的版权制度失去了所有信心。 该死的,Bob Iger,公平使用到底发生了什么?!

这是我从 DALL-E 3 获得的最佳效果。它的手指数量不正确,并且手背的方向也不正确,但在尝试设计更好的东西几个小时后,我不得不接受它。

检查 'em:徽标 DALL-E 真的 不喜欢画手背。 我试过了。

最后的润色

概念得到了验证。 该应用程序运行良好! 在我们向世界展示 Check 'em 的乐趣之前,是时候进行一些润色和宠物功能了。

我创建了一个 TODO 列表——新功能和错误修复——我可以在发布第一个版本之前实现。

// High priority -
// TODO: - Add ordering as a query item to the stored URL in the keychain
// TODO: - Haptic buzz on refresh
// TODO: - Only request push notifications when they have entered the Settings Screen
// TODO: - Add settings link to enable notifications 
// TODO: - Bug - Ignore scanned duplicates in the view model accounts - don't append scans to accounts if it's already there
// TODO: - Cancel processing tasks when opening Settings view
// TODO: - Push notification deep links to an app review prompt, when the GET is still present
// TODO: - Ultra-rare GETs not being sent?? Can't make them happen locally in simulator, but quints are fine - they appear to be queued
// TODO: - Bug - Progress view doesn't appear on the second load
// TODO: - Bug - Ignore scanned duplicates in the view model accounts - don't append scans to accounts if it's already there
// TODO: - Bug - There's a bug where the percentage fluctuates up and down when there are 2 concurrent calculations
// TODO: - Add TipKit to QR and Settings
// Low priority -
// TODO: - Use @SceneStorage for state restoration; so we aren't waiting ages for the keychain operations
// TODO: - Look back/forwards one code 
// TODO: - Create a "collection" screen using deep links - collecting the seen GETs as stored items (with a dictionary on the keychain)

当然,由于我没有看到产品经理的身影,我立即开始处理优先级最低的任务:使用深度链接构建集合——我不希望我稀有的 GET 被浪费!