Compare commits

...
Author SHA1 Message Date
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
Twyco aece653970 update macos to v0.2.0
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
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 3m17s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m33s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 3m7s
2026-09-28 08:18:22 +02:00
Twyco 2b1cf6ec13 Merge pull request 'KAN-65: Prepare macOS 0.1.5 build 5' (#163) from feature/KAN-65-release-0-1-5 into main
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 3m14s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 5s
Reviewed-on: #163
2026-09-28 06:08:54 +00:00
Twyco 38355c9507 chore(KAN-65): prepare macOS 0.1.5 build 5
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 54s
2026-09-28 08:06:55 +02:00
Twyco d0c5d65f0e Merge pull request 'KAN-65: Fix update menu and rebuild failed macOS releases' (#162) from feature/KAN-65-remove-release-resume into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
Reviewed-on: #162
2026-09-28 06:04:03 +00:00
Twyco 4f9bf2dec4 fix(KAN-65): synchronize update menu and remove dock badge
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m37s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 1m38s
2026-09-28 08:01:35 +02:00
Twyco 4ee047311f fix(KAN-65): allow rebuilding unpublished macOS releases
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
2026-09-28 07:51:56 +02:00
Twyco 41b6dc8f10 refactor(KAN-65): remove release resume mode 2026-09-28 07:43:31 +02:00
Twyco 3d5ee223e8 Merge pull request 'KAN-65: Fix Hetzner feed ETags and resume published releases' (#161) from fix/KAN-65-hetzner-feed-etag into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 42s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Resume verified release publication (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m0s
Reviewed-on: #161
2026-09-28 05:27:36 +00:00
Twyco 9dbb491dc9 fix(KAN-65): resume release publication with CI credentials
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 11s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 0s
2026-09-28 07:27:05 +02:00
Twyco 40b66bf7ce fix(KAN-65): normalize Hetzner conditional write ETags
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m16s
2026-09-28 07:24:12 +02:00
Twyco dc9f400f34 fix(KAN-65): recover verified S3 writes from empty error responses
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
2026-09-27 23:05:35 +02:00
61 changed files with 2621 additions and 229 deletions
+3 -1
View File
@@ -17,6 +17,8 @@ permissions:
contents: read contents: read
concurrency: concurrency:
# Partial release files may be replaced until the feed is promoted.
# Keep every publisher in this group to prevent overlapping rebuilds.
group: macos-release group: macos-release
cancel-in-progress: false cancel-in-progress: false
@@ -198,7 +200,7 @@ jobs:
id: package id: package
run: node native/macos-ci.mjs package run: node native/macos-ci.mjs package
- name: Publish immutable files and verify public Hetzner downloads - name: Upload release files and promote the verified update feed
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
+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.1.3", "version": "0.2.3",
"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": "About KanbanGit and Software updates now show the installed app build number correctly." "releaseNotes": "Open saved workspaces directly in the dashboard on macOS and quit cleanly when closing the workspace window."
} }
+161 -29
View File
@@ -63,6 +63,31 @@ export function storageConfiguration(env = process.env) {
const sha256 = (data) => createHash('sha256').update(data).digest('hex'); const sha256 = (data) => createHash('sha256').update(data).digest('hex');
export function conditionalEtag(etag) {
const value =
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
? etag.slice(1, -1)
: etag;
if (
typeof value !== 'string' ||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically replace an object without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
return value;
}
function requiresExistingObjectCheck(error) {
// AWS CLI can crash while formatting Hetzner's empty error Message.
// This only permits a readback; content checks and conditional writes still apply.
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
error.message,
);
}
export async function publishMacos({ export async function publishMacos({
env = process.env, env = process.env,
directory, directory,
@@ -116,13 +141,21 @@ export async function publishMacos({
's3api', 's3api',
]; ];
function s3(operation, args) { function s3(operation, args) {
return run('aws', [ try {
...common, return run('aws', [
operation, ...common,
'--bucket', operation,
storage.bucket, '--bucket',
...args, storage.bucket,
]); ...args,
]);
} catch (error) {
const keyIndex = args.indexOf('--key');
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
cause: error,
});
}
} }
const versioningOutput = s3('get-bucket-versioning', [ const versioningOutput = s3('get-bucket-versioning', [
'--output', '--output',
@@ -152,12 +185,15 @@ export async function publishMacos({
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-')); const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`; const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`; const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
async function verifyPublic(name, contents, prefix = publicPrefix) { async function verifyPublic(name, contents, prefix = publicPrefix) {
for (let attempt = 1; attempt <= 5; attempt++) { for (let attempt = 1; attempt <= 5; attempt++) {
let response; let response;
try { try {
response = await fetchPublic(`${prefix}/${name}`, { response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error', redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(60000), signal: AbortSignal.timeout(60000),
}); });
} catch (error) { } catch (error) {
@@ -192,36 +228,90 @@ export async function publishMacos({
await wait(2000); await wait(2000);
} }
} }
function putImmutable(name, contents, contentType) { function assertUnpublishedRelease(allowSameVersion = false) {
const file = join(staging, 'published-latest.json');
try {
s3('get-object', ['--key', latestKey, file]);
} catch (error) {
if (/\(NoSuchKey\)/.test(error.message)) {
return;
}
if (requiresExistingObjectCheck(error)) {
// A broken CLI error message cannot distinguish missing from denied.
// Only an authenticated, explicitly empty listing proves absence.
const listing = JSON.parse(
s3('list-objects-v2', [
'--prefix',
latestKey,
'--max-keys',
'1',
'--output',
'json',
]),
);
if (
listing?.KeyCount === 0 &&
listing.IsTruncated === false &&
Array.isArray(listing.Contents ?? []) &&
(listing.Contents ?? []).length === 0
) {
return;
}
}
throw error;
}
const published = JSON.parse(readFileSync(file, 'utf8'));
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(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
);
}
}
// The macos-release concurrency group serializes the entire publication.
// Never run independent publishers against the same release destination.
function putReleaseFile(name, contents, contentType) {
const file = join(staging, name); const file = join(staging, name);
writeFileSync(file, contents); writeFileSync(file, contents);
try { function put(condition) {
s3('put-object', [ s3('put-object', [
'--key', '--key',
`${keyPrefix}/${name}`, `${keyPrefix}/${name}`,
'--body', '--body',
file, file,
'--if-none-match', ...condition,
'*',
'--content-type', '--content-type',
contentType, contentType,
'--cache-control', '--cache-control',
'public, max-age=31536000, immutable', 'no-cache, max-age=0, must-revalidate',
'--content-md5', '--content-md5',
createHash('md5').update(contents).digest('base64'), createHash('md5').update(contents).digest('base64'),
'--metadata', '--metadata',
`sha256=${sha256(contents)}`, `sha256=${sha256(contents)}`,
]); ]);
}
try {
put(['--if-none-match', '*']);
} catch (error) { } catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { if (!requiresExistingObjectCheck(error)) {
throw error; throw error;
} }
const existing = join(staging, `existing-${name}`); const existing = join(staging, `existing-${name}`);
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]); const response = JSON.parse(
s3('get-object', [
'--key',
`${keyPrefix}/${name}`,
'--output',
'json',
existing,
]),
);
if (sha256(readFileSync(existing)) !== sha256(contents)) { if (sha256(readFileSync(existing)) !== sha256(contents)) {
throw new Error( assertUnpublishedRelease();
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`, put(['--if-match', conditionalEtag(response.ETag)]);
);
} }
} }
} }
@@ -246,7 +336,7 @@ export async function publishMacos({
], ],
]; ];
for (const [name, contents, type] of files) { for (const [name, contents, type] of files) {
putImmutable(name, contents, type); putReleaseFile(name, contents, type);
await verifyPublic(name, contents); await verifyPublic(name, contents);
} }
// Completion metadata is written only after every public artifact matches. // Completion metadata is written only after every public artifact matches.
@@ -263,7 +353,7 @@ export async function publishMacos({
2, 2,
) + '\n', ) + '\n',
); );
putImmutable('release.json', manifest, 'application/json'); putReleaseFile('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest); await verifyPublic('release.json', manifest);
const latest = Buffer.from( const latest = Buffer.from(
JSON.stringify( JSON.stringify(
@@ -277,10 +367,57 @@ export async function publishMacos({
2, 2,
) + '\n', ) + '\n',
); );
putImmutable('update.json', latest, 'application/json'); putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest); await verifyPublic('update.json', latest);
const latestPrefix = `${storage.prefix}/${metadata.target}`; // An older release must not populate an absent download alias either.
const latestKey = `${latestPrefix}/latest.json`; 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) {
@@ -301,7 +438,7 @@ export async function publishMacos({
try { try {
publishLatest(['--if-none-match', '*']); publishLatest(['--if-none-match', '*']);
} catch (error) { } catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { if (!requiresExistingObjectCheck(error)) {
throw error; throw error;
} }
const previousFile = join(staging, 'previous-latest.json'); const previousFile = join(staging, 'previous-latest.json');
@@ -330,12 +467,7 @@ export async function publishMacos({
); );
} }
} else { } else {
if (!response.ETag) { publishLatest(['--if-match', conditionalEtag(response.ETag)]);
throw new Error(
'Cannot atomically update the feed without its ETag.',
);
}
publishLatest(['--if-match', response.ETag]);
} }
} }
await verifyPublic( await verifyPublic(
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.3" version = "0.2.3"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.3" version = "0.2.3"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -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()
@@ -58,6 +59,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 +78,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,6 +91,7 @@ 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.alwaysBounceVertical = true
@@ -152,6 +161,7 @@ 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 = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
@@ -222,8 +232,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
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 +260,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,6 +270,7 @@ 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
@@ -296,7 +311,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()
@@ -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"] {
@@ -40,7 +105,7 @@ final class WorkspaceTests: XCTestCase {
controller.open(url) controller.open(url)
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.loadSimulatedRequest(URLRequest(url: url), responseHTML: "<html><body><input id='draft' value='unsaved'></body></html>")
var loaded = false var loaded = false
for _ in 0..<50 { for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
@@ -57,10 +122,12 @@ final class WorkspaceTests: XCTestCase {
XCTAssertTrue(webview.canGoBack) XCTAssertTrue(webview.canGoBack)
XCTAssertTrue(toolbar.items?.first?.isEnabled == true) XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
webview.goBack() webview.goBack()
var returned = false
for _ in 0..<20 { for _ in 0..<20 {
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break } if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break }
try await Task.sleep(for: .milliseconds(50)) try await Task.sleep(for: .milliseconds(50))
} }
XCTAssertTrue(returned)
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value") let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
XCTAssertEqual(draft as? String, "unsaved") XCTAssertEqual(draft as? String, "unsaved")
} }
@@ -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() }
@@ -49,7 +49,9 @@
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>"; };
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; };
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>"; };
@@ -146,7 +148,9 @@
2B96972F58CC158AB3DFF94A /* lib.rs */, 2B96972F58CC158AB3DFF94A /* lib.rs */,
AEBAD9ABE758897C50BC1F2E /* locale.rs */, AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */, 7440C7F0426E6B3977DD1D94 /* main.rs */,
5F71F06AB639140AB9714425 /* metadata.rs */,
E8BA104C81071623E1FF367D /* server.rs */, E8BA104C81071623E1FF367D /* server.rs */,
6610D376CF901AC1733D394E /* updates.rs */,
); );
name = src; name = src;
path = ../../src; path = ../../src;
@@ -269,12 +273,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" */;
@@ -332,7 +342,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 +414,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 = 3;
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 +432,10 @@
); );
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=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.3;
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 +510,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 +591,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 = 3;
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 +609,10 @@
); );
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=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.3;
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 +624,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 +644,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 +663,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>
+26 -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.3"
CURRENT_PROJECT_VERSION: "3"
configs:
debug:
CODE_SIGN_IDENTITY: Apple Development
release:
CODE_SIGN_IDENTITY: Apple Distribution
targetTemplates: targetTemplates:
app: app:
type: application type: application
@@ -55,8 +62,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 +95,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:
@@ -108,7 +121,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 +134,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
+95 -85
View File
@@ -169,76 +169,77 @@ 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) { .on_navigation(move |destination| {
open_external(destination); if !allows_server_navigation(&server_url, destination) {
return false; open_external(destination);
} return false;
begin_navigation(&handle, &navigation_label);
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() .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 +299,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,
@@ -308,7 +311,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
})); }));
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)
.item( .item(
&SubmenuBuilder::new(app, en::APP_NAME) &SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME)
.about_with_text( .about_with_text(
en::ABOUT, en::ABOUT,
Some(about_metadata(app.package_info().version.to_string())), Some(about_metadata(app.package_info().version.to_string())),
@@ -385,6 +388,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 +411,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 +427,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(())
}) })
} }
+3
View File
@@ -20,6 +20,9 @@ pub use desktop::{builder, context};
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
pub use ios::{builder, context}; pub use ios::{builder, context};
#[cfg(all(target_os = "macos", feature = "smoke-test"))]
pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
builder().run(context()).expect("Could not start KanbanGit"); builder().run(context()).expect("Could not start KanbanGit");
-1
View File
@@ -2,7 +2,6 @@ pub mod en {
pub const ABOUT: &str = "About KanbanGit"; pub const ABOUT: &str = "About KanbanGit";
pub const CHECK_UPDATES: &str = "Check For Updates…"; pub const CHECK_UPDATES: &str = "Check For Updates…";
pub const INSTALL_UPDATE: &str = "Install Update…"; pub const INSTALL_UPDATE: &str = "Install Update…";
pub const UPDATE_BADGE: &str = "Update";
pub const UPDATES: &str = "KanbanGit Updates"; pub const UPDATES: &str = "KanbanGit Updates";
pub const APP_NAME: &str = "KanbanGit"; pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…"; pub const SERVER_SETTINGS: &str = "Server settings…";
+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();
+15 -6
View File
@@ -123,19 +123,28 @@ fn trusted_navigation(url: &Url) -> bool {
&& url.username().is_empty() && url.username().is_empty()
&& url.password().is_none() && url.password().is_none()
} }
pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) {
if let Some(menu) = app
.menu()
.and_then(|menu| menu.get("app-menu"))
.and_then(|item| {
item.as_submenu()
.and_then(|menu| menu.get("install-update"))
})
{
if let Some(item) = menu.as_menuitem() {
let _ = item.set_enabled(available && !busy);
}
}
}
pub fn refresh(app: &AppHandle) { pub fn refresh(app: &AppHandle) {
let (view, available, busy) = { let (view, available, busy) = {
let state = app.state::<Mutex<State>>(); let state = app.state::<Mutex<State>>();
let state = state.lock().unwrap(); let state = state.lock().unwrap();
(state.view.clone(), state.update.is_some(), state.busy()) (state.view.clone(), state.update.is_some(), state.busy())
}; };
if let Some(menu) = app.menu().and_then(|m| m.get("install-update")) { refresh_menu(app, available, busy);
if let Some(item) = menu.as_menuitem() {
let _ = item.set_enabled(available && !busy);
}
}
if let Some(window) = app.get_webview_window("updates") { if let Some(window) = app.get_webview_window("updates") {
let _ = window.set_badge_label(available.then(|| crate::locale::en::UPDATE_BADGE.into()));
if let Ok(json) = serde_json::to_string(&view) { if let Ok(json) = serde_json::to_string(&view) {
let _ = window.eval(format!("window.updateStatus?.({json})")); let _ = window.eval(format!("window.updateStatus?.({json})"));
} }
+2 -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.1.3", "version": "0.2.3",
"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": "3", "bundleVersion": "9",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
+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": "3"
}
} }
} }
+107 -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,7 @@ 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'; 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 +139,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 +169,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 +190,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 +216,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),
+29 -1
View File
@@ -1,3 +1,27 @@
#[cfg(target_os = "macos")]
#[tauri::command]
fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> {
let submenu = app.menu().unwrap().get("app-menu").unwrap();
let item = submenu.as_submenu().unwrap().get("install-update").unwrap();
let item = item.as_menuitem().unwrap();
for (available, busy, expected) in [
(false, false, false), // No update.
(true, false, true), // Available update, also after a retryable failure.
(true, true, false), // Download or installation in progress.
(true, false, true), // Retry after a failed download.
(false, true, false), // A new check clears the previous update.
(false, false, false), // Up to date.
] {
kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy);
if item.is_enabled().map_err(|error| error.to_string())? != expected {
return Err(format!(
"Native install menu has wrong state: available={available}, busy={busy}"
));
}
}
Ok(())
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[tauri::command] #[tauri::command]
fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) { fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) {
@@ -36,6 +60,7 @@ fn main() {
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50)); for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50));
if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent); if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent);
await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu');
const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } }; const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } };
window.updateStatus(state); window.updateStatus(state);
if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed'); if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed');
@@ -64,7 +89,10 @@ fn main() {
context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id()); context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id());
let app = kanbangit_native::builder() let app = kanbangit_native::builder()
.manage(passed.clone()) .manage(passed.clone())
.invoke_handler(tauri::generate_handler![updater_smoke_report]) .invoke_handler(tauri::generate_handler![
updater_smoke_report,
updater_smoke_menu
])
.plugin(plugin) .plugin(plugin)
.build(context) .build(context)
.expect("App must start"); .expect("App must start");
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { conditionalEtag } from '../publish-macos.mjs';
test(
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
{
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
},
() => {
const bucket = process.env.AWS_BUCKET;
assert(bucket, 'Set AWS_BUCKET to a test bucket');
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
const file = join(directory, 'object.txt');
const downloaded = join(directory, 'downloaded.txt');
const run = (operation, args) => {
const result = spawnSync(
'aws',
[
'--debug',
'--endpoint-url',
process.env.AWS_ENDPOINT,
'--region',
process.env.AWS_DEFAULT_REGION,
'--no-cli-pager',
's3api',
operation,
'--bucket',
bucket,
'--key',
key,
...args,
],
{ encoding: 'utf8', timeout: 30000 },
);
// Never expose debug headers: they contain the signed Authorization header.
return {
status: result.status,
stdout: result.stdout,
codes: [
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
].map((match) => match[1]),
};
};
let created = false;
try {
writeFileSync(file, 'first');
const initial = run('put-object', [
'--body',
file,
'--if-none-match',
'*',
]);
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
created = true;
const read = run('get-object', ['--output', 'json', downloaded]);
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
const etag = JSON.parse(read.stdout).ETag;
const quoted = run('put-object', [
'--body',
file,
'--if-match',
etag,
]);
console.log(
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
);
const bare = conditionalEtag(etag);
writeFileSync(file, 'second');
const updated = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.equal(
updated.status,
0,
`Bare ETag replacement failed: ${updated.codes}`,
);
writeFileSync(file, 'stale writer');
const stale = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.notEqual(stale.status, 0);
assert(
stale.codes.includes('PreconditionFailed'),
`Unexpected stale error: ${stale.codes}`,
);
assert.equal(run('get-object', [downloaded]).status, 0);
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
} finally {
try {
if (created) {
const cleanup = run('delete-object', []);
assert.equal(
cleanup.status,
0,
`Cleanup failed: ${cleanup.codes}`,
);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
},
);
+534
View File
@@ -0,0 +1,534 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
appleTeamId,
buildIos,
bundleIdentifier,
selectIdentity,
validateProfile,
} from '../build-ios.mjs';
const certificate = Buffer.from('synthetic test certificate');
const fingerprint = createHash('sha1')
.update(certificate)
.digest('hex')
.toUpperCase();
function profileFor(mode = 'testflight') {
return {
UUID: '00000000-1111-2222-3333-444444444444',
TeamIdentifier: [appleTeamId],
Platform: ['iOS'],
ExpirationDate: '2099-01-01T00:00:00Z',
DeveloperCertificates: [certificate.toString('base64')],
...(mode === 'development'
? { ProvisionedDevices: ['test-device'] }
: {}),
Entitlements: {
'application-identifier': `${appleTeamId}.${bundleIdentifier}`,
'com.apple.developer.team-identifier': appleTeamId,
'get-task-allow': mode === 'development',
'beta-reports-active': mode === 'testflight',
},
};
}
function fixture(t, mode = 'testflight', overrides = {}) {
const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const directory = join(root, 'repo/native');
const profilePath = join(root, 'profile.mobileprovision');
const config = {
productName: 'KanbanGit',
identifier: 'de.kanbangit.desktop',
version: '1.2.3',
};
const ios = {
identifier: bundleIdentifier,
bundle: {
iOS: {
developmentTeam: appleTeamId,
bundleVersion: '7',
minimumSystemVersion: '17.0',
},
},
};
mkdirSync(join(directory, 'src-tauri'), { recursive: true });
writeFileSync(
join(directory, 'src-tauri/tauri.conf.json'),
JSON.stringify(config),
);
writeFileSync(
join(directory, 'src-tauri/tauri.ios.conf.json'),
JSON.stringify(ios),
);
writeFileSync(profilePath, 'external provisioning profile');
const profile = profileFor(mode);
const calls = [];
const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`);
const home = join(root, 'home');
const installedProfile = join(
home,
`Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`,
);
const info = {
CFBundleIdentifier: bundleIdentifier,
CFBundleShortVersionString: '1.2.3',
CFBundleVersion: '7',
};
function app(path) {
mkdirSync(path, { recursive: true });
writeFileSync(
join(path, 'Info.plist'),
JSON.stringify({ ...info, ...overrides.info }),
);
}
const options = {
directory,
home,
mode,
platform: 'darwin',
log() {},
env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath },
run(command, args, settings) {
calls.push({ command, args, settings });
if (overrides.failStep === settings.step) {
throw new Error('Simulated build failure');
}
if (command === 'python3') {
return settings.input.toString();
}
if (command === 'security') {
if (args[0] === 'cms') {
const embedded = args
.at(-1)
.endsWith('embedded.mobileprovision');
return JSON.stringify(
embedded && overrides.embeddedProfile
? overrides.embeddedProfile
: profile,
);
}
return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`;
}
if (command === 'rustup') {
if (args[0] === 'target') {
return (
overrides.targets ??
'aarch64-apple-ios\naarch64-apple-ios-sim'
);
}
return `/permanent/toolchain/bin/${args[1]}`;
}
if (command === '/permanent/toolchain/bin/cargo') {
const library = join(
directory,
'src-tauri/target',
args[args.indexOf('--target') + 1],
args.includes('--release') ? 'release' : 'debug',
'libkanbangit_native.a',
);
mkdirSync(dirname(library), { recursive: true });
writeFileSync(library, 'compiled library');
}
if (command === 'xcodebuild' && args.includes('archive')) {
assert.ok(mode === 'simulator' || existsSync(installedProfile));
app(
join(
args[args.indexOf('-archivePath') + 1],
'Products/Applications/KanbanGit.app',
),
);
}
if (command === 'xcodebuild' && args.includes('-exportArchive')) {
const output = args[args.indexOf('-exportPath') + 1];
mkdirSync(output, { recursive: true });
if (!overrides.missingIpa) {
writeFileSync(
join(output, 'KanbanGit.ipa'),
'signed ipa bytes',
);
}
}
if (command === 'ditto') {
app(join(args.at(-1), 'Payload/KanbanGit.app'));
}
if (command === 'xcrun') {
return '/permanent/Xcode/SDK';
}
return '';
},
};
return {
options,
calls,
profile,
profilePath,
destination,
installedProfile,
ios,
};
}
test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => {
const f = fixture(t);
f.options.env.APPLE_API_KEY = 'must-not-reach-build';
f.options.env.IOS_CERTIFICATE = 'must-not-reach-build';
assert.equal(buildIos(f.options), f.destination);
const options = JSON.parse(
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
);
assert.equal(options.method, 'app-store-connect');
assert.equal(options.destination, 'export');
assert.equal(options.signingStyle, 'manual');
assert.equal(options.manageAppVersionAndBuildNumber, false);
assert.equal(options.signingCertificate, fingerprint);
assert.deepEqual(options.provisioningProfiles, {
[bundleIdentifier]: f.profile.UUID,
});
assert.equal(
readFileSync(
join(f.destination, 'export/KanbanGit.ipa.sha256'),
'utf8',
),
`${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`,
);
assert.equal(
f.calls.filter(
(call) =>
call.command === 'codesign' && call.args.includes('--verify'),
).length,
2,
);
assert.equal(existsSync(f.installedProfile), false);
const verification = f.calls.find((call) => call.command === 'codesign');
const requirement = verification.args[verification.args.indexOf('-R') + 1];
assert.ok(requirement.includes('! entitlement["get-task-allow"] exists'));
assert.ok(
requirement.includes('entitlement["beta-reports-active"] exists'),
);
assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`));
for (const call of f.calls) {
assert.equal(call.settings.env.APPLE_API_KEY, undefined);
assert.equal(call.settings.env.IOS_CERTIFICATE, undefined);
assert.ok(
!call.args.some((arg) =>
/allowProvisioning|authenticationKey|upload|downloadPlatform/.test(
arg,
),
),
);
}
const archive = f.calls.find((call) => call.args.includes('archive'));
assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual'));
assert.ok(
archive.args.includes(
`PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`,
),
);
assert.ok(
archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith(
'/aarch64-apple-ios/release/libkanbangit_native.a',
),
);
});
test('the simulator needs no certificate or profile and disables signing explicitly', (t) => {
const f = fixture(t, 'simulator');
delete f.options.env.IOS_PROVISIONING_PROFILE;
buildIos(f.options);
assert.ok(
f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')),
);
assert.ok(
f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')),
);
assert.ok(
!f.calls.some(
(call) =>
['security', 'codesign'].includes(call.command) ||
call.args.includes('-exportArchive'),
),
);
});
test('local device development uses a registered-device development profile', (t) => {
const f = fixture(t, 'development');
buildIos(f.options);
const options = JSON.parse(
readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'),
);
assert.equal(options.method, 'debugging');
assert.equal(options.destination, 'export');
});
for (const [name, change, message] of [
[
'different team',
(p) => {
p.TeamIdentifier = ['OLDTEAM'];
},
/explicitly match/,
],
[
'wildcard identifier',
(p) => {
p.Entitlements['application-identifier'] = `${appleTeamId}.*`;
},
/explicitly match/,
],
[
'macOS identifier',
(p) => {
p.Entitlements['application-identifier'] =
`${appleTeamId}.de.kanbangit.desktop`;
},
/explicitly match/,
],
[
'expired profile',
(p) => {
p.ExpirationDate = '2000-01-01';
},
/expired/,
],
[
'invalid expiry',
(p) => {
p.ExpirationDate = 'invalid';
},
/expired/,
],
[
'invalid UUID',
(p) => {
p.UUID = '../unexpected';
},
/invalid/,
],
[
'missing certificate',
(p) => {
p.DeveloperCertificates = [];
},
/invalid/,
],
[
'wrong platform',
(p) => {
p.Platform = ['OSX'];
},
/iOS profile/,
],
[
'Ad Hoc profile',
(p) => {
p.ProvisionedDevices = ['device'];
},
/App Store Connect/,
],
[
'enterprise profile',
(p) => {
p.ProvisionsAllDevices = true;
},
/App Store Connect/,
],
[
'development entitlement',
(p) => {
p.Entitlements['get-task-allow'] = true;
},
/App Store Connect/,
],
[
'missing beta entitlement',
(p) => {
delete p.Entitlements['beta-reports-active'];
},
/App Store Connect/,
],
]) {
test(`rejects ${name} before signing`, () => {
const profile = profileFor();
change(profile);
assert.throws(() => validateProfile(profile, 'testflight'), message);
});
}
test('a distribution profile cannot be used for device development', () => {
assert.throws(
() => validateProfile(profileFor(), 'development'),
/App Development/,
);
});
test('identity selection requires the profile certificate and a usable private key of the correct kind', () => {
const profile = profileFor();
for (const identities of [
'0 valid identities found',
`1) ${'A'.repeat(40)} "Apple Distribution: Other"`,
`1) ${fingerprint} "Developer ID Application: Example"`,
`1) ${fingerprint} "Apple Development: Example"`,
]) {
assert.throws(
() => selectIdentity(profile, identities, 'testflight'),
/private key/,
);
}
});
for (const step of [
'Generating the Xcode project',
'Building the iOS Rust library',
'Archiving locally without Apple provisioning updates',
'Verifying the Apple signature and team',
'Exporting the signed IPA locally (no upload)',
]) {
test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => {
const f = fixture(t, 'testflight', { failStep: step });
assert.throws(() => buildIos(f.options), /Simulated build failure/);
assert.equal(existsSync(f.destination), false);
assert.equal(existsSync(f.installedProfile), false);
});
}
test('an existing identical local profile is preserved after the build', (t) => {
const f = fixture(t);
mkdirSync(dirname(f.installedProfile), { recursive: true });
writeFileSync(f.installedProfile, readFileSync(f.profilePath));
buildIos(f.options);
assert.equal(existsSync(f.installedProfile), true);
});
test('a conflicting local profile is never overwritten or deleted', (t) => {
const f = fixture(t);
mkdirSync(dirname(f.installedProfile), { recursive: true });
writeFileSync(f.installedProfile, 'user profile');
assert.throws(() => buildIos(f.options), /same UUID/);
assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile');
});
test('missing toolchain targets fail before compilation without installing anything', (t) => {
const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' });
assert.throws(
() => buildIos(f.options),
/rustup target add aarch64-apple-ios/,
);
assert.ok(
!f.calls.some(
(call) => call.args.includes('build') || call.args.includes('add'),
),
);
});
test('missing profile and ambient team overrides fail clearly', (t) => {
const f = fixture(t);
delete f.options.env.IOS_PROVISIONING_PROFILE;
assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/);
f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM';
assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/);
delete f.options.env.APPLE_DEVELOPMENT_TEAM;
f.options.env.TAURI_CONFIG = '{}';
assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/);
});
for (const [name, overrides, message] of [
[
'wrong app version',
{ info: { CFBundleVersion: '8' } },
/unexpected identifier, version/,
],
[
'changed embedded profile',
{
embeddedProfile: {
...profileFor(),
UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
},
},
/unexpected provisioning profile/,
],
['missing IPA', { missingIpa: true }, /did not produce/],
]) {
test(`rejects ${name} in the build output`, (t) => {
const f = fixture(t, 'testflight', overrides);
assert.throws(() => buildIos(f.options), message);
assert.equal(existsSync(f.destination), false);
});
}
test('completed artifacts are not silently overwritten', (t) => {
const f = fixture(t);
buildIos(f.options);
assert.throws(() => buildIos(f.options), /already exists/);
});
for (const mode of ['simulator', 'development']) {
test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => {
const overrides = {};
const f = fixture(t, mode, overrides);
buildIos(f.options);
const previousBuild = join(f.destination, 'previous-build');
writeFileSync(previousBuild, 'verified artifact');
overrides.failStep = 'Building the iOS Rust library';
assert.throws(() => buildIos(f.options), /Simulated build failure/);
assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact');
delete overrides.failStep;
buildIos(f.options);
assert.equal(existsSync(previousBuild), false);
assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive')));
assert.equal(existsSync(f.installedProfile), false);
});
}
test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => {
const assets = fileURLToPath(
new URL(
'../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/',
import.meta.url,
),
);
const catalog = JSON.parse(
readFileSync(join(assets, 'Contents.json'), 'utf8'),
);
assert.ok(
catalog.images.some(
(icon) =>
icon.idiom === 'ios-marketing' && icon.size === '1024x1024',
),
);
for (const icon of catalog.images) {
const png = readFileSync(join(assets, icon.filename));
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
const [width, height] = icon.size.split('x').map(Number);
const scale = Number.parseInt(icon.scale, 10);
assert.equal(png.readUInt32BE(16), width * scale, icon.filename);
assert.equal(png.readUInt32BE(20), height * scale, icon.filename);
assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`);
for (
let offset = 8;
offset < png.length;
offset += png.readUInt32BE(offset) + 12
) {
assert.notEqual(
png.subarray(offset + 4, offset + 8).toString(),
'tRNS',
icon.filename,
);
}
}
});
+7 -6
View File
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
}; };
try { 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);
}
});
+35 -1
View File
@@ -75,7 +75,7 @@ function fixture(t) {
); );
return { ...result, output: readFileSync(output, 'utf8') }; return { ...result, output: readFileSync(output, 'utf8') };
}; };
return { run, git, checkout, commit }; return { run, git, checkout, remote, commit };
} }
test('release selection succeeds using Git alone without an administrative API token', (t) => { test('release selection succeeds using Git alone without an administrative API token', (t) => {
@@ -111,6 +111,39 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
}); });
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit); assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/);
assert.match(workflow, /--ci --target .* --bundles app,dmg/);
assert.match(workflow, /node native\/macos-ci.mjs package/);
assert.match(workflow, /node native\/publish-macos.mjs upload/);
});
test('a moved macOS tag selects the new main commit for a full rebuild', (t) => {
const f = fixture(t);
f.git(
f.remote,
'-c',
'commit.gpgsign=false',
'commit',
'--allow-empty',
'-m',
'Fix failed release',
);
const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD');
f.git(f.remote, 'tag', '--force', 'macos-v1.2.3');
f.git(
f.checkout,
'fetch',
'--force',
'origin',
'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3',
);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`);
assert.notEqual(rebuilt, f.commit);
assert.match(workflow, /tags:\n\s+- "macos-v\*"/);
assert.match(workflow, /^ {2}group: macos-release$/m);
assert.match(workflow, /^ {2}cancel-in-progress: false$/m);
}); });
test('release DMG packaging disables Finder automation in the Tauri action environment', () => { test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
@@ -127,6 +160,7 @@ test('release DMG packaging disables Finder automation in the Tauri action envir
}); });
for (const [name, overrides] of [ for (const [name, overrides] of [
['wrong tag case', { GITHUB_REF: 'refs/tags/macOS-V1.2.3' }],
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }], ['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }], ['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
[ [
+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',
); );
+487 -28
View File
@@ -1,14 +1,64 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test'; import test from 'node:test';
import { publishMacos, storageConfiguration } from '../publish-macos.mjs'; import { fileURLToPath } from 'node:url';
import {
conditionalEtag,
publishMacos,
storageConfiguration,
} from '../publish-macos.mjs';
import { signedFixture } from './updater-fixture.mjs'; import { signedFixture } from './updater-fixture.mjs';
function fixture(t) { test('publisher rejects the removed resume command before accessing storage', () => {
const result = spawnSync(
process.execPath,
[
fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)),
'resume',
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(
result.stderr,
/Use config or upload; upload requires RELEASE_DIRECTORY/,
);
});
test('conditional writes use bare S3 ETags without weakening the condition', () => {
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
}
for (const invalid of [
undefined,
null,
'',
'*',
'"*"',
`W/"${etag}"`,
`"${etag}`,
`${etag}"`,
`${etag},${etag}`,
`${etag}\r\n`,
{},
42,
]) {
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
}
});
function fixture(
t,
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true })); t.after(() => rmSync(directory, { recursive: true, force: true }));
const metadata = { const metadata = {
@@ -49,7 +99,7 @@ function fixture(t) {
const operation = args[args.indexOf('s3api') + 1]; const operation = args[args.indexOf('s3api') + 1];
const value = (name) => args[args.indexOf(name) + 1]; const value = (name) => args[args.indexOf(name) + 1];
const key = value('--key'); const key = value('--key');
events.push({ operation, key }); events.push({ operation, key, args });
if (operation === 'get-bucket-versioning') { if (operation === 'get-bucket-versioning') {
assert.equal(value('--output'), 'json'); assert.equal(value('--output'), 'json');
return ''; return '';
@@ -60,18 +110,24 @@ function fixture(t) {
} }
if (objects.has(key)) { if (objects.has(key)) {
if (!args.includes('--if-match')) { if (!args.includes('--if-match')) {
throw new Error( throw new Error(conflictMessage);
'aws failed: (PreconditionFailed) object exists',
);
} }
assert.equal(value('--if-match'), '"previous-etag"'); assert.equal(
value('--if-match'),
'b88312b96169f4b2098a4cc43b0abb6a',
);
} }
objects.set(key, readFileSync(value('--body'))); objects.set(key, readFileSync(value('--body')));
return '{}'; return '{}';
} }
if (operation === 'get-object') { if (operation === 'get-object') {
if (!objects.has(key)) {
throw new Error('aws failed: (NoSuchKey) object missing');
}
writeFileSync(args.at(-1), objects.get(key)); writeFileSync(args.at(-1), objects.get(key));
return JSON.stringify({ ETag: '"previous-etag"' }); return JSON.stringify({
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
});
} }
throw new Error(`Unexpected S3 operation: ${operation}`); throw new Error(`Unexpected S3 operation: ${operation}`);
}; };
@@ -103,7 +159,7 @@ function fixture(t) {
}; };
} }
test('uploads immutable files and writes completion record after public verification', async (t) => { test('uploads revalidated files and writes completion record after public verification', async (t) => {
const f = fixture(t); const f = fixture(t);
const url = await publishMacos(f); const url = await publishMacos(f);
assert.equal( assert.equal(
@@ -112,14 +168,42 @@ test('uploads immutable files and writes completion record after public verifica
); );
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(
(event) => event.operation === 'put-object',
)) {
assert.equal(
event.args[event.args.indexOf('--cache-control') + 1],
'no-cache, max-age=0, must-revalidate',
);
}
assert(f.events.at(-1).key.endsWith('/latest.json')); assert(f.events.at(-1).key.endsWith('/latest.json'));
assert.equal( assert.equal(
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(
@@ -138,6 +222,91 @@ test('uploads immutable files and writes completion record after public verifica
); );
}); });
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);
@@ -147,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'));
}); });
@@ -173,18 +342,24 @@ 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,
); );
}); });
test('different existing release is never overwritten', async (t) => { for (const published of ['1.2.3', '1.2.4']) {
const f = fixture(t); test(`published release stays protected when the feed is at ${published}`, async (t) => {
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; const f = fixture(t);
f.objects.set(key, Buffer.from('previous version content')); const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
await assert.rejects(publishMacos(f), /bump the app version/); f.objects.set(key, Buffer.from('previous version content'));
assert.equal(f.objects.get(key).toString(), 'previous version content'); f.objects.set(
assert.equal(f.objects.size, 1); `releases/macos/${f.metadata.target}/latest.json`,
}); Buffer.from(JSON.stringify({ version: published })),
);
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.get(key).toString(), 'previous version content');
assert.equal(f.objects.size, 2);
});
}
test('checksum mismatch aborts before contacting S3', async (t) => { test('checksum mismatch aborts before contacting S3', async (t) => {
const f = fixture(t); const f = fixture(t);
@@ -239,7 +414,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
}); });
} }
test('a partial upload can resume with the same files', async (t) => { test('a partial upload can be retried with the same files', async (t) => {
const f = fixture(t); const f = fixture(t);
const run = f.run; const run = f.run;
f.run = (command, args) => { f.run = (command, args) => {
@@ -255,9 +430,206 @@ test('a partial upload can resume 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) {
const updater = Buffer.from('rebuilt signed update');
const signing = signedFixture(updater);
f.publicKey = signing.publicKey;
f.metadata.notes = 'Fixed build of the same version';
for (const [name, contents] of [
[f.metadata.filename, Buffer.from('rebuilt disk image')],
[f.metadata.updaterFilename, updater],
]) {
writeFileSync(join(f.directory, name), contents);
writeFileSync(
join(f.directory, `${name}.sha256`),
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
);
}
writeFileSync(
join(f.directory, `${f.metadata.updaterFilename}.sig`),
signing.signature,
);
}
for (const previous of [null, '1.0.0']) {
for (const interruptedFile of ['.sha256', '/latest.json']) {
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
const f = fixture(t);
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
const previousBytes =
previous === null
? undefined
: Buffer.from(JSON.stringify({ version: previous }));
if (previousBytes) {
f.objects.set(latestKey, previousBytes);
}
const run = f.run;
f.run = (command, args) => {
if (
args.includes('put-object') &&
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
) {
throw new Error('upload interrupted');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /upload interrupted/);
assert.deepEqual(f.objects.get(latestKey), previousBytes);
rebuild(f);
f.events.length = 0;
let promoted = false;
f.run = (command, args) => {
if (!promoted) {
assert.deepEqual(f.objects.get(latestKey), previousBytes);
}
const result = run(command, args);
if (args.includes('put-object') && args.includes(latestKey)) {
promoted = true;
}
return result;
};
await publishMacos(f);
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)).notes,
f.metadata.notes,
);
for (const name of [
f.metadata.filename,
`${f.metadata.filename}.sha256`,
f.metadata.updaterFilename,
`${f.metadata.updaterFilename}.sig`,
`${f.metadata.updaterFilename}.sha256`,
]) {
assert.deepEqual(
f.objects.get(
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
),
readFileSync(join(f.directory, name)),
);
}
const published = [...f.objects.entries()];
rebuild(f);
await assert.rejects(publishMacos(f), /already been released/);
assert.deepEqual([...f.objects.entries()], published);
});
}
}
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
const f = fixture(t);
const fetchPublic = f.fetchPublic;
f.fetchPublic = async (url, options) =>
url.endsWith('/latest.json')
? { ok: false, status: 403 }
: fetchPublic(url, options);
await assert.rejects(publishMacos(f), /HTTP 403/);
const published = [...f.objects.entries()];
rebuild(f);
await assert.rejects(publishMacos(f), /already been released/);
assert.deepEqual([...f.objects.entries()], published);
});
test('a concurrent change to a partial artifact is not overwritten', async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('unfinished old build'));
const run = f.run;
f.run = (command, args) => {
if (args.includes('--if-match')) {
assert(args.includes(key));
f.objects.set(key, 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(key).toString(), 'concurrent winner');
assert.equal(f.objects.size, 1);
});
for (const failure of [
'AccessDenied',
'network failure',
'invalid JSON',
'invalid version',
]) {
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from('unfinished old build'));
f.objects.set(
latestKey,
Buffer.from(
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
),
);
const originals = [...f.objects.entries()];
const run = f.run;
f.run = (command, args) => {
if (
args.includes('get-object') &&
args.includes(latestKey) &&
!failure.startsWith('invalid')
) {
throw new Error(failure);
}
return run(command, args);
};
await assert.rejects(publishMacos(f));
assert.deepEqual([...f.objects.entries()], originals);
});
}
for (const listing of [
{ KeyCount: 0, IsTruncated: false },
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
{},
{ KeyCount: 0, IsTruncated: true },
{ KeyCount: 0, IsTruncated: false, Contents: '' },
'AccessDenied',
]) {
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
const f = fixture(t);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from('unfinished old build'));
const run = f.run;
f.run = (command, args) => {
if (args.includes('get-object') && args.includes(latestKey)) {
throw new Error(
"argument of type 'NoneType' is not a container or iterable",
);
}
if (args.includes('list-objects-v2')) {
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
if (listing === 'AccessDenied') {
throw new Error('AccessDenied');
}
return JSON.stringify(listing);
}
return run(command, args);
};
if (
listing.KeyCount === 0 &&
listing.IsTruncated === false &&
!('Contents' in listing)
) {
await publishMacos(f);
assert.equal(f.objects.size, 9);
} else {
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
assert.equal(f.objects.size, 1);
}
});
}
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) { for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
test(`temporary public download failure ${status} recovers before completion`, async (t) => { test(`temporary public download failure ${status} recovers before completion`, async (t) => {
const f = fixture(t); const f = fixture(t);
@@ -282,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'));
}); });
} }
@@ -326,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 [
@@ -378,13 +750,13 @@ test('rejects a corrupt updater archive before publishing any object', async (t)
assert.equal(f.objects.size, 0); assert.equal(f.objects.size, 0);
}); });
test('atomically promotes a newer feed only after all immutable objects are public', async (t) => { test('atomically promotes a newer feed only after all release files are public', async (t) => {
const f = fixture(t); const f = fixture(t);
const latestKey = `releases/macos/${f.metadata.target}/latest.json`; const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' }))); f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f); await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3'); assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
const immutableVerified = f.events.findIndex( const releaseVerified = f.events.findIndex(
(event) => (event) =>
event.operation === 'public-get' && event.operation === 'public-get' &&
event.key.endsWith('/update.json'), event.key.endsWith('/update.json'),
@@ -392,7 +764,7 @@ test('atomically promotes a newer feed only after all immutable objects are publ
const latestPut = f.events.findIndex( const latestPut = f.events.findIndex(
(event) => event.operation === 'put-object' && event.key === latestKey, (event) => event.operation === 'put-object' && event.key === latestKey,
); );
assert(latestPut > immutableVerified); assert(latestPut > releaseVerified);
}); });
for (const previous of [ for (const previous of [
@@ -427,3 +799,90 @@ test('concurrent feed replacement fails without overwriting the winner', async (
await assert.rejects(publishMacos(f), /concurrent publication/); await assert.rejects(publishMacos(f), /concurrent publication/);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0'); assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
}); });
for (const message of [
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
]) {
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
const published = [...f.objects.entries()];
await publishMacos(f);
assert.deepEqual([...f.objects.entries()], published);
});
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('different existing release'));
f.objects.set(
`releases/macos/${f.metadata.target}/latest.json`,
Buffer.from(JSON.stringify({ version: '1.2.3' })),
);
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.size, 2);
assert.equal(
f.objects.get(key).toString(),
'different existing release',
);
});
for (const previous of [
{ version: '2.0.0' },
{ version: '1.2.3', notes: 'different' },
]) {
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from(JSON.stringify(previous));
f.objects.set(key, original);
await assert.rejects(publishMacos(f), /downgrade|differs/);
assert.deepEqual(f.objects.get(key), original);
});
}
test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
const f = fixture(t, message);
const run = f.run;
f.run = (command, args) => {
if (args.includes('put-object')) {
throw new Error(message);
}
if (args.includes('get-object')) {
throw new Error('AccessDenied');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
assert.equal(f.objects.size, 0);
});
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
let attempts = 0;
f.run = (command, args) => {
if (args.includes('--if-match')) {
attempts++;
f.objects.set(
key,
Buffer.from(JSON.stringify({ version: '2.0.0' })),
);
throw new Error(message);
}
return run(command, args);
};
await assert.rejects(
publishMacos(f),
/S3 put-object .*latest.json: .*NoneType/,
);
assert.equal(attempts, 1);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
});
}
+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 -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),