This commit is contained in:
wt committed 2026-07-30 20:07:51 +07:00
commit 92660a4ba0
228 files changed
+24496

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import SwiftUI
@main
@MainActor
struct LumaWatchApp: App {
@StateObject private var model = WatchSessionModel()
var body: some Scene {
WindowGroup {
WatchChatListView(model: model)
}
}
}
+240
View File
@@ -0,0 +1,240 @@
import Foundation
import WatchConnectivity
struct WatchSnapshot: Codable {
struct Chat: Codable, Identifiable, Hashable {
var id: String { jid }
let jid: String
let name: String
let unread: Int
let messages: [Message]
}
struct Message: Codable, Identifiable, Hashable {
let id: String
let body: String
let timestamp: Date
let outgoing: Bool
let encrypted: Bool
}
let chats: [Chat]
}
@MainActor
final class WatchSessionModel: NSObject, ObservableObject {
@Published private(set) var chats: [WatchSnapshot.Chat] = []
@Published private(set) var phoneReachable = false
@Published private(set) var voiceStatusByJID: [String: String] = [:]
@Published var errorMessage: String?
private let session: WCSession?
private let cacheKey = "luma.watch.snapshot.v1"
private let voiceTransferCacheKey = "luma.watch.voice-transfers.v1"
private var outgoingVoiceTransfers: [String: OutgoingVoiceTransfer] = [:]
override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
loadCachedSnapshot()
loadOutgoingVoiceTransfers()
session?.delegate = self
session?.activate()
}
func send(text rawText: String, to jid: String) {
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, let session else { return }
let payload: [String: Any] = [
"action": "reply",
"jid": jid,
"text": text
]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { [weak self] error in
Task { @MainActor in self?.errorMessage = error.localizedDescription }
}
} else {
session.transferUserInfo(payload)
}
}
@discardableResult
func sendVoice(fileURL: URL, duration: TimeInterval, to jid: String) -> Bool {
guard let session else {
errorMessage = "WatchConnectivity недоступен."
return false
}
guard session.activationState == .activated else {
errorMessage = "Соединение с iPhone ещё не готово. Повторите через несколько секунд."
return false
}
guard session.isCompanionAppInstalled else {
errorMessage = "Установите Luma на связанный iPhone."
return false
}
guard duration.isFinite, duration >= 0.4,
FileManager.default.fileExists(atPath: fileURL.path) else {
errorMessage = "Запись голосового сообщения недоступна."
return false
}
let transferID = UUID().uuidString
let normalizedJID = jid.lowercased()
let metadata: [String: Any] = [
"action": "voice",
"transferID": transferID,
"jid": normalizedJID,
"filename": fileURL.lastPathComponent,
"duration": duration
]
outgoingVoiceTransfers[transferID] = OutgoingVoiceTransfer(
jid: normalizedJID,
fileURL: fileURL,
createdAt: Date()
)
persistOutgoingVoiceTransfers()
voiceStatusByJID[normalizedJID] = "Голосовое в очереди на iPhone…"
session.transferFile(fileURL, metadata: metadata)
return true
}
func voiceStatus(for jid: String) -> String? {
voiceStatusByJID[jid.lowercased()]
}
private func apply(data: Data) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
guard let snapshot = try? decoder.decode(WatchSnapshot.self, from: data) else { return }
chats = snapshot.chats
UserDefaults.standard.set(data, forKey: cacheKey)
}
private func loadCachedSnapshot() {
guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return }
apply(data: data)
}
private func consume(_ payload: [String: Any]) {
guard payload["action"] as? String == "voiceResult",
let transferID = payload["transferID"] as? String,
let success = payload["success"] as? Bool else { return }
let transfer = outgoingVoiceTransfers.removeValue(forKey: transferID)
let jid = transfer?.jid ?? (payload["jid"] as? String)?.lowercased()
guard let jid else { return }
if let transfer {
try? FileManager.default.removeItem(at: transfer.fileURL)
}
persistOutgoingVoiceTransfers()
if success {
voiceStatusByJID[jid] = "Голосовое отправлено"
} else {
voiceStatusByJID[jid] = "Голосовое не отправлено"
errorMessage = (payload["error"] as? String) ?? "iPhone не смог отправить голосовое сообщение."
}
}
private func finishVoiceTransfer(
transferID: String?,
fallbackURL: URL,
errorDescription: String?
) {
guard let transferID,
let transfer = outgoingVoiceTransfers[transferID] else {
try? FileManager.default.removeItem(at: fallbackURL)
return
}
// Once WatchConnectivity has copied the file to the iPhone the source
// recording is no longer needed. Keep only the transfer metadata while
// waiting for the actual XMPP upload result from the companion app.
try? FileManager.default.removeItem(at: transfer.fileURL)
if let errorDescription {
outgoingVoiceTransfers.removeValue(forKey: transferID)
persistOutgoingVoiceTransfers()
voiceStatusByJID[transfer.jid] = "Не удалось передать запись"
errorMessage = errorDescription
} else {
voiceStatusByJID[transfer.jid] = "iPhone получил запись. Отправляем…"
}
}
private func loadOutgoingVoiceTransfers() {
guard let data = UserDefaults.standard.data(forKey: voiceTransferCacheKey),
let stored = try? JSONDecoder().decode(
[String: OutgoingVoiceTransfer].self,
from: data
) else { return }
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
outgoingVoiceTransfers = stored.filter { $0.value.createdAt >= expiration }
for transfer in outgoingVoiceTransfers.values {
voiceStatusByJID[transfer.jid] = "Ожидает подтверждения iPhone…"
}
persistOutgoingVoiceTransfers()
}
private func persistOutgoingVoiceTransfers() {
guard let data = try? JSONEncoder().encode(outgoingVoiceTransfers) else { return }
UserDefaults.standard.set(data, forKey: voiceTransferCacheKey)
}
}
private struct OutgoingVoiceTransfer: Codable {
let jid: String
let fileURL: URL
let createdAt: Date
}
extension WatchSessionModel: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor in
phoneReachable = session.isReachable
if let error { errorMessage = error.localizedDescription }
if let data = session.receivedApplicationContext["snapshot"] as? Data {
apply(data: data)
}
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor in phoneReachable = session.isReachable }
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
guard let data = applicationContext["snapshot"] as? Data else { return }
Task { @MainActor in apply(data: data) }
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Task { @MainActor in consume(message) }
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
Task { @MainActor in consume(userInfo) }
}
nonisolated func session(
_ session: WCSession,
didFinish fileTransfer: WCSessionFileTransfer,
error: Error?
) {
let transferID = fileTransfer.file.metadata?["transferID"] as? String
let fileURL = fileTransfer.file.fileURL
let errorDescription = error?.localizedDescription
Task { @MainActor in
finishVoiceTransfer(
transferID: transferID,
fallbackURL: fileURL,
errorDescription: errorDescription
)
}
}
}
+228
View File
@@ -0,0 +1,228 @@
import Foundation
import SwiftUI
struct WatchChatListView: View {
@ObservedObject var model: WatchSessionModel
var body: some View {
NavigationStack {
Group {
if model.chats.isEmpty {
ContentUnavailableView(
"Нет чатов",
systemImage: "iphone.and.arrow.forward",
description: Text("Откройте Luma на iPhone для синхронизации.")
)
} else {
List(model.chats) { chat in
NavigationLink(value: chat) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(chat.name).font(.headline).lineLimit(1)
Spacer()
if chat.unread > 0 {
Text("\(chat.unread)")
.font(.caption2.bold())
.padding(5)
.background(.blue, in: Circle())
}
}
Text(chat.messages.last?.body ?? chat.jid)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
}
.navigationTitle("Luma")
.navigationDestination(for: WatchSnapshot.Chat.self) { chat in
WatchChatView(model: model, chat: chat)
}
}
.alert("Luma", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)) {
Button("OK") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "Ошибка")
}
}
}
private struct WatchChatView: View {
@ObservedObject var model: WatchSessionModel
let chat: WatchSnapshot.Chat
@StateObject private var voiceRecorder = WatchVoiceRecorder()
@State private var reply = ""
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(chat.messages) { message in
HStack {
if message.outgoing { Spacer(minLength: 16) }
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
if message.encrypted {
Image(systemName: "lock.fill").font(.caption2)
}
Text(message.body)
}
Text(message.timestamp, format: .dateTime.hour().minute())
.font(.caption2)
.opacity(0.7)
}
.padding(7)
.background(message.outgoing ? Color.blue : Color.secondary.opacity(0.25), in: RoundedRectangle(cornerRadius: 11))
if !message.outgoing { Spacer(minLength: 16) }
}
.id(message.id)
}
Section {
TextField("Ответ", text: $reply)
Button("Отправить") {
model.send(text: reply, to: chat.jid)
reply = ""
}
.disabled(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Section("Голосовое") {
voiceComposer
if let status = model.voiceStatus(for: chat.jid) {
Label(status, systemImage: "iphone.and.arrow.forward")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Section("Быстрый ответ") {
ForEach(["Хорошо", "Спасибо!", "Отвечу позже"], id: \.self) { text in
Button(text) { model.send(text: text, to: chat.jid) }
}
}
}
.navigationTitle(chat.name)
.onAppear {
if let id = chat.messages.last?.id { proxy.scrollTo(id, anchor: .bottom) }
}
.onDisappear {
voiceRecorder.reset()
}
}
}
@ViewBuilder
private var voiceComposer: some View {
if voiceRecorder.isRecording {
VStack(spacing: 8) {
Label(
formatDuration(voiceRecorder.elapsed),
systemImage: "waveform"
)
.font(.headline.monospacedDigit())
.foregroundStyle(.red)
ProgressView(
value: voiceRecorder.elapsed,
total: WatchVoiceRecorder.maximumDuration
)
.tint(.red)
HStack(spacing: 8) {
Button(role: .destructive) {
voiceRecorder.cancelRecording()
} label: {
Label("Отмена", systemImage: "xmark")
}
.buttonStyle(.borderless)
Button {
finishVoiceRecording()
} label: {
Label("Готово", systemImage: "stop.fill")
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
.font(.caption)
}
} else if let recording = voiceRecorder.recording {
VStack(alignment: .leading, spacing: 8) {
HStack {
Button {
do {
try voiceRecorder.togglePlayback()
} catch {
model.errorMessage = error.localizedDescription
}
} label: {
Image(systemName: voiceRecorder.isPlaying ? "pause.fill" : "play.fill")
}
.buttonStyle(.bordered)
.accessibilityLabel(voiceRecorder.isPlaying ? "Пауза" : "Прослушать")
VStack(alignment: .leading, spacing: 2) {
Text("Предпрослушивание")
.font(.caption)
Text(formatDuration(recording.duration))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
Button {
if model.sendVoice(
fileURL: recording.url,
duration: recording.duration,
to: chat.jid
) {
_ = voiceRecorder.detachRecording()
}
} label: {
Label("Отправить голосовое", systemImage: "paperplane.fill")
}
.buttonStyle(.borderedProminent)
Button(role: .destructive) {
voiceRecorder.discardRecording()
} label: {
Label("Удалить запись", systemImage: "trash")
}
.font(.caption)
}
} else {
Button {
Task {
do {
try await voiceRecorder.start()
} catch {
model.errorMessage = error.localizedDescription
}
}
} label: {
Label("Записать голосовое", systemImage: "mic.fill")
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
}
private func finishVoiceRecording() {
do {
try voiceRecorder.finish()
} catch {
model.errorMessage = error.localizedDescription
}
}
private func formatDuration(_ value: TimeInterval) -> String {
let seconds = max(0, Int(value.rounded(.down)))
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
+205
View File
@@ -0,0 +1,205 @@
import AVFoundation
import Combine
import Foundation
@MainActor
final class WatchVoiceRecorder: NSObject, ObservableObject {
struct Recording: Equatable {
let url: URL
let duration: TimeInterval
}
static let maximumDuration: TimeInterval = 60
@Published private(set) var isRecording = false
@Published private(set) var isPlaying = false
@Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var recording: Recording?
private var recorder: AVAudioRecorder?
private var player: AVAudioPlayer?
private var timer: Timer?
private var startedAt: Date?
func start() async throws {
guard !isRecording else { return }
discardRecording()
guard await requestPermission() else { throw WatchVoiceRecorderError.permissionDenied }
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playAndRecord, mode: .default)
try audioSession.setActive(true)
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaWatchRecordings", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32_000,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.prepareToRecord()
guard recorder.record(forDuration: Self.maximumDuration) else {
throw WatchVoiceRecorderError.cannotStart
}
self.recorder = recorder
startedAt = Date()
elapsed = 0
isRecording = true
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = min(Date().timeIntervalSince(startedAt), Self.maximumDuration)
if !self.recorderIsActivelyRecording {
_ = try? self.finish()
}
}
}
} catch {
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
throw error
}
}
@discardableResult
func finish() throws -> Recording {
guard let recorder, isRecording else { throw WatchVoiceRecorderError.notRecording }
let duration = min(max(recorder.currentTime, elapsed), Self.maximumDuration)
let url = recorder.url
recorder.stop()
stopRecordingState()
guard duration >= 0.4 else {
try? FileManager.default.removeItem(at: url)
throw WatchVoiceRecorderError.tooShort
}
let recording = Recording(url: url, duration: duration)
self.recording = recording
return recording
}
func cancelRecording() {
let url = recorder?.url
recorder?.stop()
stopRecordingState()
if let url {
try? FileManager.default.removeItem(at: url)
}
}
func togglePlayback() throws {
if isPlaying {
stopPlayback()
return
}
guard let recording else { return }
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .spokenAudio)
try audioSession.setActive(true)
do {
let player = try AVAudioPlayer(contentsOf: recording.url)
player.delegate = self
player.prepareToPlay()
guard player.play() else { throw WatchVoiceRecorderError.cannotPlay }
self.player = player
isPlaying = true
} catch {
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
throw error
}
}
func discardRecording() {
stopPlayback()
if let url = recording?.url {
try? FileManager.default.removeItem(at: url)
}
recording = nil
}
func detachRecording() -> Recording? {
stopPlayback()
defer { recording = nil }
return recording
}
func reset() {
cancelRecording()
discardRecording()
}
private var recorderIsActivelyRecording: Bool {
recorder?.isRecording == true
}
private func stopRecordingState() {
timer?.invalidate()
timer = nil
recorder = nil
startedAt = nil
elapsed = 0
isRecording = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func stopPlayback() {
player?.stop()
player = nil
isPlaying = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func requestPermission() async -> Bool {
switch AVAudioApplication.shared.recordPermission {
case .granted:
return true
case .denied:
return false
case .undetermined:
return await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
@unknown default:
return false
}
}
}
extension WatchVoiceRecorder: AVAudioPlayerDelegate {
nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
Task { @MainActor [weak self] in
self?.stopPlayback()
}
}
}
private enum WatchVoiceRecorderError: LocalizedError {
case permissionDenied
case cannotStart
case notRecording
case tooShort
case cannotPlay
var errorDescription: String? {
switch self {
case .permissionDenied:
return "Разрешите Luma доступ к микрофону в настройках Apple Watch."
case .cannotStart:
return "Не удалось начать запись на Apple Watch."
case .notRecording:
return "Запись голосового сообщения не запущена."
case .tooShort:
return "Голосовое сообщение получилось слишком коротким."
case .cannotPlay:
return "Не удалось воспроизвести запись."
}
}
}