import UIKit import WebKit @_cdecl("install_ios_workspace") func installIOSWorkspace() { DispatchQueue.main.async { guard let root = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }).flatMap(\.windows) .first(where: { $0.isKeyWindow })?.rootViewController ?? UIApplication.shared.windows.first?.rootViewController else { return } guard !root.children.contains(where: { $0 is WorkspaceViewController }) else { return } let controller = WorkspaceViewController() root.view.subviews.forEach { $0.isHidden = true } root.addChild(controller) root.view.addSubview(controller.view) controller.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ controller.view.topAnchor.constraint(equalTo: root.view.topAnchor), controller.view.bottomAnchor.constraint(equalTo: root.view.bottomAnchor), controller.view.leadingAnchor.constraint(equalTo: root.view.leadingAnchor), controller.view.trailingAnchor.constraint(equalTo: root.view.trailingAnchor), ]) controller.didMove(toParent: root) } } final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate { private let defaults: UserDefaults private let serverKey = "ios.activeServer" private(set) var server: WorkspaceServer? private(set) var workspace: WKWebView? private var connection = WorkspaceConnection() private var pendingNavigation: WKNavigation? private var timeout: DispatchWorkItem? private let loading = UIActivityIndicatorView(style: .large) private let launcher = UIScrollView() private let stack = UIStackView() private let address = UITextField() private let status = UILabel() private let connectButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system) private let toolbar = UIToolbar() private var appearance: UISegmentedControl! init(defaults: UserDefaults = .standard) { self.defaults = defaults super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func text(_ key: String) -> String { NSLocalizedString(key, comment: "KanbanGit iOS workspace") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1) loading.accessibilityIdentifier = "workspaceLoading" loading.accessibilityLabel = text("connecting") loading.translatesAutoresizingMaskIntoConstraints = false view.addSubview(loading) NSLayoutConstraint.activate([ loading.centerXAnchor.constraint(equalTo: view.centerXAnchor), loading.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) configureLauncher() configureToolbar() if let data = defaults.data(forKey: serverKey) { if let saved = try? JSONDecoder().decode(WorkspaceServer.self, from: data), WorkspaceServer.normalize(saved.url.absoluteString) == saved.url { server = saved } else { status.text = text("storageError") } } address.text = server?.url.absoluteString ?? "https://kanbangit.de/" if let server { open(server.url) } else { showSettings() } } private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel { let label = UILabel() label.text = text(key) label.font = .preferredFont(forTextStyle: style) label.adjustsFontForContentSizeCategory = true label.numberOfLines = 0 return label } private func configureLauncher() { launcher.isHidden = true launcher.accessibilityIdentifier = "serverSettings" launcher.keyboardDismissMode = .interactive launcher.bounces = false view.addSubview(launcher) launcher.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ launcher.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), launcher.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), launcher.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), launcher.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) stack.axis = .vertical stack.spacing = 18 stack.translatesAutoresizingMaskIntoConstraints = false launcher.addSubview(stack) let width = stack.widthAnchor.constraint(equalTo: launcher.frameLayoutGuide.widthAnchor, constant: -40) width.priority = .defaultHigh NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: launcher.contentLayoutGuide.topAnchor, constant: 32), stack.bottomAnchor.constraint(equalTo: launcher.contentLayoutGuide.bottomAnchor, constant: -32), stack.centerXAnchor.constraint(equalTo: launcher.frameLayoutGuide.centerXAnchor), stack.leadingAnchor.constraint(greaterThanOrEqualTo: launcher.contentLayoutGuide.leadingAnchor, constant: 20), stack.trailingAnchor.constraint(lessThanOrEqualTo: launcher.contentLayoutGuide.trailingAnchor, constant: -20), stack.widthAnchor.constraint(lessThanOrEqualToConstant: 480), width, ]) stack.addArrangedSubview(label("appName", style: .headline)) stack.addArrangedSubview(label("title", style: .largeTitle)) stack.addArrangedSubview(label("description")) stack.addArrangedSubview(label("serverLabel", style: .headline)) address.borderStyle = .roundedRect address.keyboardType = .URL address.textContentType = .URL address.autocorrectionType = .no address.autocapitalizationType = .none address.clearButtonMode = .whileEditing address.returnKeyType = .go address.delegate = self address.accessibilityLabel = text("serverLabel") address.accessibilityIdentifier = "serverAddress" address.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true stack.addArrangedSubview(address) stack.addArrangedSubview(label("serverHelp", style: .footnote)) connectButton.configuration = .filled() connectButton.setTitle(text("openWorkspace"), for: .normal) connectButton.accessibilityIdentifier = "openWorkspace" connectButton.addTarget(self, action: #selector(connect), for: .touchUpInside) stack.addArrangedSubview(connectButton) status.numberOfLines = 0 status.accessibilityIdentifier = "connectionStatus" status.font = .preferredFont(forTextStyle: .body) status.adjustsFontForContentSizeCategory = true stack.addArrangedSubview(status) resumeButton.setTitle(text("resume"), for: .normal) resumeButton.accessibilityIdentifier = "resumeWorkspace" resumeButton.addTarget(self, action: #selector(showWorkspace), for: .touchUpInside) stack.addArrangedSubview(resumeButton) stack.addArrangedSubview(label("appearance", style: .headline)) appearance = UISegmentedControl(items: [text("system"), text("light"), text("dark")]) appearance.selectedSegmentIndex = min(2, max(0, defaults.integer(forKey: "ios.appearance"))) appearance.accessibilityIdentifier = "appearance" appearance.addTarget(self, action: #selector(changeAppearance), for: .valueChanged) stack.addArrangedSubview(appearance) [connectButton, resumeButton, appearance].forEach { $0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true } changeAppearance() } private func configureToolbar() { toolbar.isHidden = true toolbar.translatesAutoresizingMaskIntoConstraints = false view.addSubview(toolbar) let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings)) settings.accessibilityIdentifier = "showServerSettings" toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings] NSLayoutConstraint.activate([ toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) } @objc private func changeAppearance() { defaults.set(appearance.selectedSegmentIndex, forKey: "ios.appearance") overrideUserInterfaceStyle = [.unspecified, .light, .dark][appearance.selectedSegmentIndex] } func textFieldShouldReturn(_ textField: UITextField) -> Bool { connect(); return true } @objc private func connect() { guard let url = WorkspaceServer.normalize(address.text ?? "") else { status.text = text("invalidServer") UIAccessibility.post(notification: .announcement, argument: status.text) return } if let server, server.url != url { let alert = UIAlertController(title: text("confirmSwitch"), message: text("switchWarning"), preferredStyle: .alert) alert.addAction(UIAlertAction(title: text("cancel"), style: .cancel)) alert.addAction(UIAlertAction(title: text("confirmSwitch"), style: .destructive) { [weak self] _ in self?.open(url) }) present(alert, animated: true) } else { open(url) } } func open(_ url: URL) { if server?.url == url, connection.ready { showWorkspace(); return } let selected = WorkspaceServer.selecting(url, current: server) guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } defaults.set(data, forKey: serverKey) timeout?.cancel() workspace?.stopLoading() workspace?.navigationDelegate = nil workspace?.removeFromSuperview() connection = WorkspaceConnection() server = selected let configuration = WKWebViewConfiguration() configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile) let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")! let script = try! String(contentsOf: scriptURL, encoding: .utf8) configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)) // Remote content receives no native message handlers or Tauri bridge. let webview = WKWebView(frame: .zero, configuration: configuration) webview.navigationDelegate = self webview.uiDelegate = self webview.allowsBackForwardNavigationGestures = true webview.scrollView.bounces = false webview.accessibilityIdentifier = "workspace" webview.translatesAutoresizingMaskIntoConstraints = false webview.isHidden = true view.insertSubview(webview, belowSubview: launcher) NSLayoutConstraint.activate([ webview.topAnchor.constraint(equalTo: toolbar.bottomAnchor), webview.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), webview.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) workspace = webview view.endEditing(true) address.text = url.absoluteString launcher.isHidden = true toolbar.isHidden = true loading.startAnimating() beginNavigation() pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL)) } private func beginNavigation() { timeout?.cancel() let revision = connection.begin() status.text = text("connecting") connectButton.isEnabled = false resumeButton.isHidden = !connection.ready let work = DispatchWorkItem { [weak self] in self?.fail(revision) } timeout = work DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work) } private func fail(_ revision: Int) { guard connection.fail(revision) else { return } timeout?.cancel() status.text = text("connectionError") connectButton.isEnabled = true connectButton.setTitle(text("retry"), for: .normal) showSettings() } @objc private func showSettings() { loading.stopAnimating() view.endEditing(true) launcher.isHidden = false toolbar.isHidden = true workspace?.isHidden = true resumeButton.isHidden = !connection.ready } @objc private func showWorkspace() { guard connection.ready else { return } loading.stopAnimating() view.endEditing(true) launcher.isHidden = true toolbar.isHidden = false workspace?.isHidden = false } func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard server?.allows(url) == true else { decisionHandler(.cancel) if action.navigationType == .linkActivated, action.sourceFrame.isMainFrame, action.targetFrame == nil || action.targetFrame?.isMainFrame == true, WorkspaceServer.allowsExternal(url) { UIApplication.shared.open(url) } return } if action.targetFrame == nil { decisionHandler(.cancel) webView.load(action.request) } else { decisionHandler(.allow) } } func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { guard webView === workspace, let url = response.response.url, server?.allows(url) == true else { decisionHandler(.cancel); return } decisionHandler(.allow) } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { guard webView === workspace else { return } pendingNavigation = navigation beginNavigation() } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { guard webView === workspace, navigation === pendingNavigation, connection.connecting, let url = webView.url, server?.allows(url) == true else { return } let firstLoad = !connection.ready timeout?.cancel() connection.finish() connectButton.isEnabled = true connectButton.setTitle(text("openWorkspace"), for: .normal) status.text = text("connected") resumeButton.isHidden = false if firstLoad { showWorkspace() } } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { navigationFailed(webView, navigation, error) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { navigationFailed(webView, navigation, error) } private func navigationFailed(_ webView: WKWebView, _ navigation: WKNavigation?, _ error: Error) { guard webView === workspace, navigation === pendingNavigation, (error as NSError).code != NSURLErrorCancelled else { return } fail(connection.revision) } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { guard webView === workspace else { return } connection = WorkspaceConnection() status.text = text("processTerminated") connectButton.isEnabled = true showSettings() } }