mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
The iOS client is now a screen-for-screen port of the Android app over the same pstr-android UniFFI surface, with no Rust changes: Library, title page, History, Shares (public links and Drive folders), Downloads and Settings, with the same strings, undo snackbars and two-pane layout on wide screens. - Theme from the engine's palette (Material role names, Inter, the same type ramp and corners), repainted live from the appearance settings. - Account sign-in with two-factor code, mailbox password and the Proton CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and watch-history sync every five minutes, after sign-in, on player close and when the app leaves the screen. - Player over libmpv: resume, progress saved every 5 s, autoplay with the up-next card, skip and auto-skip from the chapter plan, track, speed, volume and episode pickers, landscape lock, mini transport, and lock-screen controls with interruption and route-change handling. - Offline downloads with the Android record format, free-space floor, retries, transfer rate and the Wi-Fi-only policy; interrupted transfers resume at next launch. - Config, data and download state are excluded from backup, as Android's allowBackup=false: a restore would bring shares back without their Keychain secrets and give two devices one sync identity. Picture-in-picture, the share sheet and background downloads are not possible the same way on iOS; docs/IOS.md lists why and what replaces them. Adds XCTest ports of the Android unit tests, a `test` command to scripts/build-ios.sh, the app icon and its render script, and an iOS workflow on macos-15 that runs the tests and builds the unsigned .ipa.
318 lines
13 KiB
Swift
318 lines
13 KiB
Swift
import CryptoKit
|
|
import ImageIO
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// One file to pull Proton's own thumbnail from when no provider poster exists.
|
|
///
|
|
/// Proton renders a thumbnail per file, so any episode of a show is a frame of
|
|
/// the right show — the desktop client picks the first for the same reason.
|
|
struct ThumbnailSource: Hashable, Sendable {
|
|
let shareId: String
|
|
let volumeId: String
|
|
let linkId: String
|
|
|
|
var key: String { "\(shareId)/\(linkId)" }
|
|
}
|
|
|
|
extension TitleRecord {
|
|
/// What the first episode of a title offers as a poster. Metadata lookups
|
|
/// are off by default, so for a freshly crawled library this is the only
|
|
/// artwork there is.
|
|
var thumbnailSource: ThumbnailSource? {
|
|
seasons.lazy.compactMap(\.episodes.first).first.map(\.thumbnailSource)
|
|
}
|
|
}
|
|
|
|
extension EpisodeRecord {
|
|
var thumbnailSource: ThumbnailSource { ThumbnailSource(shareId: shareId, volumeId: volumeId, linkId: linkId) }
|
|
}
|
|
|
|
/// HTTPS-only artwork, cropped to whatever frame its parent gives it.
|
|
///
|
|
/// `fallback` is used only when there is no provider artwork or it fails to
|
|
/// load, which — with metadata off — is the common case rather than the rare
|
|
/// one. Until either answers, the box pulses as a skeleton; when neither has
|
|
/// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
|
|
/// initials when `labelled` is false, for art with the name already over it.
|
|
struct RemoteArtwork: View {
|
|
let url: String?
|
|
let description: String
|
|
var fallback: ThumbnailSource?
|
|
var labelled = true
|
|
@State private var load: ArtworkLoad
|
|
|
|
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
|
|
self.url = url
|
|
self.description = description
|
|
self.fallback = fallback
|
|
self.labelled = labelled
|
|
// A poster already decoded draws in the first frame, so scrolling back
|
|
// over the grid does not flash a skeleton per tile.
|
|
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
|
|
}
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.overlay {
|
|
switch load {
|
|
case .pending:
|
|
Skeleton()
|
|
case .missing:
|
|
ArtworkPlaceholder(name: description, labelled: labelled)
|
|
case let .loaded(image):
|
|
Image(uiImage: image).resizable().scaledToFill()
|
|
}
|
|
}
|
|
.clipped()
|
|
.accessibilityElement()
|
|
.accessibilityLabel(description)
|
|
.task(id: Request(url: url, fallback: fallback)) {
|
|
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
|
|
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
|
|
if Task.isCancelled { return }
|
|
load = loaded.map(ArtworkLoad.loaded) ?? .missing
|
|
}
|
|
}
|
|
|
|
private struct Request: Hashable {
|
|
let url: String?
|
|
let fallback: ThumbnailSource?
|
|
}
|
|
}
|
|
|
|
private enum ArtworkLoad {
|
|
case pending
|
|
case missing
|
|
case loaded(UIImage)
|
|
}
|
|
|
|
/// Something being loaded: the surface tint, breathing.
|
|
///
|
|
/// A spinner per tile is a grid of spinners; a shape where the poster will be
|
|
/// says what is coming and where, and settles the layout before it arrives.
|
|
struct Skeleton: View {
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var dim = false
|
|
|
|
var body: some View {
|
|
scheme.surfaceVariant
|
|
.opacity(dim ? 0.55 : 1)
|
|
.onAppear {
|
|
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Art for a title that has none: the accent as a gradient, and its initials.
|
|
///
|
|
/// Each name sets where on the accent ramp its gradient starts, so neighbours
|
|
/// differ while every one stays in the theme's colours — and since the hash is
|
|
/// Java's, a title gets the same colour here as on Android.
|
|
struct ArtworkPlaceholder: View {
|
|
let name: String
|
|
var labelled = true
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
let start = mix(scheme.primary, scheme.secondary, Double(javaHash(name).modulo(5)) / 4)
|
|
GeometryReader { geometry in
|
|
LinearGradient(colors: [start, mix(start, .black, 0.55)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
.overlay {
|
|
if labelled {
|
|
Text(initials(name))
|
|
.font(.inter(size: min(geometry.size.width, geometry.size.height) * 0.3, semibold: true))
|
|
.foregroundStyle(Color.white.opacity(0.9))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
|
/// capitalised words, so particles and articles in a romanised name do not
|
|
/// take a slot. A name with no capitals uses its first two words.
|
|
func initials(_ name: String) -> String {
|
|
let words = name
|
|
.split(whereSeparator: { " -_.:".contains($0) })
|
|
.filter { $0.first.map { $0.isLetter || $0.isNumber } ?? false }
|
|
let capitalised = words.filter { $0.first!.isUppercase || $0.first!.isNumber }
|
|
return (capitalised.isEmpty ? words : capitalised).prefix(2).map { $0.first!.uppercased() }.joined()
|
|
}
|
|
|
|
/// `String.hashCode()` from the JVM, over UTF-16 code units.
|
|
func javaHash(_ text: String) -> Int32 {
|
|
text.utf16.reduce(Int32(0)) { hash, unit in hash &* 31 &+ Int32(unit) }
|
|
}
|
|
|
|
private extension Int32 {
|
|
/// Kotlin's `mod`: never negative.
|
|
func modulo(_ divisor: Int32) -> Int32 {
|
|
let remainder = self % divisor
|
|
return remainder < 0 ? remainder + divisor : remainder
|
|
}
|
|
}
|
|
|
|
/// The HTTPS-only fetch behind every poster, its disk cache under
|
|
/// `Caches/metadata-art`, and Proton's own thumbnails as the fallback.
|
|
final class ArtworkLoader: @unchecked Sendable {
|
|
static let shared = ArtworkLoader()
|
|
|
|
/// Decoded posters, so scrolling the grid re-reads neither disk nor
|
|
/// decoder. Keyed by artwork URL or by `share/link` for a Proton
|
|
/// thumbnail; the two cannot collide, since one is always an `https:` URL.
|
|
private let decoded: NSCache<NSString, UIImage> = {
|
|
let cache = NSCache<NSString, UIImage>()
|
|
cache.totalCostLimit = 32 * 1024 * 1024
|
|
return cache
|
|
}()
|
|
|
|
/// Files known to have no Proton thumbnail, so the answer is paid for once.
|
|
/// Deliberately not persisted: a re-upload can add one.
|
|
private var missing = Set<String>()
|
|
private let lock = NSLock()
|
|
|
|
private let session: URLSession = {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = 10
|
|
configuration.urlCache = nil
|
|
return URLSession(configuration: configuration)
|
|
}()
|
|
|
|
private static let maxBytes = 12 * 1024 * 1024
|
|
private static let maxCacheBytes = 48 * 1024 * 1024
|
|
/// Longest edge worth decoding: a poster on a phone, or lock-screen art.
|
|
private static let maxEdge = 1024
|
|
|
|
func cached(url: String?, fallback: ThumbnailSource?) -> UIImage? {
|
|
if let url, let image = decoded.object(forKey: url as NSString) { return image }
|
|
if let key = fallback?.key { return decoded.object(forKey: key as NSString) }
|
|
return nil
|
|
}
|
|
|
|
func artwork(url: String?, fallback: ThumbnailSource?) async -> UIImage? {
|
|
if let image = await remote(url) { return image }
|
|
return await thumbnail(fallback)
|
|
}
|
|
|
|
/// Provider artwork, from the disk cache or the network.
|
|
func remote(_ requested: String?) async -> UIImage? {
|
|
guard let requested else { return nil }
|
|
if let image = decoded.object(forKey: requested as NSString) { return image }
|
|
guard let url = URL(string: requested), url.scheme?.lowercased() == "https" else { return nil }
|
|
let directory = Self.directory
|
|
let digest = SHA256.hash(data: Data(requested.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
let cachedFile = directory.appendingPathComponent("\(digest).img")
|
|
let files = FileManager.default
|
|
let fresh = !files.fileExists(atPath: cachedFile.path)
|
|
let bytes: Data
|
|
if fresh {
|
|
guard let downloaded = await download(url) else { return nil }
|
|
bytes = downloaded
|
|
} else {
|
|
guard let read = try? Data(contentsOf: cachedFile) else { return nil }
|
|
bytes = read
|
|
}
|
|
guard let image = Self.decodeSampled(bytes) else { return nil }
|
|
if fresh {
|
|
// Written only once it has decoded: caching the bytes first is what
|
|
// turns one hostile poster into a failure on every launch.
|
|
let temporary = directory.appendingPathComponent("\(digest).part")
|
|
if (try? bytes.write(to: temporary)) != nil {
|
|
try? files.moveItem(at: temporary, to: cachedFile)
|
|
Self.prune(directory)
|
|
}
|
|
} else {
|
|
try? files.setAttributes([.modificationDate: Date()], ofItemAtPath: cachedFile.path)
|
|
}
|
|
decoded.setObject(image, forKey: requested as NSString, cost: image.cost)
|
|
return image
|
|
}
|
|
|
|
/// Proton's own thumbnail for one file, decoded. A failed call is not
|
|
/// remembered — an unreachable share is transient — but a file with no
|
|
/// thumbnail is, for the session.
|
|
func thumbnail(_ source: ThumbnailSource?) async -> UIImage? {
|
|
guard let source else { return nil }
|
|
let key = source.key
|
|
if let image = decoded.object(forKey: key as NSString) { return image }
|
|
if lock.withLock({ missing.contains(key) }) { return nil }
|
|
let fetched: Data?
|
|
do {
|
|
fetched = try await NativeRuntime.engine().thumbnail(shareId: source.shareId, volumeId: source.volumeId, linkId: source.linkId)
|
|
} catch {
|
|
return nil
|
|
}
|
|
guard let bytes = fetched, let image = Self.decodeSampled(bytes) else {
|
|
lock.withLock { _ = missing.insert(key) }
|
|
return nil
|
|
}
|
|
decoded.setObject(image, forKey: key as NSString, cost: image.cost)
|
|
return image
|
|
}
|
|
|
|
private func download(_ url: URL) async -> Data? {
|
|
guard let (stream, response) = try? await session.bytes(from: url),
|
|
let http = response as? HTTPURLResponse,
|
|
(200 ... 299).contains(http.statusCode),
|
|
// A redirect must not leave HTTPS.
|
|
http.url?.scheme?.lowercased() == "https"
|
|
else { return nil }
|
|
var bytes = Data()
|
|
do {
|
|
for try await byte in stream {
|
|
bytes.append(byte)
|
|
if bytes.count > Self.maxBytes { return nil }
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
/// Decode within a bound the display can actually use: the compressed size
|
|
/// says nothing about what an image costs decoded.
|
|
private static func decodeSampled(_ bytes: Data) -> UIImage? {
|
|
guard let source = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
|
]
|
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
|
|
return UIImage(cgImage: image)
|
|
}
|
|
|
|
private static var directory: URL {
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let directory = caches.appendingPathComponent("metadata-art", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
|
|
/// Hold the cache directory under `maxCacheBytes`, oldest first.
|
|
private static func prune(_ directory: URL) {
|
|
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
|
guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys) else { return }
|
|
let entries = files.compactMap { file -> (URL, Date, Int)? in
|
|
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
|
|
return (file, values.contentModificationDate ?? .distantPast, values.fileSize ?? 0)
|
|
}.sorted { $0.1 < $1.1 }
|
|
var total = entries.reduce(0) { $0 + $1.2 }
|
|
for (file, _, size) in entries {
|
|
if total <= maxCacheBytes { return }
|
|
total -= size
|
|
try? FileManager.default.removeItem(at: file)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIImage {
|
|
var cost: Int {
|
|
guard let image = cgImage else { return 0 }
|
|
return image.bytesPerRow * image.height
|
|
}
|
|
}
|