Compare commits

...
Author SHA1 Message Date
Twyco b124a77cd1 update macos to v0.2.0
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m26s
CI/CD / test_and_check_app (push) Successful in 6m25s
CI/CD / Publish immutable image (push) Successful in 1m15s
2026-09-28 21:28:07 +02:00
Twyco 2725816273 Merge pull request 'KAN-69: Open saved native workspace directly in dashboard' (#166) from feature/KAN-69-open-app-in-dashboard into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
CI/CD / test_and_check_app (push) Successful in 6m17s
CI/CD / Publish immutable image (push) Successful in 1m15s
Reviewed-on: #166
2026-09-28 19:24:03 +00:00
Twyco 32a6756419 feat(KAN-69): open saved native workspace directly in dashboard
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m17s
CI/CD / test_and_check_app (pull_request) Successful in 6m24s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m27s
2026-09-28 21:00:46 +02:00
Twyco 2a637affdf Merge pull request 'KAN-66: iOS und iPadOS für TestFlight signieren' (#165) from feature/KAN-66-ios-testflight-signing into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Successful in 6m36s
CI/CD / Publish immutable image (push) Successful in 1m22s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
Reviewed-on: #165
2026-09-28 14:29:06 +00:00
Twyco 188aa07059 feat(KAN-66): prepare local iOS TestFlight signing
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m33s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-28 13:17:45 +02:00
45 changed files with 1468 additions and 106 deletions
+509
View File
@@ -0,0 +1,509 @@
import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
export const appleTeamId = 'ZZX2KZGTHU';
export const bundleIdentifier = 'de.kanbangit.ios';
const plistReader = `import base64, datetime, json, plistlib, sys
def encode(value):
if isinstance(value, bytes): return base64.b64encode(value).decode()
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
raise TypeError(type(value).__name__)
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
function execute(command, args, options) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
input: options.input,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
stdio: options.inherit ? 'inherit' : 'pipe',
});
if (result.error || result.status !== 0) {
throw new Error(
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
);
}
return result.stdout?.trim() || '';
}
export function validateProfile(profile, mode, now = new Date()) {
const entitlements = profile.Entitlements ?? {};
if (
profile.TeamIdentifier?.length !== 1 ||
profile.TeamIdentifier[0] !== appleTeamId ||
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
entitlements['application-identifier'] !==
`${appleTeamId}.${bundleIdentifier}`
) {
throw new Error(
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
);
}
if (
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
!(new Date(profile.ExpirationDate) > now) ||
!profile.DeveloperCertificates?.length ||
!profile.Platform?.includes('iOS')
) {
throw new Error(
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
);
}
if (mode === 'testflight') {
if (
entitlements['get-task-allow'] !== false ||
entitlements['beta-reports-active'] !== true ||
'ProvisionedDevices' in profile ||
profile.ProvisionsAllDevices
) {
throw new Error(
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
);
}
} else if (
entitlements['get-task-allow'] !== true ||
!profile.ProvisionedDevices?.length ||
profile.ProvisionsAllDevices
) {
throw new Error(
'Device development requires an iOS App Development profile containing your registered device.',
);
}
return profile;
}
export function selectIdentity(profile, identities, mode) {
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
createHash('sha1')
.update(Buffer.from(certificate, 'base64'))
.digest('hex')
.toUpperCase(),
);
const prefix =
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
const matches = [
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
].filter(
([, fingerprint, name]) =>
fingerprints.includes(fingerprint.toUpperCase()) &&
name.startsWith(prefix),
);
if (matches.length !== 1) {
throw new Error(
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
);
}
return matches[0][1].toUpperCase();
}
export function buildIos({
mode = 'testflight',
env = process.env,
platform = process.platform,
directory = dirname(fileURLToPath(import.meta.url)),
home = homedir(),
run = execute,
log = console.log,
now = new Date(),
} = {}) {
if (!['testflight', 'development', 'simulator'].includes(mode)) {
throw new Error('Choose --mode testflight, development or simulator.');
}
if (platform !== 'darwin') {
throw new Error(
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
);
}
if (
env.APPLE_DEVELOPMENT_TEAM &&
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
) {
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
}
if (env.TAURI_CONFIG) {
throw new Error(
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
);
}
const config = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
);
const ios = JSON.parse(
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
);
const version = config.version;
const buildNumber = ios.bundle?.iOS?.bundleVersion;
if (
ios.identifier !== bundleIdentifier ||
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
config.identifier !== 'de.kanbangit.desktop' ||
config.productName !== 'KanbanGit' ||
!/^\d+\.\d+\.\d+$/.test(version) ||
!/^[1-9]\d*$/.test(buildNumber)
) {
throw new Error(
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
);
}
const simulator = mode === 'simulator';
const configuration = mode === 'testflight' ? 'release' : 'debug';
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
const project = join(directory, 'src-tauri/gen/apple');
const targetDirectory = join(directory, 'src-tauri/target');
const outputRoot = join(targetDirectory, `ios-${mode}`);
const destination = join(outputRoot, `${version}-${buildNumber}`);
if (mode === 'testflight' && existsSync(destination)) {
throw new Error(
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
);
}
const buildEnv = Object.fromEntries(
Object.entries(env).filter(
([key]) =>
!key.startsWith('APPLE_') &&
!key.startsWith('IOS_') &&
!key.startsWith('TAURI_') &&
![
'SDKROOT',
'CARGO_ENCODED_RUSTFLAGS',
'RUSTFLAGS',
'RUSTC_WRAPPER',
'RUSTC_WORKSPACE_WRAPPER',
'XCODE_XCCONFIG_FILE',
].includes(key),
),
);
function step(label, command, args, options = {}) {
log(label);
return run(command, args, {
cwd: directory,
env: buildEnv,
step: label,
...options,
});
}
function parsePlist(xml) {
return JSON.parse(
step(
'Reading local plist metadata',
'python3',
['-c', plistReader],
{ input: xml },
),
);
}
function readProfile(path) {
return validateProfile(
parsePlist(
step('Reading provisioning profile', 'security', [
'cms',
'-D',
'-i',
path,
]),
),
mode,
now,
);
}
step(
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
'xcodegen',
['--version'],
);
step('Checking Xcode', 'xcodebuild', ['-version']);
const installedTargets = step('Checking installed Rust targets', 'rustup', [
'target',
'list',
'--installed',
]);
if (!installedTargets.split(/\s+/).includes(target)) {
throw new Error(
`Install the missing target permanently with rustup target add ${target}, then retry.`,
);
}
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
'which',
'cargo',
]);
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
'which',
'rustc',
]);
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
'--sdk',
sdk,
'--show-sdk-path',
]);
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
buildEnv.CARGO_TARGET_DIR = targetDirectory;
let profile;
let profilePath;
let identity;
if (!simulator) {
if (
!env.IOS_PROVISIONING_PROFILE ||
!existsSync(env.IOS_PROVISIONING_PROFILE)
) {
throw new Error(
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
);
}
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
const withinProject = relative(
realpathSync(join(directory, '..')),
profilePath,
);
if (
!withinProject.startsWith(
`..${process.platform === 'win32' ? '\\' : '/'}`,
) &&
!isAbsolute(withinProject)
) {
throw new Error(
'Keep provisioning profiles outside the repository.',
);
}
profile = readProfile(profilePath);
identity = selectIdentity(
profile,
step('Checking local signing identities', 'security', [
'find-identity',
'-v',
'-p',
'codesigning',
]),
mode,
);
}
mkdirSync(outputRoot, { recursive: true });
const working = mkdtempSync(join(outputRoot, '.building-'));
let installedProfile;
try {
if (profile) {
const installedPath = join(
home,
'Library/Developer/Xcode/UserData/Provisioning Profiles',
`${profile.UUID}.mobileprovision`,
);
mkdirSync(dirname(installedPath), { recursive: true });
if (existsSync(installedPath)) {
if (
!readFileSync(installedPath).equals(
readFileSync(profilePath),
)
) {
throw new Error(
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
);
}
} else {
copyFileSync(profilePath, installedPath);
installedProfile = installedPath;
}
}
step('Generating the Xcode project', 'xcodegen', [
'generate',
'--spec',
join(project, 'project.yml'),
]);
step(
'Building the iOS Rust library',
cargo,
[
'build',
'--manifest-path',
join(directory, 'src-tauri/Cargo.toml'),
'--lib',
'--locked',
'--target',
target,
'--features',
'custom-protocol',
...(configuration === 'release' ? ['--release'] : []),
],
{ inherit: true },
);
const library = join(
targetDirectory,
target,
configuration,
'libkanbangit_native.a',
);
if (!existsSync(library)) {
throw new Error(
`The iOS Rust library was not produced: ${library}`,
);
}
buildEnv.KANBANGIT_IOS_LIBRARY = library;
const archive = join(working, 'KanbanGit.xcarchive');
step(
'Archiving locally without Apple provisioning updates',
'xcodebuild',
[
'-project',
join(project, 'kanbangit-native.xcodeproj'),
'-scheme',
'kanbangit-native_iOS',
'-configuration',
configuration,
'-sdk',
sdk,
'-destination',
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
'-archivePath',
archive,
'-derivedDataPath',
join(targetDirectory, `ios-derived-${mode}`),
'archive',
`MARKETING_VERSION=${version}`,
`CURRENT_PROJECT_VERSION=${buildNumber}`,
`DEVELOPMENT_TEAM=${appleTeamId}`,
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
'CODE_SIGN_STYLE=Manual',
'ARCHS=arm64',
...(simulator
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
: [
`CODE_SIGN_IDENTITY=${identity}`,
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
]),
],
{ inherit: true },
);
function verifyApp(app) {
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
if (
info.CFBundleIdentifier !== bundleIdentifier ||
info.CFBundleShortVersionString !== version ||
info.CFBundleVersion !== buildNumber
) {
throw new Error(
'The built app has an unexpected identifier, version or build number.',
);
}
if (!simulator) {
step('Verifying the Apple signature and team', 'codesign', [
'--verify',
'--deep',
'--strict',
'-R',
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
app,
]);
if (
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
profile.UUID
) {
throw new Error(
'The app was signed with an unexpected provisioning profile.',
);
}
}
}
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
if (!simulator) {
const exportOptions = join(working, 'ExportOptions.plist');
writeFileSync(
exportOptions,
JSON.stringify({
method:
mode === 'testflight'
? 'app-store-connect'
: 'debugging',
destination: 'export',
signingStyle: 'manual',
teamID: appleTeamId,
signingCertificate: identity,
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
manageAppVersionAndBuildNumber: false,
}),
);
step('Preparing local export options', 'plutil', [
'-convert',
'xml1',
exportOptions,
]);
step(
'Exporting the signed IPA locally (no upload)',
'xcodebuild',
[
'-exportArchive',
'-archivePath',
archive,
'-exportPath',
join(working, 'export'),
'-exportOptionsPlist',
exportOptions,
],
{ inherit: true },
);
const ipa = join(working, 'export/KanbanGit.ipa');
if (!existsSync(ipa)) {
throw new Error('Xcode did not produce the signed IPA.');
}
const extracted = join(working, 'verify');
step('Verifying the exported IPA contents', 'ditto', [
'-x',
'-k',
ipa,
extracted,
]);
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
rmSync(extracted, { recursive: true, force: true });
writeFileSync(
`${ipa}.sha256`,
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
);
}
if (mode !== 'testflight') {
rmSync(destination, { recursive: true, force: true });
}
renameSync(working, destination);
log(`Verified local ${mode} build: ${destination}`);
return destination;
} catch (error) {
log(`Build did not complete. Diagnostic output remains in ${working}`);
throw error;
} finally {
if (installedProfile) {
rmSync(installedProfile);
}
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
const { values } = parseArgs({
options: { mode: { type: 'string', default: 'testflight' } },
});
buildIos({ mode: values.mode });
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "kanbangit-native",
"version": "0.2.1",
"version": "0.2.2",
"private": true,
"type": "module",
"scripts": {
@@ -14,8 +14,9 @@
"build:android": "tauri android build --debug --apk --target aarch64",
"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",
"build:ios:simulator": "node build-ios.mjs --mode simulator",
"build:ios:device": "node build-ios.mjs --mode development",
"release:ios": "node build-ios.mjs --mode testflight",
"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",
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
version = "0.2.1"
version = "0.2.2"
description = "KanbanGit native application"
edition = "2021"
publish = false
@@ -5,6 +5,7 @@ import android.os.SystemClock
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.lifecycle.Lifecycle
@@ -59,6 +60,59 @@ class PlatformTest {
instrumentation.waitForIdleSync()
}
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
preferences.edit().clear().apply {
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
if (mode == "invalid") putString("server", "http://invalid.example/")
}.commit()
val scenario = ActivityScenario.launch(MainActivity::class.java)
val launcher = AtomicReference<WebView>()
fun findLauncher(view: View): WebView? {
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
if (view is ViewGroup) for (index in 0 until view.childCount) {
findLauncher(view.getChildAt(index))?.let { return it }
}
return null
}
waitFor {
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
}
if (mode == "saved") {
waitFor {
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
if (state == "\"connecting\"") {
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
}
state == "\"connectionError\""
}
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
scenario.onActivity {
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
assertFalse(it.workspace!!.canGoBack())
it.openWorkspace("https://127.0.0.1:9/app/")
}
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
} else {
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
scenario.onActivity { assertNull(it.workspace) }
}
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
val remote = AtomicReference<WebView>()
waitFor {
var visible = false
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
visible && remote.get() != null
}
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
assertEquals("1", evaluate(remote.get(), "history.length"))
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
scenario.moveToState(Lifecycle.State.CREATED)
}
@Test fun workspacePreservesFormsAndIsolatesServers() {
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
val scenario = ActivityScenario.launch(MainActivity::class.java)
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
if (initialized) return
initialized = true
val saved = preferences.getString("server", null)
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
updateLauncher()
if (saved != null) openWorkspace(server)
val restored = saved?.let(NavigationPolicy::installation)
server = restored ?: NavigationPolicy.DEFAULT_SERVER
if (restored != null) openWorkspace(restored) else updateLauncher()
}
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
"/action/resume" -> if (ready) showWorkspace()
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
val firstLoad = !ready
ready = true
CookieManager.getInstance().flush()
updateLauncher()
if (firstLoad) showWorkspace()
updateLauncher()
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
remote.visibility = View.GONE
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
startTimeout(remote)
remote.loadUrl(server)
remote.loadUrl(NavigationPolicy.dashboard(server))
}
private fun startTimeout(view: WebView) {
@@ -21,6 +21,8 @@ object NavigationPolicy {
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
}.getOrNull()
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
fun allows(server: String, destination: String): Boolean = runCatching {
val base = URI(server)
val target = URI(destination)
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
}
}
@Test fun startupTargetsDashboardWithinTheInstallation() {
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
}
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
val server = "https://example.com:8443/app/"
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable {
return components.url
}
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
func allows(_ destination: URL) -> Bool {
destination.scheme?.lowercased() == "https"
&& destination.host?.lowercased() == url.host?.lowercased()
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private var connection = WorkspaceConnection()
private var pendingNavigation: WKNavigation?
private var timeout: DispatchWorkItem?
private let loading = UIActivityIndicatorView(style: .large)
private let launcher = UIScrollView()
private let stack = UIStackView()
private let address = UITextField()
@@ -58,6 +59,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
loading.accessibilityIdentifier = "workspaceLoading"
loading.accessibilityLabel = text("connecting")
loading.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loading)
NSLayoutConstraint.activate([
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
configureLauncher()
configureToolbar()
if let data = defaults.data(forKey: serverKey) {
@@ -69,8 +78,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
}
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
showSettings()
if let server { open(server.url) }
if let server { open(server.url) } else { showSettings() }
}
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
@@ -83,6 +91,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
private func configureLauncher() {
launcher.isHidden = true
launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive
launcher.alwaysBounceVertical = true
@@ -152,6 +161,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
private func configureToolbar() {
toolbar.isHidden = true
toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar)
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
@@ -222,8 +232,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
view.endEditing(true)
address.text = url.absoluteString
launcher.isHidden = true
toolbar.isHidden = true
loading.startAnimating()
beginNavigation()
pendingNavigation = webview.load(URLRequest(url: url))
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
}
private func beginNavigation() {
@@ -247,6 +260,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
@objc private func showSettings() {
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = false
toolbar.isHidden = true
@@ -256,6 +270,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
@objc private func showWorkspace() {
guard connection.ready else { return }
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = true
toolbar.isHidden = false
@@ -296,7 +311,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard webView === workspace, navigation === pendingNavigation else { return }
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
let url = webView.url, server?.allows(url) == true else { return }
let firstLoad = !connection.ready
timeout?.cancel()
connection.finish()
@@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase {
}
}
func testDashboardAddressPreservesTheInstallationPath() throws {
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
"https://example.com:8443/kanban/dashboard")
}
@MainActor
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
let name = "KAN69-startup-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
let data = try JSONEncoder().encode(saved)
defaults.set(data, forKey: "ios.activeServer")
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
XCTAssertTrue(launcher.isHidden)
XCTAssertTrue(loading.isAnimating)
XCTAssertEqual(controller.server, saved)
let webview = try XCTUnwrap(controller.workspace)
XCTAssertFalse(webview.canGoBack)
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
webview.stopLoading()
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
controller.webView(webview, didStartProvisionalNavigation: navigation)
XCTAssertTrue(launcher.isHidden)
controller.webView(webview, didFailProvisionalNavigation: navigation,
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
XCTAssertFalse(launcher.isHidden)
XCTAssertFalse(loading.isAnimating)
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
controller.webView(webview, didFinish: navigation)
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
controller.open(saved.url)
XCTAssertTrue(launcher.isHidden)
XCTAssertTrue(loading.isAnimating)
XCTAssertEqual(controller.server?.profile, saved.profile)
}
@MainActor
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
let name = "KAN69-setup-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
defaults.set(data, forKey: "ios.activeServer")
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
XCTAssertNil(controller.workspace)
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
XCTAssertFalse(launcher.isHidden)
}
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
controller.open(URL(string: "https://first.invalid/")!)
controller.open(URL(string: "https://second.invalid/app/")!)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, controller.server)
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
}
func testExternalLinksStayOutsideTheSelectedServer() throws {
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
@@ -40,7 +105,7 @@ final class WorkspaceTests: XCTestCase {
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url)
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "<html><body><input id='draft' value='unsaved'></body></html>")
var loaded = false
for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
@@ -57,10 +122,12 @@ final class WorkspaceTests: XCTestCase {
XCTAssertTrue(webview.canGoBack)
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
webview.goBack()
var returned = false
for _ in 0..<20 {
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break }
try await Task.sleep(for: .milliseconds(50))
}
XCTAssertTrue(returned)
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(draft as? String, "unsaved")
}
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
XCTAssertTrue(element.isHittable)
}
@MainActor
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
defer { XCUIDevice.shared.orientation = .portrait }
let app = XCUIApplication()
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
app.launch()
let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() }
let address = app.textFields["serverAddress"]
XCTAssertTrue(address.waitForExistence(timeout: 20))
address.tap()
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
address.typeText("https://127.0.0.1:9/")
app.buttons["openWorkspace"].tap()
let confirmation = app.alerts.buttons["Switch server"]
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
app.terminate()
app.launch()
// An unreachable saved server may fail immediately; only recovery may reveal setup.
for _ in 0..<30 {
if address.exists {
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
break
}
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
Thread.sleep(forTimeInterval: 0.1)
}
XCTAssertTrue(address.waitForExistence(timeout: 20))
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
}
@MainActor
func testLauncherKeyboardValidationRotationAndAppearance() {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
defer { XCUIDevice.shared.orientation = .portrait }
let app = XCUIApplication()
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
app.launch()
let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() }
@@ -49,7 +49,9 @@
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; 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>"; };
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
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>"; };
@@ -146,7 +148,9 @@
2B96972F58CC158AB3DFF94A /* lib.rs */,
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */,
5F71F06AB639140AB9714425 /* metadata.rs */,
E8BA104C81071623E1FF367D /* server.rs */,
6610D376CF901AC1733D394E /* updates.rs */,
);
name = src;
path = ../../src;
@@ -269,12 +273,18 @@
LastUpgradeCheck = 1430;
TargetAttributes = {
721CF221CEDDC28FF2C5EBD5 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
};
988CFF304CB81C8F3A4D6612 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
};
CBC4561F8018D37DD49A0598 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
@@ -332,7 +342,7 @@
);
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";
shellScript = "set -eu\nif [ -n \"${KANBANGIT_IOS_LIBRARY:-}\" ]; then\n mkdir -p \"$SRCROOT/Externals/arm64/$CONFIGURATION\"\n cp \"$KANBANGIT_IOS_LIBRARY\" \"$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a\"\n exit 0\nfi\nexport 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 */
@@ -404,9 +414,10 @@
);
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 = "";
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
@@ -421,9 +432,10 @@
);
"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;
MARKETING_VERSION = 0.2.1;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit";
PRODUCT_NAME = KanbanGit;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -498,13 +510,15 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -577,9 +591,10 @@
);
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 = "";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
@@ -594,9 +609,10 @@
);
"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;
MARKETING_VERSION = 0.2.1;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit";
PRODUCT_NAME = KanbanGit;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -608,13 +624,15 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -626,13 +644,15 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -643,13 +663,15 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
+26 -9
View File
@@ -1,6 +1,6 @@
name: kanbangit-native
options:
bundleIdPrefix: de.kanbangit.desktop
bundleIdPrefix: de.kanbangit.ios
deploymentTarget:
iOS: 17.0
fileGroups: [../../src]
@@ -14,10 +14,17 @@ settingGroups:
SWIFT_VERSION: "5.0"
PRODUCT_MODULE_NAME: KanbanGitNative
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: ""
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.1"
CURRENT_PROJECT_VERSION: "1"
configs:
debug:
CODE_SIGN_IDENTITY: Apple Development
release:
CODE_SIGN_IDENTITY: Apple Distribution
targetTemplates:
app:
type: application
@@ -55,8 +62,8 @@ targets:
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.0
CFBundleVersion: "0.1.0"
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
entitlements:
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
scheme:
@@ -88,10 +95,16 @@ targets:
- sdk: WebKit.framework
preBuildScripts:
- script: |
set -eu
if [ -n "${KANBANGIT_IOS_LIBRARY:-}" ]; then
mkdir -p "$SRCROOT/Externals/arm64/$CONFIGURATION"
cp "$KANBANGIT_IOS_LIBRARY" "$SRCROOT/Externals/arm64/$CONFIGURATION/libapp.a"
exit 0
fi
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:?}
./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:
@@ -108,7 +121,9 @@ targets:
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0"
WorkspaceUITests:
type: bundle.ui-testing
@@ -119,6 +134,8 @@ targets:
settings:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.uitests
DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0"
TEST_TARGET_NAME: kanbangit-native_iOS
+11 -4
View File
@@ -169,7 +169,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let popup_server = server_url.clone();
let popup_handle = app.clone();
let popup_label = label.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
let window =
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
.title(en::APP_NAME)
.inner_size(1280.0, 850.0)
.min_inner_size(375.0, 600.0)
@@ -202,7 +203,12 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let state = app.state::<Mutex<Connection>>();
let should_show = {
let mut state = state.lock().unwrap();
if state.label.as_deref() != Some(window.label()) {
if state.label.as_deref() != Some(window.label())
|| state.phase != "connecting"
|| !state.server.as_ref().is_some_and(|server| {
allows_server_navigation(&server.url, payload.url())
})
{
return;
}
let first_load = !state.ready;
@@ -210,7 +216,6 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
state.phase = "connected";
first_load
};
update_launcher(app);
if should_show {
let _ = window.show();
let _ = window.set_focus();
@@ -218,6 +223,7 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let _ = launcher.hide();
}
}
update_launcher(app);
})
.build()
.map_err(|_| "connectionError")?;
@@ -298,7 +304,8 @@ fn about_metadata(version: String) -> AboutMetadata<'static> {
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"),
Ok(Some(server)) => (Some(server), "connecting"),
Ok(None) => (None, "idle"),
Err(_) => (None, "storageError"),
};
app.manage(Mutex::new(Connection {
+21
View File
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
}
impl Server {
pub fn dashboard_url(&self) -> Url {
self.url
.join("dashboard")
.expect("Validated installation URL")
}
pub fn new(url: Url) -> io::Result<Self> {
let mut profile = [0; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
@@ -111,6 +117,21 @@ mod tests {
}
}
#[test]
fn startup_targets_dashboard_within_the_installation() {
for (base, expected) in [
("https://example.com/", "https://example.com/dashboard"),
(
"https://example.com:8443/kanban/",
"https://example.com:8443/kanban/dashboard",
),
] {
let server = Server::new(validate_url(base).unwrap()).unwrap();
assert_eq!(server.dashboard_url().as_str(), expected);
assert_eq!(server.url.as_str(), base);
}
}
#[test]
fn configuration_round_trip_preserves_only_the_address_and_profile() {
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.2.1",
"version": "0.2.2",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
@@ -25,7 +25,7 @@
],
"macOS": {
"minimumSystemVersion": "14.0",
"bundleVersion": "7",
"bundleVersion": "8",
"dmg": {
"windowSize": {
"width": 660,
+6 -1
View File
@@ -1,6 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"identifier": "de.kanbangit.ios",
"bundle": {
"iOS": { "minimumSystemVersion": "17.0" }
"iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "2"
}
}
}
+22 -2
View File
@@ -11,9 +11,26 @@ fn main() {
let page_stage = stage.clone();
let result_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
.setup(|app, _| {
let directory = app.path().app_config_dir()?;
std::fs::create_dir_all(&directory)?;
std::fs::write(directory.join("active-server"), "https://127.0.0.1:9/\n0123456789abcdef0123456789abcdef")?;
Ok(())
})
.js_init_script(r#"
if (location.protocol === 'tauri:') {
window.__selectionFlashed = false;
new MutationObserver(() => {
const selection = document.querySelector('#workspace-settings');
const phase = document.querySelector('#connection-status')?.dataset.state;
if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true;
}).observe(document, { subtree: true, attributes: true, childList: true });
}
"#)
.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| {
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
if payload.event() != PageLoadEvent::Finished {
return;
}
@@ -28,7 +45,9 @@ fn main() {
throw new Error('Timed out waiting for local state');
};
try {
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError');
if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup');
if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded');
const input = document.querySelector('#server-url');
const form = document.querySelector('#server-form');
const status = document.querySelector('#connection-status');
@@ -76,7 +95,7 @@ fn main() {
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';
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})();
"#).unwrap();
@@ -90,6 +109,7 @@ fn main() {
})
.on_navigation(move |webview, url| {
eprintln!("Smoke navigation: {} {}", webview.label(), url);
if url.path() != "/__native_smoke_result" {
return true;
}
+534
View File
@@ -0,0 +1,534 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
appleTeamId,
buildIos,
bundleIdentifier,
selectIdentity,
validateProfile,
} from '../build-ios.mjs';
const certificate = Buffer.from('synthetic test certificate');
const fingerprint = createHash('sha1')
.update(certificate)
.digest('hex')
.toUpperCase();
function profileFor(mode = 'testflight') {
return {
UUID: '00000000-1111-2222-3333-444444444444',
TeamIdentifier: [appleTeamId],
Platform: ['iOS'],
ExpirationDate: '2099-01-01T00:00:00Z',
DeveloperCertificates: [certificate.toString('base64')],
...(mode === 'development'
? { ProvisionedDevices: ['test-device'] }
: {}),
Entitlements: {
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
'com.apple.developer.team-identifier': appleTeamId,
'get-task-allow': mode === 'development',
'beta-reports-active': mode === 'testflight',
},
};
}
function fixture(t, mode = 'testflight', overrides = {}) {
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const directory = join(root, 'repo/native');
const profilePath = join(root, 'profile.mobileprovision');
const config = {
productName: 'KanbanGit',
identifier: 'de.kanbangit.desktop',
version: '1.2.3',
};
const ios = {
identifier: bundleIdentifier,
bundle: {
iOS: {
developmentTeam: appleTeamId,
bundleVersion: '7',
minimumSystemVersion: '17.0',
},
},
};
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
writeFileSync(
join(directory, 'src-tauri/tauri.conf.json'),
JSON.stringify(config),
);
writeFileSync(
join(directory, 'src-tauri/tauri.ios.conf.json'),
JSON.stringify(ios),
);
writeFileSync(profilePath, 'external provisioning profile');
const profile = profileFor(mode);
const calls = [];
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
const home = join(root, 'home');
const installedProfile = join(
home,
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
);
const info = {
CFBundleIdentifier: bundleIdentifier,
CFBundleShortVersionString: '1.2.3',
CFBundleVersion: '7',
};
function app(path) {
mkdirSync(path, { recursive: true });
writeFileSync(
join(path, 'Info.plist'),
JSON.stringify({ ...info, ...overrides.info }),
);
}
const options = {
directory,
home,
mode,
platform: 'darwin',
log() {},
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
run(command, args, settings) {
calls.push({ command, args, settings });
if (overrides.failStep === settings.step) {
throw new Error('Simulated build failure');
}
if (command === 'python3') {
return settings.input.toString();
}
if (command === 'security') {
if (args[0] === 'cms') {
const embedded = args
.at(-1)
.endsWith('embedded.mobileprovision');
return JSON.stringify(
embedded && overrides.embeddedProfile
? overrides.embeddedProfile
: profile,
);
}
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
}
if (command === 'rustup') {
if (args[0] === 'target') {
return (
overrides.targets ??
'aarch64-apple-ios\naarch64-apple-ios-sim'
);
}
return `/permanent/toolchain/bin/${args[1]}`;
}
if (command === '/permanent/toolchain/bin/cargo') {
const library = join(
directory,
'src-tauri/target',
args[args.indexOf('--target') + 1],
args.includes('--release') ? 'release' : 'debug',
'libkanbangit_native.a',
);
mkdirSync(dirname(library), { recursive: true });
writeFileSync(library, 'compiled library');
}
if (command === 'xcodebuild' && args.includes('archive')) {
assert.ok(mode === 'simulator' || existsSync(installedProfile));
app(
join(
args[args.indexOf('-archivePath') + 1],
'Products/Applications/KanbanGit.app',
),
);
}
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
const output = args[args.indexOf('-exportPath') + 1];
mkdirSync(output, { recursive: true });
if (!overrides.missingIpa) {
writeFileSync(
join(output, 'KanbanGit.ipa'),
'signed ipa bytes',
);
}
}
if (command === 'ditto') {
app(join(args.at(-1), 'Payload/KanbanGit.app'));
}
if (command === 'xcrun') {
return '/permanent/Xcode/SDK';
}
return '';
},
};
return {
options,
calls,
profile,
profilePath,
destination,
installedProfile,
ios,
};
}
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
const f = fixture(t);
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
assert.equal(buildIos(f.options), f.destination);
const options = JSON.parse(
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
);
assert.equal(options.method, 'app-store-connect');
assert.equal(options.destination, 'export');
assert.equal(options.signingStyle, 'manual');
assert.equal(options.manageAppVersionAndBuildNumber, false);
assert.equal(options.signingCertificate, fingerprint);
assert.deepEqual(options.provisioningProfiles, {
[bundleIdentifier]: f.profile.UUID,
});
assert.equal(
readFileSync(
join(f.destination, 'export/KanbanGit.ipa.sha256'),
'utf8',
),
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
);
assert.equal(
f.calls.filter(
(call) =>
call.command === 'codesign' && call.args.includes('--verify'),
).length,
2,
);
assert.equal(existsSync(f.installedProfile), false);
const verification = f.calls.find((call) => call.command === 'codesign');
const requirement = verification.args[verification.args.indexOf('-R') + 1];
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
assert.ok(
requirement.includes('entitlement["beta-reports-active"] exists'),
);
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
for (const call of f.calls) {
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
assert.ok(
!call.args.some((arg) =>
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
arg,
),
),
);
}
const archive = f.calls.find((call) => call.args.includes('archive'));
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
assert.ok(
archive.args.includes(
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
),
);
assert.ok(
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
'/aarch64-apple-ios/release/libkanbangit_native.a',
),
);
});
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
const f = fixture(t, 'simulator');
delete f.options.env.IOS_PROVISIONING_PROFILE;
buildIos(f.options);
assert.ok(
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
);
assert.ok(
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
);
assert.ok(
!f.calls.some(
(call) =>
['security', 'codesign'].includes(call.command) ||
call.args.includes('-exportArchive'),
),
);
});
test('local device development uses a registered-device development profile', (t) => {
const f = fixture(t, 'development');
buildIos(f.options);
const options = JSON.parse(
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
);
assert.equal(options.method, 'debugging');
assert.equal(options.destination, 'export');
});
for (const [name, change, message] of [
[
'different team',
(p) => {
p.TeamIdentifier = ['OLDTEAM'];
},
/explicitly match/,
],
[
'wildcard identifier',
(p) => {
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
},
/explicitly match/,
],
[
'macOS identifier',
(p) => {
p.Entitlements['application-identifier'] =
`${appleTeamId}.de.kanbangit.desktop`;
},
/explicitly match/,
],
[
'expired profile',
(p) => {
p.ExpirationDate = '2000-01-01';
},
/expired/,
],
[
'invalid expiry',
(p) => {
p.ExpirationDate = 'invalid';
},
/expired/,
],
[
'invalid UUID',
(p) => {
p.UUID = '../unexpected';
},
/invalid/,
],
[
'missing certificate',
(p) => {
p.DeveloperCertificates = [];
},
/invalid/,
],
[
'wrong platform',
(p) => {
p.Platform = ['OSX'];
},
/iOS profile/,
],
[
'Ad Hoc profile',
(p) => {
p.ProvisionedDevices = ['device'];
},
/App Store Connect/,
],
[
'enterprise profile',
(p) => {
p.ProvisionsAllDevices = true;
},
/App Store Connect/,
],
[
'development entitlement',
(p) => {
p.Entitlements['get-task-allow'] = true;
},
/App Store Connect/,
],
[
'missing beta entitlement',
(p) => {
delete p.Entitlements['beta-reports-active'];
},
/App Store Connect/,
],
]) {
test(`rejects ${name} before signing`, () => {
const profile = profileFor();
change(profile);
assert.throws(() => validateProfile(profile, 'testflight'), message);
});
}
test('a distribution profile cannot be used for device development', () => {
assert.throws(
() => validateProfile(profileFor(), 'development'),
/App Development/,
);
});
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
const profile = profileFor();
for (const identities of [
'0 valid identities found',
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
`1) ${fingerprint} "Developer ID Application: Example"`,
`1) ${fingerprint} "Apple Development: Example"`,
]) {
assert.throws(
() => selectIdentity(profile, identities, 'testflight'),
/private key/,
);
}
});
for (const step of [
'Generating the Xcode project',
'Building the iOS Rust library',
'Archiving locally without Apple provisioning updates',
'Verifying the Apple signature and team',
'Exporting the signed IPA locally (no upload)',
]) {
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
const f = fixture(t, 'testflight', { failStep: step });
assert.throws(() => buildIos(f.options), /Simulated build failure/);
assert.equal(existsSync(f.destination), false);
assert.equal(existsSync(f.installedProfile), false);
});
}
test('an existing identical local profile is preserved after the build', (t) => {
const f = fixture(t);
mkdirSync(dirname(f.installedProfile), { recursive: true });
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
buildIos(f.options);
assert.equal(existsSync(f.installedProfile), true);
});
test('a conflicting local profile is never overwritten or deleted', (t) => {
const f = fixture(t);
mkdirSync(dirname(f.installedProfile), { recursive: true });
writeFileSync(f.installedProfile, 'user profile');
assert.throws(() => buildIos(f.options), /same UUID/);
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
});
test('missing toolchain targets fail before compilation without installing anything', (t) => {
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
assert.throws(
() => buildIos(f.options),
/rustup target add aarch64-apple-ios/,
);
assert.ok(
!f.calls.some(
(call) => call.args.includes('build') || call.args.includes('add'),
),
);
});
test('missing profile and ambient team overrides fail clearly', (t) => {
const f = fixture(t);
delete f.options.env.IOS_PROVISIONING_PROFILE;
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
f.options.env.TAURI_CONFIG = '{}';
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
});
for (const [name, overrides, message] of [
[
'wrong app version',
{ info: { CFBundleVersion: '8' } },
/unexpected identifier, version/,
],
[
'changed embedded profile',
{
embeddedProfile: {
...profileFor(),
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
},
},
/unexpected provisioning profile/,
],
['missing IPA', { missingIpa: true }, /did not produce/],
]) {
test(`rejects ${name} in the build output`, (t) => {
const f = fixture(t, 'testflight', overrides);
assert.throws(() => buildIos(f.options), message);
assert.equal(existsSync(f.destination), false);
});
}
test('completed artifacts are not silently overwritten', (t) => {
const f = fixture(t);
buildIos(f.options);
assert.throws(() => buildIos(f.options), /already exists/);
});
for (const mode of ['simulator', 'development']) {
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
const overrides = {};
const f = fixture(t, mode, overrides);
buildIos(f.options);
const previousBuild = join(f.destination, 'previous-build');
writeFileSync(previousBuild, 'verified artifact');
overrides.failStep = 'Building the iOS Rust library';
assert.throws(() => buildIos(f.options), /Simulated build failure/);
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
delete overrides.failStep;
buildIos(f.options);
assert.equal(existsSync(previousBuild), false);
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
assert.equal(existsSync(f.installedProfile), false);
});
}
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
const assets = fileURLToPath(
new URL(
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
import.meta.url,
),
);
const catalog = JSON.parse(
readFileSync(join(assets, 'Contents.json'), 'utf8'),
);
assert.ok(
catalog.images.some(
(icon) =>
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
),
);
for (const icon of catalog.images) {
const png = readFileSync(join(assets, icon.filename));
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
const [width, height] = icon.size.split('x').map(Number);
const scale = Number.parseInt(icon.scale, 10);
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
for (
let offset = 8;
offset < png.length;
offset += png.readUInt32BE(offset) + 12
) {
assert.notEqual(
png.subarray(offset + 4, offset + 8).toString(),
'tRNS',
icon.filename,
);
}
}
});
+7 -6
View File
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
};
try {
await page.goto(`http://127.0.0.1:${server.address().port}/`);
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
await state('connecting');
await page.waitForTimeout(500);
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
assert.equal(await page.locator('#startup-loading').isVisible(), true);
await page.reload();
await state('idle');
for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 });
@@ -98,12 +104,7 @@ try {
'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,
);
assert.equal(await page.locator('.open-workspace').isDisabled(), true);
await state('connectionError', false, 'https://first.example.com/');
assert.equal(
await page.getByLabel('Server address').inputValue(),
+5
View File
@@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js';
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
test('workspace selection is hidden before native configuration is available', () => {
assert.match(html, /<main id="workspace-settings" hidden>/);
assert.match(html, /id="startup-loading" role="status"/);
});
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);
+12
View File
@@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co
try {
await import('../ui/main.js');
assert.equal(messages.shift().pathname, '/action/ready');
assert.equal(element('#workspace-settings').hidden, true);
window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=connecting&ready=false',
);
assert.equal(element('#workspace-settings').hidden, true);
assert.equal(element('#startup-loading').hidden, false);
window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=connectionError&ready=false',
);
assert.equal(element('#workspace-settings').hidden, false);
assert.equal(element('#startup-loading').hidden, true);
assert.equal(element('.open-workspace').textContent, 'Try again');
window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
);
+2 -1
View File
@@ -9,7 +9,8 @@
<script type="module" src="./main.js"></script>
</head>
<body>
<main>
<p id="startup-loading" role="status" aria-live="polite" data-i18n="connecting"></p>
<main id="workspace-settings" hidden>
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1>
+3
View File
@@ -31,6 +31,9 @@ window.updateLauncher = (query) => {
const state = new URLSearchParams(query);
const phase = state.get('phase');
const server = state.get('server');
const loading = phase === 'connecting' && state.get('ready') !== 'true';
document.querySelector('#startup-loading').hidden = !loading;
document.querySelector('#workspace-settings').hidden = loading;
if (!initialized) {
input.value = server;
initialized = true;
+19
View File
@@ -117,6 +117,25 @@ class AuthenticationTest extends TestCase
$this->assertAuthenticatedAs($user);
}
public function test_workspace_start_without_a_valid_session_returns_to_dashboard_after_login(): void
{
$user = User::factory()->create();
$this->get(route('dashboard'))
->assertRedirect(route('login'))
->assertSessionHas('url.intended', route('dashboard'));
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('dashboard'));
$this->assertAuthenticatedAs($user);
$this->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page->component('Dashboard'));
}
public function test_users_are_rate_limited()
{
$user = User::factory()->create();