feat(ui): add family controls, casting and native notifications

This commit is contained in:
saphid committed 2026-09-28 22:29:58 +10:00
1 parent 522c46ed6f
commit 0622afcae9
11 files changed
+316 -7

No files matched your search

@@ -17,6 +17,7 @@
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */; };
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
@@ -48,6 +49,7 @@
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfortNotificationTests.swift; sourceTree = "<group>"; };
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -107,6 +109,7 @@
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
isa = PBXGroup;
children = (
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
);
path = FrameControlTests;
@@ -274,6 +277,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
+44 -2
View File
@@ -1,6 +1,7 @@
import SwiftUI
import UIKit
import WebKit
import UserNotifications
/// The Frame Control page, served by the server on the headset, in a web view.
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
@@ -48,6 +49,7 @@ struct WebShell: UIViewRepresentable {
let installCb = null;
window.frameApp = {
platform: "ios",
notify: (message, request) => call("notify", { message, request }),
readClipboard: () => call("readClipboard"),
setUpConnection: () => call("setUpConnection"),
open: (what) => call("open", what),
@@ -58,13 +60,31 @@ struct WebShell: UIViewRepresentable {
})();
"""
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate, UNUserNotificationCenterDelegate {
let model: AppModel
weak var web: WKWebView?
var loaded: URL?
private var installReady = false
init(model: AppModel) { self.model = model }
init(model: AppModel) {
self.model = model
super.init()
UNUserNotificationCenter.current().delegate = self
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound, .list])
}
static func notificationContent(_ message: String) -> UNMutableNotificationContent? {
guard !message.isEmpty, message.count <= 500 else { return nil }
let content = UNMutableNotificationContent()
content.title = "Frame Control"
content.body = message
content.sound = .default
return content
}
// MARK: bridge
@@ -76,6 +96,28 @@ struct WebShell: UIViewRepresentable {
}
let arg = body["arg"]
switch name {
case "notify":
guard message.frameInfo.isMainFrame,
message.frameInfo.securityOrigin.host == "127.0.0.1",
let args = arg as? [String: Any], let text = args["message"] as? String,
let content = Self.notificationContent(text) else {
return replyHandler(nil, "Invalid notification")
}
let center = UNUserNotificationCenter.current()
let send: (Bool, Error?) -> Void = { allowed, error in
guard allowed else {
return replyHandler(nil, error?.localizedDescription ?? "Notifications are off. Enable them in iOS Settings.")
}
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
center.add(request) { error in replyHandler(error == nil, error?.localizedDescription) }
}
if args["request"] as? Bool == true {
center.requestAuthorization(options: [.alert, .sound], completionHandler: send)
} else {
center.getNotificationSettings { settings in
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
}
}
case "readClipboard":
replyHandler(UIPasteboard.general.string ?? "", nil)
case "setUpConnection":
@@ -0,0 +1,14 @@
import XCTest
import UserNotifications
@testable import Frame_Control
final class ComfortNotificationTests: XCTestCase {
func testNotificationContentAndBounds() {
let content = WebShell.Coordinator.notificationContent("Time for a break")
XCTAssertEqual(content?.title, "Frame Control")
XCTAssertEqual(content?.body, "Time for a break")
XCTAssertNotNil(content?.sound)
XCTAssertNil(WebShell.Coordinator.notificationContent(""))
XCTAssertNil(WebShell.Coordinator.notificationContent(String(repeating: "x", count: 501)))
}
}