Compare commits

..
Author SHA1 Message Date
Twyco 752ba2cd38 Merge pull request 'KAN-71: Use interactive WebKit gestures on iOS and macOS' (#169) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m24s
CI/CD / test_and_check_app (push) Successful in 6m43s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #169
2026-09-29 06:04:38 +00:00
Twyco 1cf7524a7b fix(KAN-71): use interactive WebKit navigation gestures
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 08:02:10 +02:00
Twyco 386b25858b Merge pull request 'KAN-71: Correct iOS and macOS navigation gestures and scroll bounds' (#168) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m26s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m37s
CI/CD / Publish immutable image (push) Successful in 1m17s
Reviewed-on: #168
2026-09-29 05:28:53 +00:00
Twyco aa14068c66 chore(KAN-71): bump native version to 0.2.4 and mobile build numbers
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Successful in 6m39s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 07:27:05 +02:00
Twyco 4afaa79834 fix(KAN-71): correct Apple navigation gestures and scroll bounds
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
CI/CD / test_and_check_app (pull_request) Successful in 6m44s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 07:22:45 +02:00
Twyco 0662a6970e Merge pull request 'KAN-69: Fix macOS startup and window closing; release 0.2.3' (#167) from fix/KAN-69-macos-startup-and-close into main
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / Publish immutable image (push) Successful in 1m20s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m8s
CI/CD / preconditions (push) Successful in 1s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m27s
CI/CD / test_and_check_app (push) Successful in 6m28s
Reviewed-on: #167
2026-09-28 20:01:57 +00:00
Twyco 6820e0b25e fix(KAN-69): hide macOS launcher on startup and quit on close
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Canceled after 1m34s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m29s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-28 22:00:06 +02:00
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
Twyco fa8a0cc512 Merge pull request 'Add permanent macOS app download' (#164) from feature/macos-download-app into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m36s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m3s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
CI/CD / test_and_check_app (push) Successful in 7m13s
CI/CD / preconditions (push) Successful in 2s
CI/CD / Publish immutable image (push) Successful in 14s
Reviewed-on: #164
2026-09-28 07:22:09 +00:00
Twyco d18fffe665 feat: add stable macOS app download to website
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m3s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m41s
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-28 09:12:37 +02:00
61 changed files with 2330 additions and 209 deletions
+10
View File
@@ -1,5 +1,15 @@
# Working Agreements # Working Agreements
## Native app versions and build numbers
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
## Missing programs and SDKs ## Missing programs and SDKs
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project. If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
+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;
}
}
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.2.0", "version": "0.2.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -14,8 +14,9 @@
"build:android": "tauri android build --debug --apk --target aarch64", "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", "smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
"dev:ios": "tauri ios dev", "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:simulator": "node build-ios.mjs --mode simulator",
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only", "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", "open:ios": "tauri ios dev --open --no-dev-server",
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml", "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", "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",
@@ -24,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
}, },
"releaseNotes": "Update to full release v0.2.0" "releaseNotes": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
} }
+55 -2
View File
@@ -228,7 +228,7 @@ export async function publishMacos({
await wait(2000); await wait(2000);
} }
} }
function assertUnpublishedRelease() { function assertUnpublishedRelease(allowSameVersion = false) {
const file = join(staging, 'published-latest.json'); const file = join(staging, 'published-latest.json');
try { try {
s3('get-object', ['--key', latestKey, file]); s3('get-object', ['--key', latestKey, file]);
@@ -261,7 +261,11 @@ export async function publishMacos({
throw error; throw error;
} }
const published = JSON.parse(readFileSync(file, 'utf8')); const published = JSON.parse(readFileSync(file, 'utf8'));
if (compareVersions(metadata.version, published.version) <= 0) { const comparison = compareVersions(metadata.version, published.version);
if (allowSameVersion && comparison < 0) {
throw new Error('Refusing to downgrade the stable download.');
}
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
throw new Error( throw new Error(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.', 'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
); );
@@ -365,6 +369,55 @@ export async function publishMacos({
); );
putReleaseFile('update.json', latest, 'application/json'); putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest); await verifyPublic('update.json', latest);
// An older release must not populate an absent download alias either.
assertUnpublishedRelease(true);
const downloadName = 'KanbanGit.dmg';
const downloadKey = `${latestPrefix}/${downloadName}`;
const downloadFile = join(staging, downloadName);
writeFileSync(downloadFile, archive);
function publishDownload(condition) {
s3('put-object', [
'--key',
downloadKey,
'--body',
downloadFile,
...condition,
'--content-type',
'application/x-apple-diskimage',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(archive).digest('base64'),
'--metadata',
`sha256=${checksum}`,
]);
}
try {
publishDownload(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-download.dmg');
const response = JSON.parse(
s3('get-object', [
'--key',
downloadKey,
'--output',
'json',
previousFile,
]),
);
if (sha256(readFileSync(previousFile)) !== checksum) {
assertUnpublishedRelease();
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
downloadName,
archive,
`${storage.publicBase}/${latestPrefix}`,
);
const latestFile = join(staging, 'latest.json'); const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest); writeFileSync(latestFile, latest);
function publishLatest(condition) { function publishLatest(condition) {
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.0" version = "0.2.5"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.0" version = "0.2.5"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
+48
View File
@@ -1,3 +1,51 @@
fn main() { fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
build_navigation();
}
tauri_build::build(); tauri_build::build();
} }
fn build_navigation() {
use std::{env, path::PathBuf, process::Command};
println!("cargo:rerun-if-changed=src/navigation.m");
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let object = output.join("navigation.o");
let archive = output.join("libkanban_navigation.a");
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
"aarch64" => "arm64",
"x86_64" => "x86_64",
_ => panic!("Unsupported macOS architecture"),
};
assert!(Command::new("xcrun")
.args([
"--sdk",
"macosx",
"clang",
"-fobjc-arc",
"-fblocks",
"-Wall",
"-Werror",
"-mmacosx-version-min=14.0",
"-arch",
arch,
"-c",
"src/navigation.m",
"-o"
])
.arg(&object)
.status()
.expect("Xcode clang is required")
.success());
assert!(Command::new("xcrun")
.args(["ar", "crs"])
.arg(&archive)
.arg(&object)
.status()
.expect("Xcode ar is required")
.success());
println!("cargo:rustc-link-search=native={}", output.display());
println!("cargo:rustc-link-lib=static=kanban_navigation");
println!("cargo:rustc-link-lib=framework=AppKit");
println!("cargo:rustc-link-lib=framework=WebKit");
}
@@ -5,6 +5,7 @@ import android.os.SystemClock
import android.view.InputDevice import android.view.InputDevice
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebView import android.webkit.WebView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -59,6 +60,59 @@ class PlatformTest {
instrumentation.waitForIdleSync() 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() { @Test fun workspacePreservesFormsAndIsolatesServers() {
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit() instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
val scenario = ActivityScenario.launch(MainActivity::class.java) val scenario = ActivityScenario.launch(MainActivity::class.java)
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
if (initialized) return if (initialized) return
initialized = true initialized = true
val saved = preferences.getString("server", null) val saved = preferences.getString("server", null)
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER val restored = saved?.let(NavigationPolicy::installation)
updateLauncher() server = restored ?: NavigationPolicy.DEFAULT_SERVER
if (saved != null) openWorkspace(server) if (restored != null) openWorkspace(restored) else updateLauncher()
} }
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty()) "/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
"/action/resume" -> if (ready) showWorkspace() "/action/resume" -> if (ready) showWorkspace()
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
val firstLoad = !ready val firstLoad = !ready
ready = true ready = true
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
updateLauncher()
if (firstLoad) showWorkspace() if (firstLoad) showWorkspace()
updateLauncher()
} }
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
remote.visibility = View.GONE remote.visibility = View.GONE
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1)) content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
startTimeout(remote) startTimeout(remote)
remote.loadUrl(server) remote.loadUrl(NavigationPolicy.dashboard(server))
} }
private fun startTimeout(view: WebView) { private fun startTimeout(view: WebView) {
@@ -21,6 +21,8 @@ object NavigationPolicy {
"https://${uri.host.lowercase(Locale.ROOT)}$port$path" "https://${uri.host.lowercase(Locale.ROOT)}$port$path"
}.getOrNull() }.getOrNull()
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
fun allows(server: String, destination: String): Boolean = runCatching { fun allows(server: String, destination: String): Boolean = runCatching {
val base = URI(server) val base = URI(server)
val target = URI(destination) 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() { @Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
val server = "https://example.com:8443/app/" val server = "https://example.com:8443/app/"
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) { 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 return components.url
} }
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
func allows(_ destination: URL) -> Bool { func allows(_ destination: URL) -> Bool {
destination.scheme?.lowercased() == "https" destination.scheme?.lowercased() == "https"
&& destination.host?.lowercased() == url.host?.lowercased() && destination.host?.lowercased() == url.host?.lowercased()
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private var connection = WorkspaceConnection() private var connection = WorkspaceConnection()
private var pendingNavigation: WKNavigation? private var pendingNavigation: WKNavigation?
private var timeout: DispatchWorkItem? private var timeout: DispatchWorkItem?
private let loading = UIActivityIndicatorView(style: .large)
private let launcher = UIScrollView() private let launcher = UIScrollView()
private let stack = UIStackView() private let stack = UIStackView()
private let address = UITextField() private let address = UITextField()
@@ -39,8 +40,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system) private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar() private let toolbar = UIToolbar()
private var historyObservation: NSKeyValueObservation?
private var backButton: UIBarButtonItem!
private var appearance: UISegmentedControl! private var appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
@@ -58,6 +57,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1) 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() configureLauncher()
configureToolbar() configureToolbar()
if let data = defaults.data(forKey: serverKey) { if let data = defaults.data(forKey: serverKey) {
@@ -69,8 +76,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
} }
address.text = server?.url.absoluteString ?? "https://kanbangit.de/" address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
showSettings() if let server { open(server.url) } else { showSettings() }
if let server { open(server.url) }
} }
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel { private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
@@ -83,9 +89,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
private func configureLauncher() { private func configureLauncher() {
launcher.isHidden = true
launcher.accessibilityIdentifier = "serverSettings" launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive launcher.keyboardDismissMode = .interactive
launcher.alwaysBounceVertical = true launcher.bounces = false
view.addSubview(launcher) view.addSubview(launcher)
launcher.translatesAutoresizingMaskIntoConstraints = false launcher.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -152,13 +159,12 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
private func configureToolbar() { private func configureToolbar() {
toolbar.isHidden = true
toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar) 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)) let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings" settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings] toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
@@ -193,7 +199,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey) defaults.set(data, forKey: serverKey)
timeout?.cancel() timeout?.cancel()
historyObservation = nil
workspace?.stopLoading() workspace?.stopLoading()
workspace?.navigationDelegate = nil workspace?.navigationDelegate = nil
workspace?.removeFromSuperview() workspace?.removeFromSuperview()
@@ -201,11 +206,15 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
server = selected server = selected
let configuration = WKWebViewConfiguration() let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile) configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge. let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// Remote content receives no native message handlers or Tauri bridge.
let webview = WKWebView(frame: .zero, configuration: configuration) let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self webview.navigationDelegate = self
webview.uiDelegate = self webview.uiDelegate = self
webview.allowsBackForwardNavigationGestures = true webview.allowsBackForwardNavigationGestures = true
webview.scrollView.bounces = false
webview.accessibilityIdentifier = "workspace" webview.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true webview.isHidden = true
@@ -217,13 +226,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
]) ])
workspace = webview workspace = webview
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
self?.backButton.isEnabled = view.canGoBack
}
view.endEditing(true) view.endEditing(true)
address.text = url.absoluteString address.text = url.absoluteString
launcher.isHidden = true
toolbar.isHidden = true
loading.startAnimating()
beginNavigation() beginNavigation()
pendingNavigation = webview.load(URLRequest(url: url)) pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
} }
private func beginNavigation() { private func beginNavigation() {
@@ -247,6 +256,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
@objc private func showSettings() { @objc private func showSettings() {
loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = false launcher.isHidden = false
toolbar.isHidden = true toolbar.isHidden = true
@@ -256,15 +266,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
@objc private func showWorkspace() { @objc private func showWorkspace() {
guard connection.ready else { return } guard connection.ready else { return }
loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = true launcher.isHidden = true
toolbar.isHidden = false toolbar.isHidden = false
workspace?.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) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else { guard server?.allows(url) == true else {
@@ -296,7 +304,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 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 let firstLoad = !connection.ready
timeout?.cancel() timeout?.cancel()
connection.finish() connection.finish()
@@ -304,7 +313,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
connectButton.setTitle(text("openWorkspace"), for: .normal) connectButton.setTitle(text("openWorkspace"), for: .normal)
status.text = text("connected") status.text = text("connected")
resumeButton.isHidden = false resumeButton.isHidden = false
backButton.isEnabled = webView.canGoBack
if firstLoad { showWorkspace() } if firstLoad { showWorkspace() }
} }
@@ -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 { func testExternalLinksStayOutsideTheSelectedServer() throws {
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID()) 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"] { for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
@@ -30,39 +95,53 @@ final class WorkspaceTests: XCTestCase {
} }
@MainActor @MainActor
func testBackButtonTracksSameDocumentHistory() async throws { func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
let name = "KAN55-history-\(UUID())" let name = "KAN71-history-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) } defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults) let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded() controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/")) controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.open(url) let url = URL(string: "https://example.invalid/dashboard")!
controller.open(URL(string: "https://example.invalid/")!)
let webview = try XCTUnwrap(controller.workspace) let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading() webview.stopLoading()
webview.loadHTMLString("<html><body><input id='draft' value='unsaved'></body></html>", baseURL: url) webview.frame = controller.view.bounds
var loaded = false webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
for _ in 0..<50 { <html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), <body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
value as? String == "unsaved" { loaded = true; break } """)
try await Task.sleep(for: .milliseconds(100)) try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
} XCTAssertFalse(webview.canGoBack)
XCTAssertTrue(loaded) XCTAssertNil(webview.goBack())
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')") XCTAssertNil(webview.goForward())
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) try await waitFor(webview, "location.pathname === '/dashboard'")
for _ in 0..<20 { XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
if toolbar.items?.first?.isEnabled == true { break } XCTAssertFalse(webview.scrollView.bounces)
try await Task.sleep(for: .milliseconds(50)) XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
} let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
XCTAssertTrue(webview.canGoBack) XCTAssertEqual(customNavigation as? String, "undefined")
XCTAssertTrue(toolbar.items?.first?.isEnabled == true) let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
webview.goBack()
for _ in 0..<20 {
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break }
try await Task.sleep(for: .milliseconds(50))
}
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(draft as? String, "unsaved") XCTAssertEqual(draft as? String, "unsaved")
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
XCTAssertEqual(scroll as? String, "none")
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
XCTAssertEqual(horizontal as? String, "auto")
controller.open(URL(string: "https://other.invalid/")!)
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
"Changing workspaces must retain native gesture navigation")
}
@MainActor
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
for _ in 0..<100 {
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
try await Task.sleep(for: .milliseconds(50))
}
XCTFail("WebView condition timed out: \(expression)")
} }
func testServerChangesNeverReusePreviousProfile() throws { func testServerChangesNeverReusePreviousProfile() throws {
@@ -122,7 +201,7 @@ final class WorkspaceTests: XCTestCase {
controller.open(url) controller.open(url)
let server = try XCTUnwrap(controller.server) let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile) XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true) XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
let restored = WorkspaceViewController(defaults: defaults) let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded() restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server) XCTAssertEqual(restored.server, server)
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
XCTAssertTrue(element.isHittable) 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 @MainActor
func testLauncherKeyboardValidationRotationAndAppearance() { func testLauncherKeyboardValidationRotationAndAppearance() {
continueAfterFailure = false continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait XCUIDevice.shared.orientation = .portrait
defer { XCUIDevice.shared.orientation = .portrait }
let app = XCUIApplication() let app = XCUIApplication()
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
app.launch() app.launch()
let settings = app.buttons["showServerSettings"] let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() } if settings.waitForExistence(timeout: 3) { settings.tap() }
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; }; 009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; }; 0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; }; 205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; }; 333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
@@ -18,6 +19,7 @@
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; }; 7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; }; 73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; }; 98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; }; A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; }; B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; }; C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
@@ -49,9 +51,13 @@
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 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>"; }; 4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; 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; }; 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>"; }; 7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; }; 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; }; 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>"; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
@@ -108,6 +114,7 @@
8606F3EE5A736385061692EF /* Sources */, 8606F3EE5A736385061692EF /* Sources */,
335B1262A9019C70C7F887A5 /* src */, 335B1262A9019C70C7F887A5 /* src */,
140A7DB7E2461E64B1FBFA70 /* Tests */, 140A7DB7E2461E64B1FBFA70 /* Tests */,
709BE6DF191B953724A55555 /* ui */,
3C9F31BB065F210267ECA390 /* UITests */, 3C9F31BB065F210267ECA390 /* UITests */,
3028C3786B437115B6486F1C /* Frameworks */, 3028C3786B437115B6486F1C /* Frameworks */,
59D2DE9FD94E6D2D64CB0117 /* Products */, 59D2DE9FD94E6D2D64CB0117 /* Products */,
@@ -146,7 +153,10 @@
2B96972F58CC158AB3DFF94A /* lib.rs */, 2B96972F58CC158AB3DFF94A /* lib.rs */,
AEBAD9ABE758897C50BC1F2E /* locale.rs */, AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */, 7440C7F0426E6B3977DD1D94 /* main.rs */,
5F71F06AB639140AB9714425 /* metadata.rs */,
8C0F963909020429B3D257D5 /* navigation.m */,
E8BA104C81071623E1FF367D /* server.rs */, E8BA104C81071623E1FF367D /* server.rs */,
6610D376CF901AC1733D394E /* updates.rs */,
); );
name = src; name = src;
path = ../../src; path = ../../src;
@@ -179,6 +189,15 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
709BE6DF191B953724A55555 /* ui */ = {
isa = PBXGroup;
children = (
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
);
name = ui;
path = ../../../ui;
sourceTree = "<group>";
};
8606F3EE5A736385061692EF /* Sources */ = { 8606F3EE5A736385061692EF /* Sources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -247,6 +266,7 @@
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */; buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
buildPhases = ( buildPhases = (
2A0467365B852D44D1A9FD5B /* Sources */, 2A0467365B852D44D1A9FD5B /* Sources */,
DC3295DE0893151786FEA37B /* Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -269,12 +289,18 @@
LastUpgradeCheck = 1430; LastUpgradeCheck = 1430;
TargetAttributes = { TargetAttributes = {
721CF221CEDDC28FF2C5EBD5 = { 721CF221CEDDC28FF2C5EBD5 = {
DevelopmentTeam = ""; DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Automatic; ProvisioningStyle = Manual;
}; };
988CFF304CB81C8F3A4D6612 = { 988CFF304CB81C8F3A4D6612 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
TestTargetID = 721CF221CEDDC28FF2C5EBD5; TestTargetID = 721CF221CEDDC28FF2C5EBD5;
}; };
CBC4561F8018D37DD49A0598 = {
DevelopmentTeam = ZZX2KZGTHU;
ProvisioningStyle = Manual;
};
}; };
}; };
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */; buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
@@ -307,6 +333,15 @@
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */, C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */, 34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */, A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DC3295DE0893151786FEA37B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -332,7 +367,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; 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 */ /* End PBXShellScriptBuildPhase section */
@@ -404,9 +439,10 @@
); );
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
@@ -421,9 +457,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=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)"; "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.5;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit"; PRODUCT_NAME = KanbanGit;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -498,13 +535,15 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@loader_path/Frameworks", "@loader_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -577,9 +616,10 @@
); );
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ""; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
@@ -594,9 +634,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=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)"; "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.5;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = "KanbanGit"; PRODUCT_NAME = KanbanGit;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -608,13 +649,15 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@loader_path/Frameworks", "@loader_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.uitests;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -626,13 +669,15 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@loader_path/Frameworks", "@loader_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -643,13 +688,15 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@loader_path/Frameworks", "@loader_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios.tests;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.0</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.1.0</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@@ -45,4 +45,4 @@
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+28 -9
View File
@@ -1,6 +1,6 @@
name: kanbangit-native name: kanbangit-native
options: options:
bundleIdPrefix: de.kanbangit.desktop bundleIdPrefix: de.kanbangit.ios
deploymentTarget: deploymentTarget:
iOS: 17.0 iOS: 17.0
fileGroups: [../../src] fileGroups: [../../src]
@@ -14,10 +14,17 @@ settingGroups:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
PRODUCT_MODULE_NAME: KanbanGitNative PRODUCT_MODULE_NAME: KanbanGitNative
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.5"
CURRENT_PROJECT_VERSION: "5"
configs:
debug:
CODE_SIGN_IDENTITY: Apple Development
release:
CODE_SIGN_IDENTITY: Apple Distribution
targetTemplates: targetTemplates:
app: app:
type: application type: application
@@ -35,6 +42,7 @@ targets:
platform: iOS platform: iOS
sources: sources:
- path: Sources - path: Sources
- path: ../../../ui/workspace-navigation.js
- path: Assets.xcassets - path: Assets.xcassets
- path: kanbangit-native_iOS - path: kanbangit-native_iOS
- path: LaunchScreen.storyboard - path: LaunchScreen.storyboard
@@ -55,8 +63,8 @@ targets:
- UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.0 CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "0.1.0" CFBundleVersion: $(CURRENT_PROJECT_VERSION)
entitlements: entitlements:
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
scheme: scheme:
@@ -88,10 +96,16 @@ targets:
- sdk: WebKit.framework - sdk: WebKit.framework
preBuildScripts: preBuildScripts:
- script: | - 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 PATH="$(dirname "$(rustup which cargo)"):$PATH"
export RUSTC="$(rustup which rustc)" export RUSTC="$(rustup which rustc)"
cd "$SRCROOT/../../.." 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 name: Build Rust Code
basedOnDependencyAnalysis: false basedOnDependencyAnalysis: false
outputFiles: outputFiles:
@@ -101,6 +115,7 @@ targets:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS
sources: sources:
- path: ../../../ui/workspace-navigation.js
- path: Tests - path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift - path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift - path: Sources/kanbangit-native/WorkspaceViewController.swift
@@ -108,7 +123,9 @@ targets:
settings: settings:
base: base:
GENERATE_INFOPLIST_FILE: YES 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" SWIFT_VERSION: "5.0"
WorkspaceUITests: WorkspaceUITests:
type: bundle.ui-testing type: bundle.ui-testing
@@ -119,6 +136,8 @@ targets:
settings: settings:
base: base:
GENERATE_INFOPLIST_FILE: YES 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" SWIFT_VERSION: "5.0"
TEST_TARGET_NAME: kanbangit-native_iOS TEST_TARGET_NAME: kanbangit-native_iOS
+101 -81
View File
@@ -169,76 +169,87 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let popup_server = server_url.clone(); let popup_server = server_url.clone();
let popup_handle = app.clone(); let popup_handle = app.clone();
let popup_label = label.clone(); let popup_label = label.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url)) let window =
.title(en::APP_NAME) WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
.inner_size(1280.0, 850.0) .title(en::APP_NAME)
.min_inner_size(375.0, 600.0) .inner_size(1280.0, 850.0)
.center() .min_inner_size(375.0, 600.0)
.visible(false) .center()
.data_store_identifier(server.profile) .visible(false)
.on_navigation(move |destination| { .data_store_identifier(server.profile)
if !allows_server_navigation(&server_url, destination) { .initialization_script(include_str!("../../ui/workspace-navigation.js"))
open_external(destination); .on_navigation(move |destination| {
return false; if !allows_server_navigation(&server_url, destination) {
} open_external(destination);
begin_navigation(&handle, &navigation_label); return false;
true
})
.on_new_window(move |destination, _| {
if allows_server_navigation(&popup_server, &destination) {
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
let _ = window.navigate(destination);
} }
} else { begin_navigation(&handle, &navigation_label);
open_external(&destination); true
} })
NewWindowResponse::Deny .on_new_window(move |destination, _| {
}) if allows_server_navigation(&popup_server, &destination) {
.on_page_load(|window, payload| { if let Some(window) = popup_handle.get_webview_window(&popup_label) {
if payload.event() != PageLoadEvent::Finished { let _ = window.navigate(destination);
return; }
} } else {
let app = window.app_handle(); open_external(&destination);
let state = app.state::<Mutex<Connection>>(); }
let should_show = { NewWindowResponse::Deny
let mut state = state.lock().unwrap(); })
if state.label.as_deref() != Some(window.label()) { .on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished {
return; return;
} }
let first_load = !state.ready; let app = window.app_handle();
state.ready = true; let state = app.state::<Mutex<Connection>>();
state.phase = "connected"; let should_show = {
first_load let mut state = state.lock().unwrap();
}; if state.label.as_deref() != Some(window.label())
update_launcher(app); || state.phase != "connecting"
if should_show { || !state.server.as_ref().is_some_and(|server| {
let _ = window.show(); allows_server_navigation(&server.url, payload.url())
let _ = window.set_focus(); })
if let Some(launcher) = app.get_webview_window("launcher") { {
let _ = launcher.hide(); return;
}
let first_load = !state.ready;
state.ready = true;
state.phase = "connected";
first_load
};
if should_show {
let _ = window.show();
let _ = window.set_focus();
if let Some(launcher) = app.get_webview_window("launcher") {
let _ = launcher.hide();
}
} }
update_launcher(app);
})
.build()
.map_err(|_| "connectionError")?;
#[cfg(target_os = "macos")]
window
.with_webview(|webview| unsafe {
unsafe extern "C" {
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
} }
install_workspace_navigation(webview.inner().cast());
}) })
.build()
.map_err(|_| "connectionError")?; .map_err(|_| "connectionError")?;
let handle = app.clone(); let handle = app.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) { if let WindowEvent::CloseRequested { api, .. } = event {
let state = handle.state::<Mutex<Connection>>(); let current = handle
let current = { .state::<Mutex<Connection>>()
let mut state = state.lock().unwrap(); .lock()
if state.label.as_deref() == Some(&label) { .unwrap()
state.label = None; .label
state.ready = false; .as_deref()
state.revision += 1; == Some(&label);
state.phase = "idle";
true
} else {
false
}
};
if current { if current {
show_launcher(&handle); api.prevent_close();
handle.exit(0);
} }
} }
}); });
@@ -298,9 +309,11 @@ fn about_metadata(version: String) -> AboutMetadata<'static> {
pub fn builder() -> tauri::Builder<tauri::Wry> { pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| { tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) { 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"), Err(_) => (None, "storageError"),
}; };
let startup_server = server.clone();
app.manage(Mutex::new(Connection { app.manage(Mutex::new(Connection {
server, server,
phase, phase,
@@ -385,6 +398,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
.title(en::APP_NAME) .title(en::APP_NAME)
.inner_size(560.0, 720.0) .inner_size(560.0, 720.0)
.min_inner_size(375.0, 640.0) .min_inner_size(375.0, 640.0)
.visible(false)
.center() .center()
.on_navigation(move |destination| { .on_navigation(move |destination| {
if !allows_launcher_navigation(destination) { if !allows_launcher_navigation(destination) {
@@ -407,25 +421,15 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
update_launcher(&app); update_launcher(&app);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
crate::updates::refresh(&app); crate::updates::refresh(&app);
std::thread::spawn(move || { let needs_setup = app
let handle = app.clone(); .state::<Mutex<Connection>>()
let _ = app.run_on_main_thread(move || { .lock()
let server = handle .unwrap()
.state::<Mutex<Connection>>() .server
.lock() .is_none();
.unwrap() if needs_setup {
.server show_launcher(&app);
.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()?; .build()?;
@@ -433,9 +437,25 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
launcher.on_window_event(move |event| { launcher.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
let _ = window.hide(); let has_workspace = window
.app_handle()
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.ready;
if has_workspace {
let _ = window.hide();
} else {
window.app_handle().exit(0);
}
} }
}); });
if let Some(server) = startup_server {
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app.handle());
}
}
Ok(()) Ok(())
}) })
} }
+6
View File
@@ -0,0 +1,6 @@
#import <WebKit/WebKit.h>
void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = YES;
}
+21
View File
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
} }
impl Server { impl Server {
pub fn dashboard_url(&self) -> Url {
self.url
.join("dashboard")
.expect("Validated installation URL")
}
pub fn new(url: Url) -> io::Result<Self> { pub fn new(url: Url) -> io::Result<Self> {
let mut profile = [0; 16]; let mut profile = [0; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?; 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] #[test]
fn configuration_round_trip_preserves_only_the_address_and_profile() { fn configuration_round_trip_preserves_only_the_address_and_profile() {
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap(); let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
+5 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.2.0", "version": "0.2.5",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -25,7 +25,7 @@
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0", "minimumSystemVersion": "14.0",
"bundleVersion": "6", "bundleVersion": "10",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
@@ -40,6 +40,9 @@
"y": 170 "y": 170
} }
} }
},
"android": {
"versionCode": 2004
} }
}, },
"plugins": { "plugins": {
+6 -1
View File
@@ -1,6 +1,11 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"identifier": "de.kanbangit.ios",
"bundle": { "bundle": {
"iOS": { "minimumSystemVersion": "17.0" } "iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "5"
}
} }
} }
+109 -7
View File
@@ -6,18 +6,64 @@ fn main() {
}; };
use tauri::{webview::PageLoadEvent, Manager}; use tauri::{webview::PageLoadEvent, Manager};
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
let online = startup == "online";
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
let passed = Arc::new(AtomicBool::new(false)); let passed = Arc::new(AtomicBool::new(false));
let stage = Arc::new(AtomicUsize::new(0)); let stage = Arc::new(AtomicUsize::new(0));
let page_stage = stage.clone(); let page_stage = stage.clone();
let result_passed = passed.clone(); let result_passed = passed.clone();
let setup_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke") 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);") .setup(move |app, _| {
let directory = app.path().app_config_dir()?;
std::fs::create_dir_all(&directory)?;
if needs_setup {
if startup == "invalid" {
std::fs::write(directory.join("active-server"), "invalid configuration")?;
}
return Ok(());
}
let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" };
std::fs::write(directory.join("active-server"), format!("{server}\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/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
.on_page_load(move |webview, payload| { .on_page_load(move |webview, payload| {
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
if payload.event() != PageLoadEvent::Finished { if payload.event() != PageLoadEvent::Finished {
return; return;
} }
if webview.label() == "launcher" { if webview.label() == "launcher" {
if needs_setup {
let app = webview.app_handle().clone();
let passed = setup_passed.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let handle = app.clone();
app.run_on_main_thread(move || {
let launcher = handle.get_webview_window("launcher").unwrap();
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
passed.store(true, Ordering::SeqCst);
launcher.close().unwrap();
}).unwrap();
});
return;
}
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
if online { return; }
webview.eval(r#" webview.eval(r#"
(async () => { (async () => {
const wait = async predicate => { const wait = async predicate => {
@@ -28,7 +74,9 @@ fn main() {
throw new Error('Timed out waiting for local state'); throw new Error('Timed out waiting for local state');
}; };
try { 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 input = document.querySelector('#server-url');
const form = document.querySelector('#server-form'); const form = document.querySelector('#server-form');
const status = document.querySelector('#connection-status'); const status = document.querySelector('#connection-status');
@@ -43,6 +91,7 @@ fn main() {
} }
input.value = 'https://127.0.0.1:9/'; input.value = 'https://127.0.0.1:9/';
form.requestSubmit(); form.requestSubmit();
await wait(() => status.dataset.state === 'connecting');
await wait(() => status.dataset.state === 'connectionError'); 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'); 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/'; input.value = 'https://kanbangit.de/';
@@ -53,7 +102,7 @@ fn main() {
form.requestSubmit(); form.requestSubmit();
document.querySelector('#confirm-switch').click(); document.querySelector('#confirm-switch').click();
} catch (error) { } catch (error) {
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message); location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
} }
})(); })();
"#).unwrap(); "#).unwrap();
@@ -76,7 +125,9 @@ fn main() {
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated'); localStorage.setItem('kan52_fixture', 'isolated');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined'; if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})(); })();
"#).unwrap(); "#).unwrap();
@@ -90,7 +141,8 @@ fn main() {
}) })
.on_navigation(move |webview, url| { .on_navigation(move |webview, url| {
if url.path() != "/__native_smoke_result" { eprintln!("Smoke navigation: {} {}", webview.label(), url);
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
return true; return true;
} }
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true"); let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
@@ -119,7 +171,17 @@ fn main() {
}); });
} else { } else {
result_passed.store(success, Ordering::SeqCst); result_passed.store(success, Ordering::SeqCst);
webview.app_handle().exit(if success { 0 } else { 1 }); if success {
let app = webview.app_handle().clone();
let label = webview.label().to_string();
let handle = app.clone();
app.run_on_main_thread(move || {
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
handle.get_webview_window(&label).unwrap().close().unwrap();
}).unwrap();
} else {
webview.app_handle().exit(1);
}
} }
false false
}) })
@@ -130,6 +192,25 @@ fn main() {
.plugin(plugin) .plugin(plugin)
.build(context) .build(context)
.expect("The macOS application must start"); .expect("The macOS application must start");
let visibility_handle = app.handle().clone();
let visibility_stage = stage.clone();
let visibility_check = std::thread::spawn(move || {
if needs_setup {
return;
}
for _ in 0..30 {
if visibility_stage.load(Ordering::SeqCst) > 0 {
break;
}
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
assert!(
!launcher.is_visible().unwrap(),
"Launcher flashed while the workspace was loading"
);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
});
let config_dir = app.path().app_config_dir().unwrap(); let config_dir = app.path().app_config_dir().unwrap();
let handle = app.handle().clone(); let handle = app.handle().clone();
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -137,7 +218,28 @@ fn main() {
eprintln!("macOS smoke test timed out"); eprintln!("macOS smoke test timed out");
handle.exit(1); handle.exit(1);
}); });
app.run_return(|_, _| {}); let closed = Arc::new(AtomicBool::new(false));
let closed_result = closed.clone();
let exit_code = app.run_return(move |app, event| {
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
closed_result.store(true, Ordering::SeqCst);
assert!(
needs_setup
|| !app
.get_webview_window("launcher")
.unwrap()
.is_visible()
.unwrap(),
"Closing the workspace must not reopen settings"
);
}
});
visibility_check.join().unwrap();
assert_eq!(exit_code, 0);
assert!(
closed.load(Ordering::SeqCst),
"The close button must quit the application"
);
let _ = std::fs::remove_dir_all(config_dir); let _ = std::fs::remove_dir_all(config_dir);
assert!( assert!(
passed.load(Ordering::SeqCst), passed.load(Ordering::SeqCst),
+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 { try {
await page.goto(`http://127.0.0.1:${server.address().port}/`); 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'); await state('idle');
for (const width of [320, 390, 1440]) { for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
@@ -98,12 +104,7 @@ try {
'https://first.example.com/', 'https://first.example.com/',
); );
await state('connecting', false, 'https://first.example.com/'); await state('connecting', false, 'https://first.example.com/');
assert.equal( assert.equal(await page.locator('.open-workspace').isDisabled(), true);
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.isDisabled(),
true,
);
await state('connectionError', false, 'https://first.example.com/'); await state('connectionError', false, 'https://first.example.com/');
assert.equal( assert.equal(
await page.getByLabel('Server address').inputValue(), 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'); 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', () => { test('every launcher text resolves to a nonempty English translation', () => {
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) { for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
assert.equal(typeof en[key], 'string', key); assert.equal(typeof en[key], 'string', key);
+43
View File
@@ -578,3 +578,46 @@ for (const change of [
assert.equal(existsSync(f.destination), false); assert.equal(existsSync(f.destination), false);
}); });
} }
test('release versions agree across native manifests', () => {
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
const config = JSON.parse(read('../src-tauri/tauri.conf.json'));
const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json'));
const packageInfo = JSON.parse(read('../package.json'));
assert.equal(packageInfo.version, config.version);
assert.equal(
read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1],
config.version,
);
assert.equal(
read('../src-tauri/Cargo.lock').match(
/name = "kanbangit-native"\nversion = "([^"]+)"/,
)?.[1],
config.version,
);
assert.equal(
read('../src-tauri/gen/apple/project.yml').match(
/MARKETING_VERSION: "([^"]+)"/,
)?.[1],
config.version,
);
assert.equal(
read('../src-tauri/gen/apple/project.yml').match(
/CURRENT_PROJECT_VERSION: "([^"]+)"/,
)?.[1],
ios.bundle.iOS.bundleVersion,
);
const project = read(
'../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj',
);
for (const [, version] of project.matchAll(
/MARKETING_VERSION = ([^;]+);/g,
)) {
assert.equal(version, config.version);
}
for (const [, version] of project.matchAll(
/CURRENT_PROJECT_VERSION = ([^;]+);/g,
)) {
assert.equal(version, ios.bundle.iOS.bundleVersion);
}
});
+12
View File
@@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co
try { try {
await import('../ui/main.js'); await import('../ui/main.js');
assert.equal(messages.shift().pathname, '/action/ready'); 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( window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android', 'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
); );
+94
View File
@@ -0,0 +1,94 @@
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 script = await readFile(
new URL('../ui/workspace-navigation.js', import.meta.url),
'utf8',
);
const server = createServer((request, response) => {
response.setHeader('Content-Type', 'text/html');
response.end(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
body { margin: 0; } #board { overflow-x: auto; width: 300px; height: 100px; }
#board > div { width: 1500px; height: 100px; } dialog { max-height: 200px; overflow-y: auto; }
</style></head><body><p id="page">${request.url}</p><input id="draft" value="retained"><div id="board"><div>Board columns</div></div>
<dialog><button id="close">Close</button><div style="height:1000px">Long dialog</div></dialog><div id="long"></div></body></html>`);
});
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({ viewport: { width: 390, height: 844 } });
await page.addInitScript(script);
const root = `http://127.0.0.1:${server.address().port}`;
try {
await page.goto(root);
assert.equal(
await page.evaluate(() => typeof window.__kanbanNavigation),
'undefined',
);
await page.goto(`${root}/full-page`);
await page.goBack();
await page.waitForURL(`${root}/`);
await page.goForward();
await page.waitForURL(`${root}/full-page`);
await page.locator('#draft').fill('unsaved draft');
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
assert.equal(
await page.evaluate(
() =>
getComputedStyle(document.documentElement).overscrollBehaviorY,
),
'none',
);
assert.equal(
await page.evaluate(
() =>
getComputedStyle(document.documentElement).overscrollBehaviorX,
),
'auto',
);
assert.equal(
await page.evaluate(() => document.documentElement.scrollHeight),
844,
);
await page.mouse.move(380, 400);
await page.mouse.wheel(0, -1000);
await page.waitForTimeout(200);
assert.equal(await page.evaluate(() => scrollY), 0);
await page
.locator('#long')
.evaluate((element) => (element.style.height = '3000px'));
await page.mouse.wheel(0, 1000);
await page.waitForFunction(() => scrollY > 0);
await page.evaluate(() =>
scrollTo(0, document.documentElement.scrollHeight),
);
await page.mouse.wheel(0, 5000);
await page.waitForTimeout(200);
assert.equal(
await page.evaluate(() => scrollY + innerHeight),
await page.evaluate(() => document.documentElement.scrollHeight),
);
await page.evaluate(() => scrollTo(0, 0));
await page.locator('#board').hover();
await page.mouse.wheel(500, 0);
await page.waitForFunction(
() => document.querySelector('#board').scrollLeft > 0,
);
assert.equal(new URL(page.url()).pathname, '/full-page');
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
await page.locator('dialog').hover();
await page.mouse.wheel(0, 500);
await page.waitForFunction(
() => document.querySelector('dialog').scrollTop > 0,
);
console.log(
'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
);
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
+74
View File
@@ -0,0 +1,74 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import "../src-tauri/src/navigation.m"
static void check(BOOL condition, NSString *message) {
if (!condition) {
fprintf(stderr, "FAIL: %s\n", message.UTF8String);
exit(1);
}
}
static void waitUntil(BOOL (^condition)(void), NSString *message) {
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
while (!condition() && deadline.timeIntervalSinceNow > 0) {
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
}
check(condition(), message);
}
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic) NSUInteger finished;
@end
@implementation WorkspacePages
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
dataUsingEncoding:NSUTF8StringEncoding];
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:task.request.URL MIMEType:@"text/html"
expectedContentLength:body.length textEncodingName:@"utf-8"];
[task didReceiveResponse:response];
[task didReceiveData:body];
[task didFinish];
}
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)task {}
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
self.finished++;
}
@end
int main(void) {
@autoreleasepool {
[NSApplication sharedApplication];
WorkspacePages *pages = [WorkspacePages new];
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.navigationDelegate = pages;
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
install_workspace_navigation((__bridge void *)webview);
install_workspace_navigation((__bridge void *)webview);
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
check([webview goBack] != nil, @"Native back navigation must start");
waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
@"Back must restore the first page and retain forward history");
check([webview goBack] == nil, @"Back at the first page must leave it in place");
check([webview goForward] != nil, @"Native forward navigation must start");
waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
@"Forward must restore the second page");
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
}
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
test(
'WebKit owns interactive navigation and preserves native back/forward history',
{ skip: process.platform !== 'darwin' },
() => {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
const binary = join(directory, 'navigation-test');
try {
const compile = spawnSync(
'xcrun',
[
'--sdk',
'macosx',
'clang',
'-fobjc-arc',
'-fblocks',
'-Wall',
'-Werror',
'-framework',
'AppKit',
'-framework',
'WebKit',
fileURLToPath(
new URL('./navigation-macos.m', import.meta.url),
),
'-o',
binary,
],
{ encoding: 'utf8', timeout: 30000 },
);
assert.equal(
compile.status,
0,
compile.stderr || compile.error?.message,
);
const result = spawnSync(binary, [], {
encoding: 'utf8',
timeout: 30000,
});
assert.equal(
result.status,
0,
result.stderr || result.error?.message,
);
assert.match(result.stdout, /PASS: WebKit/);
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
);
+114 -9
View File
@@ -168,9 +168,9 @@ test('uploads revalidated files and writes completion record after public verifi
); );
assert.equal( assert.equal(
f.events.filter((event) => event.operation === 'put-object').length, f.events.filter((event) => event.operation === 'put-object').length,
8, 9,
); );
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
for (const event of f.events.filter( for (const event of f.events.filter(
(event) => event.operation === 'put-object', (event) => event.operation === 'put-object',
)) { )) {
@@ -184,6 +184,26 @@ test('uploads revalidated files and writes completion record after public verifi
f.events.at(-1).key, f.events.at(-1).key,
'releases/macos/aarch64-apple-darwin/latest.json', 'releases/macos/aarch64-apple-darwin/latest.json',
); );
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
assert.deepEqual(
f.objects.get(downloadKey),
readFileSync(join(f.directory, f.metadata.filename)),
);
const downloadPut = f.events.findIndex(
(event) =>
event.operation === 'put-object' && event.key === downloadKey,
);
const downloadVerified = f.events.findIndex(
(event) =>
event.operation === 'public-get' && event.key === downloadKey,
);
const feedPut = f.events.findIndex(
(event) =>
event.operation === 'put-object' &&
event.key.endsWith('/latest.json'),
);
assert(downloadPut > 0 && downloadVerified > downloadPut);
assert(feedPut > downloadVerified);
assert.deepEqual( assert.deepEqual(
JSON.parse( JSON.parse(
f.objects.get( f.objects.get(
@@ -202,6 +222,91 @@ test('uploads revalidated files and writes completion record after public verifi
); );
}); });
test('replaces the stable download only for a newer verified release', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(downloadKey, Buffer.from('previous download'));
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.deepEqual(
f.objects.get(downloadKey),
readFileSync(join(f.directory, f.metadata.filename)),
);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
const replacement = f.events.find(
(event) =>
event.operation === 'put-object' &&
event.key === downloadKey &&
event.args.includes('--if-match'),
);
assert(replacement);
});
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
await assert.rejects(publishMacos(f), /downgrade/);
assert.equal(f.objects.has(downloadKey), false);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
});
test('a published version cannot replace a different stable download', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from('published download');
f.objects.set(downloadKey, original);
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.deepEqual(f.objects.get(downloadKey), original);
});
test('a failed public stable download blocks feed promotion', async (t) => {
const f = fixture(t);
const fetchPublic = f.fetchPublic;
f.fetchPublic = (url, options) =>
url.endsWith('/KanbanGit.dmg')
? Promise.resolve({ ok: false, status: 403 })
: fetchPublic(url, options);
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
assert.equal(
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
false,
);
});
test('concurrent stable download replacement preserves the winner', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(downloadKey, Buffer.from('previous download'));
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
f.run = (command, args) => {
if (args.includes('--if-match') && args.includes(downloadKey)) {
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
throw new Error('(PreconditionFailed) concurrent replacement');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /concurrent replacement/);
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
});
for (const response of ['', ' \n\t', '{}']) { for (const response of ['', ' \n\t', '{}']) {
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => { test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
const f = fixture(t); const f = fixture(t);
@@ -211,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) {
return args.includes('get-bucket-versioning') ? response : result; return args.includes('get-bucket-versioning') ? response : result;
}; };
await publishMacos(f); await publishMacos(f);
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
assert.equal(f.events.at(-1).operation, 'public-get'); assert.equal(f.events.at(-1).operation, 'public-get');
assert(f.events.at(-1).key.endsWith('/latest.json')); assert(f.events.at(-1).key.endsWith('/latest.json'));
}); });
@@ -237,7 +342,7 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
assert.deepEqual([...f.objects.entries()], originals); assert.deepEqual([...f.objects.entries()], originals);
assert.equal( assert.equal(
f.events.filter((event) => event.operation === 'get-object').length, f.events.filter((event) => event.operation === 'get-object').length,
8, 11,
); );
}); });
@@ -325,7 +430,7 @@ test('a partial upload can be retried with the same files', async (t) => {
assert.equal(f.objects.size, 3); assert.equal(f.objects.size, 3);
f.run = run; f.run = run;
await publishMacos(f); await publishMacos(f);
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
}); });
function rebuild(f) { function rebuild(f) {
@@ -387,7 +492,7 @@ for (const previous of [null, '1.0.0']) {
return result; return result;
}; };
await publishMacos(f); await publishMacos(f);
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3'); assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
assert.equal( assert.equal(
JSON.parse(f.objects.get(latestKey)).notes, JSON.parse(f.objects.get(latestKey)).notes,
@@ -516,7 +621,7 @@ for (const listing of [
!('Contents' in listing) !('Contents' in listing)
) { ) {
await publishMacos(f); await publishMacos(f);
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
} else { } else {
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/); await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
assert.equal(f.objects.get(key).toString(), 'unfinished old build'); assert.equal(f.objects.get(key).toString(), 'unfinished old build');
@@ -549,7 +654,7 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
await publishMacos(f); await publishMacos(f);
assert.deepEqual(waits, [2000, 2000]); assert.deepEqual(waits, [2000, 2000]);
assert.equal(cancelled, status === 'network' ? 0 : 2); assert.equal(cancelled, status === 'network' ? 0 : 2);
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
assert(f.events.at(-1).key.endsWith('/latest.json')); assert(f.events.at(-1).key.endsWith('/latest.json'));
}); });
} }
@@ -593,7 +698,7 @@ test('checksum access retries finish before publishing the completion record', a
}; };
await publishMacos(f); await publishMacos(f);
assert.equal(failures, 3); assert.equal(failures, 3);
assert.equal(f.objects.size, 8); assert.equal(f.objects.size, 9);
}); });
for (const overrides of [ for (const overrides of [
+2 -1
View File
@@ -9,7 +9,8 @@
<script type="module" src="./main.js"></script> <script type="module" src="./main.js"></script>
</head> </head>
<body> <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" /> <img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p> <p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1> <h1 data-i18n="title"></h1>
+3
View File
@@ -31,6 +31,9 @@ window.updateLauncher = (query) => {
const state = new URLSearchParams(query); const state = new URLSearchParams(query);
const phase = state.get('phase'); const phase = state.get('phase');
const server = state.get('server'); 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) { if (!initialized) {
input.value = server; input.value = server;
initialized = true; initialized = true;
+18
View File
@@ -0,0 +1,18 @@
(() => {
if (window.top !== window) {
return;
}
const constrainScroll = () => {
if (document.documentElement) {
document.documentElement.style.overscrollBehaviorY = 'none';
}
if (document.body) {
document.body.style.overscrollBehaviorY = 'none';
}
};
constrainScroll();
document.addEventListener('DOMContentLoaded', constrainScroll, {
once: true,
});
})();
+18 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next'; import { Download, Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue'; import UserMenuContent from '@/components/UserMenuContent.vue';
@@ -23,6 +23,7 @@ import {
} from '@/composables/useAppearance'; } from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useMacDownload } from '@/composables/useMacDownload';
import { create as createProject } from '@/routes/projects'; import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board'; import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets'; import { create as createTicket } from '@/routes/projects/tickets';
@@ -35,6 +36,7 @@ const page = usePage();
const { project } = useAppNavigation(); const { project } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl(); const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization(); const { t } = useLocalization();
const { macDownloadUrl, showMacDownload } = useMacDownload();
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed( const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value], () => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
@@ -69,6 +71,21 @@ const createAction = computed(() => {
<span v-else class="truncate font-semibold">KanbanGit</span> <span v-else class="truncate font-semibold">KanbanGit</span>
</div> </div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2"> <div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="showMacDownload"
as-child
variant="outline"
:aria-label="t('navigation.downloadAppAppleSilicon')"
:title="t('navigation.downloadAppAppleSilicon')"
class="px-3"
>
<a :href="macDownloadUrl"
><Download aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.downloadApp') }}</span
></a
>
</Button>
<Button <Button
v-if="createAction" v-if="createAction"
as-child as-child
@@ -0,0 +1,30 @@
import { onMounted, ref } from 'vue';
import updateSource from '../../../native/update-source.json';
export const macDownloadUrl = `${updateSource.baseUrl}/aarch64-apple-darwin/KanbanGit.dmg`;
export function isMacDownloadAvailable(
userAgent: string,
maxTouchPoints: number,
isTauri: unknown,
): boolean {
return (
userAgent.includes('Macintosh') &&
maxTouchPoints < 2 &&
isTauri !== true
);
}
export function useMacDownload() {
const showMacDownload = ref(false);
onMounted(() => {
showMacDownload.value = isMacDownloadAvailable(
navigator.userAgent,
navigator.maxTouchPoints,
Reflect.get(window, 'isTauri'),
);
});
return { macDownloadUrl, showMacDownload };
}
+20 -2
View File
@@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next'; import { Download, ShieldCheck } from 'lucide-vue-next';
import type { Component } from 'vue'; import type { Component } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import AppearanceTabs from '@/components/AppearanceTabs.vue'; import AppearanceTabs from '@/components/AppearanceTabs.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useMacDownload } from '@/composables/useMacDownload';
import { home } from '@/routes/index'; import { home } from '@/routes/index';
defineProps<{ title: string; description: string; icon?: Component }>(); defineProps<{ title: string; description: string; icon?: Component }>();
const { t } = useLocalization(); const { t } = useLocalization();
const { macDownloadUrl, showMacDownload } = useMacDownload();
</script> </script>
<template> <template>
<div class="bg-background text-foreground flex min-h-dvh flex-col"> <div class="bg-background text-foreground flex min-h-dvh flex-col">
<header <header
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start" class="bg-card px-page flex items-center justify-between gap-3 border-b py-4"
> >
<Link <Link
:href="home()" :href="home()"
@@ -22,6 +25,21 @@ const { t } = useLocalization();
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2" class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
><AppLogo ><AppLogo
/></Link> /></Link>
<Button
v-if="showMacDownload"
as-child
variant="outline"
size="sm"
:aria-label="t('navigation.downloadAppAppleSilicon')"
:title="t('navigation.downloadAppAppleSilicon')"
>
<a :href="macDownloadUrl"
><Download aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.downloadApp') }}</span
></a
>
</Button>
</header> </header>
<main <main
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12" class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
+4
View File
@@ -718,6 +718,7 @@ export const en = {
'welcome.action.dashboard': 'Open dashboard', 'welcome.action.dashboard': 'Open dashboard',
'welcome.action.dashboardShort': 'Dashboard', 'welcome.action.dashboardShort': 'Dashboard',
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.', 'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
'welcome.downloadAppHint': 'For Macs with Apple Silicon.',
'welcome.preview.label': 'KanbanGit board preview', 'welcome.preview.label': 'KanbanGit board preview',
'welcome.preview.project': 'Product delivery board', 'welcome.preview.project': 'Product delivery board',
'welcome.preview.connected': 'Git connected', 'welcome.preview.connected': 'Git connected',
@@ -899,6 +900,9 @@ export const en = {
'settings.eyebrow': 'Account settings', 'settings.eyebrow': 'Account settings',
'navigation.createTicket': 'New ticket', 'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project', 'navigation.createProject': 'New project',
'navigation.downloadApp': 'Download App',
'navigation.downloadAppAppleSilicon':
'Download App for Macs with Apple Silicon',
'navigation.open': 'Open navigation', 'navigation.open': 'Open navigation',
'navigation.close': 'Close navigation', 'navigation.close': 'Close navigation',
'navigation.expand': 'Expand sidebar', 'navigation.expand': 'Expand sidebar',
+41 -11
View File
@@ -1,13 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next'; import {
ArrowRight,
Columns3,
Download,
GitBranch,
ListChecks,
} from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useMacDownload } from '@/composables/useMacDownload';
import { dashboard, home, login } from '@/routes/index'; import { dashboard, home, login } from '@/routes/index';
const { t } = useLocalization(); const { t } = useLocalization();
const { macDownloadUrl, showMacDownload } = useMacDownload();
const features = computed(() => [ const features = computed(() => [
{ {
@@ -112,16 +120,38 @@ const previewColumns = computed(() => [
{{ t('welcome.description') }} {{ t('welcome.description') }}
</p> </p>
</div> </div>
<Button as-child size="lg"> <div
<Link v-if="$page.props.auth.user" :href="dashboard()"> class="flex flex-wrap items-start justify-center gap-3"
{{ t('welcome.action.dashboard') }} >
<ArrowRight aria-hidden="true" /> <Button as-child size="lg">
</Link> <Link
<Link v-else :href="login()"> v-if="$page.props.auth.user"
{{ t('welcome.action.signIn') }} :href="dashboard()"
<ArrowRight aria-hidden="true" /> >
</Link> {{ t('welcome.action.dashboard') }}
</Button> <ArrowRight aria-hidden="true" />
</Link>
<Link v-else :href="login()">
{{ t('welcome.action.signIn') }}
<ArrowRight aria-hidden="true" />
</Link>
</Button>
<div
v-if="showMacDownload"
class="grid justify-items-center gap-1"
>
<Button as-child size="lg" variant="outline">
<a :href="macDownloadUrl"
><Download aria-hidden="true" />{{
t('navigation.downloadApp')
}}</a
>
</Button>
<p class="text-caption text-muted-foreground">
{{ t('welcome.downloadAppHint') }}
</p>
</div>
</div>
<p class="text-caption text-muted-foreground"> <p class="text-caption text-muted-foreground">
{{ t('welcome.actionHint') }} {{ t('welcome.actionHint') }}
</p> </p>
+19
View File
@@ -117,6 +117,25 @@ class AuthenticationTest extends TestCase
$this->assertAuthenticatedAs($user); $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() public function test_users_are_rate_limited()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
+22
View File
@@ -90,10 +90,32 @@ if (serve) {
const { en } = await server.ssrLoadModule( const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts', '/resources/js/locales/en.ts',
); );
const { isMacDownloadAvailable, macDownloadUrl } =
await server.ssrLoadModule(
'/resources/js/composables/useMacDownload.ts',
);
const { default: Welcome } = await server.ssrLoadModule( const { default: Welcome } = await server.ssrLoadModule(
'/resources/js/pages/Welcome.vue', '/resources/js/pages/Welcome.vue',
); );
assert.equal(
macDownloadUrl,
'https://petsch-studios.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/KanbanGit.dmg',
);
const macUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
assert.equal(isMacDownloadAvailable(macUserAgent, 0, undefined), true);
assert.equal(isMacDownloadAvailable(macUserAgent, 0, true), false);
assert.equal(isMacDownloadAvailable(macUserAgent, 5, undefined), false);
assert.equal(
isMacDownloadAvailable(
'Mozilla/5.0 (Windows NT 10.0)',
0,
undefined,
),
false,
);
const render = async (url, localization = createLocalization()) => { const render = async (url, localization = createLocalization()) => {
const response = await createInertiaApp({ const response = await createInertiaApp({
page: makePage(url), page: makePage(url),