319 lines
15 KiB
Swift
319 lines
15 KiB
Swift
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 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 backButton: UIBarButtonItem!
|
|
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)
|
|
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/"
|
|
showSettings()
|
|
if let server { open(server.url) }
|
|
}
|
|
|
|
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.accessibilityIdentifier = "serverSettings"
|
|
launcher.keyboardDismissMode = .interactive
|
|
launcher.alwaysBounceVertical = true
|
|
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.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(toolbar)
|
|
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
|
backButton.accessibilityIdentifier = "workspaceBack"
|
|
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
|
settings.accessibilityIdentifier = "showServerSettings"
|
|
toolbar.items = [backButton, 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)
|
|
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
|
webview.navigationDelegate = self
|
|
webview.uiDelegate = self
|
|
webview.allowsBackForwardNavigationGestures = true
|
|
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
|
|
beginNavigation()
|
|
pendingNavigation = webview.load(URLRequest(url: url))
|
|
}
|
|
|
|
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() {
|
|
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 }
|
|
view.endEditing(true)
|
|
launcher.isHidden = true
|
|
toolbar.isHidden = false
|
|
workspace?.isHidden = false
|
|
backButton.isEnabled = workspace?.canGoBack == true
|
|
}
|
|
|
|
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
|
|
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
guard webView === workspace, let url = action.request.url,
|
|
server?.allows(url) == true else { decisionHandler(.cancel); 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 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
|
|
backButton.isEnabled = webView.canGoBack
|
|
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()
|
|
}
|
|
}
|