mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-20 11:06:46 +08:00
[feat] add iOS announcement inbox
This commit is contained in:
@@ -27,6 +27,9 @@
|
|||||||
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; };
|
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; };
|
||||||
100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; };
|
100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; };
|
||||||
100000000000000000000015 /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* PRIVACY.md */; };
|
100000000000000000000015 /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* PRIVACY.md */; };
|
||||||
|
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
|
||||||
|
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
|
||||||
|
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -54,6 +57,9 @@
|
|||||||
200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = "<group>"; };
|
200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = "<group>"; };
|
||||||
200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = "<group>"; };
|
200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = "<group>"; };
|
||||||
200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
|
200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
|
||||||
|
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
|
||||||
|
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
|
||||||
|
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -98,6 +104,9 @@
|
|||||||
200000000000000000000013 /* ThumbnailFileStore.swift */,
|
200000000000000000000013 /* ThumbnailFileStore.swift */,
|
||||||
200000000000000000000012 /* PrivacyPreferences.swift */,
|
200000000000000000000012 /* PrivacyPreferences.swift */,
|
||||||
200000000000000000000002 /* RemoteSessionModel.swift */,
|
200000000000000000000002 /* RemoteSessionModel.swift */,
|
||||||
|
200000000000000000000019 /* AnnouncementInbox.swift */,
|
||||||
|
20000000000000000000001A /* AnnouncementText.swift */,
|
||||||
|
20000000000000000000001B /* AnnouncementInboxView.swift */,
|
||||||
200000000000000000000018 /* RecentConnection.swift */,
|
200000000000000000000018 /* RecentConnection.swift */,
|
||||||
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
||||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
||||||
@@ -264,6 +273,9 @@
|
|||||||
files = (
|
files = (
|
||||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
|
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
|
||||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
|
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
|
||||||
|
100000000000000000000019 /* AnnouncementInbox.swift in Sources */,
|
||||||
|
10000000000000000000001A /* AnnouncementText.swift in Sources */,
|
||||||
|
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */,
|
||||||
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
||||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
||||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import Combine
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Announcement: Decodable, Identifiable, Equatable {
|
||||||
|
let id: Int64
|
||||||
|
let revision: Int64
|
||||||
|
let title: String
|
||||||
|
let body: String
|
||||||
|
let updated_at: Int64
|
||||||
|
var read = false
|
||||||
|
|
||||||
|
// Reading state belongs to this device, never to the server response.
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, revision, title, body, updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
var date: Date { Date(timeIntervalSince1970: TimeInterval(updated_at)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnnouncementSnapshot: Equatable {
|
||||||
|
var items: [Announcement] = []
|
||||||
|
var total = 0
|
||||||
|
var unread = 0
|
||||||
|
var loaded = false
|
||||||
|
var loading = false
|
||||||
|
var failed = false
|
||||||
|
var readSaveFailed = false
|
||||||
|
var deleteFailed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main-thread inbox using the desktop announcements_list protocol and local
|
||||||
|
/// revision semantics. Summary pages count all unread items; bodies load in 20s.
|
||||||
|
final class AnnouncementInbox: ObservableObject {
|
||||||
|
@Published private(set) var state = AnnouncementSnapshot()
|
||||||
|
var send: ((Data, String) -> Void)?
|
||||||
|
|
||||||
|
private struct Entry: Decodable {
|
||||||
|
let id: Int64
|
||||||
|
let revision: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Response: Decodable {
|
||||||
|
let request_id: String
|
||||||
|
let summary_only: Bool
|
||||||
|
let offset: Int
|
||||||
|
let total: Int
|
||||||
|
let catalog_revision: Int64
|
||||||
|
let items: [Entry]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BodyResponse: Decodable { let items: [Announcement] }
|
||||||
|
private struct Request: Encodable {
|
||||||
|
let type = "announcements_list"
|
||||||
|
let request_id: String
|
||||||
|
let summary_only: Bool
|
||||||
|
let offset: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LocalState: Codable {
|
||||||
|
let version: Int
|
||||||
|
let scope: String
|
||||||
|
let reads: [String: Int64]
|
||||||
|
let dismissed: [String: Int64]?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let directory: URL
|
||||||
|
private var scope = ""
|
||||||
|
private var file: URL?
|
||||||
|
private var reads: [Int64: Int64] = [:]
|
||||||
|
private var dismissed: [Int64: Int64] = [:]
|
||||||
|
private var catalog: [Int64: Int64] = [:]
|
||||||
|
private var pendingCatalog: [Int64: Int64] = [:]
|
||||||
|
private var pageIDs: [Int: [Int64]] = [:]
|
||||||
|
private var pageBodies: [Int: [Announcement]] = [:]
|
||||||
|
private var catalogRevision: Int64 = -1
|
||||||
|
private var pendingRevision: Int64 = -1
|
||||||
|
private var pendingTotal = 0
|
||||||
|
private var pageOffset = 0
|
||||||
|
private var visibleLimit = 20
|
||||||
|
private var summary = true
|
||||||
|
private var connected = false
|
||||||
|
private var requestID: String?
|
||||||
|
private var timeout: Timer?
|
||||||
|
|
||||||
|
init(directory: URL = FileManager.default.urls(
|
||||||
|
for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("AnnouncementReads", isDirectory: true)) {
|
||||||
|
self.directory = directory
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { timeout?.invalidate() }
|
||||||
|
|
||||||
|
func configure(host: String, port: Int, deviceID: String) {
|
||||||
|
guard !host.isEmpty, port > 0, !deviceID.isEmpty else { return }
|
||||||
|
var host = host.lowercased()
|
||||||
|
if host.hasSuffix(".") { host.removeLast() }
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: [host, port, deviceID],
|
||||||
|
options: [.fragmentsAllowed]),
|
||||||
|
let nextScope = String(data: data, encoding: .utf8),
|
||||||
|
scope != nextScope else { return }
|
||||||
|
let wasConnected = connected
|
||||||
|
reset()
|
||||||
|
connected = wasConnected
|
||||||
|
scope = nextScope
|
||||||
|
let name = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
|
file = directory.appendingPathComponent(name + ".json")
|
||||||
|
loadLocalState()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
cancelRequest()
|
||||||
|
state = AnnouncementSnapshot()
|
||||||
|
scope = ""
|
||||||
|
file = nil
|
||||||
|
reads = [:]
|
||||||
|
dismissed = [:]
|
||||||
|
catalog = [:]
|
||||||
|
pendingCatalog = [:]
|
||||||
|
pageIDs = [:]
|
||||||
|
pageBodies = [:]
|
||||||
|
catalogRevision = -1
|
||||||
|
pendingRevision = -1
|
||||||
|
pendingTotal = 0
|
||||||
|
visibleLimit = 20
|
||||||
|
connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setConnected(_ value: Bool) {
|
||||||
|
connected = value
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
cancelRequest()
|
||||||
|
pendingCatalog = [:]
|
||||||
|
pendingRevision = -1
|
||||||
|
pendingTotal = 0
|
||||||
|
summary = true
|
||||||
|
request(offset: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore() {
|
||||||
|
guard connected, !state.loading, !state.failed, catalogRevision >= 0,
|
||||||
|
visibleLimit < state.total else { return }
|
||||||
|
visibleLimit += min(20, state.total - visibleLimit)
|
||||||
|
rebuildVisibleItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(_ item: Announcement) {
|
||||||
|
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }),
|
||||||
|
reads[item.id] != item.revision else { return }
|
||||||
|
var updated = reads
|
||||||
|
updated[item.id] = item.revision
|
||||||
|
guard saveLocalState(reads: updated, dismissed: dismissed) else {
|
||||||
|
state.readSaveFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reads = updated
|
||||||
|
state.readSaveFailed = false
|
||||||
|
state.deleteFailed = false
|
||||||
|
updateReadingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss(_ item: Announcement) {
|
||||||
|
// A confirmation can outlive the displayed revision.
|
||||||
|
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }) else {
|
||||||
|
state.deleteFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updated = dismissed
|
||||||
|
updated[item.id] = item.revision
|
||||||
|
guard saveLocalState(reads: reads, dismissed: updated) else {
|
||||||
|
state.deleteFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dismissed = updated
|
||||||
|
state.deleteFailed = false
|
||||||
|
state.readSaveFailed = false
|
||||||
|
if summary && state.loading {
|
||||||
|
state.items.removeAll { dismissed[$0.id] == $0.revision }
|
||||||
|
updateReadingState()
|
||||||
|
} else {
|
||||||
|
cancelRequest()
|
||||||
|
rebuildVisibleItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failed(requestID: String) {
|
||||||
|
guard self.requestID == requestID else { return }
|
||||||
|
cancelRequest()
|
||||||
|
state.failed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(_ data: Data) {
|
||||||
|
guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||||
|
let id = object["request_id"] as? String, id == requestID else { return }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
guard object["error"] == nil, let response = try? decoder.decode(Response.self, from: data) else {
|
||||||
|
failed(requestID: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let limit = summary ? 200 : 20
|
||||||
|
guard response.summary_only == summary, response.offset >= 0,
|
||||||
|
response.total >= response.offset, response.total <= Int32.max,
|
||||||
|
response.catalog_revision >= 0,
|
||||||
|
response.items.count == min(limit, response.total - response.offset),
|
||||||
|
response.items.allSatisfy({ $0.id > 0 && $0.revision > 0 }) else {
|
||||||
|
failed(requestID: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let expectedRevision = summary ? pendingRevision : catalogRevision
|
||||||
|
if expectedRevision >= 0 && expectedRevision != response.catalog_revision {
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary {
|
||||||
|
guard response.offset == pendingCatalog.count,
|
||||||
|
pendingRevision < 0 || pendingTotal == response.total else {
|
||||||
|
failed(requestID: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for item in response.items {
|
||||||
|
guard pendingCatalog.updateValue(item.revision, forKey: item.id) == nil else {
|
||||||
|
failed(requestID: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingRevision = response.catalog_revision
|
||||||
|
pendingTotal = response.total
|
||||||
|
cancelRequest()
|
||||||
|
if pendingCatalog.count < pendingTotal {
|
||||||
|
request(offset: pendingCatalog.count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if catalogRevision != pendingRevision {
|
||||||
|
pageIDs = [:]
|
||||||
|
pageBodies = [:]
|
||||||
|
}
|
||||||
|
catalog = pendingCatalog
|
||||||
|
catalogRevision = pendingRevision
|
||||||
|
reads = reads.filter { catalog[$0.key] != nil }
|
||||||
|
dismissed = dismissed.filter { catalog[$0.key] != nil }
|
||||||
|
} else {
|
||||||
|
guard response.offset == pageOffset, response.total == catalog.count,
|
||||||
|
Set(response.items.map(\.id)).count == response.items.count,
|
||||||
|
let bodies = try? decoder.decode(BodyResponse.self, from: data).items,
|
||||||
|
bodies.allSatisfy({ item in
|
||||||
|
catalog[item.id] == item.revision &&
|
||||||
|
(1...32_503_680_000).contains(item.updated_at) &&
|
||||||
|
item.title.utf8.count <= 240 && item.body.utf8.count <= 8000
|
||||||
|
}) else {
|
||||||
|
failed(requestID: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelRequest()
|
||||||
|
pageIDs[response.offset] = bodies.map(\.id)
|
||||||
|
pageBodies[response.offset] = bodies
|
||||||
|
}
|
||||||
|
state.failed = false
|
||||||
|
rebuildVisibleItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelRequest() {
|
||||||
|
timeout?.invalidate()
|
||||||
|
timeout = nil
|
||||||
|
requestID = nil
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(offset: Int) {
|
||||||
|
guard connected, !scope.isEmpty else { return }
|
||||||
|
let id = UUID().uuidString
|
||||||
|
guard let data = try? JSONEncoder().encode(Request(
|
||||||
|
request_id: id, summary_only: summary, offset: offset)) else { return }
|
||||||
|
requestID = id
|
||||||
|
state.loading = true
|
||||||
|
state.failed = false
|
||||||
|
let timer = Timer(timeInterval: 12, repeats: false) { [weak self] _ in
|
||||||
|
self?.failed(requestID: id)
|
||||||
|
}
|
||||||
|
timeout = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
send?(data, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateReadingState() {
|
||||||
|
let visible = catalog.filter { dismissed[$0.key] != $0.value }
|
||||||
|
state.total = visible.count
|
||||||
|
state.unread = visible.filter { reads[$0.key] != $0.value }.count
|
||||||
|
state.items = state.items.map { item in
|
||||||
|
var item = item
|
||||||
|
item.read = reads[item.id] == item.revision
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildVisibleItems() {
|
||||||
|
updateReadingState()
|
||||||
|
let wanted = min(visibleLimit, state.total)
|
||||||
|
var visible: [Announcement] = []
|
||||||
|
var retainedPages: Set<Int> = []
|
||||||
|
var position = 0
|
||||||
|
var nextOffset: Int?
|
||||||
|
summary = false
|
||||||
|
if wanted > 0 {
|
||||||
|
for offset in stride(from: 0, to: catalog.count, by: 20) {
|
||||||
|
guard let ids = pageIDs[offset] else { nextOffset = offset; break }
|
||||||
|
var selection: Set<Int64> = []
|
||||||
|
for id in ids {
|
||||||
|
guard let revision = catalog[id], dismissed[id] != revision else { continue }
|
||||||
|
if position < wanted { selection.insert(id) }
|
||||||
|
position += 1
|
||||||
|
}
|
||||||
|
if selection.isEmpty { pageBodies[offset] = nil; continue }
|
||||||
|
retainedPages.insert(offset)
|
||||||
|
guard let bodies = pageBodies[offset] else { nextOffset = offset; break }
|
||||||
|
visible += bodies.filter { selection.contains($0.id) }
|
||||||
|
if visible.count == wanted { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if visible.count == wanted {
|
||||||
|
pageBodies = pageBodies.filter { retainedPages.contains($0.key) }
|
||||||
|
} else {
|
||||||
|
// Retain unchanged bodies while fetching a refreshed catalog.
|
||||||
|
var retained = Set(visible.map(\.id))
|
||||||
|
for item in state.items where catalog[item.id] == item.revision &&
|
||||||
|
dismissed[item.id] != item.revision && retained.insert(item.id).inserted {
|
||||||
|
visible.append(item)
|
||||||
|
if visible.count == wanted { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.items = visible
|
||||||
|
state.loaded = true
|
||||||
|
updateReadingState()
|
||||||
|
if let nextOffset {
|
||||||
|
pageOffset = nextOffset
|
||||||
|
request(offset: nextOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadLocalState() {
|
||||||
|
guard let file,
|
||||||
|
let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
||||||
|
size <= 16 * 1024 * 1024,
|
||||||
|
let data = try? Data(contentsOf: file),
|
||||||
|
let saved = try? JSONDecoder().decode(LocalState.self, from: data),
|
||||||
|
saved.scope == scope, [1, 2].contains(saved.version) else { return }
|
||||||
|
func revisions(_ entries: [String: Int64]) -> [Int64: Int64] {
|
||||||
|
var result: [Int64: Int64] = [:]
|
||||||
|
for (key, revision) in entries {
|
||||||
|
if let id = Int64(key), id > 0, revision > 0 { result[id] = revision }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
reads = revisions(saved.reads)
|
||||||
|
dismissed = revisions(saved.dismissed ?? [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLocalState(reads: [Int64: Int64], dismissed: [Int64: Int64]) -> Bool {
|
||||||
|
guard let file else { return false }
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
var directory = directory
|
||||||
|
var values = URLResourceValues()
|
||||||
|
values.isExcludedFromBackup = true
|
||||||
|
try directory.setResourceValues(values)
|
||||||
|
let saved = LocalState(version: 2, scope: scope,
|
||||||
|
reads: Dictionary(uniqueKeysWithValues: reads.map { (String($0.key), $0.value) }),
|
||||||
|
dismissed: Dictionary(uniqueKeysWithValues: dismissed.map { (String($0.key), $0.value) }))
|
||||||
|
try JSONEncoder().encode(saved).write(to: file, options: .atomic)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnnouncementBadge: View {
|
||||||
|
@ObservedObject var inbox: AnnouncementInbox
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: "bell")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground),
|
||||||
|
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if inbox.state.unread > 0 {
|
||||||
|
Text(inbox.state.unread > 99 ? "99+" : "\(inbox.state.unread)")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.frame(minWidth: 16, minHeight: 16)
|
||||||
|
.background(.red, in: Capsule())
|
||||||
|
.offset(x: 5, y: -3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("通知公告")
|
||||||
|
.accessibilityValue(inbox.state.unread > 0 ? "\(inbox.state.unread) 条未读" : "无未读公告")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnnouncementSelection: Hashable {
|
||||||
|
let id: Int64
|
||||||
|
let revision: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AnnouncementDate {
|
||||||
|
static let formatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func string(_ item: Announcement) -> String { formatter.string(from: item.date) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnnouncementInboxView: View {
|
||||||
|
@ObservedObject var session: RemoteSessionModel
|
||||||
|
@ObservedObject var inbox: AnnouncementInbox
|
||||||
|
@State private var pendingDelete: Announcement?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if inbox.state.deleteFailed {
|
||||||
|
Text("无法删除这条公告,请重试。")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
if inbox.state.failed && session.announcementsConnected {
|
||||||
|
HStack {
|
||||||
|
Text("公告加载失败,请重试。")
|
||||||
|
Spacer()
|
||||||
|
Button("重试") { session.refreshAnnouncements() }
|
||||||
|
.disabled(!session.announcementsConnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(inbox.state.items) { item in
|
||||||
|
NavigationLink(value: HomeDestination.announcement(
|
||||||
|
AnnouncementSelection(id: item.id, revision: item.revision))) {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(item.read ? Color.clear : Color.accentColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.padding(.top, 7)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
Text(verbatim: item.title)
|
||||||
|
.fontWeight(item.read ? .regular : .semibold)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(AnnouncementDate.string(item))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityValue(item.read ? "已读" : "未读")
|
||||||
|
}
|
||||||
|
.swipeActions {
|
||||||
|
Button("删除", role: .destructive) { pendingDelete = item }
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button("删除公告", role: .destructive) { pendingDelete = item }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inbox.state.loading {
|
||||||
|
HStack { Spacer(); ProgressView("加载中…"); Spacer() }
|
||||||
|
} else if inbox.state.items.count < inbox.state.total && !inbox.state.failed {
|
||||||
|
Button("加载更多") { inbox.loadMore() }
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(!session.announcementsConnected)
|
||||||
|
.onAppear { inbox.loadMore() }
|
||||||
|
} else if inbox.state.loaded && inbox.state.total == 0 &&
|
||||||
|
!inbox.state.failed && session.announcementsConnected {
|
||||||
|
Text("暂无公告")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 120)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("通知公告")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button { session.refreshAnnouncements() } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("刷新公告")
|
||||||
|
.disabled(inbox.state.loading || !session.announcementsConnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
session.refreshAnnouncements()
|
||||||
|
while inbox.state.loading && !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { session.refreshAnnouncements() }
|
||||||
|
.alert("删除公告", isPresented: Binding(
|
||||||
|
get: { pendingDelete != nil },
|
||||||
|
set: { if !$0 { pendingDelete = nil } }
|
||||||
|
)) {
|
||||||
|
Button("取消", role: .cancel) { pendingDelete = nil }
|
||||||
|
Button("删除", role: .destructive) {
|
||||||
|
if let item = pendingDelete { inbox.dismiss(item) }
|
||||||
|
pendingDelete = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("删除这条公告?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnnouncementDetailView: View {
|
||||||
|
@ObservedObject var inbox: AnnouncementInbox
|
||||||
|
let selection: AnnouncementSelection
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
private var item: Announcement? {
|
||||||
|
inbox.state.items.first { $0.id == selection.id && $0.revision == selection.revision }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if let item {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(verbatim: item.title)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
Text("\(AnnouncementDate.string(item)) · \(item.read ? "已读" : "未读")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if inbox.state.readSaveFailed {
|
||||||
|
Text("无法保存本地阅读状态,请重试。")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Button("重试保存阅读状态") { inbox.markRead(item) }
|
||||||
|
}
|
||||||
|
Text(AnnouncementText.format(item.body))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
guard AnnouncementText.isWebURL(url) else { return .discarded }
|
||||||
|
openURL(url)
|
||||||
|
return .handled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
} else {
|
||||||
|
Text("这条公告已更新或删除,请返回公告列表查看。")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("公告详情")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
if let item { inbox.markRead(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AnnouncementText {
|
||||||
|
/// Match desktop: preserve plain text and line breaks; recognize only web
|
||||||
|
/// URLs and [label](URL), without interpreting arbitrary Markdown or HTML.
|
||||||
|
static func format(_ body: String) -> AttributedString {
|
||||||
|
let text = Array(body.replacingOccurrences(of: "\r\n", with: "\n"))
|
||||||
|
var output = AttributedString()
|
||||||
|
var literal = ""
|
||||||
|
var position = 0
|
||||||
|
|
||||||
|
func hasScheme(at start: Int) -> Bool {
|
||||||
|
let prefix = String(text[start..<min(text.count, start + 8)]).lowercased()
|
||||||
|
return prefix.hasPrefix("https://") || prefix.hasPrefix("http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlEnd(from start: Int) -> Int {
|
||||||
|
let punctuation = Set("<>\"'`\\{},。;:!?、()【】《》「」『』“”‘’")
|
||||||
|
var parentheses = 0
|
||||||
|
var brackets = 0
|
||||||
|
var end = start
|
||||||
|
while end < text.count {
|
||||||
|
let character = text[end]
|
||||||
|
if punctuation.contains(character) || character.unicodeScalars.contains(where: {
|
||||||
|
$0.value <= 0x20 || $0.value == 0x7f
|
||||||
|
}) { break }
|
||||||
|
if character == "(" { parentheses += 1 }
|
||||||
|
if character == ")" {
|
||||||
|
if parentheses == 0 { break }
|
||||||
|
parentheses -= 1
|
||||||
|
}
|
||||||
|
if character == "[" { brackets += 1 }
|
||||||
|
if character == "]" {
|
||||||
|
if brackets == 0 { break }
|
||||||
|
brackets -= 1
|
||||||
|
}
|
||||||
|
end += 1
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendLink(label: String, destination: String) -> Bool {
|
||||||
|
guard let url = URL(string: destination), isWebURL(url) else { return false }
|
||||||
|
output.append(AttributedString(literal))
|
||||||
|
literal = ""
|
||||||
|
var link = AttributedString(label)
|
||||||
|
link.link = url
|
||||||
|
output.append(link)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
while position < text.count {
|
||||||
|
if text[position] == "[",
|
||||||
|
let labelEnd = text[(position + 1)...].firstIndex(of: "]"),
|
||||||
|
labelEnd > position + 1, labelEnd + 2 < text.count,
|
||||||
|
text[labelEnd + 1] == "(",
|
||||||
|
!text[(position + 1)..<labelEnd].contains(where: { "[]\r\n".contains($0) }),
|
||||||
|
hasScheme(at: labelEnd + 2) {
|
||||||
|
let end = urlEnd(from: labelEnd + 2)
|
||||||
|
if end < text.count, text[end] == ")",
|
||||||
|
appendLink(label: String(text[(position + 1)..<labelEnd]),
|
||||||
|
destination: String(text[(labelEnd + 2)..<end])) {
|
||||||
|
position = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasScheme(at: position) {
|
||||||
|
var end = urlEnd(from: position)
|
||||||
|
while end > position, ".,;:!?".contains(text[end - 1]) { end -= 1 }
|
||||||
|
let address = String(text[position..<end])
|
||||||
|
if appendLink(label: address, destination: address) {
|
||||||
|
position = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal.append(text[position])
|
||||||
|
position += 1
|
||||||
|
}
|
||||||
|
output.append(AttributedString(literal))
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isWebURL(_ url: URL) -> Bool {
|
||||||
|
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||||
|
let host = url.host, !host.isEmpty else { return false }
|
||||||
|
return !url.absoluteString.unicodeScalars.contains {
|
||||||
|
$0.value <= 0x20 || $0.value == 0x7f || "\\<>\"".unicodeScalars.contains($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,10 @@ typedef struct {
|
|||||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||||
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
|
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
|
||||||
snapshot:(BOOL)snapshot;
|
snapshot:(BOOL)snapshot;
|
||||||
|
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||||
|
didReceiveAnnouncementMessage:(NSData *)message;
|
||||||
|
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||||
|
didFailAnnouncementRequest:(NSString *)requestID;
|
||||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||||
didReceiveCursorVisible:(BOOL)visible
|
didReceiveCursorVisible:(BOOL)visible
|
||||||
shape:(NSInteger)shape
|
shape:(NSInteger)shape
|
||||||
@@ -150,6 +154,8 @@ typedef struct {
|
|||||||
subscribe:(BOOL)subscribe
|
subscribe:(BOOL)subscribe
|
||||||
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
|
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
|
||||||
- (void)invalidatePresence;
|
- (void)invalidatePresence;
|
||||||
|
- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID
|
||||||
|
NS_SWIFT_NAME(sendAnnouncementRequest(_:requestID:));
|
||||||
|
|
||||||
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
|
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
|
||||||
- (void)disconnect;
|
- (void)disconnect;
|
||||||
|
|||||||
@@ -615,13 +615,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
|||||||
if (load_status == errSecSuccess) {
|
if (load_status == errSecSuccess) {
|
||||||
self->_state->identity_with_password = cached.UTF8String;
|
self->_state->identity_with_password = cached.UTF8String;
|
||||||
self->_state->identity_base = BaseIdentity(self->_state->identity_with_password);
|
self->_state->identity_base = BaseIdentity(self->_state->identity_with_password);
|
||||||
NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()];
|
|
||||||
DispatchMain(^{
|
|
||||||
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
|
||||||
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
|
|
||||||
[delegate rtcBridge:self didProvisionIdentity:identifier];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (load_status != errSecItemNotFound) {
|
} else if (load_status != errSecItemNotFound) {
|
||||||
[self reportIdentityStorageStatus:load_status];
|
[self reportIdentityStorageStatus:load_status];
|
||||||
// Locked/unavailable Keychain is not the same as a missing identity.
|
// Locked/unavailable Keychain is not the same as a missing identity.
|
||||||
@@ -631,6 +624,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[self createIdentityPeer];
|
[self createIdentityPeer];
|
||||||
|
if (load_status == errSecSuccess) {
|
||||||
|
NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()];
|
||||||
|
const uint64_t identity_generation = self->_identityGeneration.load();
|
||||||
|
DispatchMain(^{
|
||||||
|
if (networking_generation != self->_networkingGeneration.load() ||
|
||||||
|
identity_generation != self->_identityGeneration.load()) return;
|
||||||
|
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||||
|
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
|
||||||
|
[delegate rtcBridge:self didProvisionIdentity:identifier];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +663,32 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
|||||||
_presenceGeneration.fetch_add(1);
|
_presenceGeneration.fetch_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID {
|
||||||
|
NSData *payload = [request copy];
|
||||||
|
NSString *identifier = [requestID copy];
|
||||||
|
const uint64_t generation = _identityGeneration.load();
|
||||||
|
const uint64_t signal_generation = _signalGeneration.load();
|
||||||
|
const uint64_t networking_generation = _networkingGeneration.load();
|
||||||
|
dispatch_async(_rtcQueue, ^{
|
||||||
|
if (generation != self->_identityGeneration.load() ||
|
||||||
|
signal_generation != self->_signalGeneration.load() ||
|
||||||
|
networking_generation != self->_networkingGeneration.load()) return;
|
||||||
|
if (self->_state->identity_peer && self->_state->identity_ready &&
|
||||||
|
!self->_state->identity_base.empty() &&
|
||||||
|
SendSignalMessage(self->_state->identity_peer,
|
||||||
|
static_cast<const char *>(payload.bytes), payload.length) == 0) return;
|
||||||
|
DispatchMain(^{
|
||||||
|
if (generation != self->_identityGeneration.load() ||
|
||||||
|
signal_generation != self->_signalGeneration.load() ||
|
||||||
|
networking_generation != self->_networkingGeneration.load()) return;
|
||||||
|
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||||
|
if ([delegate respondsToSelector:@selector(rtcBridge:didFailAnnouncementRequest:)]) {
|
||||||
|
[delegate rtcBridge:self didFailAnnouncementRequest:identifier];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
|
- (void)sendVideoSettingsWithQuality:(NSInteger)quality
|
||||||
frameRate:(NSInteger)frameRate
|
frameRate:(NSInteger)frameRate
|
||||||
preference:(NSInteger)preference
|
preference:(NSInteger)preference
|
||||||
@@ -1216,10 +1247,11 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
|||||||
size:(size_t)size
|
size:(size_t)size
|
||||||
role:(PeerRole)role
|
role:(PeerRole)role
|
||||||
generation:(uint64_t)generation {
|
generation:(uint64_t)generation {
|
||||||
constexpr size_t kMaxPresenceMessageSize = 256 * 1024;
|
// Twenty 8 KiB bodies may expand substantially when JSON-escaped.
|
||||||
|
constexpr size_t kMaxSignalMessageSize = 1024 * 1024;
|
||||||
if (role != PeerRole::Identity || generation != _identityGeneration.load() ||
|
if (role != PeerRole::Identity || generation != _identityGeneration.load() ||
|
||||||
!message || size == 0 ||
|
!message || size == 0 ||
|
||||||
size > kMaxPresenceMessageSize) {
|
size > kMaxSignalMessageSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1267,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
|||||||
NSString *type = object[@"type"];
|
NSString *type = object[@"type"];
|
||||||
if (![type isKindOfClass:NSString.class]) return;
|
if (![type isKindOfClass:NSString.class]) return;
|
||||||
|
|
||||||
|
if ([type isEqualToString:@"announcements"] ||
|
||||||
|
[type isEqualToString:@"announcements_changed"]) {
|
||||||
|
DispatchMain(^{
|
||||||
|
if (generation != self->_identityGeneration.load() ||
|
||||||
|
signal_generation != self->_signalGeneration.load()) return;
|
||||||
|
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
|
||||||
|
if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveAnnouncementMessage:)]) {
|
||||||
|
[delegate rtcBridge:self didReceiveAnnouncementMessage:payload];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
NSMutableDictionary<NSString *, NSNumber *> *presence =
|
NSMutableDictionary<NSString *, NSNumber *> *presence =
|
||||||
[NSMutableDictionary dictionary];
|
[NSMutableDictionary dictionary];
|
||||||
if ([type isEqualToString:@"presence"]) {
|
if ([type isEqualToString:@"presence"]) {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
enum HomeDestination: Hashable {
|
||||||
|
case settings
|
||||||
|
case announcements
|
||||||
|
case announcement(AnnouncementSelection)
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@ObservedObject var session: RemoteSessionModel
|
@ObservedObject var session: RemoteSessionModel
|
||||||
@@ -15,6 +21,19 @@ struct ContentView: View {
|
|||||||
ConnectionHomeView(session: session)
|
ConnectionHomeView(session: session)
|
||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
// Keep inbox and detail in the same value-based stack.
|
||||||
|
// Mixing an isPresented inbox with a value-based detail
|
||||||
|
// can reinsert the inbox above the detail on updates.
|
||||||
|
.navigationDestination(for: HomeDestination.self) { destination in
|
||||||
|
switch destination {
|
||||||
|
case .settings:
|
||||||
|
ServerSettingsView(session: session)
|
||||||
|
case .announcements:
|
||||||
|
AnnouncementInboxView(session: session, inbox: session.announcements)
|
||||||
|
case .announcement(let selection):
|
||||||
|
AnnouncementDetailView(inbox: session.announcements, selection: selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(Color(.systemGroupedBackground), for: .navigationBar)
|
.toolbarBackground(Color(.systemGroupedBackground), for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
@@ -142,7 +161,6 @@ private struct ConnectionHomeView: View {
|
|||||||
@ObservedObject var session: RemoteSessionModel
|
@ObservedObject var session: RemoteSessionModel
|
||||||
@FocusState private var remoteIDFocused: Bool
|
@FocusState private var remoteIDFocused: Bool
|
||||||
@State private var passwordPromptVisible = false
|
@State private var passwordPromptVisible = false
|
||||||
@State private var settingsVisible = false
|
|
||||||
@State private var promptRemoteID = ""
|
@State private var promptRemoteID = ""
|
||||||
@State private var promptPassword = ""
|
@State private var promptPassword = ""
|
||||||
@State private var promptRememberPassword = false
|
@State private var promptRememberPassword = false
|
||||||
@@ -248,9 +266,6 @@ private struct ConnectionHomeView: View {
|
|||||||
.presentationDetents([.height(340)])
|
.presentationDetents([.height(340)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $settingsVisible) {
|
|
||||||
ServerSettingsView(session: session)
|
|
||||||
}
|
|
||||||
.alert("设备离线", isPresented: Binding(
|
.alert("设备离线", isPresented: Binding(
|
||||||
get: { session.deviceOfflineAlertVisible },
|
get: { session.deviceOfflineAlertVisible },
|
||||||
set: { visible in
|
set: { visible in
|
||||||
@@ -267,6 +282,7 @@ private struct ConnectionHomeView: View {
|
|||||||
session.remoteID = formatted
|
session.remoteID = formatted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear { remoteIDFocused = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
@@ -288,10 +304,14 @@ private struct ConnectionHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
NavigationLink(value: HomeDestination.announcements) {
|
||||||
remoteIDFocused = false
|
AnnouncementBadge(inbox: session.announcements)
|
||||||
settingsVisible = true
|
}
|
||||||
} label: {
|
.foregroundStyle(.primary)
|
||||||
|
.disabled(session.isConnecting)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
NavigationLink(value: HomeDestination.settings) {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
@Published private(set) var remotePlatform: RemoteHostPlatform = .unknown
|
@Published private(set) var remotePlatform: RemoteHostPlatform = .unknown
|
||||||
|
|
||||||
let bridge = CrossDeskRTCBridge()
|
let bridge = CrossDeskRTCBridge()
|
||||||
|
let announcements = AnnouncementInbox()
|
||||||
private let audioPlayer = RemoteAudioPlayer()
|
private let audioPlayer = RemoteAudioPlayer()
|
||||||
private var activeRemoteID = ""
|
private var activeRemoteID = ""
|
||||||
private var remoteAppVersion = ""
|
private var remoteAppVersion = ""
|
||||||
@@ -387,6 +388,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
bridge.delegate = self
|
bridge.delegate = self
|
||||||
|
announcements.send = { [weak bridge] data, requestID in
|
||||||
|
bridge?.sendAnnouncementRequest(data, requestID: requestID)
|
||||||
|
}
|
||||||
if !savesConnectionThumbnails { clearConnectionThumbnails() }
|
if !savesConnectionThumbnails { clearConnectionThumbnails() }
|
||||||
if hasNetworkConsent { configureBridge() }
|
if hasNetworkConsent { configureBridge() }
|
||||||
}
|
}
|
||||||
@@ -401,6 +405,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
func applicationDidBecomeActive() {
|
func applicationDidBecomeActive() {
|
||||||
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
|
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
|
||||||
refreshRecentConnectionPresenceAfterForeground()
|
refreshRecentConnectionPresenceAfterForeground()
|
||||||
|
refreshAnnouncements()
|
||||||
resumeVideoAfterForeground()
|
resumeVideoAfterForeground()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +445,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
// disconnect() only closes the remote session. Consent withdrawal must
|
// disconnect() only closes the remote session. Consent withdrawal must
|
||||||
// also destroy the persistent signaling peer and its reconnect loop.
|
// also destroy the persistent signaling peer and its reconnect loop.
|
||||||
bridge.stopNetworking()
|
bridge.stopNetworking()
|
||||||
|
announcements.reset()
|
||||||
bridgeConfiguration = nil
|
bridgeConfiguration = nil
|
||||||
localIdentity = ""
|
localIdentity = ""
|
||||||
signalStatus = "等待隐私授权"
|
signalStatus = "等待隐私授权"
|
||||||
@@ -500,6 +506,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
if bridgeConfiguration != configuration {
|
if bridgeConfiguration != configuration {
|
||||||
bridgeConfiguration = configuration
|
bridgeConfiguration = configuration
|
||||||
signalConnected = false
|
signalConnected = false
|
||||||
|
localIdentity = ""
|
||||||
|
announcements.reset()
|
||||||
signalStatus = "正在连接信令服务"
|
signalStatus = "正在连接信令服务"
|
||||||
stopPresenceMonitoring()
|
stopPresenceMonitoring()
|
||||||
invalidatePresence()
|
invalidatePresence()
|
||||||
@@ -1040,6 +1048,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
didChange state: CrossDeskSignalState) {
|
didChange state: CrossDeskSignalState) {
|
||||||
guard hasNetworkConsent else { return }
|
guard hasNetworkConsent else { return }
|
||||||
signalConnected = state.rawValue == 1
|
signalConnected = state.rawValue == 1
|
||||||
|
announcements.setConnected(signalConnected)
|
||||||
invalidatePresence()
|
invalidatePresence()
|
||||||
switch state.rawValue {
|
switch state.rawValue {
|
||||||
case 0: signalStatus = "正在连接信令服务"
|
case 0: signalStatus = "正在连接信令服务"
|
||||||
@@ -1114,12 +1123,38 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
|
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
|
||||||
guard hasNetworkConsent else { return }
|
guard hasNetworkConsent else { return }
|
||||||
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
|
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
|
||||||
|
if let configuration = bridgeConfiguration {
|
||||||
|
announcements.configure(host: configuration.host, port: configuration.signalPort,
|
||||||
|
deviceID: localIdentity)
|
||||||
|
}
|
||||||
refreshRecentConnectionPresence()
|
refreshRecentConnectionPresence()
|
||||||
if let pendingPresenceRemoteID {
|
if let pendingPresenceRemoteID {
|
||||||
requestPresenceProbe(pendingPresenceRemoteID)
|
requestPresenceProbe(pendingPresenceRemoteID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var announcementsConnected: Bool { hasNetworkConsent && signalConnected }
|
||||||
|
|
||||||
|
func refreshAnnouncements() {
|
||||||
|
guard announcementsConnected else { return }
|
||||||
|
announcements.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAnnouncementMessage message: Data) {
|
||||||
|
guard announcementsConnected else { return }
|
||||||
|
let object = (try? JSONSerialization.jsonObject(with: message)) as? [String: Any]
|
||||||
|
if object?["type"] as? String == "announcements_changed" {
|
||||||
|
announcements.refresh()
|
||||||
|
} else {
|
||||||
|
announcements.receive(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rtcBridge(_ bridge: CrossDeskRTCBridge, didFailAnnouncementRequest requestID: String) {
|
||||||
|
guard hasNetworkConsent else { return }
|
||||||
|
announcements.failed(requestID: requestID)
|
||||||
|
}
|
||||||
|
|
||||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||||
didReceivePresence presence: [String: NSNumber],
|
didReceivePresence presence: [String: NSNumber],
|
||||||
snapshot: Bool) {
|
snapshot: Bool) {
|
||||||
|
|||||||
@@ -77,6 +77,28 @@ for that server. Remote control sessions then log in as `C-<identity>` and use
|
|||||||
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
|
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
|
||||||
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
|
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
|
||||||
|
|
||||||
|
## Announcements
|
||||||
|
|
||||||
|
The home toolbar's bell opens **通知公告** and shows the unread count (capped
|
||||||
|
at `99+` on the badge). The persistent identity peer fetches announcements after
|
||||||
|
signaling connects, so a remote desktop session is not required. Opening the
|
||||||
|
inbox, returning to the foreground, reconnecting, and `announcements_changed`
|
||||||
|
events refresh the catalog. Pull to refresh or use the refresh button to retry.
|
||||||
|
|
||||||
|
The iOS inbox follows the desktop `announcements_list` protocol: summaries load
|
||||||
|
in batches of 200 to count all unread announcements, while full bodies load in
|
||||||
|
batches of 20 as the list scrolls. Opening a detail marks that revision read.
|
||||||
|
Swipe or long-press a row to delete it after confirmation. Read and deleted
|
||||||
|
revisions persist only on this device, scoped by server, port and device ID;
|
||||||
|
updating an announcement makes its new revision visible and unread again.
|
||||||
|
Plain text, line breaks, HTTP(S) URLs and `[label](URL)` links are supported.
|
||||||
|
|
||||||
|
Local state lives in Application Support and is excluded from backups. Failed
|
||||||
|
writes leave the item unread or undeleted and display a retry message. Failed
|
||||||
|
requests time out after 12 seconds and retain previously loaded content.
|
||||||
|
Changing servers or withdrawing network consent clears the displayed inbox and
|
||||||
|
invalidates pending responses; consent is required before fetching again.
|
||||||
|
|
||||||
## Video codecs
|
## Video codecs
|
||||||
|
|
||||||
Every iOS build includes VideoToolbox, dav1d, and SVT-AV1. H.264 uses
|
Every iOS build includes VideoToolbox, dav1d, and SVT-AV1. H.264 uses
|
||||||
|
|||||||
Reference in New Issue
Block a user