From 31acb6b2f00ad8b0c3ca05c52f150df9f032c9f8 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Tue, 29 Sep 2026 23:43:32 +0800 Subject: [PATCH] [feat] add iOS announcement inbox --- .../CrossDeskMobile.xcodeproj/project.pbxproj | 12 + .../CrossDeskMobile/AnnouncementInbox.swift | 378 ++++++++++++++++++ .../AnnouncementInboxView.swift | 189 +++++++++ .../CrossDeskMobile/AnnouncementText.swift | 90 +++++ .../Bridge/CrossDeskRTCBridge.h | 6 + .../Bridge/CrossDeskRTCBridge.mm | 63 ++- apps/ios/CrossDeskMobile/ContentView.swift | 36 +- .../CrossDeskMobile/RemoteSessionModel.swift | 35 ++ apps/ios/README.md | 22 + 9 files changed, 814 insertions(+), 17 deletions(-) create mode 100644 apps/ios/CrossDeskMobile/AnnouncementInbox.swift create mode 100644 apps/ios/CrossDeskMobile/AnnouncementInboxView.swift create mode 100644 apps/ios/CrossDeskMobile/AnnouncementText.swift diff --git a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj index 0ea1224d..c6949e22 100644 --- a/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj +++ b/apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -27,6 +27,9 @@ 100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; }; 100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; }; 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 */ /* Begin PBXFileReference section */ @@ -54,6 +57,9 @@ 200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = ""; }; 200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = ""; }; 200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = ""; }; + 200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = ""; }; + 20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = ""; }; + 20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -98,6 +104,9 @@ 200000000000000000000013 /* ThumbnailFileStore.swift */, 200000000000000000000012 /* PrivacyPreferences.swift */, 200000000000000000000002 /* RemoteSessionModel.swift */, + 200000000000000000000019 /* AnnouncementInbox.swift */, + 20000000000000000000001A /* AnnouncementText.swift */, + 20000000000000000000001B /* AnnouncementInboxView.swift */, 200000000000000000000018 /* RecentConnection.swift */, 200000000000000000000016 /* RemoteVideoSettings.swift */, 200000000000000000000017 /* RemoteNetworkStatistics.swift */, @@ -264,6 +273,9 @@ files = ( 100000000000000000000001 /* CrossDeskMobileApp.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 */, 100000000000000000000016 /* RemoteVideoSettings.swift in Sources */, 100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */, diff --git a/apps/ios/CrossDeskMobile/AnnouncementInbox.swift b/apps/ios/CrossDeskMobile/AnnouncementInbox.swift new file mode 100644 index 00000000..a6874cc9 --- /dev/null +++ b/apps/ios/CrossDeskMobile/AnnouncementInbox.swift @@ -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 = [] + 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 = [] + 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 + } + } +} diff --git a/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift b/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift new file mode 100644 index 00000000..acd5f5b5 --- /dev/null +++ b/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift @@ -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) } + } + } +} diff --git a/apps/ios/CrossDeskMobile/AnnouncementText.swift b/apps/ios/CrossDeskMobile/AnnouncementText.swift new file mode 100644 index 00000000..c6677fd1 --- /dev/null +++ b/apps/ios/CrossDeskMobile/AnnouncementText.swift @@ -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.. 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).. position, ".,;:!?".contains(text[end - 1]) { end -= 1 } + let address = String(text[position.. 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) + } + } +} diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 1ff0a1f2..9830c4b8 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -99,6 +99,10 @@ typedef struct { - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceivePresence:(NSDictionary *)presence snapshot:(BOOL)snapshot; +- (void)rtcBridge:(CrossDeskRTCBridge *)bridge + didReceiveAnnouncementMessage:(NSData *)message; +- (void)rtcBridge:(CrossDeskRTCBridge *)bridge + didFailAnnouncementRequest:(NSString *)requestID; - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceiveCursorVisible:(BOOL)visible shape:(NSInteger)shape @@ -150,6 +154,8 @@ typedef struct { subscribe:(BOOL)subscribe NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:)); - (void)invalidatePresence; +- (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID + NS_SWIFT_NAME(sendAnnouncementRequest(_:requestID:)); - (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password; - (void)disconnect; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 3dee9878..b080aa5a 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -615,13 +615,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id, if (load_status == errSecSuccess) { self->_state->identity_with_password = cached.UTF8String; self->_state->identity_base = BaseIdentity(self->_state->identity_with_password); - NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()]; - DispatchMain(^{ - id delegate = self.delegate; - if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) { - [delegate rtcBridge:self didProvisionIdentity:identifier]; - } - }); } else if (load_status != errSecItemNotFound) { [self reportIdentityStorageStatus:load_status]; // 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; } [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 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); } +- (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(payload.bytes), payload.length) == 0) return; + DispatchMain(^{ + if (generation != self->_identityGeneration.load() || + signal_generation != self->_signalGeneration.load() || + networking_generation != self->_networkingGeneration.load()) return; + id delegate = self.delegate; + if ([delegate respondsToSelector:@selector(rtcBridge:didFailAnnouncementRequest:)]) { + [delegate rtcBridge:self didFailAnnouncementRequest:identifier]; + } + }); + }); +} + - (void)sendVideoSettingsWithQuality:(NSInteger)quality frameRate:(NSInteger)frameRate preference:(NSInteger)preference @@ -1216,10 +1247,11 @@ Params MakeParams(const RTCState &state, const std::string &user_id, size:(size_t)size role:(PeerRole)role 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() || !message || size == 0 || - size > kMaxPresenceMessageSize) { + size > kMaxSignalMessageSize) { return; } @@ -1235,6 +1267,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id, NSString *type = object[@"type"]; 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 delegate = self.delegate; + if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveAnnouncementMessage:)]) { + [delegate rtcBridge:self didReceiveAnnouncementMessage:payload]; + } + }); + return; + } + NSMutableDictionary *presence = [NSMutableDictionary dictionary]; if ([type isEqualToString:@"presence"]) { diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index 333d1b05..8497141e 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -1,6 +1,12 @@ import SwiftUI import UIKit +enum HomeDestination: Hashable { + case settings + case announcements + case announcement(AnnouncementSelection) +} + struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @ObservedObject var session: RemoteSessionModel @@ -15,6 +21,19 @@ struct ContentView: View { ConnectionHomeView(session: session) .navigationTitle("") .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(.visible, for: .navigationBar) @@ -142,7 +161,6 @@ private struct ConnectionHomeView: View { @ObservedObject var session: RemoteSessionModel @FocusState private var remoteIDFocused: Bool @State private var passwordPromptVisible = false - @State private var settingsVisible = false @State private var promptRemoteID = "" @State private var promptPassword = "" @State private var promptRememberPassword = false @@ -248,9 +266,6 @@ private struct ConnectionHomeView: View { .presentationDetents([.height(340)]) .presentationDragIndicator(.visible) } - .navigationDestination(isPresented: $settingsVisible) { - ServerSettingsView(session: session) - } .alert("设备离线", isPresented: Binding( get: { session.deviceOfflineAlertVisible }, set: { visible in @@ -267,6 +282,7 @@ private struct ConnectionHomeView: View { session.remoteID = formatted } } + .onDisappear { remoteIDFocused = false } } @ToolbarContentBuilder @@ -288,10 +304,14 @@ private struct ConnectionHomeView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - Button { - remoteIDFocused = false - settingsVisible = true - } label: { + NavigationLink(value: HomeDestination.announcements) { + AnnouncementBadge(inbox: session.announcements) + } + .foregroundStyle(.primary) + .disabled(session.isConnecting) + } + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink(value: HomeDestination.settings) { Image(systemName: "gearshape") .font(.system(size: 18, weight: .semibold)) .frame(width: 40, height: 40) diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index dcabe5fd..ebc7aabf 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -338,6 +338,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe @Published private(set) var remotePlatform: RemoteHostPlatform = .unknown let bridge = CrossDeskRTCBridge() + let announcements = AnnouncementInbox() private let audioPlayer = RemoteAudioPlayer() private var activeRemoteID = "" private var remoteAppVersion = "" @@ -387,6 +388,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe override init() { super.init() bridge.delegate = self + announcements.send = { [weak bridge] data, requestID in + bridge?.sendAnnouncementRequest(data, requestID: requestID) + } if !savesConnectionThumbnails { clearConnectionThumbnails() } if hasNetworkConsent { configureBridge() } } @@ -401,6 +405,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func applicationDidBecomeActive() { privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent) refreshRecentConnectionPresenceAfterForeground() + refreshAnnouncements() resumeVideoAfterForeground() } @@ -440,6 +445,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe // disconnect() only closes the remote session. Consent withdrawal must // also destroy the persistent signaling peer and its reconnect loop. bridge.stopNetworking() + announcements.reset() bridgeConfiguration = nil localIdentity = "" signalStatus = "等待隐私授权" @@ -500,6 +506,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe if bridgeConfiguration != configuration { bridgeConfiguration = configuration signalConnected = false + localIdentity = "" + announcements.reset() signalStatus = "正在连接信令服务" stopPresenceMonitoring() invalidatePresence() @@ -1040,6 +1048,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe didChange state: CrossDeskSignalState) { guard hasNetworkConsent else { return } signalConnected = state.rawValue == 1 + announcements.setConnected(signalConnected) invalidatePresence() switch state.rawValue { case 0: signalStatus = "正在连接信令服务" @@ -1114,12 +1123,38 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) { guard hasNetworkConsent else { return } localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity + if let configuration = bridgeConfiguration { + announcements.configure(host: configuration.host, port: configuration.signalPort, + deviceID: localIdentity) + } refreshRecentConnectionPresence() if let 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, didReceivePresence presence: [String: NSNumber], snapshot: Bool) { diff --git a/apps/ios/README.md b/apps/ios/README.md index b7e87ceb..3bdd57de 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -77,6 +77,28 @@ for that server. Remote control sessions then log in as `C-` and use the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`, `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 Every iOS build includes VideoToolbox, dav1d, and SVT-AV1. H.264 uses