feat(account): add QR invitation poster and 8-character invitation codes

- Invite opens an appearance-matched poster with the app icon, inviter name,
  QR code, and short code; tap for a full-brightness QR view; save (add-only
  Photos access), share, or copy the link. Mac shows the poster in a popover
  and can save it as PNG.
- Accounts get look-alike-free 8-character codes. "I have a code" accepts a
  code, link, or pasted message during the server-defined binding window.
- Permanently rejected redemptions show a specific message and drop the
  stored code instead of retrying on every sign-in.
This commit is contained in:
2026-10-20 20:06:36 +02:00
parent eaacc35cf7
commit 03bfbbe08b
26 changed files with 2269 additions and 86 deletions
@@ -5,6 +5,7 @@
import AuthenticationServices
@testable import OSGKeyboard
@testable import OSGKeyboardHostSupport
import XCTest
final class AccountCenterViewModelTests: XCTestCase {
@@ -34,6 +35,185 @@ final class AccountCenterViewModelTests: XCTestCase {
}
}
func testPastedInputAcceptsCodeLinkAndChatMessage() {
let inputs = [
validCode,
" \(validCode)\n",
"https://osglab.com/i/\(validCode)",
"https://osglab.com/i/\(validCode)?source=poster",
"快来试试 OSGKeyboard:https://osglab.com/i/\(validCode)",
"邀请你加入https://osglab.com/i/\(validCode),一起领积分",
"Join me: <https://osglab.com/i/\(validCode)>"
]
for input in inputs {
XCTAssertEqual(
ReferralUniversalLink.code(fromUserInput: input),
validCode,
"Failed to extract from \(input)"
)
}
}
func testPastedInputRejectsForeignLinksAndPlainText() {
let inputs = [
"",
"hello",
"https://example.com/i/\(validCode)",
"http://osglab.com/i/\(validCode)",
"https://osglab.com/i/short",
String(validCode.dropLast())
]
for input in inputs {
XCTAssertNil(
ReferralUniversalLink.code(fromUserInput: input),
"Unexpectedly accepted \(input)"
)
}
}
func testTypedInputAcceptsShortCodeInAnyCaseWithOrWithoutHyphen() {
let inputs = [
"K7M2Q9XA",
"k7m2-q9xa",
" K7M2-Q9XA \n",
"邀请码K7M2-Q9XA",
"我的邀请码:K7M2-Q9XA,快来"
]
for input in inputs {
XCTAssertEqual(
ReferralUniversalLink.code(fromUserInput: input),
"K7M2Q9XA",
"Failed to extract from \(input)"
)
}
}
func testTypedInputRejectsAmbiguousOrMalformedShortCodes() {
let inputs = [
"K7M2Q9X",
"K7M2Q9XAB",
"K7M-2Q9XA",
"K0M2Q9XA",
"KOM2Q9XA",
"K1M2Q9XA",
"KLM2Q9XA"
]
for input in inputs {
XCTAssertNil(
ReferralUniversalLink.code(fromUserInput: input),
"Unexpectedly accepted \(input)"
)
}
}
func testInvitationLinkWinsOverShortCodeLikeWordsInTheSameMessage() {
// "HEXAGRAM" uses only short-code characters.
let input = "HEXAGRAM https://osglab.com/i/\(validCode)"
XCTAssertEqual(ReferralUniversalLink.code(fromUserInput: input), validCode)
}
func testShortCodeDisplayAddsHyphen() {
XCTAssertEqual(ReferralShortCode.display("K7M2Q9XA"), "K7M2-Q9XA")
XCTAssertEqual(ReferralShortCode.display("bogus"), "bogus")
}
@MainActor
func testPendingStoreKeepsShortCodesForRedemptionAfterSignIn() throws {
let suiteName = "PendingReferralShortCode.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsPendingReferralCodeStore(defaults: defaults)
store.save("K7M2Q9XA")
XCTAssertEqual(store.code, "K7M2Q9XA")
}
func testBindingWindowFollowsServerDeadlineWhenPresent() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let deadline = Date(timeIntervalSince1970: 1_700_003_600)
let profile = ReferralProfile(
code: try makeReferralCode(),
binding: nil,
bindingDeadline: deadline
)
XCTAssertTrue(ReferralBindingWindow.isOpen(for: account, profile: profile, now: deadline))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: deadline.addingTimeInterval(1)
))
}
func testBindingWindowFallsBackToSevenDaysWithoutServerDeadline() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let profile = ReferralProfile(code: try makeReferralCode(), binding: nil)
let start = Date(timeIntervalSince1970: 1_700_000_000)
XCTAssertTrue(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: start.addingTimeInterval(6 * 24 * 60 * 60)
))
}
func testBindingWindowClosesOnceBound() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let binding = try JSONDecoder().decode(
ReferralBinding.self,
from: Data(#"{"inviterUserId":"x"}"#.utf8)
)
let profile = ReferralProfile(
code: try makeReferralCode(),
binding: binding,
bindingDeadline: .distantFuture
)
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: Date(timeIntervalSince1970: 1_700_000_001)
))
}
private func makeReferralCode() throws -> ReferralCode {
ReferralCode(
code: validCode,
inviteURL: try XCTUnwrap(URL(string: "https://osglab.com/i/\(validCode)")),
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
shortCode: "K7M2Q9XA"
)
}
func testBindingWindowClosesSevenDaysAfterRegistration() {
let registeredAt: Int64 = 1_700_000_000
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: registeredAt
)
let start = Date(timeIntervalSince1970: TimeInterval(registeredAt))
XCTAssertTrue(ReferralBindingWindow.isOpen(
for: account,
now: start.addingTimeInterval(6 * 24 * 60 * 60)
))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
now: start.addingTimeInterval(7 * 24 * 60 * 60)
))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: AccountSession(accountID: UUID(), createdAtEpochSeconds: 0),
now: start
))
}
func testReferralSummaryCountsEveryServerStatus() {
let referrals = [
makeReferral(status: .pending),
@@ -439,6 +619,151 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(coordinator.operationErrorKey, "account.error.redeemReferral")
}
@MainActor
func testPermanentRedemptionRejectionsDropTheStoredCode() async {
let cases: [(AccountAPIError, String)] = [
(
.server(statusCode: 404, code: "http_error", message: "Referral code does not exist"),
"account.referral.redeem.error.invalid"
),
(
.server(statusCode: 422, code: "http_error", message: "Referral binding window has expired"),
"account.referral.redeem.error.expired"
),
(
.server(statusCode: 409, code: "http_error", message: "Self-referral is not allowed"),
"account.referral.redeem.error.self"
),
(
.server(
statusCode: 409,
code: "http_error",
message: "This account is already bound to another inviter"
),
"account.referral.redeem.error.alreadyBound"
),
(
.conflict("This account is not eligible for referral rewards"),
"account.referral.redeem.error.ineligible"
)
]
for (error, expectedKey) in cases {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
redemptionError: error
)
let store = InMemoryPendingReferralStore(code: validCode)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
XCTAssertNil(store.code, "Kept code after \(error)")
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertEqual(coordinator.operationErrorKey, expectedKey)
}
}
@MainActor
func testPastedInvitationWhileSignedOutIsSavedForSignIn() async {
let service = AccountServiceSpy(restoredSession: nil)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let invalid = await coordinator.submitReferralInput("not an invitation")
let saved = await coordinator.submitReferralInput(
"Join me https://osglab.com/i/\(validCode)"
)
XCTAssertEqual(invalid, .invalidInput)
XCTAssertEqual(saved, .savedForSignIn)
XCTAssertEqual(store.code, validCode)
XCTAssertEqual(coordinator.pendingReferralCode, validCode)
XCTAssertFalse(coordinator.consumeAccountCenterPresentation())
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [])
}
@MainActor
func testPastedInvitationWhileSignedInRedeemsImmediately() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let outcome = await coordinator.submitReferralInput(validCode)
XCTAssertEqual(outcome, .redeemed)
XCTAssertNil(store.code)
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertNil(coordinator.operationErrorKey)
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [validCode])
}
@MainActor
func testPastedInvitationRejectionIsReturnedInsteadOfAlerting() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
redemptionError: .server(
statusCode: 422,
code: "http_error",
message: "Referral binding window has expired"
)
)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let outcome = await coordinator.submitReferralInput(validCode)
XCTAssertEqual(outcome, .failed(messageKey: "account.referral.redeem.error.expired"))
XCTAssertNil(store.code)
XCTAssertNil(coordinator.operationErrorKey)
}
@MainActor
func testSignOutClearsRemoteAccountStateOnly() async {
let account = AccountSession(
@@ -800,6 +1125,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let centerSnapshot: AccountCenterSnapshot?
private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool
private let redemptionError: AccountAPIError?
private let shouldFailAccountRefresh: Bool
private let shouldFailSignOut: Bool
private let signOutDelayNanoseconds: UInt64
@@ -822,6 +1148,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
snapshot: AccountCenterSnapshot? = nil,
refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false,
redemptionError: AccountAPIError? = nil,
shouldFailAccountRefresh: Bool = false,
shouldFailSignOut: Bool = false,
signOutDelayNanoseconds: UInt64 = 0,
@@ -836,6 +1163,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
centerSnapshot = snapshot
self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption
self.redemptionError = redemptionError
self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.shouldFailSignOut = shouldFailSignOut
self.signOutDelayNanoseconds = signOutDelayNanoseconds
@@ -925,6 +1253,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
if shouldFailRedemption {
throw AccountServiceSpyError.failed
}
if let redemptionError {
throw redemptionError
}
redeemed.append(code)
}
+123
View File
@@ -0,0 +1,123 @@
// InvitePosterTests.swift
// OSGKeyboardTests
//
// The poster is only useful if a camera can read it back, so these tests
// decode the generated QR code instead of only checking that an image exists.
import CoreImage
@testable import OSGKeyboard
import OSGKeyboardShared
import XCTest
final class InvitePosterTests: XCTestCase {
private let inviteURL = URL(
string: "https://osglab.com/i/Abcdefghij_1234567890-?source=server"
)!
func testQRCodeDecodesToTheExactInvitationLink() throws {
let image = try XCTUnwrap(InviteQRCode.makeImage(for: inviteURL))
XCTAssertEqual(decodedMessages(in: image), [inviteURL.absoluteString])
}
@MainActor
func testPosterExportsAtFixedSizeAndStaysScannable() throws {
let poster = try XCTUnwrap(
InvitePosterRenderer.render(
inviteURL: inviteURL,
inviterName: nil,
language: .chinese,
colorScheme: .light
)
)
XCTAssertEqual(poster.width, 1080)
XCTAssertEqual(poster.height, 1440)
XCTAssertEqual(decodedMessages(in: poster), [inviteURL.absoluteString])
XCTAssertGreaterThan(cornerLuminance(of: poster), 0.95)
}
@MainActor
func testDarkPosterWithShortCodeAndLongNameStaysScannable() throws {
let poster = try XCTUnwrap(
InvitePosterRenderer.render(
inviteURL: inviteURL,
shortCode: "K7M2-Q9XA",
inviterName: "一位用户名比较长但仍然需要完整显示的朋友",
language: .chinese,
colorScheme: .dark
)
)
XCTAssertEqual(poster.width, 1080)
XCTAssertEqual(poster.height, 1440)
XCTAssertEqual(decodedMessages(in: poster), [inviteURL.absoluteString])
XCTAssertGreaterThan(
darkPixelFraction(
in: poster,
crop: CGRect(x: 300, y: 600, width: 480, height: 500)
),
0.15
)
// The graphite paper carries a subtle daylight wash, but remains
// distinctly dark rather than approaching the warm-paper surface.
XCTAssertLessThan(cornerLuminance(of: poster), 0.18)
}
private func decodedMessages(in image: CGImage) -> [String] {
let detector = CIDetector(
ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
)
return (detector?.features(in: CIImage(cgImage: image)) ?? [])
.compactMap { ($0 as? CIQRCodeFeature)?.messageString }
}
private func cornerLuminance(of image: CGImage) -> Double {
guard let corner = image.cropping(to: CGRect(x: 0, y: 0, width: 1, height: 1)) else {
return -1
}
var pixel = [UInt8](repeating: 0, count: 4)
let context = CGContext(
data: &pixel,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
context?.draw(corner, in: CGRect(x: 0, y: 0, width: 1, height: 1))
return (0.2126 * Double(pixel[0])
+ 0.7152 * Double(pixel[1])
+ 0.0722 * Double(pixel[2])) / 255
}
private func darkPixelFraction(in image: CGImage, crop: CGRect) -> Double {
guard let cropped = image.cropping(to: crop) else { return 0 }
let width = 120
let height = 120
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return 0
}
context.interpolationQuality = .none
context.draw(cropped, in: CGRect(x: 0, y: 0, width: width, height: height))
let darkCount = (0..<(width * height)).count { index in
let offset = index * 4
return Int(pixels[offset])
+ Int(pixels[offset + 1])
+ Int(pixels[offset + 2]) < 3 * 100
}
return Double(darkCount) / Double(width * height)
}
}
@@ -37,6 +37,59 @@ final class ReferralProfileTests: XCTestCase {
XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL)
XCTAssertNil(profile.code.campaignID)
XCTAssertNotNil(profile.binding)
XCTAssertNil(profile.code.shortCode)
XCTAssertNil(profile.bindingDeadline)
}
func testReferralProfileDecodesShortCodeAndBindingDeadlineAndSurvivesCache() throws {
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
"createdAt": "2026-08-20T12:34:56Z",
"shortCode": "K7M2Q9XA"
},
"bindingDeadline": "2026-08-27T12:34:56Z"
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
let cached = try JSONDecoder().decode(
ReferralProfile.self,
from: JSONEncoder().encode(profile)
)
XCTAssertEqual(profile.code.shortCode, "K7M2Q9XA")
XCTAssertEqual(profile.code.displayShortCode, "K7M2-Q9XA")
XCTAssertEqual(
profile.bindingDeadline,
ISO8601DateFormatter().date(from: "2026-08-27T12:34:56Z")
)
XCTAssertEqual(cached, profile)
}
func testMalformedShortCodeIsDroppedWithoutRejectingTheProfile() throws {
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
"createdAt": "2026-08-20T12:34:56Z",
"shortCode": "0OIL0OIL"
},
"bindingDeadline": "not a date"
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
XCTAssertNil(profile.code.shortCode)
XCTAssertNil(profile.bindingDeadline)
}
func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws {
@@ -0,0 +1,81 @@
// ScreenBrightnessBoostTests.swift
// OSGKeyboardTests
//
// Whatever order holds and scene changes arrive in, the user's own
// brightness must come back once nothing needs the boost.
@testable import OSGKeyboard
import XCTest
@MainActor
final class ScreenBrightnessBoostTests: XCTestCase {
private final class FakeScreen: ScreenBrightnessControlling {
var brightness: CGFloat
init(brightness: CGFloat) { self.brightness = brightness }
}
func testHoldRaisesBrightnessAndReleaseRestoresIt() {
let screen = FakeScreen(brightness: 0.4)
let boost = ScreenBrightnessBoost { screen }
boost.acquire()
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.4)
}
func testOverlappingHoldsKeepTheOriginalLevel() {
let screen = FakeScreen(brightness: 0.3)
let boost = ScreenBrightnessBoost { screen }
// A new view appears before the previous one disappears.
boost.acquire()
boost.acquire()
boost.release()
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.3)
}
func testBackgroundRestoresAndForegroundReapplies() {
let screen = FakeScreen(brightness: 0.5)
let boost = ScreenBrightnessBoost { screen }
boost.acquire()
boost.setInForeground(false)
XCTAssertEqual(screen.brightness, 0.5)
boost.setInForeground(true)
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.5)
}
func testUnbalancedReleaseLeavesBrightnessAlone() {
let screen = FakeScreen(brightness: 0.6)
let boost = ScreenBrightnessBoost { screen }
boost.release()
boost.acquire()
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.6)
}
func testMissingScreenIsRetriedOnTheNextChange() {
var screen: FakeScreen?
let boost = ScreenBrightnessBoost { screen }
boost.acquire()
screen = FakeScreen(brightness: 0.2)
boost.setInForeground(true)
XCTAssertEqual(screen?.brightness, 1)
boost.release()
XCTAssertEqual(screen?.brightness, 0.2)
}
}