Stabilize iOS/watchOS/tvOS apps and add cross-platform audit remediation
This commit is contained in:
2
SharedCore/.gitignore
vendored
Normal file
2
SharedCore/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
35
SharedCore/Package.swift
Normal file
35
SharedCore/Package.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SharedCore",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.watchOS(.v9),
|
||||
.tvOS(.v16),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "SharedCore",
|
||||
targets: ["SharedCore"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SharedCore"
|
||||
),
|
||||
.testTarget(
|
||||
name: "SharedCoreiOSTests",
|
||||
dependencies: ["SharedCore"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SharedCoreWatchOSTests",
|
||||
dependencies: ["SharedCore"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "SharedCoreTVOSTests",
|
||||
dependencies: ["SharedCore"]
|
||||
)
|
||||
]
|
||||
)
|
||||
6
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
6
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public enum AppNotifications {
|
||||
public static let createdNewWorkout = Notification.Name("CreatedNewWorkout")
|
||||
}
|
||||
|
||||
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public struct BoundedFIFOQueue<Element> {
|
||||
private var storage: [Element] = []
|
||||
public let maxCount: Int
|
||||
|
||||
public init(maxCount: Int) {
|
||||
self.maxCount = max(1, maxCount)
|
||||
}
|
||||
|
||||
public var count: Int {
|
||||
storage.count
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
storage.isEmpty
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public mutating func enqueue(_ element: Element) -> Int {
|
||||
var droppedCount = 0
|
||||
if storage.count >= maxCount {
|
||||
droppedCount = storage.count - maxCount + 1
|
||||
storage.removeFirst(droppedCount)
|
||||
}
|
||||
storage.append(element)
|
||||
return droppedCount
|
||||
}
|
||||
|
||||
public mutating func dequeueAll() -> [Element] {
|
||||
let elements = storage
|
||||
storage.removeAll()
|
||||
return elements
|
||||
}
|
||||
|
||||
public mutating func clear() {
|
||||
storage.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public enum RuntimeSeverity: String, Sendable {
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
public struct RuntimeEvent: Sendable {
|
||||
public let severity: RuntimeSeverity
|
||||
public let message: String
|
||||
public let metadata: [String: String]
|
||||
public let timestamp: Date
|
||||
|
||||
public init(severity: RuntimeSeverity, message: String, metadata: [String: String], timestamp: Date = Date()) {
|
||||
self.severity = severity
|
||||
self.message = message
|
||||
self.metadata = metadata
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public final class RuntimeReporter {
|
||||
public typealias Sink = @Sendable (RuntimeEvent) -> Void
|
||||
|
||||
public static let shared = RuntimeReporter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.werkout.sharedcore", category: "runtime")
|
||||
private let lock = NSLock()
|
||||
private var sink: Sink?
|
||||
|
||||
private init() {}
|
||||
|
||||
public func setSink(_ sink: Sink?) {
|
||||
lock.lock()
|
||||
self.sink = sink
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
public func recordError(_ message: String, metadata: [String: String] = [:]) {
|
||||
record(.error, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
public func recordWarning(_ message: String, metadata: [String: String] = [:]) {
|
||||
record(.warning, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
public func recordInfo(_ message: String, metadata: [String: String] = [:]) {
|
||||
record(.info, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
private func record(_ severity: RuntimeSeverity, message: String, metadata: [String: String]) {
|
||||
let flattenedMetadata = metadata
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
|
||||
let logMessage = flattenedMetadata.isEmpty ? message : "\(message) | \(flattenedMetadata)"
|
||||
|
||||
switch severity {
|
||||
case .info:
|
||||
logger.info("\(logMessage, privacy: .public)")
|
||||
case .warning:
|
||||
logger.warning("\(logMessage, privacy: .public)")
|
||||
case .error:
|
||||
logger.error("\(logMessage, privacy: .public)")
|
||||
}
|
||||
|
||||
lock.lock()
|
||||
let sink = self.sink
|
||||
lock.unlock()
|
||||
|
||||
sink?(RuntimeEvent(severity: severity, message: message, metadata: metadata))
|
||||
}
|
||||
}
|
||||
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
public enum TokenSecurity {
|
||||
public static let defaultRotationWindow: TimeInterval = 60 * 60
|
||||
private static let authPrefixes = ["token", "bearer"]
|
||||
|
||||
// Basic high-entropy hex token detector for accidental commits.
|
||||
private static let tokenRegex = try? NSRegularExpression(pattern: "\\b[a-fA-F0-9]{32,}\\b")
|
||||
|
||||
public static func containsPotentialHardcodedToken(in text: String) -> Bool {
|
||||
guard let tokenRegex else {
|
||||
return false
|
||||
}
|
||||
let range = NSRange(location: 0, length: text.utf16.count)
|
||||
return tokenRegex.firstMatch(in: text, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
public static func isRedactedToken(_ token: String?) -> Bool {
|
||||
guard let token else { return false }
|
||||
let upper = token.uppercased()
|
||||
return upper.contains("REDACTED") || upper.contains("YOUR_TOKEN") || upper.contains("PLACEHOLDER")
|
||||
}
|
||||
|
||||
public static func sanitizeToken(_ token: String?) -> String? {
|
||||
guard let rawToken = token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
rawToken.isEmpty == false else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalized = normalizeAuthPrefix(rawToken)
|
||||
guard normalized.isEmpty == false,
|
||||
isRedactedToken(normalized) == false else {
|
||||
return nil
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
public static func jwtExpiration(_ token: String) -> Date? {
|
||||
let segments = token.split(separator: ".")
|
||||
guard segments.count == 3 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let payloadSegment = String(segments[1])
|
||||
guard let payloadData = base64URLDecode(payloadSegment),
|
||||
let object = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
|
||||
let exp = object["exp"] as? TimeInterval else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Date(timeIntervalSince1970: exp)
|
||||
}
|
||||
|
||||
public static func isExpired(_ token: String?, now: Date = Date()) -> Bool {
|
||||
guard let token = sanitizeToken(token) else {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let expiration = jwtExpiration(token) else {
|
||||
// Non-JWT tokens cannot be locally validated for expiry.
|
||||
return false
|
||||
}
|
||||
|
||||
return expiration <= now
|
||||
}
|
||||
|
||||
public static func shouldRotate(_ token: String?, now: Date = Date(), rotationWindow: TimeInterval = defaultRotationWindow) -> Bool {
|
||||
guard let token = sanitizeToken(token),
|
||||
let expiration = jwtExpiration(token) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return expiration.timeIntervalSince(now) <= rotationWindow
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ input: String) -> Data? {
|
||||
var value = input
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
let remainder = value.count % 4
|
||||
if remainder > 0 {
|
||||
value.append(String(repeating: "=", count: 4 - remainder))
|
||||
}
|
||||
|
||||
return Data(base64Encoded: value)
|
||||
}
|
||||
|
||||
private static func normalizeAuthPrefix(_ token: String) -> String {
|
||||
let lowercased = token.lowercased()
|
||||
for prefix in authPrefixes {
|
||||
if lowercased == prefix {
|
||||
return ""
|
||||
}
|
||||
|
||||
let prefixed = "\(prefix) "
|
||||
if lowercased.hasPrefix(prefixed) {
|
||||
return String(token.dropFirst(prefixed.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public enum WatchPayloadValidationError: Error, Equatable {
|
||||
case emptyPayload
|
||||
case payloadTooLarge(actualBytes: Int, maxBytes: Int)
|
||||
case decodeFailure
|
||||
}
|
||||
|
||||
public enum WatchPayloadValidation {
|
||||
public static let defaultMaxPayloadBytes = 256 * 1024
|
||||
|
||||
public static func validate(_ payload: Data, maxBytes: Int = defaultMaxPayloadBytes) -> WatchPayloadValidationError? {
|
||||
guard payload.isEmpty == false else {
|
||||
return .emptyPayload
|
||||
}
|
||||
|
||||
guard payload.count <= maxBytes else {
|
||||
return .payloadTooLarge(actualBytes: payload.count, maxBytes: maxBytes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func decode<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
from payload: Data,
|
||||
maxBytes: Int = defaultMaxPayloadBytes,
|
||||
decoder: JSONDecoder = JSONDecoder()
|
||||
) throws -> T {
|
||||
if let validationError = validate(payload, maxBytes: maxBytes) {
|
||||
throw validationError
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: payload)
|
||||
} catch {
|
||||
throw WatchPayloadValidationError.decodeFailure
|
||||
}
|
||||
}
|
||||
}
|
||||
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
public struct WorkoutValidationIssue: Equatable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
|
||||
public init(code: String, message: String) {
|
||||
self.code = code
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
public enum WorkoutValidation {
|
||||
public static func validateSupersets(_ supersets: [[String: Any]]) -> [WorkoutValidationIssue] {
|
||||
var issues = [WorkoutValidationIssue]()
|
||||
|
||||
if supersets.isEmpty {
|
||||
issues.append(WorkoutValidationIssue(code: "empty_supersets", message: "Workout requires at least one superset."))
|
||||
return issues
|
||||
}
|
||||
|
||||
for (supersetIndex, superset) in supersets.enumerated() {
|
||||
let rounds = superset["rounds"] as? Int ?? 0
|
||||
if rounds <= 0 {
|
||||
issues.append(WorkoutValidationIssue(code: "invalid_rounds", message: "Superset \(supersetIndex + 1) must have at least one round."))
|
||||
}
|
||||
|
||||
let exercises = superset["exercises"] as? [[String: Any]] ?? []
|
||||
if exercises.isEmpty {
|
||||
issues.append(WorkoutValidationIssue(code: "empty_exercises", message: "Superset \(supersetIndex + 1) must contain at least one exercise."))
|
||||
continue
|
||||
}
|
||||
|
||||
for (exerciseIndex, exercise) in exercises.enumerated() {
|
||||
let reps = exercise["reps"] as? Int ?? 0
|
||||
let duration = exercise["duration"] as? Int ?? 0
|
||||
if reps <= 0 && duration <= 0 {
|
||||
issues.append(
|
||||
WorkoutValidationIssue(
|
||||
code: "invalid_exercise_payload",
|
||||
message: "Exercise \(exerciseIndex + 1) in superset \(supersetIndex + 1) needs reps or duration."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import XCTest
|
||||
@testable import SharedCore
|
||||
|
||||
final class RuntimeReporterTests: XCTestCase {
|
||||
func testReporterInvokesSinkWithMetadata() {
|
||||
let expectation = expectation(description: "sink called")
|
||||
|
||||
RuntimeReporter.shared.setSink { event in
|
||||
XCTAssertEqual(event.severity, .error)
|
||||
XCTAssertEqual(event.message, "network failure")
|
||||
XCTAssertEqual(event.metadata["status"], "500")
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
RuntimeReporter.shared.recordError("network failure", metadata: ["status": "500"])
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
RuntimeReporter.shared.setSink(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import XCTest
|
||||
@testable import SharedCore
|
||||
|
||||
final class BoundedFIFOQueueTests: XCTestCase {
|
||||
func testDisconnectReconnectFlushPreservesOrder() {
|
||||
var queue = BoundedFIFOQueue<Int>(maxCount: 5)
|
||||
|
||||
_ = queue.enqueue(10)
|
||||
_ = queue.enqueue(20)
|
||||
_ = queue.enqueue(30)
|
||||
|
||||
XCTAssertEqual(queue.dequeueAll(), [10, 20, 30])
|
||||
XCTAssertTrue(queue.isEmpty)
|
||||
}
|
||||
|
||||
func testOverflowDropsOldestPayloads() {
|
||||
var queue = BoundedFIFOQueue<Int>(maxCount: 3)
|
||||
|
||||
XCTAssertEqual(queue.enqueue(1), 0)
|
||||
XCTAssertEqual(queue.enqueue(2), 0)
|
||||
XCTAssertEqual(queue.enqueue(3), 0)
|
||||
XCTAssertEqual(queue.enqueue(4), 1)
|
||||
XCTAssertEqual(queue.enqueue(5), 1)
|
||||
|
||||
XCTAssertEqual(queue.dequeueAll(), [3, 4, 5])
|
||||
}
|
||||
|
||||
func testMaxCountHasLowerBoundOfOne() {
|
||||
var queue = BoundedFIFOQueue<Int>(maxCount: 0)
|
||||
|
||||
_ = queue.enqueue(1)
|
||||
XCTAssertEqual(queue.enqueue(2), 1)
|
||||
XCTAssertEqual(queue.dequeueAll(), [2])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import XCTest
|
||||
@testable import SharedCore
|
||||
|
||||
final class WatchPayloadValidationTests: XCTestCase {
|
||||
private struct MockPayload: Codable, Equatable {
|
||||
let name: String
|
||||
let count: Int
|
||||
}
|
||||
|
||||
func testValidateRejectsEmptyPayload() {
|
||||
let error = WatchPayloadValidation.validate(Data())
|
||||
XCTAssertEqual(error, .emptyPayload)
|
||||
}
|
||||
|
||||
func testValidateRejectsOversizedPayload() {
|
||||
let payload = Data(repeating: 0, count: 9)
|
||||
let error = WatchPayloadValidation.validate(payload, maxBytes: 8)
|
||||
XCTAssertEqual(error, .payloadTooLarge(actualBytes: 9, maxBytes: 8))
|
||||
}
|
||||
|
||||
func testDecodeAcceptsValidPayload() throws {
|
||||
let payload = try JSONEncoder().encode(MockPayload(name: "set", count: 12))
|
||||
let decoded = try WatchPayloadValidation.decode(MockPayload.self, from: payload)
|
||||
XCTAssertEqual(decoded, MockPayload(name: "set", count: 12))
|
||||
}
|
||||
|
||||
func testDecodeRejectsInvalidJSON() throws {
|
||||
let invalid = Data("not-json".utf8)
|
||||
|
||||
XCTAssertThrowsError(try WatchPayloadValidation.decode(MockPayload.self, from: invalid)) { error in
|
||||
XCTAssertEqual(error as? WatchPayloadValidationError, .decodeFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func testDecodeRejectsOversizedPayloadBeforeDecoding() {
|
||||
let payload = Data(repeating: 1, count: 32)
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try WatchPayloadValidation.decode(MockPayload.self, from: payload, maxBytes: 16)
|
||||
) { error in
|
||||
XCTAssertEqual(
|
||||
error as? WatchPayloadValidationError,
|
||||
.payloadTooLarge(actualBytes: 32, maxBytes: 16)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import XCTest
|
||||
@testable import SharedCore
|
||||
|
||||
final class WorkoutValidationTests: XCTestCase {
|
||||
func testValidateSupersetsRejectsEmptyPayload() {
|
||||
let issues = WorkoutValidation.validateSupersets([])
|
||||
XCTAssertEqual(issues.first?.code, "empty_supersets")
|
||||
}
|
||||
|
||||
func testValidateSupersetsRejectsInvalidRoundsAndExercisePayload() {
|
||||
let supersets: [[String: Any]] = [
|
||||
[
|
||||
"rounds": 0,
|
||||
"exercises": [
|
||||
["reps": 0, "duration": 0]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
let issues = WorkoutValidation.validateSupersets(supersets)
|
||||
XCTAssertTrue(issues.contains(where: { $0.code == "invalid_rounds" }))
|
||||
XCTAssertTrue(issues.contains(where: { $0.code == "invalid_exercise_payload" }))
|
||||
}
|
||||
|
||||
func testValidateSupersetsAcceptsValidPayload() {
|
||||
let supersets: [[String: Any]] = [
|
||||
[
|
||||
"rounds": 3,
|
||||
"exercises": [
|
||||
["reps": 12, "duration": 0],
|
||||
["reps": 0, "duration": 30]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
let issues = WorkoutValidation.validateSupersets(supersets)
|
||||
XCTAssertTrue(issues.isEmpty)
|
||||
}
|
||||
}
|
||||
65
SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift
Normal file
65
SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import XCTest
|
||||
@testable import SharedCore
|
||||
|
||||
final class TokenSecurityTests: XCTestCase {
|
||||
func testSanitizeTokenRejectsEmptyAndRedactedValues() {
|
||||
XCTAssertNil(TokenSecurity.sanitizeToken(nil))
|
||||
XCTAssertNil(TokenSecurity.sanitizeToken(" "))
|
||||
XCTAssertNil(TokenSecurity.sanitizeToken("REDACTED_TOKEN"))
|
||||
XCTAssertEqual(TokenSecurity.sanitizeToken(" abc123 "), "abc123")
|
||||
}
|
||||
|
||||
func testSanitizeTokenNormalizesAuthorizationPrefixes() {
|
||||
XCTAssertEqual(TokenSecurity.sanitizeToken("Token abc123"), "abc123")
|
||||
XCTAssertEqual(TokenSecurity.sanitizeToken("bearer xyz789"), "xyz789")
|
||||
XCTAssertNil(TokenSecurity.sanitizeToken("Token "))
|
||||
}
|
||||
|
||||
func testContainsPotentialHardcodedTokenDetectsLongHexBlob() {
|
||||
let content = "private let token = \"0123456789abcdef0123456789abcdef\""
|
||||
XCTAssertTrue(TokenSecurity.containsPotentialHardcodedToken(in: content))
|
||||
}
|
||||
|
||||
func testJWTExpirationAndRotationWindow() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let expiration = now.addingTimeInterval(30 * 60)
|
||||
|
||||
let token = try makeJWT(exp: expiration)
|
||||
|
||||
XCTAssertEqual(TokenSecurity.jwtExpiration(token), expiration)
|
||||
XCTAssertFalse(TokenSecurity.isExpired(token, now: now))
|
||||
XCTAssertTrue(TokenSecurity.shouldRotate(token, now: now, rotationWindow: 60 * 60))
|
||||
}
|
||||
|
||||
func testExpiredJWTReturnsExpired() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let expiration = now.addingTimeInterval(-10)
|
||||
let token = try makeJWT(exp: expiration)
|
||||
|
||||
XCTAssertTrue(TokenSecurity.isExpired(token, now: now))
|
||||
}
|
||||
|
||||
func testMalformedTokenDoesNotCrashAndDoesNotTriggerRotation() {
|
||||
let malformed = "not-a-jwt"
|
||||
XCTAssertNil(TokenSecurity.jwtExpiration(malformed))
|
||||
XCTAssertFalse(TokenSecurity.isExpired(malformed))
|
||||
XCTAssertFalse(TokenSecurity.shouldRotate(malformed))
|
||||
}
|
||||
|
||||
private func makeJWT(exp: Date) throws -> String {
|
||||
let header = ["alg": "HS256", "typ": "JWT"]
|
||||
let payload = ["exp": Int(exp.timeIntervalSince1970)]
|
||||
|
||||
let headerData = try JSONSerialization.data(withJSONObject: header)
|
||||
let payloadData = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
return "\(base64URL(headerData)).\(base64URL(payloadData)).signature"
|
||||
}
|
||||
|
||||
private func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user