Add iOS platform service boundaries

This commit is contained in:
Mikhail Kilin
2026-05-20 15:48:33 +03:00
parent 593b19ba8e
commit 8bea159569
4 changed files with 242 additions and 1 deletions

View File

@@ -0,0 +1,163 @@
import Foundation
import AVFoundation
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AppKit)
import AppKit
#endif
public protocol ClipboardWriting: Sendable {
func write(text: String) async
}
public struct SystemClipboardWriter: ClipboardWriting {
public init() {}
public func write(text: String) async {
#if os(iOS) && canImport(UIKit)
await MainActor.run {
UIPasteboard.general.string = text
}
#elseif os(macOS) && canImport(AppKit)
await MainActor.run {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
#endif
}
}
public actor InMemoryClipboardWriter: ClipboardWriting {
public private(set) var lastText: String?
public init() {}
public func write(text: String) async {
lastText = text
}
public func currentText() async -> String? {
lastText
}
}
public struct NotificationPolicy: Sendable {
public init() {}
public func shouldNotify(chat: ChatSummary, message: Message, mentionOnly: Bool) -> Bool {
guard !message.isOutgoing, !chat.isMuted else {
return false
}
if mentionOnly {
return message.text.contains("@")
}
return true
}
}
public protocol NotificationScheduling: Sendable {
func schedule(chat: ChatSummary, message: Message) async throws
}
public actor RecordingNotificationScheduler: NotificationScheduling {
public private(set) var scheduled: [(ChatSummary, Message)] = []
public init() {}
public func schedule(chat: ChatSummary, message: Message) async throws {
scheduled.append((chat, message))
}
}
public protocol URLOpening: Sendable {
func open(_ url: URL) async
}
public struct SystemURLOpener: URLOpening {
public init() {}
public func open(_ url: URL) async {
#if os(iOS) && canImport(UIKit)
await MainActor.run {
UIApplication.shared.open(url)
}
#elseif os(macOS) && canImport(AppKit)
await MainActor.run {
_ = NSWorkspace.shared.open(url)
}
#endif
}
}
public struct MediaCache: Sendable {
public var root: URL
public init(root: URL) {
self.root = root
}
public func photoPath(fileId: Int32) -> URL {
root.appendingPathComponent("photos", isDirectory: true).appendingPathComponent("\(fileId).jpg")
}
public func voicePath(fileId: Int32) -> URL {
root.appendingPathComponent("voices", isDirectory: true).appendingPathComponent("\(fileId).ogg")
}
}
public protocol VoicePlayback: Sendable {
func load(url: URL) async throws
func play() async
func pause() async
func seek(to seconds: TimeInterval) async
}
public actor RecordingVoicePlayer: VoicePlayback {
public private(set) var loadedURL: URL?
public private(set) var isPlaying = false
public private(set) var position: TimeInterval = 0
public init() {}
public func load(url: URL) async throws {
loadedURL = url
position = 0
}
public func currentLoadedURL() async -> URL? {
loadedURL
}
public func play() async {
isPlaying = true
}
public func pause() async {
isPlaying = false
}
public func seek(to seconds: TimeInterval) async {
position = seconds
}
}
@MainActor
public final class AccountSwitcherViewModel: ObservableObject {
@Published public private(set) var accounts: [Account]
@Published public private(set) var activeAccount: Account
public init(accounts: [Account], activeAccount: Account) {
self.accounts = accounts
self.activeAccount = activeAccount
}
public func switchToAccount(id: String) {
guard let next = accounts.first(where: { $0.id == id }) else {
return
}
activeAccount = next
}
}

View File

@@ -13,6 +13,13 @@ public struct AppStoragePaths: Sendable {
.appendingPathComponent(accountId, isDirectory: true)
.appendingPathComponent("tdlib", isDirectory: true)
}
public func mediaCachePath(for accountId: String) -> URL {
root
.appendingPathComponent("Accounts", isDirectory: true)
.appendingPathComponent(accountId, isDirectory: true)
.appendingPathComponent("Media", isDirectory: true)
}
}
public protocol CredentialStore: Sendable {

View File

@@ -264,8 +264,15 @@ public final class ProfileViewModel: ObservableObject {
public final class MediaViewModel: ObservableObject {
@Published public private(set) var activePhotoPath: String?
@Published public private(set) var activeVoicePath: String?
@Published public private(set) var isVoicePlaying = false
public init() {}
private let cache: MediaCache?
private let voicePlayer: VoicePlayback?
public init(cache: MediaCache? = nil, voicePlayer: VoicePlayback? = nil) {
self.cache = cache
self.voicePlayer = voicePlayer
}
public func showPhoto(path: String) {
activePhotoPath = path
@@ -274,4 +281,27 @@ public final class MediaViewModel: ObservableObject {
public func showVoice(path: String) {
activeVoicePath = path
}
public func cachedPhotoPath(fileId: Int32) -> URL? {
cache?.photoPath(fileId: fileId)
}
public func cachedVoicePath(fileId: Int32) -> URL? {
cache?.voicePath(fileId: fileId)
}
public func playVoice(url: URL) async {
do {
try await voicePlayer?.load(url: url)
await voicePlayer?.play()
isVoicePlaying = true
} catch {
isVoicePlaying = false
}
}
public func pauseVoice() async {
await voicePlayer?.pause()
isVoicePlaying = false
}
}