Compare commits

..
3 Commits
Author SHA1 Message Date
Twyco 2c0372b421 feat(KAN-54): add local iOS and iPadOS Xcode builds 2026-09-18 09:03:28 +02:00
Twyco 1be2d06ff4 feat(KAN-52): add server selection and isolated native sessions 2026-09-16 18:58:49 +02:00
Twyco 0f62445c3e KAN-51: add isolated Tauri macOS app scaffold
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-16 13:13:53 +02:00
66 changed files with 7917 additions and 10 deletions
+4
View File
@@ -43,3 +43,7 @@ storage/framework/sessions/*
storage/framework/testing/*
storage/framework/views/*
storage/logs/*
# Native build artifacts are independent of the Laravel image.
native/src-tauri/target
native/src-tauri/gen
+12
View File
@@ -28,3 +28,15 @@ yarn-error.log
/.vscode
/.zed
.codex/config.toml
/native/node_modules
/native/src-tauri/target
/native/src-tauri/gen/*
!/native/src-tauri/gen/apple/
/native/src-tauri/gen/apple/build/
/native/src-tauri/gen/apple/Externals/
/native/src-tauri/gen/apple/assets/
/native/src-tauri/gen/apple/Pods/
/native/src-tauri/gen/apple/ExportOptions.plist
/native/src-tauri/gen/apple/**/*.xcuserstate
/native/src-tauri/gen/apple/**/xcuserdata/
+3 -1
View File
@@ -27,7 +27,9 @@ const eslintConfig = [
ignores: [
'bootstrap/ssr/**',
'**/dist/**',
'node_modules/**',
'**/node_modules/**',
'native/src-tauri/target/**',
'native/src-tauri/gen/**',
'.github/**',
'**/*.yml',
'**/*.yaml',
+23
View File
@@ -0,0 +1,23 @@
{
"name": "kanbangit-native",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"build:macos": "tauri build --debug --bundles app",
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:ios": "tauri ios dev",
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
"open:ios": "tauri ios dev --open --no-dev-server",
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
},
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
}
}
+140
View File
@@ -0,0 +1,140 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@tauri-apps/cli':
specifier: 2.11.4
version: 2.11.4
packages:
'@tauri-apps/cli-darwin-arm64@2.11.4':
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.11.4':
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.4':
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.11.4':
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
engines: {node: '>= 10'}
hasBin: true
snapshots:
'@tauri-apps/cli-darwin-arm64@2.11.4':
optional: true
'@tauri-apps/cli-darwin-x64@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.11.4':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
optional: true
'@tauri-apps/cli@2.11.4':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.11.4
'@tauri-apps/cli-darwin-x64': 2.11.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
'@tauri-apps/cli-linux-x64-musl': 2.11.4
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
+1
View File
@@ -0,0 +1 @@
minimumReleaseAge: 10080
+4509
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "kanbangit-native"
version = "0.1.0"
description = "KanbanGit native application"
edition = "2021"
publish = false
[lib]
name = "kanbangit_native"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
serde_json = "1"
[features]
smoke-test = []
custom-protocol = ["tauri/custom-protocol"]
[[test]]
name = "macos_smoke"
harness = false
required-features = ["smoke-test"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "AppIcon-512@2x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+14
View File
@@ -0,0 +1,14 @@
platform :ios, '17.0'
target 'kanbangit-native_iOS' do
# Pods for kanbangit-native_iOS
end
# Inherit the deployment target from the Podfile.
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
end
end
end
@@ -0,0 +1,58 @@
import Foundation
struct WorkspaceServer: Codable, Equatable {
let url: URL
let profile: UUID
static func normalize(_ input: String) -> URL? {
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard input.lowercased().hasPrefix("https://"),
!input.contains("\\"),
input.rangeOfCharacter(from: .controlCharacters) == nil,
var components = URLComponents(string: input),
components.scheme?.lowercased() == "https",
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil,
components.query == nil, components.fragment == nil,
components.port.map({ (1...65535).contains($0) }) ?? true else { return nil }
components.scheme = "https"
components.host = host.lowercased()
if components.port == 443 { components.port = nil }
if !components.path.hasSuffix("/") { components.path += "/" }
return components.url
}
func allows(_ destination: URL) -> Bool {
destination.scheme?.lowercased() == "https"
&& destination.host?.lowercased() == url.host?.lowercased()
&& (destination.port ?? 443) == (url.port ?? 443)
&& destination.user == nil && destination.password == nil
}
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
}
}
struct WorkspaceConnection {
private(set) var revision = 0
private(set) var ready = false
private(set) var connecting = false
mutating func begin() -> Int {
revision += 1
connecting = true
return revision
}
mutating func finish() {
connecting = false
ready = true
}
mutating func fail(_ expectedRevision: Int) -> Bool {
guard expectedRevision == revision, connecting else { return false }
connecting = false
return true
}
}
@@ -0,0 +1,318 @@
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()
}
}
@@ -0,0 +1,7 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}
@@ -0,0 +1,23 @@
"appName" = "KanbanGit";
"title" = "Choose your workspace.";
"description" = "Connect to your KanbanGit installation over HTTPS.";
"serverLabel" = "Server address";
"serverHelp" = "Use the installation address without login details, a query or a fragment.";
"openWorkspace" = "Open workspace";
"retry" = "Try again";
"resume" = "Return to workspace";
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server";
"cancel" = "Cancel";
"connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
"invalidServer" = "Enter a valid HTTPS installation address without login details, a query or a fragment.";
"storageError" = "The saved server setting could not be read. Enter your server address again.";
"processTerminated" = "iOS closed the workspace to free memory. Open it again to continue. Unsaved inputs may have been lost.";
"appearance" = "Appearance";
"system" = "System";
"light" = "Light";
"dark" = "Dark";
"back" = "Back";
"serverSettings" = "Server settings";
@@ -0,0 +1,36 @@
#include "bindings/bindings.h"
#import <UIKit/UIKit.h>
#include <cstdlib>
#include <unistd.h>
extern "C" void install_ios_workspace(void);
int main(int argc, char * argv[]) {
@autoreleasepool {
// Xcode mirrors os_log to stderr. Tauri 2.11 redirects stderr back to os_log,
// which can fill its pipe and block UIKit/XCTest. Keep Xcode's original streams.
const bool debuggerLogging = getenv("OS_ACTIVITY_DT_MODE") != nullptr
|| getenv("XCTestConfigurationFilePath") != nullptr;
__block int originalOut = debuggerLogging ? dup(STDOUT_FILENO) : -1;
__block int originalError = debuggerLogging ? dup(STDERR_FILENO) : -1;
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil
queue:NSOperationQueue.mainQueue
usingBlock:^(NSNotification *notification) {
if (originalOut >= 0) {
dup2(originalOut, STDOUT_FILENO);
close(originalOut);
originalOut = -1;
}
if (originalError >= 0) {
dup2(originalError, STDERR_FILENO);
close(originalError);
originalError = -1;
}
install_ios_workspace();
}];
ffi::start_app();
return 0;
}
}
@@ -0,0 +1,118 @@
import XCTest
import WebKit
final class WorkspaceTests: XCTestCase {
func testServerValidationAndOriginBoundary() throws {
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
let server = WorkspaceServer(url: url, profile: UUID())
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
XCTAssertNil(WorkspaceServer.normalize(value), value)
}
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
}
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
}
}
func testServerChangesNeverReusePreviousProfile() throws {
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
let first = WorkspaceServer.selecting(firstURL, current: nil)
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
let second = WorkspaceServer.selecting(otherURL, current: first)
XCTAssertNotEqual(second.profile, first.profile)
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
}
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
var state = WorkspaceConnection()
let first = state.begin()
let second = state.begin()
XCTAssertFalse(state.fail(first))
state.finish()
XCTAssertFalse(state.fail(second))
let third = state.begin()
XCTAssertTrue(state.fail(third))
XCTAssertTrue(state.ready)
XCTAssertFalse(state.fail(third))
}
@MainActor
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
let profile = UUID()
let first = WKWebViewConfiguration()
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
let cookie = try XCTUnwrap(HTTPCookie(properties: [
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
]))
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
let restored = WKWebViewConfiguration()
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
let other = WKWebsiteDataStore(forIdentifier: UUID())
let otherCookies = await other.httpCookieStore.allCookies()
XCTAssertTrue(otherCookies.isEmpty)
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
}
@MainActor
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
let name = "KAN54-tests-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server)
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
restored.open(other)
XCTAssertNotEqual(restored.server?.profile, server.profile)
}
@MainActor
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
let name = "KAN54-form-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></body></html>", baseURL: url))
var loaded = false
for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
value as? String == "unsaved ticket" { loaded = true; break }
try await Task.sleep(for: .milliseconds(100))
}
XCTAssertTrue(loaded)
controller.webView(webview, didFinish: navigation)
controller.webView(webview, didStartProvisionalNavigation: navigation)
controller.webView(webview, didFailProvisionalNavigation: navigation,
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
XCTAssertTrue(webview.isHidden)
controller.open(url)
XCTAssertTrue(controller.workspace === webview)
XCTAssertFalse(webview.isHidden)
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(value as? String, "unsaved ticket")
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
XCTAssertEqual(bridge as? String, "undefined")
}
}
@@ -0,0 +1,44 @@
import XCTest
final class WorkspaceUITests: XCTestCase {
@MainActor
private func reveal(_ element: XCUIElement, in scrollView: XCUIElement) {
for _ in 0..<8 {
if scrollView.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)), element.isHittable { return }
let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let direction: CGFloat = element.frame.midY > scrollView.frame.midY ? 0.2 : 0.8
let end = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: direction))
start.press(forDuration: 0.05, thenDragTo: end)
}
XCTAssertTrue(element.isHittable)
}
@MainActor
func testLauncherKeyboardValidationRotationAndAppearance() {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
let app = XCUIApplication()
app.launch()
let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() }
let address = app.textFields["serverAddress"]
XCTAssertTrue(address.waitForExistence(timeout: 15))
address.tap()
let clear = address.buttons.firstMatch
if clear.exists { clear.tap() }
address.typeText("http://invalid.example")
app.buttons["openWorkspace"].tap()
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("valid HTTPS"))
XCUIDevice.shared.orientation = .landscapeLeft
let launcher = app.scrollViews["serverSettings"]
reveal(address, in: launcher)
let open = app.buttons["openWorkspace"]
reveal(open, in: launcher)
let dark = app.segmentedControls["appearance"].buttons["Dark"]
reveal(dark, in: launcher)
dark.tap()
XCTAssertTrue(dark.isSelected)
app.segmentedControls["appearance"].buttons["System"].tap()
XCUIDevice.shared.orientation = .portrait
}
}
@@ -0,0 +1,687 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */; };
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8243D0C3821DAC731FECF105 /* UIKit.framework */; };
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
01E10FB645809173AF1D4C2D /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
D581BB90601D7A89F8F188E2 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
DE3B71153677F722CAC6DCF7 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
E8BA104C81071623E1FF367D /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = "<group>"; };
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUITests.swift; sourceTree = "<group>"; };
ED4469441C3E0F37B1123572 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceViewController.swift; sourceTree = "<group>"; };
F46963153D20DB21D97843E9 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
32E70C778E91CB2E8CD86715 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D8772A5E508092D6F831058A /* libapp.a in Frameworks */,
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */,
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */,
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */,
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */,
B939982700E800053A9C7663 /* Security.framework in Frameworks */,
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */,
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
140A7DB7E2461E64B1FBFA70 /* Tests */ = {
isa = PBXGroup;
children = (
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
157AF88201BDE4D99AA2E14C = {
isa = PBXGroup;
children = (
31630170C358EA8657E34854 /* Assets.xcassets */,
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */,
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */,
8606F3EE5A736385061692EF /* Sources */,
335B1262A9019C70C7F887A5 /* src */,
140A7DB7E2461E64B1FBFA70 /* Tests */,
3C9F31BB065F210267ECA390 /* UITests */,
3028C3786B437115B6486F1C /* Frameworks */,
59D2DE9FD94E6D2D64CB0117 /* Products */,
);
sourceTree = "<group>";
};
2A11A26F0082A5075CD710C6 /* bindings */ = {
isa = PBXGroup;
children = (
01E10FB645809173AF1D4C2D /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
3028C3786B437115B6486F1C /* Frameworks */ = {
isa = PBXGroup;
children = (
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */,
91991DFDB0C064B06DF854ED /* libapp.a */,
CA65E67046326EA008175045 /* Metal.framework */,
D581BB90601D7A89F8F188E2 /* MetalKit.framework */,
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */,
DE3B71153677F722CAC6DCF7 /* Security.framework */,
8243D0C3821DAC731FECF105 /* UIKit.framework */,
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
335B1262A9019C70C7F887A5 /* src */ = {
isa = PBXGroup;
children = (
05A753935B6CB0316DA56D96 /* desktop.rs */,
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
2B96972F58CC158AB3DFF94A /* lib.rs */,
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */,
E8BA104C81071623E1FF367D /* server.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
3C9F31BB065F210267ECA390 /* UITests */ = {
isa = PBXGroup;
children = (
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */,
);
path = UITests;
sourceTree = "<group>";
};
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */ = {
isa = PBXGroup;
children = (
D13B8D854BEB7757AB95231B /* Info.plist */,
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */,
);
path = "kanbangit-native_iOS";
sourceTree = "<group>";
};
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup;
children = (
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
8606F3EE5A736385061692EF /* Sources */ = {
isa = PBXGroup;
children = (
89AA023B8C28CB860513C685 /* kanbangit-native */,
);
path = Sources;
sourceTree = "<group>";
};
89AA023B8C28CB860513C685 /* kanbangit-native */ = {
isa = PBXGroup;
children = (
4C76059D31C50B198779A5F7 /* en.lproj */,
F46963153D20DB21D97843E9 /* main.mm */,
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
2A11A26F0082A5075CD710C6 /* bindings */,
C8C02BC0E757897278EB954A /* Localizable.strings */,
);
path = "kanbangit-native";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */;
buildPhases = (
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */,
F39C45598E4345705F3820FA /* Sources */,
4A4C7FC1B57785DDD6B301FB /* Resources */,
32E70C778E91CB2E8CD86715 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "kanbangit-native_iOS";
packageProductDependencies = (
);
productName = "kanbangit-native_iOS";
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
productType = "com.apple.product-type.application";
};
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */;
buildPhases = (
0258B26407CD2D8C61079F84 /* Sources */,
);
buildRules = (
);
dependencies = (
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */,
);
name = WorkspaceUITests;
packageProductDependencies = (
);
productName = WorkspaceUITests;
productReference = 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
CBC4561F8018D37DD49A0598 /* WorkspaceTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
buildPhases = (
2A0467365B852D44D1A9FD5B /* Sources */,
);
buildRules = (
);
dependencies = (
);
name = WorkspaceTests;
packageProductDependencies = (
);
productName = WorkspaceTests;
productReference = A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA6AD2A91A9E86D319D86D59 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
721CF221CEDDC28FF2C5EBD5 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
988CFF304CB81C8F3A4D6612 = {
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
};
};
};
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 157AF88201BDE4D99AA2E14C;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 59D2DE9FD94E6D2D64CB0117 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */,
CBC4561F8018D37DD49A0598 /* WorkspaceTests */,
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
4A4C7FC1B57785DDD6B301FB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0258B26407CD2D8C61079F84 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
2A0467365B852D44D1A9FD5B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */,
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */,
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F39C45598E4345705F3820FA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */,
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */,
333E7761EA87AE29CE04F1AC /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
ED4469441C3E0F37B1123572 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0C46162024E99BEFF872CABF /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit";
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
390A27695F28BF3AA9A049C9 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
5110A47C3B60906E18EE09D2 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "kanbangit-native_iOS";
};
name = debug;
};
C81211E40BBB158A79AEBE3D /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
D41150B2CAE4512D04C26759 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit";
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
EF104E75BD04AFEF735BAA52 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "kanbangit-native_iOS";
};
name = release;
};
FB6E824120F9ED3CFE30E713 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = debug;
};
FF1553DD6765EEA42FF50CDD /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FB6E824120F9ED3CFE30E713 /* debug */,
FF1553DD6765EEA42FF50CDD /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */ = {
isa = XCConfigurationList;
buildConfigurations = (
390A27695F28BF3AA9A049C9 /* debug */,
C81211E40BBB158A79AEBE3D /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5110A47C3B60906E18EE09D2 /* debug */,
EF104E75BD04AFEF735BAA52 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D41150B2CAE4512D04C26759 /* debug */,
0C46162024E99BEFF872CABF /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = AA6AD2A91A9E86D319D86D59 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CBC4561F8018D37DD49A0598"
BuildableName = "WorkspaceTests.xctest"
BlueprintName = "WorkspaceTests"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "988CFF304CB81C8F3A4D6612"
BuildableName = "WorkspaceUITests.xctest"
BlueprintName = "WorkspaceUITests"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
BuildableName = "KanbanGit.app"
BlueprintName = "kanbangit-native_iOS"
ReferencedContainer = "container:kanbangit-native.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+122
View File
@@ -0,0 +1,122 @@
name: kanbangit-native
options:
bundleIdPrefix: de.kanbangit.desktop
deploymentTarget:
iOS: 17.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: KanbanGit
SWIFT_VERSION: "5.0"
PRODUCT_MODULE_NAME: KanbanGitNative
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: ""
ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
kanbangit-native_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: kanbangit-native_iOS
- path: LaunchScreen.storyboard
info:
path: kanbangit-native_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.0
CFBundleVersion: "0.1.0"
entitlements:
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
testTargets:
- WorkspaceTests
- WorkspaceUITests
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: |
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
export RUSTC="$(rustup which rustc)"
cd "$SRCROOT/../../.."
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
WorkspaceTests:
type: bundle.unit-test
platform: iOS
sources:
- path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift
- path: Sources/kanbangit-native/en.lproj
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
SWIFT_VERSION: "5.0"
WorkspaceUITests:
type: bundle.ui-testing
platform: iOS
sources: [UITests]
dependencies:
- target: kanbangit-native_iOS
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
SWIFT_VERSION: "5.0"
TEST_TARGET_NAME: kanbangit-native_iOS
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+456
View File
@@ -0,0 +1,456 @@
use crate::locale::en;
use crate::server::{validate_url, Server, DEFAULT_SERVER};
use std::{path::PathBuf, sync::Mutex, time::Duration};
use tauri::{
menu::{MenuBuilder, SubmenuBuilder},
webview::{NewWindowResponse, PageLoadEvent},
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
#[derive(Default)]
struct Connection {
server: Option<Server>,
label: Option<String>,
revision: u64,
ready: bool,
phase: &'static str,
}
impl Connection {
fn begin_navigation(&mut self) -> u64 {
self.revision += 1;
self.phase = "connecting";
self.revision
}
fn timeout(&mut self, revision: u64) -> bool {
if self.revision != revision || self.phase != "connecting" {
return false;
}
self.phase = "connectionError";
true
}
}
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
destination.scheme() == "https"
&& destination.origin() == server.origin()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost")
&& destination.port().is_none()
&& destination.username().is_empty()
&& destination.password().is_none()
}
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
Ok(app.path().app_config_dir()?.join("active-server"))
}
fn update_launcher(app: &AppHandle) {
let state = app.state::<Mutex<Connection>>();
let state = state.lock().unwrap();
let mut message = Url::parse("tauri://localhost/").unwrap();
message
.query_pairs_mut()
.append_pair(
"server",
state
.server
.as_ref()
.map_or(DEFAULT_SERVER, |server| server.url.as_str()),
)
.append_pair("phase", state.phase)
.append_pair("ready", if state.ready { "true" } else { "false" });
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.eval(format!(
"window.updateLauncher?.('{}')",
message.query().unwrap()
));
}
}
fn show_launcher(app: &AppHandle) {
update_launcher(app);
if let Some(window) = app.get_webview_window("launcher") {
let _ = window.show();
let _ = window.set_focus();
}
}
fn begin_navigation(app: &AppHandle, label: &str) {
let state = app.state::<Mutex<Connection>>();
let revision = {
let mut state = state.lock().unwrap();
if state.label.as_deref() != Some(label) {
return;
}
state.begin_navigation()
};
update_launcher(app);
let handle = app.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(15));
let state = handle.state::<Mutex<Connection>>();
let timed_out = state.lock().unwrap().timeout(revision);
if timed_out {
show_launcher(&handle);
}
});
}
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let url = validate_url(input)?;
let state = app.state::<Mutex<Connection>>();
let (server, old_label, label) = {
let mut state = state.lock().unwrap();
let same_server = state
.server
.as_ref()
.is_some_and(|server| server.url == url);
if same_server && state.ready {
if let Some(window) = state
.label
.as_ref()
.and_then(|label| app.get_webview_window(label))
{
window.show().map_err(|_| "connectionError")?;
window.set_focus().map_err(|_| "connectionError")?;
return Ok(());
}
}
let server = if same_server {
state.server.clone().unwrap()
} else {
Server::new(url).map_err(|_| "storageError")?
};
server
.save(&configuration_path(app).map_err(|_| "storageError")?)
.map_err(|_| "storageError")?;
let old_label = state.label.take();
state.server = Some(server.clone());
state.ready = false;
state.revision += 1;
let label = format!("workspace-{}", state.revision);
state.label = Some(label.clone());
(server, old_label, label)
};
if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) {
window.close().map_err(|_| "connectionError")?;
}
begin_navigation(app, &label);
let handle = app.clone();
let navigation_label = label.clone();
let server_url = server.url.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
.title(en::APP_NAME)
.inner_size(1280.0, 850.0)
.min_inner_size(375.0, 600.0)
.center()
.visible(false)
.data_store_identifier(server.profile)
.on_navigation(move |destination| {
if !allows_server_navigation(&server_url, destination) {
return false;
}
begin_navigation(&handle, &navigation_label);
true
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
}
let app = window.app_handle();
let state = app.state::<Mutex<Connection>>();
let should_show = {
let mut state = state.lock().unwrap();
if state.label.as_deref() != Some(window.label()) {
return;
}
let first_load = !state.ready;
state.ready = true;
state.phase = "connected";
first_load
};
update_launcher(app);
if should_show {
let _ = window.show();
let _ = window.set_focus();
if let Some(launcher) = app.get_webview_window("launcher") {
let _ = launcher.hide();
}
}
})
.build()
.map_err(|_| "connectionError")?;
let handle = app.clone();
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) {
let state = handle.state::<Mutex<Connection>>();
let current = {
let mut state = state.lock().unwrap();
if state.label.as_deref() == Some(&label) {
state.label = None;
state.ready = false;
state.revision += 1;
state.phase = "idle";
true
} else {
false
}
};
if current {
show_launcher(&handle);
}
}
});
Ok(())
}
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() {
"/action/open" => {
let input = destination
.query_pairs()
.find(|(key, _)| key == "server")
.map(|(_, value)| value.into_owned())
.unwrap_or_default();
open_workspace(app, &input)
}
"/action/resume" => {
let label = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.label
.clone();
if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) {
let _ = window.show();
let _ = window.set_focus();
}
Ok(())
}
_ => Ok(()),
};
if let Err(error) = result {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app);
}
}
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
Ok(server) => (server, "idle"),
Err(_) => (None, "storageError"),
};
app.manage(Mutex::new(Connection {
server,
phase,
..Default::default()
}));
let menu = MenuBuilder::new(app)
.item(
&SubmenuBuilder::new(app, en::APP_NAME)
.text("server-settings", en::SERVER_SETTINGS)
.separator()
.quit()
.build()?,
)
.item(
&SubmenuBuilder::new(app, en::EDIT)
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?,
)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app, event| {
if event.id().as_ref() == "server-settings" {
show_launcher(app);
}
});
let handle = app.handle().clone();
let launcher =
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
.title(en::APP_NAME)
.inner_size(560.0, 720.0)
.min_inner_size(375.0, 640.0)
.center()
.on_navigation(move |destination| {
if !allows_launcher_navigation(destination) {
return false;
}
if destination.path().starts_with("/action/") {
let handle = handle.clone();
let destination = destination.clone();
let _ = handle.clone().run_on_main_thread(move || {
handle_launcher_action(&handle, &destination)
});
return false;
}
matches!(destination.path(), "" | "/" | "/index.html")
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_page_load(|window, payload| {
if payload.event() == PageLoadEvent::Finished {
let app = window.app_handle().clone();
update_launcher(&app);
std::thread::spawn(move || {
let handle = app.clone();
let _ = app.run_on_main_thread(move || {
let server = handle
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.server
.clone();
if let Some(server) = server {
if let Err(error) = open_workspace(&handle, server.url.as_str())
{
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
error;
show_launcher(&handle);
}
}
});
});
}
})
.build()?;
let window = launcher.clone();
launcher.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
});
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigation_is_limited_to_the_selected_https_origin() {
let server = validate_url("https://example.com:8443/app/").unwrap();
for path in [
"/login",
"/two-factor-challenge",
"/projects/1?ticket=52#details",
] {
assert!(allows_server_navigation(
&server,
&server.join(path).unwrap()
));
}
for destination in [
"http://example.com:8443/",
"https://example.com/",
"https://other.example.com:8443/",
"https://user:secret@example.com:8443/",
"tauri://localhost/action/open?server=https://evil.test",
"file:///etc/passwd",
"data:text/html,hello",
"javascript:alert(1)",
] {
assert!(!allows_server_navigation(
&server,
&Url::parse(destination).unwrap()
));
}
}
#[test]
fn launcher_actions_are_restricted_to_the_bundled_origin() {
assert!(allows_launcher_navigation(
&Url::parse("tauri://localhost/action/open").unwrap()
));
for input in [
DEFAULT_SERVER,
"https://localhost/",
"tauri://other/index.html",
"tauri://user@localhost/index.html",
] {
assert!(!allows_launcher_navigation(&Url::parse(input).unwrap()));
}
}
#[test]
fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() {
let mut state = Connection::default();
let first = state.begin_navigation();
let second = state.begin_navigation();
assert!(!state.timeout(first));
state.ready = true;
assert!(state.timeout(second));
assert!(state.ready);
assert!(!state.timeout(second));
let third = state.begin_navigation();
state.phase = "connected";
assert!(!state.timeout(third));
}
#[test]
fn remote_pages_cannot_invoke_native_commands() {
let app = tauri::test::mock_builder().build(context()).unwrap();
let window = WebviewWindowBuilder::new(
&app,
"workspace-1",
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
)
.build()
.unwrap();
for command in [
"plugin:window|close",
"plugin:webview|create_webview_window",
"plugin:path|resolve_directory",
] {
let response = tauri::test::get_ipc_response(
&window,
tauri::webview::InvokeRequest {
cmd: command.into(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: Url::parse(DEFAULT_SERVER).unwrap(),
body: tauri::ipc::InvokeBody::default(),
headers: Default::default(),
invoke_key: tauri::test::INVOKE_KEY.into(),
},
);
assert!(response
.unwrap_err()
.as_str()
.unwrap()
.contains("not allowed"));
}
}
#[test]
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
assert_eq!(config["app"]["withGlobalTauri"], false);
assert_eq!(
config["app"]["security"]["capabilities"],
serde_json::json!([])
);
}
}
+14
View File
@@ -0,0 +1,14 @@
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
}
pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| {
WebviewWindowBuilder::new(app, "host", WebviewUrl::App("index.html".into()))
.on_navigation(|_| false)
.build()?;
Ok(())
})
}
+18
View File
@@ -0,0 +1,18 @@
#[cfg(not(target_os = "ios"))]
mod desktop;
#[cfg(target_os = "ios")]
mod ios;
#[cfg(not(target_os = "ios"))]
mod locale;
#[cfg(not(target_os = "ios"))]
mod server;
#[cfg(not(target_os = "ios"))]
pub use desktop::{builder, context};
#[cfg(target_os = "ios")]
pub use ios::{builder, context};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
}
+5
View File
@@ -0,0 +1,5 @@
pub mod en {
pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…";
pub const EDIT: &str = "Edit";
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
kanbangit_native::run();
}
+137
View File
@@ -0,0 +1,137 @@
use std::{fs, io, io::Read, path::Path};
use tauri::Url;
pub const DEFAULT_SERVER: &str = "https://kanbangit.de/";
#[derive(Clone, Debug, PartialEq)]
pub struct Server {
pub url: Url,
pub profile: [u8; 16],
}
pub fn validate_url(input: &str) -> Result<Url, &'static str> {
let input = input.trim();
if !input
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|| input
.chars()
.any(|character| character.is_control() || character == '\\')
{
return Err("invalidServer");
}
let mut url = Url::parse(input).map_err(|_| "invalidServer")?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("invalidServer");
}
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
impl Server {
pub fn new(url: Url) -> io::Result<Self> {
let mut profile = [0; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
Ok(Self { url, profile })
}
pub fn load(path: &Path) -> io::Result<Option<Self>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "Invalid server configuration");
let (url, profile) = contents.split_once('\n').ok_or_else(invalid)?;
let url = validate_url(url).map_err(|_| invalid())?;
if profile.len() != 32 || !profile.is_ascii() {
return Err(invalid());
}
let mut bytes = [0; 16];
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = u8::from_str_radix(&profile[index * 2..index * 2 + 2], 16)
.map_err(|_| invalid())?;
}
Ok(Some(Self {
url,
profile: bytes,
}))
}
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let profile: String = self
.profile
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let temporary = path.with_extension("tmp");
fs::write(&temporary, format!("{}\n{profile}", self.url))?;
fs::rename(temporary, path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_and_normalizes_installation_addresses() {
assert_eq!(
validate_url(" HTTPS://Example.com:443/kanban ")
.unwrap()
.as_str(),
"https://example.com/kanban/"
);
assert!(validate_url("https://[::1]:8443/").is_ok());
for input in [
"http://example.com",
"https:example.com",
"https://user:secret@example.com",
"https://user@example.com",
"https://example.com/?token=secret",
"https://example.com/#secret",
"https://exa\nmple.com",
"https://example.com\\other",
"file:///tmp/test",
"javascript:alert(1)",
"",
] {
assert!(validate_url(input).is_err(), "{input}");
}
}
#[test]
fn configuration_round_trip_preserves_only_the_address_and_profile() {
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
let path =
std::env::temp_dir().join(format!("kanbangit-server-{:x?}/active", server.profile));
assert_eq!(Server::load(&path).unwrap(), None);
server.save(&path).unwrap();
assert_eq!(Server::load(&path).unwrap(), Some(server.clone()));
let other = Server::new(validate_url("https://other.example.com:444/").unwrap()).unwrap();
assert_ne!(server.profile, other.profile);
other.save(&path).unwrap();
assert_eq!(Server::load(&path).unwrap(), Some(other));
for contents in [
"http://example.com/\n00000000000000000000000000000000",
"invalid",
"https://example.com/\nshort",
"https://example.com/\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
] {
fs::write(&path, contents).unwrap();
assert!(Server::load(&path).is_err());
}
fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.1.0",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
},
"app": {
"windows": [],
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'",
"capabilities": []
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": ["icons/icon.png", "icons/icon.icns"],
"macOS": {
"minimumSystemVersion": "14.0"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"iOS": { "minimumSystemVersion": "17.0" }
}
}
+151
View File
@@ -0,0 +1,151 @@
#[cfg(target_os = "macos")]
fn main() {
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc,
};
use tauri::{webview::PageLoadEvent, Manager};
let passed = Arc::new(AtomicBool::new(false));
let stage = Arc::new(AtomicUsize::new(0));
let page_stage = stage.clone();
let result_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
.on_page_load(move |webview, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
}
if webview.label() == "launcher" {
webview.eval(r#"
(async () => {
const wait = async predicate => {
for (let attempt = 0; attempt < 220; attempt++) {
if (predicate()) return;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error('Timed out waiting for local state');
};
try {
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
const input = document.querySelector('#server-url');
const form = document.querySelector('#server-form');
const status = document.querySelector('#connection-status');
input.value = 'http://example.com';
form.requestSubmit();
if (input.getAttribute('aria-invalid') !== 'true' || !status.textContent.includes('HTTPS')) throw new Error('Invalid address accepted');
for (const [theme, expected] of [['dark', 'rgb(15, 23, 42)'], ['light', 'rgb(247, 249, 251)']]) {
const appearance = document.querySelector('#appearance');
appearance.value = theme;
appearance.dispatchEvent(new Event('change'));
if (getComputedStyle(document.documentElement).backgroundColor !== expected) throw new Error('Theme failed');
}
input.value = 'https://127.0.0.1:9/';
form.requestSubmit();
await wait(() => status.dataset.state === 'connectionError');
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
input.value = 'https://kanbangit.de/';
form.requestSubmit();
if (document.querySelector('#switch-confirmation').hidden) throw new Error('Missing switch confirmation');
document.querySelector('#cancel-switch').click();
if (!document.querySelector('#switch-confirmation').hidden) throw new Error('Cancel failed');
form.requestSubmit();
document.querySelector('#confirm-switch').click();
} catch (error) {
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
}
})();
"#).unwrap();
} else if webview.label().starts_with("workspace-") && payload.url().host_str() == Some("kanbangit.de") {
let current_stage = page_stage.fetch_add(1, Ordering::SeqCst);
if current_stage == 0 {
webview.eval(r#"
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('#app')?.innerText.trim().length > 100) break;
await new Promise(resolve => setTimeout(resolve, 100));
}
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
let denied = true;
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
catch (error) { denied = denied && String(error).includes('not allowed'); }
}
const input = document.createElement('input');
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})();
"#).unwrap();
} else {
webview.eval(r#"
const isolated = !document.cookie.includes('kan52_fixture') && localStorage.getItem('kan52_fixture') === null;
location.href = '/__native_smoke_result?stage=isolated&passed=' + isolated;
"#).unwrap();
}
}
})
.on_navigation(move |webview, url| {
if url.path() != "/__native_smoke_result" {
return true;
}
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
let loaded = url.query_pairs().any(|(key, value)| key == "stage" && value == "loaded");
println!("macOS WebView smoke test: {}", url.query().unwrap_or_default());
if success && loaded {
let app = webview.app_handle().clone();
let label = webview.label().to_string();
std::thread::spawn(move || {
let window = app.get_webview_window(&label).unwrap();
// A blocked cross-origin navigation must leave the existing input intact.
window.navigate(tauri::Url::parse("https://kanbangit.de:9/unreachable").unwrap()).unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
window.eval(r#"
if (document.querySelector('#kan52-unsaved')?.value !== 'unsaved input') {
location.href = '/__native_smoke_result?passed=false&stage=preservation';
}
"#).unwrap();
let launcher = app.get_webview_window("launcher").unwrap();
launcher.show().unwrap();
launcher.eval(r#"
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
document.querySelector('#server-form').requestSubmit();
document.querySelector('#confirm-switch').click();
"#).unwrap();
});
} else {
result_passed.store(success, Ordering::SeqCst);
webview.app_handle().exit(if success { 0 } else { 1 });
}
false
})
.build();
let mut context = kanbangit_native::context();
context.config_mut().identifier = format!("de.kanbangit.smoke.kan52.{}", std::process::id());
let app = kanbangit_native::builder()
.plugin(plugin)
.build(context)
.expect("The macOS application must start");
let config_dir = app.path().app_config_dir().unwrap();
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(60));
eprintln!("macOS smoke test timed out");
handle.exit(1);
});
app.run_return(|_, _| {});
let _ = std::fs::remove_dir_all(config_dir);
assert!(
passed.load(Ordering::SeqCst),
"The native smoke test must complete successfully"
);
}
#[cfg(not(target_os = "macos"))]
fn main() {
panic!("Run this smoke test on macOS with an active graphical session");
}
+148
View File
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const actions = [];
const server = createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost');
if (url.pathname.startsWith('/action/')) {
actions.push(url);
response.writeHead(204).end();
return;
}
const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
if (
![
'index.html',
'main.js',
'server.js',
'locales/en.js',
'styles.css',
'mark.svg',
].includes(file)
) {
response.writeHead(404).end();
return;
}
response.setHeader(
'Content-Type',
file.endsWith('.js')
? 'text/javascript'
: file.endsWith('.css')
? 'text/css'
: file.endsWith('.svg')
? 'image/svg+xml'
: 'text/html',
);
response.end(await readFile(new URL(`../ui/${file}`, import.meta.url)));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage();
const state = (phase, ready = false, server = 'https://kanbangit.de/') =>
page.evaluate(
({ phase, ready, server }) => {
window.updateLauncher(
new URLSearchParams({
phase,
ready: String(ready),
server,
}).toString(),
);
},
{ phase, ready, server },
);
const latestAction = async (name) => {
await page.waitForTimeout(100);
assert.equal(actions.at(-1)?.pathname, `/action/${name}`);
};
try {
await page.goto(`http://127.0.0.1:${server.address().port}/`);
await state('idle');
for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark', 'system']) {
await page.getByLabel('Appearance').selectOption(theme);
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
assert.equal(
await page
.getByRole('button', {
name: 'Open workspace',
exact: true,
})
.isVisible(),
true,
);
}
}
await page.getByLabel('Server address').fill('http://example.com/');
await page.getByLabel('Server address').press('Enter');
assert.equal(
await page.getByLabel('Server address').getAttribute('aria-invalid'),
'true',
);
await page.getByLabel('Server address').fill('https://first.example.com/');
await page.getByLabel('Server address').press('Enter');
await latestAction('open');
assert.equal(
actions.at(-1).searchParams.get('server'),
'https://first.example.com/',
);
await state('connecting', false, 'https://first.example.com/');
assert.equal(
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.isDisabled(),
true,
);
await state('connectionError', false, 'https://first.example.com/');
assert.equal(
await page.getByLabel('Server address').inputValue(),
'https://first.example.com/',
);
await page.getByRole('button', { name: 'Try again', exact: true }).click();
await latestAction('open');
await state('connected', true, 'https://first.example.com/');
await page
.getByRole('button', { name: 'Return to workspace', exact: true })
.click();
await latestAction('resume');
await page
.getByLabel('Server address')
.fill('https://second.example.com/app');
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.click();
assert.equal(
await page
.getByRole('button', { name: 'Switch server', exact: true })
.isVisible(),
true,
);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await latestAction('resume');
await page.getByLabel('Server address').press('Enter');
await page
.getByRole('button', { name: 'Switch server', exact: true })
.click();
await latestAction('open');
assert.equal(
actions.at(-1).searchParams.get('server'),
'https://second.example.com/app/',
);
console.log(
'Launcher browser tests passed: responsive themes, keyboard, validation, offline retry, resume and confirmed switching.',
);
} finally {
await browser.close();
server.close();
}
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { en } from '../ui/locales/en.js';
import { normalizeServer, needsConfirmation } from '../ui/server.js';
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
test('every launcher text resolves to a nonempty English translation', () => {
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
assert.equal(typeof en[key], 'string', key);
assert.ok(en[key].trim(), key);
}
});
test('installation addresses support custom domains, ports and base paths', () => {
assert.equal(
normalizeServer(' HTTPS://Example.com:443/kanban '),
'https://example.com/kanban/',
);
assert.equal(normalizeServer('https://[::1]:8443/'), 'https://[::1]:8443/');
for (const input of [
'http://example.com',
'https:example.com',
'https://user:secret@example.com',
'https://user@example.com',
'https://example.com/?token=secret',
'https://example.com/#secret',
'https://exa\nmple.com',
'https://example.com\\other',
'file:///tmp/test',
'javascript:alert(1)',
'',
]) {
assert.throws(() => normalizeServer(input), undefined, input);
}
});
test('changing the installation requires confirmation, retrying does not', () => {
assert.equal(needsConfirmation(null, 'https://example.com/'), false);
assert.equal(
needsConfirmation('https://example.com/', 'https://example.com/'),
false,
);
assert.equal(
needsConfirmation('https://example.com/', 'https://example.com:8443/'),
true,
);
assert.equal(
needsConfirmation('https://example.com/a/', 'https://example.com/b/'),
true,
);
});
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>KanbanGit</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./main.js"></script>
</head>
<body>
<main>
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1>
<p class="description" data-i18n="description"></p>
<form id="server-form" novalidate>
<label for="server-url" data-i18n="serverLabel"></label>
<input id="server-url" type="url" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="server-help connection-status" value="https://kanbangit.de/" required />
<p id="server-help" class="description" data-i18n="serverHelp"></p>
<button class="open-workspace" type="submit" data-i18n="openWorkspace"></button>
</form>
<p id="connection-status" class="description" role="status" aria-live="polite"></p>
<div id="switch-confirmation" hidden>
<p class="description" data-i18n="switchWarning"></p>
<button id="confirm-switch" type="button" data-i18n="confirmSwitch"></button>
<button id="cancel-switch" type="button" data-i18n="cancel"></button>
</div>
<button id="resume" type="button" data-i18n="resume" hidden></button>
<label class="appearance">
<span data-i18n="appearance"></span>
<select id="appearance">
<option value="system" data-i18n="system"></option>
<option value="light" data-i18n="light"></option>
<option value="dark" data-i18n="dark"></option>
</select>
</label>
</main>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
export const en = {
appName: 'KanbanGit',
title: 'Choose your workspace.',
description: 'Connect to your KanbanGit installation over HTTPS.',
serverLabel: 'Server address',
serverHelp:
'Use the installation address without login details, a query or a fragment.',
openWorkspace: 'Open workspace',
retry: 'Try again',
resume: 'Return to workspace',
switchWarning:
'Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.',
confirmSwitch: 'Switch server',
cancel: 'Cancel',
idle: '',
connecting: 'Connecting to your server…',
connected:
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
connectionError:
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
invalidServer:
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
storageError:
'The server setting could not be read or saved. Check your local storage permissions and try again.',
appearance: 'Appearance',
system: 'System',
light: 'Light',
dark: 'Dark',
};
+87
View File
@@ -0,0 +1,87 @@
import { en } from './locales/en.js';
import { normalizeServer, needsConfirmation } from './server.js';
for (const element of document.querySelectorAll('[data-i18n]')) {
element.textContent = en[element.dataset.i18n];
}
const form = document.querySelector('#server-form');
const input = document.querySelector('#server-url');
const status = document.querySelector('#connection-status');
const open = document.querySelector('.open-workspace');
const resume = document.querySelector('#resume');
const confirmation = document.querySelector('#switch-confirmation');
let activeServer = null;
let pendingServer = null;
let initialized = false;
function action(name, server) {
const url = new URL(`/action/${name}`, location.href);
if (server) {
url.searchParams.set('server', server);
}
location.href = url.href;
}
window.updateLauncher = (query) => {
const state = new URLSearchParams(query);
const phase = state.get('phase');
const server = state.get('server');
if (!initialized) {
input.value = server;
initialized = true;
}
if (phase === 'connecting' || phase === 'connected') {
activeServer = server;
}
status.textContent = en[phase] ?? '';
status.dataset.state = phase;
open.disabled = phase === 'connecting';
open.textContent =
en[phase === 'connectionError' ? 'retry' : 'openWorkspace'];
resume.hidden = state.get('ready') !== 'true';
form.setAttribute('aria-busy', String(phase === 'connecting'));
};
function connect(server) {
confirmation.hidden = true;
pendingServer = null;
action('open', server);
}
form.addEventListener('submit', (event) => {
event.preventDefault();
let server;
try {
server = normalizeServer(input.value);
} catch {
status.textContent = en.invalidServer;
input.setAttribute('aria-invalid', 'true');
input.focus();
return;
}
input.removeAttribute('aria-invalid');
if (needsConfirmation(activeServer, server)) {
pendingServer = server;
confirmation.hidden = false;
document.querySelector('#confirm-switch').focus();
return;
}
connect(server);
});
document.querySelector('#confirm-switch').addEventListener('click', () => {
if (pendingServer) {
connect(pendingServer);
}
});
document.querySelector('#cancel-switch').addEventListener('click', () => {
confirmation.hidden = true;
pendingServer = null;
input.focus();
});
resume.addEventListener('click', () => action('resume'));
document.querySelector('#appearance').addEventListener('change', (event) => {
document.documentElement.dataset.appearance = event.target.value;
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#22c55e" />
<g fill="none" stroke="#004b1e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<rect x="16" y="16" width="32" height="32" rx="3" />
<path d="M27 16v32M37 16v32" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+33
View File
@@ -0,0 +1,33 @@
export function normalizeServer(input) {
const value = input.trim();
if (
!/^https:\/\//i.test(value) ||
[...value].some(
(character) =>
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127 ||
character === '\\',
)
) {
throw new Error('invalidServer');
}
const url = new URL(value);
if (
url.protocol !== 'https:' ||
!url.hostname ||
url.username ||
url.password ||
value.includes('?') ||
value.includes('#')
) {
throw new Error('invalidServer');
}
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
return url.href;
}
export function needsConfirmation(activeServer, nextServer) {
return activeServer !== null && activeServer !== nextServer;
}
+180
View File
@@ -0,0 +1,180 @@
:root {
--surface: #f7f9fb;
--foreground: #191c1e;
--muted: #505f76;
--primary: #006e2f;
--border: #bccbb9;
--control: #ffffff;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color-scheme: light dark;
color: var(--foreground);
background: var(--surface);
font-synthesis: none;
}
:root[data-appearance='light'] {
color-scheme: light;
}
:root[data-appearance='dark'] {
color-scheme: dark;
--surface: #0f172a;
--foreground: #f8fafc;
--muted: #b7c8e1;
--primary: #4ae176;
--border: #334155;
--control: #1e293b;
}
@media (prefers-color-scheme: dark) {
:root:not([data-appearance='light']) {
--surface: #0f172a;
--foreground: #f8fafc;
--muted: #b7c8e1;
--primary: #4ae176;
--border: #334155;
--control: #1e293b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
main {
width: min(100%, 380px);
}
.brand {
display: block;
margin-bottom: 24px;
}
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary);
}
h1 {
margin: 12px 0;
font-size: 28px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.description,
.server,
.appearance {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.open-workspace {
display: block;
margin-top: 28px;
padding: 12px 20px;
border-radius: 4px;
color: #004b1e;
background: #22c55e;
text-align: center;
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.open-workspace:hover {
background: #4ae176;
}
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 4px;
}
.server {
text-align: center;
font-size: 12px;
}
.appearance {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 32px;
}
select {
min-height: 36px;
padding: 4px 10px;
font: inherit;
border: 1px solid var(--border);
border-radius: 4px;
color: inherit;
background: var(--control);
}
[hidden] {
display: none !important;
}
form {
margin-top: 24px;
}
label {
font-size: 14px;
font-weight: 600;
}
input,
button {
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
color: var(--foreground);
background: var(--control);
}
input {
display: block;
width: 100%;
margin-top: 8px;
font-size: 16px;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.open-workspace {
width: 100%;
border: 0;
margin-top: 16px;
}
#switch-confirmation {
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
}
+7 -9
View File
@@ -68,16 +68,14 @@ try {
]) {
await open(path);
await page
.getByRole('button', {
name: theme === 'light' ? 'Light' : 'Dark',
exact: true,
.getByRole('radio', {
name: theme === 'light' ? /^Light / : /^Dark /,
})
.click();
.press('Space');
await page
.getByRole('button', {
name: theme === 'light' ? 'Light' : 'Dark',
exact: true,
pressed: true,
.getByRole('radio', {
name: theme === 'light' ? /^Light / : /^Dark /,
checked: true,
})
.waitFor();
assert.equal(
@@ -118,7 +116,7 @@ try {
}
await page.setViewportSize({ width: 390, height: 480 });
await open('/login');
await page.getByRole('button', { name: 'System', exact: true }).click();
await page.getByRole('radio', { name: /^System / }).press('Space');
await page.emulateMedia({ colorScheme: 'light' });
await page.waitForFunction(
() => !document.documentElement.classList.contains('dark'),